R7+ orchestrator + CC3 logo home: combined patches

Orchestrator-side:
- routers/img_proxy_router.py: 4xx/5xx → 1x1 transparent PNG (eliminates cascade <img onerror>)
- static/sport2.html: removed standalone three.min.js (3d-force-graph bundles), bumped to 1.73.4

CC3 (before limit hit):
- Logo home link applied to ALL HTML pages (admin.html, admin_users.html, audit.html, crm.html, erp.html, kpi.html, login.html)
- Backups in _backups/*.cc3_pre_logo.$ts

CC4 R3 (before plan mode):
- _backups/r3_cc4/ocr.py.pre_S2.$ts

Audit screenshots (80 pages) committed to _audit/audit_20260505_023639/shots/
This commit is contained in:
2026-05-05 08:20:07 +02:00
parent 662f448590
commit 4fc8327789
106 changed files with 12789 additions and 11 deletions
+769
View File
@@ -0,0 +1,769 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · Admin Dashboard</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>A</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #06080d;
--bg-2: #0d1117;
--bg-3: #161b22;
--border: #1f2937;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #6e7681;
--accent: #00f0ff;
--accent-2: #00b8d4;
--green: #56d364;
--yellow: #d29922;
--red: #f85149;
--purple: #bc8cff;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
.app { display: grid; grid-template-columns: 1fr; min-height: 100vh; }
/* Native .sidebar hidden — shared sidebar (/static/shared/sidebar.*) handles sectioned menu */
.sidebar { display: none; }
.brand {
padding: 0 20px 20px;
border-bottom: 1px solid var(--border);
margin-bottom: 12px;
}
.brand h1 {
font-size: 16px; font-weight: 700; color: var(--accent);
font-family: 'JetBrains Mono', monospace;
}
.brand .sub { font-size: 11px; color: var(--text-3); margin-top: 2px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 10px 20px; cursor: pointer;
color: var(--text-2); font-size: 13px;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.nav-item:hover { background: var(--bg-3); color: var(--text); }
.nav-item.active {
color: var(--accent);
background: rgba(0,240,255,0.05);
border-left-color: var(--accent);
}
.nav-item .icon { font-size: 16px; width: 18px; }
.tenant-switch {
margin: auto 12px 12px;
padding: 12px;
background: var(--bg-3);
border-radius: 6px;
border: 1px solid var(--border);
}
.tenant-switch label { font-size: 11px; color: var(--text-3); display: block; margin-bottom: 4px; }
.tenant-switch select {
width: 100%;
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
padding: 6px 8px;
border-radius: 4px;
font-family: inherit;
font-size: 13px;
}
.main { padding: 20px 28px; overflow-y: auto; }
.header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 20px; padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.header h2 { font-size: 22px; font-weight: 700; }
.header .meta { color: var(--text-3); font-size: 12px; font-family: 'JetBrains Mono', monospace; }
.kpi-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px; margin-bottom: 24px;
}
.kpi-card {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: 16px;
position: relative; overflow: hidden;
}
.kpi-card::before {
content: ''; position: absolute; top: 0; left: 0;
width: 3px; height: 100%; background: var(--accent);
}
.kpi-card.green::before { background: var(--green); }
.kpi-card.yellow::before { background: var(--yellow); }
.kpi-card.purple::before { background: var(--purple); }
.kpi-label { font-size: 11px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.5px; }
.kpi-value { font-size: 28px; font-weight: 700; color: var(--text); margin-top: 6px; font-family: 'JetBrains Mono', monospace; }
.kpi-sub { font-size: 11px; color: var(--text-2); margin-top: 4px; }
.section {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: 18px; margin-bottom: 18px;
}
.section h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--accent); }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 10px; color: var(--text-3); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); }
td { padding: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
tr:hover { background: var(--bg-3); }
td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }
.badge.green { background: rgba(86,211,100,0.15); color: var(--green); }
.badge.yellow { background: rgba(210,153,34,0.15); color: var(--yellow); }
.badge.red { background: rgba(248,81,73,0.15); color: var(--red); }
.badge.gray { background: rgba(110,118,129,0.15); color: var(--text-3); }
.search {
width: 100%; max-width: 320px;
background: var(--bg); border: 1px solid var(--border);
padding: 8px 12px; border-radius: 6px;
color: var(--text); font-family: inherit; font-size: 13px;
}
.search:focus { outline: none; border-color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.iframe-wrap {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; overflow: hidden; height: 600px;
}
.iframe-wrap iframe { width: 100%; height: 100%; border: 0; }
.spinner {
display: inline-block; width: 14px; height: 14px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.tenants-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 14px;
}
.tenant-card {
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 8px; padding: 18px;
}
.tenant-card .name { font-size: 16px; font-weight: 600; margin-bottom: 4px; }
.tenant-card .slug { font-size: 11px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; }
.tenant-card .stats { margin-top: 12px; display: flex; gap: 16px; }
.tenant-card .stats .stat { font-size: 12px; color: var(--text-2); }
.tenant-card .stats .stat strong { color: var(--accent); display: block; font-size: 16px; font-family: 'JetBrains Mono', monospace; }
@media (max-width: 768px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="korisnici"></script>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand">
<h1>PGŽ SPORT</h1>
<div class="sub">Admin Dashboard v1.1</div>
</div>
<div class="nav-item active" data-tab="dashboard">
<span class="icon">⊞</span>
<span>Dashboard</span>
</div>
<div class="nav-item" data-tab="erp">
<span class="icon">€</span>
<span>ERP — Financije</span>
</div>
<div class="nav-item" data-tab="crm">
<span class="icon">◯</span>
<span>CRM — Klubovi</span>
</div>
<div class="nav-item" data-tab="osobe">
<span class="icon">⊙</span>
<span>Kontakti</span>
</div>
<div class="nav-item" data-tab="graph3d">
<span class="icon">▣</span>
<span>3D Graf</span>
</div>
<div class="nav-item" data-tab="tenants">
<span class="icon">⌂</span>
<span>Tenants</span>
</div>
<div class="nav-item" data-tab="reports">
<span class="icon">≡</span>
<span>Reports</span>
</div>
<div class="tenant-switch">
<label>Aktivan tenant</label>
<select id="tenantSel"></select>
</div>
<div style="padding:14px 14px 4px 14px;font-size:9.5px;color:var(--text-dim,#8a95b4);text-transform:uppercase;letter-spacing:1.2px;font-weight:700">Portali</div>
<a class="nav-item" href="/login"><span class="icon">🔑</span><span>Prijava</span></a>
<a class="nav-item" href="/app"><span class="icon">📱</span><span>Aplikacija</span></a>
<a class="nav-item active" href="/admin"><span class="icon">🛡</span><span>Administracija</span></a>
<a class="nav-item" href="/crm"><span class="icon">👥</span><span>CRM</span></a>
<a class="nav-item" href="/erp"><span class="icon">💰</span><span>ERP</span></a>
<a class="nav-item" href="/kpi"><span class="icon">📈</span><span>KPI</span></a>
<a class="nav-item" href="/audit"><span class="icon">📋</span><span>Audit</span></a>
<a class="nav-item" href="/static/sport2.html" target="_blank"><span class="icon">🌐</span><span>Public portal</span></a>
</aside>
<main class="main">
<div class="header">
<h2 id="pageTitle">Dashboard</h2>
<span class="meta" id="metaInfo">učitavam…</span>
</div>
<!-- DASHBOARD -->
<div class="tab-content active" id="tab-dashboard">
<div class="kpi-grid" id="kpiGrid"></div>
<div class="section">
<h3>Top Klubovi (po aktivnosti)</h3>
<table id="topKlubovi"><thead><tr><th>Naziv</th><th>Sport</th><th>Grad</th><th class="num">Članovi</th><th class="num">Računi</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- 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 + Ri.NET AI Engine 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>
</div>
<div class="section">
<h3>Putni nalozi / izdaci</h3>
<table id="expTable"><thead><tr><th>Broj</th><th>Klub</th><th>Destinacija</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- CRM klubovi -->
<div class="tab-content" id="tab-crm">
<input type="text" class="search" id="klubSearch" placeholder="Traži klub po imenu, OIB-u, gradu, sportu...">
<div class="section" style="margin-top: 14px;">
<h3>Klubovi</h3>
<table id="klubTable"><thead><tr><th>Naziv</th><th>OIB</th><th>Sport</th><th>Grad</th><th>Email</th><th class="num">Članovi</th><th class="num">Računi</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Osobe -->
<div class="tab-content" id="tab-osobe">
<input type="text" class="search" id="osobaSearch" placeholder="Traži po imenu, prezimenu, OIB-u...">
<div class="section" style="margin-top: 14px;">
<h3>Kontakti / Članovi</h3>
<table id="osobeTable"><thead><tr><th>Ime</th><th>Prezime</th><th>OIB</th><th>Klub</th><th>Pozicija</th><th>Email</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- 3D Graph -->
<div class="tab-content" id="tab-graph3d">
<div class="section">
<h3>3D Sport Graph</h3>
<p style="color: var(--text-3); margin-bottom: 12px;">Interaktivni 3D prikaz svih klubova, saveza i osoba s drill-down na detalje.</p>
<div class="iframe-wrap">
<iframe id="graph3dIframe" loading="lazy"></iframe>
</div>
</div>
</div>
<!-- Tenants -->
<div class="tab-content" id="tab-tenants">
<div class="section">
<h3>Multi-tenant Management</h3>
<p style="color: var(--text-3); margin-bottom: 16px;">Tenants u sustavu. Svaki tenant ima vlastiti scope klubova, financija i konfiguracije.</p>
<div class="tenants-grid" id="tenantsGrid"></div>
</div>
</div>
<!-- Reports -->
<div class="tab-content" id="tab-reports">
<div class="section">
<h3>Top 10 Klubova (po dokumentima i računima)</h3>
<table id="repTable"><thead><tr><th>Naziv</th><th>Sport</th><th>Grad</th><th class="num">Računi</th><th class="num">Članovi</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<script>
const API = '/admin/api';
let currentTenant = 1;
let dashboardData = null;
let tenantsList = [];
const $ = sel => document.querySelector(sel);
const $$ = sel => document.querySelectorAll(sel);
async function fetchJSON(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
return await r.json();
} catch (e) { console.error('Fetch fail:', url, e); return null; }
}
function fmt(n) {
if (n == null) return '—';
if (typeof n !== 'number') return n;
return new Intl.NumberFormat('hr-HR').format(n);
}
function fmtEur(n) { return n != null ? '€' + fmt(Math.round(n)) : '—'; }
function fmtDate(d) { return d ? d.substring(0, 10) : '—'; }
function badge(text, color) { return '<span class="badge ' + color + '">' + (text || '—') + '</span>'; }
function statusBadge(s) {
if (!s) return badge('—', 'gray');
const s2 = s.toLowerCase();
if (['paid', 'approved', 'active', 'completed'].includes(s2)) return badge(s, 'green');
if (['pending', 'submitted', 'draft', 'open'].includes(s2)) return badge(s, 'yellow');
if (['overdue', 'rejected', 'cancelled', 'failed'].includes(s2)) return badge(s, 'red');
return badge(s, 'gray');
}
async function loadDashboard() {
const d = await fetchJSON(`${API}/dashboard?tenant_id=${currentTenant}`);
if (!d) return;
dashboardData = d;
const k = d.kpi;
$('#kpiGrid').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Klubovi</div><div class="kpi-value">${fmt(k.klubovi_total)}</div><div class="kpi-sub">${fmt(k.klubovi_aktivni_90d)} aktivnih /90d</div></div>
<div class="kpi-card green"><div class="kpi-label">Osobe</div><div class="kpi-value">${fmt(k.osobe)}</div><div class="kpi-sub">članovi i kontakti</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Računi</div><div class="kpi-value">${fmt(k.invoices)}</div><div class="kpi-sub">${fmtEur(k.invoices_total_eur)}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(k.expenses)}</div><div class="kpi-sub">${fmtEur(k.expenses_total_eur)}</div></div>
<div class="kpi-card"><div class="kpi-label">Aktivnost</div><div class="kpi-value">${fmt(k.activity_30d)}</div><div class="kpi-sub">audit eventova /30d</div></div>
<div class="kpi-card green"><div class="kpi-label">Dokumenti</div><div class="kpi-value">${fmt(k.dokumenti_7d)}</div><div class="kpi-sub">novih /7d</div></div>
`;
// Top klubovi
const top = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=8`);
if (top && top.top_klubovi) {
$('#topKlubovi tbody').innerHTML = top.top_klubovi.map(k => `
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td class="num">${fmt(k.clanovi)}</td><td class="num">${fmt(k.invoices)}</td></tr>
`).join('');
}
$('#metaInfo').textContent = `Tenant: ${d.tenant.display_name} · OIB: ${d.tenant.oib || '—'} · ${new Date().toLocaleString('hr-HR')}`;
}
async function loadERP() {
const s = await fetchJSON(`${API}/erp/summary?tenant_id=${currentTenant}`);
if (s) {
$('#erpKpi').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Računi total</div><div class="kpi-value">${fmt(s.invoices.total)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_total)}</div></div>
<div class="kpi-card green"><div class="kpi-label">Plaćeno</div><div class="kpi-value">${fmt(s.invoices.paid)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_paid)}</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Neplaćeno</div><div class="kpi-value">${fmt(s.invoices.pending + s.invoices.overdue + (s.invoices.other||0))}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_unpaid)}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(s.expenses.total)}</div><div class="kpi-sub">${fmtEur(s.expenses.sum_total)}</div></div>
<div class="kpi-card"><div class="kpi-label">Plaćanja /90d</div><div class="kpi-value">${fmt(s.payments_90d.total)}</div><div class="kpi-sub">${fmtEur(s.payments_90d.sum_total)}</div></div>
<div class="kpi-card green"><div class="kpi-label">Proračun</div><div class="kpi-value">${fmtEur(s.proracun.sum_planirano)}</div><div class="kpi-sub">${s.proracun.n} godina · izvršeno: ${fmtEur(s.proracun.sum_izvrseno)}</div></div>
`;
}
const inv = await fetchJSON(`${API}/erp/invoices?tenant_id=${currentTenant}&limit=20`);
if (inv && inv.invoices) {
$('#invTable tbody').innerHTML = inv.invoices.length ? inv.invoices.map(i => `
<tr><td>${i.invoice_no || '—'}</td><td>${i.vendor_name || '—'}</td>
<td>${i.klub_naziv || '—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${statusBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
const exp = await fetchJSON(`${API}/erp/expenses?tenant_id=${currentTenant}&limit=20`);
if (exp && exp.expenses) {
$('#expTable tbody').innerHTML = exp.expenses.length ? exp.expenses.map(e => `
<tr><td>${e.report_no || '—'}</td><td>${e.klub_naziv || '—'}</td>
<td>${e.destination || '—'}</td><td class="num">${fmtEur(e.cost_total)}</td>
<td>${statusBadge(e.status)}</td><td>${fmtDate(e.created_at)}</td></tr>
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
}
async function loadCRM(q='') {
const url = `${API}/crm/klubovi?tenant_id=${currentTenant}&limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
const d = await fetchJSON(url);
if (d && d.klubovi) {
$('#klubTable tbody').innerHTML = d.klubovi.map(k => `
<tr><td><strong>${k.naziv}</strong></td><td>${k.oib || '—'}</td>
<td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td>${k.email || '—'}</td><td class="num">${fmt(k.clanovi)}</td>
<td class="num">${fmt(k.invoices_count)}</td></tr>
`).join('');
}
}
async function loadOsobe(q='') {
const url = `${API}/crm/osobe?limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
const d = await fetchJSON(url);
if (d && d.osobe) {
$('#osobeTable tbody').innerHTML = d.osobe.map(o => `
<tr><td>${o.ime}</td><td><strong>${o.prezime}</strong></td>
<td>${o.oib || '—'}</td><td>${o.klub_naziv || '—'}</td>
<td>${o.pozicija || '—'}</td><td>${o.email || '—'}</td>
<td>${o.aktivan ? badge('Aktivan', 'green') : badge('Neaktivan', 'gray')}</td></tr>
`).join('');
}
}
async function loadTenants() {
const d = await fetchJSON(`${API}/tenants`);
if (d && d.tenants) {
$('#tenantsGrid').innerHTML = d.tenants.map(t => `
<div class="tenant-card">
<div class="name">${t.display_name}</div>
<div class="slug">@${t.slug} · ${t.type} · ${t.oib || 'no OIB'}</div>
<div class="stats">
<div class="stat"><strong>${fmt(t.klubovi_count || 0)}</strong>klubovi</div>
<div class="stat"><strong>${statusBadge(t.status).match(/>([^<]+)</)[1]}</strong>status</div>
</div>
</div>
`).join('');
}
}
async function loadReports() {
const d = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=20`);
if (d && d.top_klubovi) {
$('#repTable tbody').innerHTML = d.top_klubovi.map(k => `
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td class="num">${fmt(k.invoices)}</td><td class="num">${fmt(k.clanovi)}</td></tr>
`).join('');
}
}
function load3D() {
const f = $('#graph3dIframe');
if (!f.src) f.src = '/3d';
}
async function loadTenantSelector() {
const d = await fetchJSON(`${API}/tenants`);
if (d && d.tenants) {
tenantsList = d.tenants;
$('#tenantSel').innerHTML = d.tenants.map(t =>
`<option value="${t.id}" ${t.id === currentTenant ? 'selected' : ''}>${t.display_name}</option>`
).join('');
}
}
function activateTab(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
const titles = {
dashboard: 'Dashboard',
erp: 'ERP — Financije',
crm: 'CRM — Klubovi',
osobe: 'Kontakti',
graph3d: '3D Graf',
tenants: 'Multi-tenant',
reports: 'Reports'
};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'dashboard') loadDashboard();
if (name === 'erp') loadERP();
if (name === 'crm') loadCRM();
if (name === 'osobe') loadOsobe();
if (name === 'graph3d') load3D();
if (name === 'tenants') loadTenants();
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)));
let searchTimeout;
$('#klubSearch').addEventListener('input', e => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadCRM(e.target.value), 300);
});
$('#osobaSearch').addEventListener('input', e => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadOsobe(e.target.value), 300);
});
$('#tenantSel').addEventListener('change', e => {
currentTenant = parseInt(e.target.value);
activateTab($('.nav-item.active').dataset.tab);
});
(async () => {
await loadTenantSelector();
await loadDashboard();
})();
</script>
</body>
</html>
@@ -0,0 +1,825 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · Admin · Korisnici</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>P</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #06080d; --bg-2: #0d1117; --bg-3: #161b22; --bg-4: #1c2129;
--border: #1f2937; --text: #e6edf3; --text-2: #8b949e; --text-3: #6e7681;
--accent: #00f0ff; --accent-2: #00b8d4;
--green: #56d364; --yellow: #d29922; --red: #f85149; --purple: #bc8cff; --orange: #ff9e64;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Inter', system-ui, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; font-size: 14px; line-height: 1.5; }
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; transition: grid-template-columns 0.2s; }
.app.collapsed { grid-template-columns: 60px 1fr; }
.app.collapsed .sb-text, .app.collapsed .brand-text, .app.collapsed .user-info > div { display: none; }
.app.collapsed .nav-item { justify-content: center; padding: 12px 0; }
.app.collapsed .brand { justify-content: center; padding: 18px 0; }
.app.collapsed .nav-section { display: none; }
.app.collapsed .user-box { padding: 10px 8px; }
.app.collapsed .user-info { justify-content: center; }
.app.collapsed .user-info .menu-btn { display: none; }
.sidebar { background: var(--bg-2); border-right: 1px solid var(--border); display: flex; flex-direction: column; padding: 0; position: relative; }
.brand { display: flex; align-items: center; gap: 12px; padding: 18px 20px; border-bottom: 1px solid var(--border); }
.brand-mark { width: 32px; height: 32px; flex-shrink: 0; background: var(--accent); color: var(--bg); border-radius: 6px; display: grid; place-items: center; font-weight: 700; font-family: 'JetBrains Mono', monospace; }
.brand-text h1 { font-size: 14px; font-weight: 700; letter-spacing: 0.5px; }
.brand-text .sub { font-size: 10px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; }
.sb-toggle { position: absolute; top: 16px; right: -12px; background: var(--bg-3); border: 1px solid var(--border); width: 24px; height: 24px; border-radius: 50%; color: var(--text-2); cursor: pointer; display: grid; place-items: center; font-size: 12px; z-index: 10; }
.sb-toggle:hover { color: var(--accent); border-color: var(--accent); }
nav.sb-nav { padding: 8px 0; flex: 1; overflow-y: auto; }
.nav-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; color: var(--text-2); font-size: 13px; border-left: 3px solid transparent; transition: all 0.12s; text-decoration: none; }
.nav-item:hover { background: var(--bg-3); color: var(--text); }
.nav-item.active { color: var(--accent); background: rgba(0,240,255,0.05); border-left-color: var(--accent); }
.nav-item .icon { font-size: 16px; width: 18px; flex-shrink: 0; }
.nav-section { padding: 12px 20px 4px; font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 1px; font-weight: 700; }
.user-box { margin-top: auto; padding: 14px 16px; border-top: 1px solid var(--border); }
.user-info { display: flex; align-items: center; gap: 10px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: var(--bg); display: grid; place-items: center; font-size: 12px; font-weight: 700; flex-shrink: 0; }
.user-info .name { font-size: 12px; font-weight: 600; }
.user-info .role { font-size: 10px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; }
.user-info .menu-btn { margin-left: auto; background: none; border: 0; color: var(--text-3); cursor: pointer; font-size: 16px; padding: 4px; }
.user-info .menu-btn:hover { color: var(--accent); }
.dropdown { position: absolute; bottom: 60px; left: 14px; right: 14px; background: var(--bg-3); border: 1px solid var(--border); border-radius: 6px; padding: 6px; display: none; box-shadow: 0 -8px 24px rgba(0,0,0,0.5); z-index: 20; }
.dropdown.show { display: block; }
.dropdown a { display: block; padding: 8px 10px; color: var(--text-2); font-size: 12px; cursor: pointer; border-radius: 4px; text-decoration: none; }
.dropdown a:hover { background: var(--bg-4); color: var(--accent); }
main.main { padding: 20px 28px; overflow-y: auto; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid var(--border); gap: 16px; flex-wrap: wrap; }
.page-header h2 { font-size: 22px; font-weight: 700; }
.page-header .meta { color: var(--text-3); font-size: 12px; font-family: 'JetBrains Mono', monospace; }
.page-header .actions { display: flex; gap: 10px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 8px 14px; border-radius: 6px; cursor: pointer; font-family: inherit; font-size: 13px; font-weight: 500; border: 1px solid var(--border); background: var(--bg-3); color: var(--text); text-decoration: none; transition: all 0.12s; }
.btn:hover { border-color: var(--accent); color: var(--accent); }
.btn.primary { background: var(--accent); color: var(--bg); border-color: var(--accent); font-weight: 600; }
.btn.primary:hover { background: var(--accent-2); color: var(--bg); }
.btn.danger { color: var(--red); border-color: rgba(248,81,73,0.3); }
.btn.danger:hover { background: rgba(248,81,73,0.1); border-color: var(--red); }
.filter-bar { display: grid; grid-template-columns: 1fr repeat(4, minmax(120px, 180px)); gap: 10px; margin-bottom: 16px; }
.filter-bar input, .filter-bar select { background: var(--bg-2); border: 1px solid var(--border); color: var(--text); padding: 8px 12px; border-radius: 6px; font-family: inherit; font-size: 13px; }
.filter-bar input:focus, .filter-bar select:focus { outline: none; border-color: var(--accent); }
@media (max-width: 1100px) { .filter-bar { grid-template-columns: 1fr; } }
.section { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 18px; margin-bottom: 18px; }
.section h3 { font-size: 14px; font-weight: 600; margin-bottom: 12px; color: var(--accent); display: flex; justify-content: space-between; align-items: center; }
.section h3 small { color: var(--text-3); font-weight: 400; font-family: 'JetBrains Mono', monospace; font-size: 11px; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 18px; }
.kpi-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; position: relative; overflow: hidden; }
.kpi-card::before { content: ''; position: absolute; top: 0; left: 0; width: 3px; height: 100%; background: var(--accent); }
.kpi-card.green::before { background: var(--green); }
.kpi-card.yellow::before { background: var(--yellow); }
.kpi-card.purple::before { background: var(--purple); }
.kpi-card.red::before { background: var(--red); }
.kpi-label { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 0.7px; font-weight: 600; }
.kpi-value { font-size: 26px; font-weight: 700; margin-top: 4px; font-family: 'JetBrains Mono', monospace; }
.kpi-sub { font-size: 11px; color: var(--text-2); margin-top: 2px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; padding: 8px 10px; color: var(--text-3); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); white-space: nowrap; font-weight: 600; }
td { padding: 10px; border-bottom: 1px solid var(--border); color: var(--text); }
tr:hover td { background: var(--bg-3); }
td.num, th.num { text-align: right; font-family: 'JetBrains Mono', monospace; }
td.actions-col { text-align: right; white-space: nowrap; }
td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; line-height: 1.5; }
.badge.green { background: rgba(86,211,100,0.15); color: var(--green); }
.badge.yellow { background: rgba(210,153,34,0.15); color: var(--yellow); }
.badge.red { background: rgba(248,81,73,0.15); color: var(--red); }
.badge.gray { background: rgba(110,118,129,0.15); color: var(--text-3); }
.badge.purple { background: rgba(188,140,255,0.15); color: var(--purple); }
.badge.cyan { background: rgba(0,240,255,0.15); color: var(--accent); }
.tab-content { display: none; }
.tab-content.active { display: block; }
.modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: none; z-index: 100; backdrop-filter: blur(2px); }
.modal-bg.show { display: grid; place-items: center; }
.modal { background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 24px; width: min(540px, 92vw); max-height: 92vh; overflow-y: auto; position: relative; }
.modal h3 { font-size: 18px; margin-bottom: 16px; }
.modal .close { position: absolute; top: 14px; right: 14px; background: none; border: 0; color: var(--text-3); cursor: pointer; font-size: 20px; }
.field { margin-bottom: 14px; }
.field label { display: block; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-3); margin-bottom: 6px; font-weight: 600; }
.field input, .field select, .field textarea { width: 100%; background: var(--bg); border: 1px solid var(--border); color: var(--text); padding: 10px 12px; border-radius: 6px; font-family: inherit; font-size: 13px; }
.field input:focus, .field select:focus { outline: none; border-color: var(--accent); }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-actions { display: flex; gap: 10px; margin-top: 20px; justify-content: flex-end; }
.toast { position: fixed; bottom: 24px; right: 24px; background: var(--bg-2); border: 1px solid var(--border); padding: 12px 16px; border-radius: 8px; font-size: 13px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 200; transform: translateY(100px); opacity: 0; transition: all 0.3s; }
.toast.show { transform: translateY(0); opacity: 1; }
.toast.success { border-left: 3px solid var(--green); }
.toast.error { border-left: 3px solid var(--red); }
.empty { text-align: center; padding: 40px 20px; color: var(--text-3); }
.audit-row { font-family: 'JetBrains Mono', monospace; font-size: 11px; }
.audit-action { background: var(--bg-3); padding: 2px 6px; border-radius: 3px; font-size: 11px; color: var(--accent); }
.cookie { position: fixed; bottom: 16px; left: 16px; right: 16px; max-width: 560px; margin: 0 auto; background: var(--bg-2); border: 1px solid var(--border); border-radius: 10px; padding: 14px 18px; display: none; z-index: 1000; box-shadow: 0 12px 40px rgba(0,0,0,0.5); }
.cookie.show { display: block; }
.cookie h4 { font-size: 13px; margin-bottom: 4px; }
.cookie p { font-size: 11px; color: var(--text-2); margin-bottom: 10px; }
.cookie-actions { display: flex; gap: 6px; flex-wrap: wrap; }
.cookie-actions button { background: transparent; border: 1px solid var(--border); color: var(--text-2); padding: 5px 12px; border-radius: 4px; font-family: inherit; font-size: 11px; cursor: pointer; }
.cookie-actions button.primary { background: var(--accent); border-color: var(--accent); color: var(--bg); font-weight: 600; }
@media (max-width: 768px) {
.app { grid-template-columns: 1fr; }
.sidebar { display: none; }
}
</style>
</head>
<body>
<div class="app" id="appShell">
<aside class="sidebar">
<button class="sb-toggle" id="sbToggle" title="Sklopi/raširi">⮜</button>
<div class="brand">
<div class="brand-mark">P</div>
<div class="brand-text">
<h1>PGŽ SPORT</h1>
<div class="sub">Admin · Auth v3.0</div>
</div>
</div>
<nav class="sb-nav">
<div class="nav-item active" data-tab="overview"><span class="icon">⊞</span><span class="sb-text">Pregled</span></div>
<div class="nav-section sb-text">Multi-tenant</div>
<div class="nav-item" data-tab="users"><span class="icon">⊙</span><span class="sb-text">Korisnici</span></div>
<div class="nav-item" data-tab="tenants"><span class="icon">⌂</span><span class="sb-text">Tenanti</span></div>
<div class="nav-section sb-text">Sigurnost</div>
<div class="nav-item" data-tab="audit"><span class="icon">≡</span><span class="sb-text">Audit log</span></div>
<div class="nav-item" data-tab="security"><span class="icon">⌬</span><span class="sb-text">Sigurnost</span></div>
<div class="nav-section sb-text">GDPR</div>
<div class="nav-item" data-tab="gdpr"><span class="icon">🔒</span><span class="sb-text">GDPR</span></div>
<div class="nav-section sb-text">Drugi moduli</div>
<a class="nav-item" href="/admin"><span class="icon">€</span><span class="sb-text">ERP / CRM / OCR</span></a>
<a class="nav-item" href="/static/sport2.html"><span class="icon">◊</span><span class="sb-text">Javni portal</span></a>
</nav>
<div class="user-box">
<div class="user-info">
<div class="avatar" id="userAvatar">?</div>
<div>
<div class="name" id="userName">—</div>
<div class="role" id="userRole">—</div>
</div>
<button class="menu-btn" id="userMenuBtn">⋮</button>
</div>
<div class="dropdown" id="userDropdown">
<a id="menuExport">📥 Izvezi moje podatke</a>
<a id="menuChangePwd">🔑 Promijeni lozinku</a>
<a id="menuErase">🗑️ Zatraži brisanje računa</a>
<a id="menuLogout" style="color: var(--red)">⏻ Odjava</a>
</div>
</div>
</aside>
<main class="main">
<div class="tab-content active" id="tab-overview">
<div class="page-header">
<div><h2>Pregled</h2><span class="meta" id="overviewMeta">učitavam…</span></div>
</div>
<div class="kpi-grid" id="overviewKpi"></div>
<div class="section">
<h3>Najnovije akcije <small>zadnjih 10</small></h3>
<table id="recentAuditTable"><thead><tr><th>Vrijeme</th><th>Korisnik</th><th>Akcija</th><th>Resurs</th><th>IP</th></tr></thead><tbody></tbody></table>
</div>
</div>
<div class="tab-content" id="tab-users">
<div class="page-header">
<div><h2>Korisnici</h2><span class="meta" id="usersMeta">—</span></div>
<div class="actions">
<button class="btn" id="btnRefreshUsers">↻ Osvježi</button>
<button class="btn primary" id="btnNewUser">+ Dodaj korisnika</button>
</div>
</div>
<div class="filter-bar">
<input type="text" id="usrQ" placeholder="🔍 Traži po imenu, e-mailu, OIB-u…">
<select id="usrTenant"><option value="">Svi tenanti</option></select>
<select id="usrRole">
<option value="">Sve uloge</option>
<option value="super_admin">Super admin</option>
<option value="pgz_admin">PGŽ admin</option>
<option value="pgz_user">PGŽ user</option>
<option value="pgz_finance">PGŽ finance</option>
<option value="savez_admin">Savez admin</option>
<option value="klub_admin">Klub admin</option>
<option value="klub_trener">Klub trener</option>
<option value="klub_user">Klub user</option>
<option value="klub_clan">Klub član</option>
<option value="viewer">Viewer</option>
</select>
<select id="usrStatus">
<option value="">Svi statusi</option>
<option value="true">Aktivni</option>
<option value="false">Neaktivni</option>
</select>
<select id="usrLimit">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
</select>
</div>
<div class="section">
<h3>Lista korisnika <small id="usersCount">—</small></h3>
<table>
<thead><tr><th>ID</th><th>E-mail</th><th>Ime</th><th>Uloga</th><th>Klub / Savez</th><th>Status</th><th>Zadnja prijava</th><th class="actions-col">Akcije</th></tr></thead>
<tbody id="usersTbody"></tbody>
</table>
</div>
</div>
<div class="tab-content" id="tab-tenants">
<div class="page-header"><h2>Tenanti</h2></div>
<div class="section"><h3>Hijerarhija</h3>
<table><thead><tr><th>ID</th><th>Slug</th><th>Naziv</th><th>Tip</th><th>OIB</th><th>Status</th></tr></thead><tbody id="tenantsTbody"></tbody></table>
</div>
<div class="section"><h3>Savezi</h3>
<table><thead><tr><th>ID</th><th>Naziv</th><th>Sport</th><th>Predsjednik</th><th>Tajnik</th></tr></thead><tbody id="savezi2Tbody"></tbody></table>
</div>
<div class="section"><h3>Klubovi <small id="klubCount">—</small></h3>
<table><thead><tr><th>ID</th><th>Naziv</th><th>Sport</th><th>Grad</th><th>OIB</th><th>Savez ID</th></tr></thead><tbody id="klubovi2Tbody"></tbody></table>
</div>
</div>
<div class="tab-content" id="tab-audit">
<div class="page-header"><h2>Audit log</h2><div class="actions"><button class="btn" id="btnRefreshAudit">↻ Osvježi</button></div></div>
<div class="filter-bar">
<input type="text" id="auQ" placeholder="🔍 Filtriraj akciju (login, user.create, …)">
<input type="number" id="auUid" placeholder="user_id">
<select id="auLimit"><option value="50">50</option><option value="100" selected>100</option><option value="500">500</option></select>
<span></span><span></span>
</div>
<div class="section"><h3>Događaji <small id="auditCount">—</small></h3>
<table><thead><tr><th>Vrijeme</th><th>User</th><th>Akcija</th><th>Resurs</th><th>IP</th><th>UA</th><th>Meta</th></tr></thead><tbody id="auditTbody"></tbody></table>
</div>
</div>
<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>
<div class="section"><h3>Sesije</h3>
<table><thead><tr><th>—</th></tr></thead><tbody id="sessionsTbody"><tr><td class="empty">Sesije se prate per-user kroz audit log (login.ok / logout / auth.refresh)</td></tr></tbody></table>
</div>
</div>
<div class="tab-content" id="tab-gdpr">
<div class="page-header"><h2>GDPR</h2></div>
<div class="kpi-grid" id="gdprKpi"></div>
<div class="section"><h3>Zahtjevi za brisanje <small>Art. 17</small></h3>
<table><thead><tr><th>ID</th><th>Korisnik</th><th>E-mail</th><th>Razlog</th><th>Status</th><th>Zatraženo</th><th class="actions-col">Akcije</th></tr></thead><tbody id="erasureTbody"></tbody></table>
</div>
<div class="section"><h3>Pristanak na kolačiće <small>moja povijest</small></h3>
<table><thead><tr><th>Vrijeme</th><th>Session</th><th>Nužni</th><th>Analitički</th><th>Marketing</th><th>IP</th><th>Verzija</th></tr></thead><tbody id="consentTbody"></tbody></table>
</div>
</div>
</main>
</div>
<div class="modal-bg" id="userModalBg">
<div class="modal">
<button class="close" onclick="closeModal('userModal')">×</button>
<h3 id="userModalTitle">+ Dodaj korisnika</h3>
<form id="userForm">
<input type="hidden" id="uf_id">
<div class="field-row">
<div class="field"><label>E-mail *</label><input type="email" id="uf_email" required></div>
<div class="field"><label>Telefon</label><input type="text" id="uf_telefon"></div>
</div>
<div class="field-row">
<div class="field"><label>Ime</label><input type="text" id="uf_ime"></div>
<div class="field"><label>Prezime</label><input type="text" id="uf_prezime"></div>
</div>
<div class="field-row">
<div class="field"><label>Uloga *</label>
<select id="uf_role" required>
<option value="pgz_admin">PGŽ admin</option>
<option value="pgz_user">PGŽ user</option>
<option value="pgz_finance">PGŽ finance</option>
<option value="savez_admin">Savez admin</option>
<option value="savez_user">Savez user</option>
<option value="klub_admin">Klub admin</option>
<option value="klub_trener">Klub trener</option>
<option value="klub_user">Klub user</option>
<option value="klub_clan" selected>Klub član</option>
<option value="viewer">Viewer</option>
</select></div>
<div class="field"><label>OIB</label><input type="text" id="uf_oib" maxlength="11"></div>
</div>
<div class="field-row">
<div class="field"><label>Klub ID</label><input type="number" id="uf_klub_id"></div>
<div class="field"><label>Savez ID</label><input type="number" id="uf_savez_id"></div>
</div>
<div class="field" id="uf_pwd_field">
<label>Lozinka <small style="color:var(--text-3)">(prazno = generiraj privremenu)</small></label>
<input type="text" id="uf_password" placeholder="Ostavi prazno za auto-generiranu">
</div>
<div class="modal-actions">
<button type="button" class="btn" onclick="closeModal('userModal')">Odustani</button>
<button type="submit" class="btn primary" id="uf_submit">Spremi</button>
</div>
</form>
</div>
</div>
<div class="modal-bg" id="pwdModalBg">
<div class="modal">
<button class="close" onclick="closeModal('pwdModal')">×</button>
<h3>Promjena lozinke</h3>
<form id="pwdForm">
<div class="field"><label>Stara lozinka</label><input type="password" id="pf_old"></div>
<div class="field"><label>Nova lozinka *</label><input type="password" id="pf_new" required minlength="8"></div>
<div class="field"><label>Potvrdi novu *</label><input type="password" id="pf_new2" required minlength="8"></div>
<div class="modal-actions">
<button type="button" class="btn" onclick="closeModal('pwdModal')">Odustani</button>
<button type="submit" class="btn primary">Promijeni</button>
</div>
</form>
</div>
</div>
<div id="cookie" class="cookie">
<h4>🍪 Kolačići</h4>
<p>Koristimo nužne kolačiće za prijavu i sigurnost. Ostali kolačići samo uz vaše odobrenje.</p>
<div class="cookie-actions">
<button class="primary" id="cookieAccept">Prihvati sve</button>
<button id="cookieNecessary">Samo nužni</button>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
const API = '/sport/api';
const TOKEN_KEY = 'pgz_access', REFRESH_KEY = 'pgz_refresh', USER_KEY = 'pgz_user';
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
function getToken() { return localStorage.getItem(TOKEN_KEY) || sessionStorage.getItem(TOKEN_KEY); }
function getUser() { try { return JSON.parse(localStorage.getItem(USER_KEY) || sessionStorage.getItem(USER_KEY) || 'null'); } catch { return null; } }
function clearAuth() { for (const k of [TOKEN_KEY, REFRESH_KEY, USER_KEY]) { localStorage.removeItem(k); sessionStorage.removeItem(k); } }
async function refreshToken() {
const rt = localStorage.getItem(REFRESH_KEY) || sessionStorage.getItem(REFRESH_KEY);
if (!rt) return null;
try {
const r = await fetch(API + '/auth/refresh', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({refresh_token: rt}) });
if (!r.ok) return null;
const d = await r.json();
const store = localStorage.getItem(REFRESH_KEY) ? localStorage : sessionStorage;
store.setItem(TOKEN_KEY, d.access_token);
return d.access_token;
} catch { return null; }
}
async function api(path, opts = {}) {
let tok = getToken();
if (!tok) { location.href = '/static/login.html'; return null; }
const headers = Object.assign({}, opts.headers || {}, {'Authorization': 'Bearer ' + tok});
if (opts.body && !(opts.body instanceof FormData) && !headers['Content-Type']) {
headers['Content-Type'] = 'application/json';
if (typeof opts.body !== 'string') opts.body = JSON.stringify(opts.body);
}
let r = await fetch(API + path, Object.assign({}, opts, {headers}));
if (r.status === 401) {
const newTok = await refreshToken();
if (!newTok) { clearAuth(); location.href = '/static/login.html'; return null; }
headers['Authorization'] = 'Bearer ' + newTok;
r = await fetch(API + path, Object.assign({}, opts, {headers}));
}
return r;
}
async function apiJson(path, opts) { const r = await api(path, opts); if (!r) return null; try { return await r.json(); } catch { return null; } }
function toast(msg, type='success') {
const t = $('#toast'); t.textContent = msg;
t.className = 'toast show ' + type;
setTimeout(() => t.classList.remove('show'), 3500);
}
function fmtDateTime(d) { if (!d) return '—'; try { return new Date(d).toLocaleString('hr-HR'); } catch { return d; } }
function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function roleBadge(r) {
const map = { super_admin:'red', pgz_admin:'cyan', pgz_user:'cyan', pgz_finance:'cyan', pgz_zzjz:'cyan',
savez_admin:'purple', savez_user:'purple', klub_admin:'green', klub_trener:'green', klub_user:'green', klub_clan:'green', viewer:'gray' };
return `<span class="badge ${map[r]||'gray'}">${escapeHtml(r||'—')}</span>`;
}
function statusBadge(active) { return active ? '<span class="badge green">Aktivan</span>' : '<span class="badge gray">Neaktivan</span>'; }
function openModal(name) { $('#'+name+'Bg').classList.add('show'); }
function closeModal(name) { $('#'+name+'Bg').classList.remove('show'); }
// Sidebar collapse
const sbState = localStorage.getItem('pgz_sidebar') || 'expanded';
if (sbState === 'collapsed') $('#appShell').classList.add('collapsed');
$('#sbToggle').textContent = $('#appShell').classList.contains('collapsed') ? '⮞' : '⮜';
$('#sbToggle').addEventListener('click', () => {
$('#appShell').classList.toggle('collapsed');
const c = $('#appShell').classList.contains('collapsed');
localStorage.setItem('pgz_sidebar', c ? 'collapsed' : 'expanded');
$('#sbToggle').textContent = c ? '⮞' : '⮜';
});
// Tabs
function activate(tab) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === tab));
$$('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
if (tab === 'overview') loadOverview();
if (tab === 'users') loadUsers();
if (tab === 'tenants') loadTenants();
if (tab === 'audit') loadAudit();
if (tab === 'security') loadSecurity();
if (tab === 'gdpr') loadGdpr();
history.replaceState(null, '', '#' + tab);
}
$$('.nav-item[data-tab]').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
// User dropdown
$('#userMenuBtn').addEventListener('click', e => { e.stopPropagation(); $('#userDropdown').classList.toggle('show'); });
document.addEventListener('click', () => $('#userDropdown').classList.remove('show'));
$('#userDropdown').addEventListener('click', e => e.stopPropagation());
$('#menuLogout').addEventListener('click', async () => {
await api('/auth/logout', {method:'POST'});
clearAuth();
location.href = '/static/login.html';
});
$('#menuExport').addEventListener('click', async () => {
const r = await api('/users/me/gdpr-export', {method:'POST'}); if (!r) return;
const blob = await r.blob();
const cd = r.headers.get('content-disposition') || '';
const m = cd.match(/filename="?([^";]+)"?/);
const fn = m ? m[1] : `pgz_data_export_${Date.now()}.json`;
const u = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = u; a.download = fn;
a.click(); URL.revokeObjectURL(u);
toast('Podaci preuzeti (Art. 20 GDPR)');
});
$('#menuChangePwd').addEventListener('click', () => openModal('pwdModal'));
$('#menuErase').addEventListener('click', async () => {
const reason = prompt('Razlog brisanja računa (opcionalno):'); if (reason === null) return;
const conf = prompt('Za potvrdu unesite svoj e-mail:'); if (!conf) return;
const r = await apiJson('/gdpr/erase', {method:'POST', body:{reason, confirm_email: conf}});
if (r && r.status === 'ok') toast('Zahtjev za brisanje #' + r.request_id + ' zaprimljen');
else toast(r?.detail || 'Greška', 'error');
});
$('#pwdForm').addEventListener('submit', async e => {
e.preventDefault();
const oldp = $('#pf_old').value, newp = $('#pf_new').value, n2 = $('#pf_new2').value;
if (newp !== n2) return toast('Lozinke se ne poklapaju', 'error');
const r = await apiJson('/auth/password/change', {method:'POST', body:{old_password: oldp, new_password: newp}});
if (r && r.status === 'ok') { toast('Lozinka promijenjena'); closeModal('pwdModal'); $('#pwdForm').reset(); }
else toast(r?.detail || 'Greška', 'error');
});
// Overview
async function loadOverview() {
const u = getUser();
$('#overviewMeta').textContent = `${u?.email || ''} · tenant ${u?.tenant_name || ''} · tier ${u?.tier ?? '?'}`;
const ul = await apiJson('/admin/users?limit=1');
const al = await apiJson('/admin/audit?limit=10');
const act = await apiJson('/admin/users?aktivan=true&limit=1');
$('#overviewKpi').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Korisnici</div><div class="kpi-value">${ul?.total ?? '—'}</div><div class="kpi-sub">u tenant scope-u</div></div>
<div class="kpi-card green"><div class="kpi-label">Aktivni</div><div class="kpi-value">${act?.total ?? '—'}</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Audit /10</div><div class="kpi-value">${al?.count ?? '—'}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Tenant</div><div class="kpi-value" style="font-size:14px">${escapeHtml(u?.tenant_type||'')}</div><div class="kpi-sub">${escapeHtml(u?.tenant_name||'')}</div></div>
`;
$('#recentAuditTable tbody').innerHTML = (al?.results || []).slice(0,10).map(a => `
<tr><td>${fmtDateTime(a.created_at)}</td>
<td>${escapeHtml(a.actor_email||'')}<br><small style="color:var(--text-3)">${escapeHtml(a.actor_name||'')}</small></td>
<td><span class="audit-action">${escapeHtml(a.action||'')}</span></td>
<td>${escapeHtml(a.resource_type||'')} ${a.resource_id??''}</td>
<td class="audit-row">${escapeHtml(a.ip_address||'—')}</td></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema događaja</td></tr>';
}
// Users
let usersDebounce = null;
async function loadUsers() {
const q = $('#usrQ').value, t = $('#usrTenant').value, r = $('#usrRole').value, ak = $('#usrStatus').value, lim = $('#usrLimit').value;
const params = new URLSearchParams();
if (q) params.set('q', q);
if (r) params.set('user_type', r);
if (ak !== '') params.set('aktivan', ak);
if (t) { const [tt, ti] = t.split(':'); if (tt && ti) { params.set('tenant_type', tt); params.set('tenant_id', ti); } }
params.set('limit', lim || 100);
const data = await apiJson('/admin/users?' + params.toString());
if (!data) return;
$('#usersCount').textContent = `${data.count}/${data.total} prikazano`;
$('#usersMeta').textContent = `${data.total} ukupno · ${data.count} prikazano`;
$('#usersTbody').innerHTML = (data.results || []).map(u => `
<tr><td>${u.id}</td>
<td><strong>${escapeHtml(u.email)}</strong>${u.must_change_pwd?'<br><span class="badge yellow">Promijeniti lozinku</span>':''}</td>
<td>${escapeHtml(u.full_name || ((u.ime||'')+' '+(u.prezime||'')).trim() || '—')}</td>
<td>${roleBadge(u.user_type)}</td>
<td>${escapeHtml(u.klub_naziv || u.savez_naziv || (u.klub_id?'klub#'+u.klub_id:u.savez_id?'savez#'+u.savez_id:'—'))}</td>
<td>${statusBadge(u.aktivan)}${u.locked_until?'<br><span class="badge red">Locked</span>':''}</td>
<td>${fmtDateTime(u.last_login)}</td>
<td class="actions-col">
<button class="btn" onclick="editUser(${u.id})">✎</button>
<button class="btn" onclick="resetPwd(${u.id})">🔑</button>
<button class="btn" onclick="toggleSuspend(${u.id}, ${u.aktivan})">${u.aktivan?'⏸':'▶'}</button>
<button class="btn danger" onclick="deleteUser(${u.id}, '${escapeHtml(u.email)}')">✕</button>
</td></tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema korisnika</td></tr>';
}
['usrQ','usrTenant','usrRole','usrStatus','usrLimit'].forEach(id => {
$('#'+id).addEventListener('input', () => { clearTimeout(usersDebounce); usersDebounce = setTimeout(loadUsers, 300); });
});
$('#btnRefreshUsers').addEventListener('click', loadUsers);
async function loadTenantSelect() {
const d = await apiJson('/admin/tenants'); if (!d) return;
const opts = ['<option value="">Svi tenanti</option>'];
for (const t of (d.tenants || [])) opts.push(`<option value="">— ${escapeHtml(t.display_name)} —</option>`);
for (const s of (d.savezi || [])) opts.push(`<option value="savez:${s.id}">savez · ${escapeHtml(s.naziv)}</option>`);
for (const k of (d.klubovi || [])) opts.push(`<option value="klub:${k.id}">klub · ${escapeHtml(k.naziv)}</option>`);
$('#usrTenant').innerHTML = opts.join('');
}
$('#btnNewUser').addEventListener('click', () => {
$('#userModalTitle').textContent = '+ Dodaj korisnika';
$('#userForm').reset();
$('#uf_id').value = '';
$('#uf_email').disabled = false;
$('#uf_pwd_field').style.display = '';
openModal('userModal');
});
async function editUser(id) {
const r = await apiJson('/admin/users/' + id); if (!r) return;
$('#userModalTitle').textContent = '✎ Uredi korisnika #' + id;
$('#uf_id').value = r.id;
$('#uf_email').value = r.email || '';
$('#uf_email').disabled = true;
$('#uf_telefon').value = r.telefon || '';
$('#uf_ime').value = r.ime || '';
$('#uf_prezime').value = r.prezime || '';
$('#uf_role').value = r.user_type || 'klub_clan';
$('#uf_oib').value = r.oib || '';
$('#uf_klub_id').value = r.klub_id || '';
$('#uf_savez_id').value = r.savez_id || '';
$('#uf_pwd_field').style.display = 'none';
openModal('userModal');
}
$('#userForm').addEventListener('submit', async e => {
e.preventDefault();
const id = $('#uf_id').value;
const body = {
email: $('#uf_email').value.trim(),
full_name: ($('#uf_ime').value + ' ' + $('#uf_prezime').value).trim() || null,
ime: $('#uf_ime').value || null, prezime: $('#uf_prezime').value || null,
user_type: $('#uf_role').value,
klub_id: $('#uf_klub_id').value ? +$('#uf_klub_id').value : null,
savez_id: $('#uf_savez_id').value ? +$('#uf_savez_id').value : null,
telefon: $('#uf_telefon').value || null,
oib: $('#uf_oib').value || null,
};
if ($('#uf_password').value) body.password = $('#uf_password').value;
let r;
if (id) { delete body.email; r = await apiJson('/admin/users/' + id, {method:'PUT', body}); }
else { r = await apiJson('/admin/users', {method:'POST', body}); }
if (r && (r.status === 'ok' || r.id)) {
if (r.temporary_password) {
alert('Korisnik kreiran. Privremena lozinka:\n\n' + r.temporary_password + '\n\nPošaljite ju korisniku sigurnim kanalom.');
}
toast(id ? 'Korisnik ažuriran' : 'Korisnik kreiran');
closeModal('userModal');
$('#uf_email').disabled = false;
loadUsers();
} else { toast(r?.detail || 'Greška', 'error'); }
});
async function resetPwd(id) {
if (!confirm('Resetirati lozinku ovog korisnika? Sve sesije će biti poništene.')) return;
const r = await apiJson('/admin/users/' + id + '/reset-password', {method:'POST'});
if (r?.status === 'ok') { alert('Privremena lozinka:\n\n' + r.temporary_password); toast('Lozinka resetirana'); }
else toast(r?.detail || 'Greška', 'error');
}
async function toggleSuspend(id, active) {
const path = active ? '/admin/users/' + id + '/suspend' : '/admin/users/' + id + '/unsuspend';
const body = active ? {reason: prompt('Razlog (opcionalno):') || null, minutes: null} : {};
const r = await apiJson(path, {method:'POST', body});
if (r?.status === 'ok') { toast(active?'Suspendiran':'Aktiviran'); loadUsers(); }
else toast(r?.detail || 'Greška', 'error');
}
async function deleteUser(id, email) {
if (!confirm(`Stvarno obrisati korisnika ${email}?\n(Soft delete — račun će biti deaktiviran.)`)) return;
const r = await apiJson('/admin/users/' + id, {method:'DELETE'});
if (r?.status === 'ok') { toast('Obrisano'); loadUsers(); }
else toast(r?.detail || 'Greška', 'error');
}
// Tenants
async function loadTenants() {
const d = await apiJson('/admin/tenants'); if (!d) return;
$('#tenantsTbody').innerHTML = (d.tenants || []).map(t => `
<tr><td>${t.id}</td><td><code>${escapeHtml(t.slug)}</code></td>
<td><strong>${escapeHtml(t.display_name)}</strong></td>
<td><span class="badge cyan">${escapeHtml(t.type||'—')}</span></td>
<td>${escapeHtml(t.oib||'—')}</td>
<td><span class="badge ${t.status==='active'?'green':'gray'}">${escapeHtml(t.status||'—')}</span></td></tr>
`).join('') || '<tr><td colspan="6" class="empty">—</td></tr>';
$('#savezi2Tbody').innerHTML = (d.savezi || []).map(s => `
<tr><td>${s.id}</td><td>${escapeHtml(s.naziv)}</td><td>${escapeHtml(s.sport||'—')}</td>
<td>${escapeHtml(s.predsjednik||'—')}</td><td>${escapeHtml(s.tajnik||'—')}</td></tr>
`).join('') || '<tr><td colspan="5" class="empty">—</td></tr>';
$('#klubCount').textContent = `${(d.klubovi||[]).length} prikazano`;
$('#klubovi2Tbody').innerHTML = (d.klubovi || []).slice(0, 200).map(k => `
<tr><td>${k.id}</td><td>${escapeHtml(k.naziv)}</td><td>${escapeHtml(k.sport||'—')}</td>
<td>${escapeHtml(k.grad||'—')}</td><td>${escapeHtml(k.oib||'—')}</td><td>${k.savez_id||'—'}</td></tr>
`).join('') || '<tr><td colspan="6" class="empty">—</td></tr>';
}
// Audit
let auditDebounce = null;
async function loadAudit() {
const q = $('#auQ').value, uid = $('#auUid').value, lim = $('#auLimit').value;
const params = new URLSearchParams();
if (q) params.set('action', q);
if (uid) params.set('user_id', uid);
params.set('limit', lim || 100);
const d = await apiJson('/admin/audit?' + params.toString()); if (!d) return;
$('#auditCount').textContent = `${d.count} događaja`;
$('#auditTbody').innerHTML = (d.results || []).map(a => `
<tr><td class="audit-row">${fmtDateTime(a.created_at)}</td>
<td>${escapeHtml(a.actor_email||'—')}</td>
<td><span class="audit-action">${escapeHtml(a.action||'')}</span></td>
<td>${escapeHtml(a.resource_type||'—')} ${a.resource_id??''}</td>
<td class="audit-row">${escapeHtml(a.ip_address||'—')}</td>
<td class="audit-row" title="${escapeHtml(a.user_agent||'')}">${escapeHtml((a.user_agent||'').substring(0,40))}</td>
<td class="audit-row" style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title='${escapeHtml(JSON.stringify(a.meta||{}))}'>${escapeHtml(JSON.stringify(a.meta||{}).substring(0,50))}</td></tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema događaja</td></tr>';
}
['auQ','auUid','auLimit'].forEach(id => {
$('#'+id).addEventListener('input', () => { clearTimeout(auditDebounce); auditDebounce = setTimeout(loadAudit, 300); });
});
$('#btnRefreshAudit').addEventListener('click', loadAudit);
// Security
async function loadSecurity() {
const all = await apiJson('/admin/users?limit=500');
const locked = (all?.results || []).filter(u => u.locked_until || (u.failed_login_count||0) >= 3);
const lockedNow = locked.filter(u => u.locked_until);
const active = (all?.results || []).filter(u => u.aktivan).length;
const inactive = (all?.total || 0) - active;
const audit = await apiJson('/admin/audit?action=login.fail&limit=20');
const failedRecent = audit?.count || 0;
$('#secKpi').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Aktivni</div><div class="kpi-value">${active}</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Neaktivni</div><div class="kpi-value">${inactive}</div></div>
<div class="kpi-card red"><div class="kpi-label">Zaključani</div><div class="kpi-value">${lockedNow.length}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Login fail recent</div><div class="kpi-value">${failedRecent}</div></div>
`;
$('#lockedTbody').innerHTML = locked.map(u => `
<tr><td>${escapeHtml(u.email)}</td><td>${roleBadge(u.user_type)}</td>
<td class="num">${u.failed_login_count||0}</td>
<td>${fmtDateTime(u.locked_until)}</td>
<td class="actions-col">
<button class="btn" onclick="resetPwd(${u.id})">🔑 Reset</button>
<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');
const my = await apiJson('/gdpr/consent');
const consentRecent = my?.history || [];
$('#gdprKpi').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Zahtjevi za brisanje</div><div class="kpi-value">${er?.count||0}</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Pending</div><div class="kpi-value">${(er?.results||[]).filter(r=>r.status==='pending').length}</div></div>
<div class="kpi-card green"><div class="kpi-label">Pristanci /50</div><div class="kpi-value">${consentRecent.length}</div></div>
`;
$('#erasureTbody').innerHTML = (er?.results || []).map(r => `
<tr><td>${r.id}</td><td>${r.user_id || '—'}</td>
<td>${escapeHtml(r.email||'—')}</td>
<td>${escapeHtml(r.reason||'—')}</td>
<td><span class="badge ${r.status==='pending'?'yellow':r.status==='completed'?'green':'gray'}">${r.status}</span></td>
<td>${fmtDateTime(r.requested_at)}</td>
<td class="actions-col">
${r.status==='pending' ? `
<button class="btn primary" onclick="processErasure(${r.id}, 'approve')">✓ Odobri</button>
<button class="btn danger" onclick="processErasure(${r.id}, 'deny')">✕ Odbij</button>` : '—'}
</td></tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema zahtjeva</td></tr>';
$('#consentTbody').innerHTML = consentRecent.map(c => `
<tr><td class="audit-row">${fmtDateTime(c.consent_at)}</td>
<td class="audit-row">${escapeHtml(c.session_id||'—')}</td>
<td>${c.necessary?'✓':'—'}</td>
<td>${c.analytics?'✓':'—'}</td>
<td>${c.marketing?'✓':'—'}</td>
<td class="audit-row">${escapeHtml(c.ip||'—')}</td>
<td><code>${escapeHtml(c.policy_version||'—')}</code></td></tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema zapisa</td></tr>';
}
async function processErasure(id, decision) {
const note = prompt('Bilješka (opcionalno):'); if (note === null) return;
const r = await apiJson(`/admin/gdpr/erasure-requests/${id}/process`, {method:'POST', body:{decision, note, anonymize: decision==='approve'}});
if (r?.status) { toast('Zahtjev: ' + r.status); loadGdpr(); } else toast(r?.detail || 'Greška', 'error');
}
// Cookie consent
async function showCookieIfNeeded() { if (!localStorage.getItem('pgz_consent')) $('#cookie').classList.add('show'); }
async function saveConsent(necessary, analytics, marketing) {
const session_id = localStorage.getItem('pgz_session_id') ||
(() => { const s = crypto.randomUUID(); localStorage.setItem('pgz_session_id', s); return s; })();
localStorage.setItem('pgz_consent', JSON.stringify({necessary, analytics, marketing, ts: Date.now()}));
$('#cookie').classList.remove('show');
await fetch(API + '/gdpr/consent', { method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({necessary, analytics, marketing, session_id}) }).catch(()=>{});
}
$('#cookieAccept').addEventListener('click', () => saveConsent(true, true, true));
$('#cookieNecessary').addEventListener('click', () => saveConsent(true, false, false));
// Init
(async () => {
const tok = getToken();
if (!tok) { location.href = '/static/login.html'; return; }
const r = await api('/auth/me');
if (!r || !r.ok) { clearAuth(); location.href = '/static/login.html'; return; }
const me = await r.json();
localStorage.setItem(USER_KEY, JSON.stringify(me));
$('#userName').textContent = me.full_name || me.email;
$('#userRole').textContent = (me.user_type || me.role || '') + ' · tier ' + (me.tier ?? '?');
$('#userAvatar').textContent = (me.full_name || me.email || '?')[0].toUpperCase();
await loadTenantSelect();
const initialTab = (location.hash || '#users').replace('#','');
activate(['overview','users','tenants','audit','security','gdpr'].includes(initialTab) ? initialTab : 'users');
showCookieIfNeeded();
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>Audit Log — PGŽ Sport</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<style>
:root { --bg0:#08090e; --bg1:#11141d; --bg2:#1a1f2c; --txt:#e6e9ef; --muted:#7a8294;
--pgz-blue:#003087; --pgz-gold:#F4C430; --green:#1a8754; --red:#dc3545; --orange:#fd7e14; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg0); color:var(--txt); padding:24px; line-height:1.5; }
h1 { color:var(--pgz-gold); margin-bottom:6px; }
.sub { color:var(--muted); margin-bottom:24px; }
.toolbar { display:flex; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
.btn { background:var(--pgz-blue); color:white; border:none; padding:9px 16px; border-radius:6px; cursor:pointer; font-weight:500; }
.btn:hover { background:#0040b8; }
.btn.secondary { background:var(--bg2); }
input,select { background:var(--bg2); color:var(--txt); border:1px solid #2a3144; padding:9px 12px; border-radius:6px; min-width:160px; }
table { width:100%; border-collapse:collapse; background:var(--bg1); border-radius:8px; overflow:hidden; margin-top:8px; }
th { background:var(--bg2); padding:12px; text-align:left; color:var(--pgz-gold); font-size:0.85rem; text-transform:uppercase; }
td { padding:11px 12px; border-top:1px solid #1a1f2c; font-size:0.92rem; }
tr:hover { background:#13182a; }
.badge { padding:3px 9px; border-radius:11px; font-size:0.75rem; font-weight:600; }
.b-create { background:rgba(26,135,84,0.2); color:#7fdca5; }
.b-update { background:rgba(253,126,20,0.2); color:#ffaa66; }
.b-delete { background:rgba(220,53,69,0.2); color:#ff7e85; }
.b-seal { background:rgba(244,196,48,0.2); color:var(--pgz-gold); }
.tx-link { color:#5fa8d3; text-decoration:none; font-family:monospace; font-size:0.85rem; }
.tx-link:hover { text-decoration:underline; }
.empty { padding:60px; text-align:center; color:var(--muted); }
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
.stat { background:var(--bg1); padding:14px; border-radius:8px; border-left:3px solid var(--pgz-blue); }
.stat .v { font-size:1.6rem; font-weight:700; color:var(--pgz-gold); }
.stat .l { color:var(--muted); font-size:0.82rem; text-transform:uppercase; margin-top:3px; }
body{padding:20px}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="audit"></script>
</head>
<body>
<h1>📜 Audit Log</h1>
<div class="sub">Kompletna povijest izmjena s blockchain pečatima na Polygon PoS</div>
<div class="stats" id="stats">
<div class="stat"><div class="v" id="s-total">—</div><div class="l">Ukupno akcija</div></div>
<div class="stat"><div class="v" id="s-today">—</div><div class="l">Danas</div></div>
<div class="stat"><div class="v" id="s-sealed">—</div><div class="l">Polygon zapečaćeno</div></div>
<div class="stat"><div class="v" id="s-users">—</div><div class="l">Aktivni korisnici</div></div>
</div>
<div class="toolbar">
<input id="f-q" placeholder="🔍 Pretraži..." />
<select id="f-action">
<option value="">Sve akcije</option>
<option value="create">CREATE</option>
<option value="update">UPDATE</option>
<option value="delete">DELETE</option>
<option value="seal">SEAL</option>
</select>
<select id="f-resource">
<option value="">Svi resursi</option>
<option value="users">Korisnici</option>
<option value="klubovi">Klubovi</option>
<option value="invoices">Računi</option>
<option value="putni_nalozi">Putni nalozi</option>
<option value="sufinanciranje">Sufinanciranje</option>
</select>
<button class="btn" onclick="load()">Filtriraj</button>
<button class="btn secondary" onclick="window.location.href='/app'">← Natrag na app</button>
</div>
<table id="tbl">
<thead>
<tr>
<th>Vrijeme</th>
<th>Korisnik</th>
<th>Akcija</th>
<th>Resurs</th>
<th>Detalji</th>
<th>Polygon Tx</th>
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="6" class="empty">⏳ Učitavam...</td></tr>
</tbody>
</table>
<script>
async function load() {
const q = document.getElementById('f-q').value;
const action = document.getElementById('f-action').value;
const resource = document.getElementById('f-resource').value;
const tbody = document.getElementById('tbody');
let url = '/sport/api/audit/log?limit=200';
if (q) url += '&q=' + encodeURIComponent(q);
if (action) url += '&action=' + action;
if (resource) url += '&resource=' + resource;
try {
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
const items = data.items || data.entries || data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty">📭 Nema zapisa</td></tr>';
return;
}
tbody.innerHTML = items.map(item => {
const action = (item.action || 'unknown').toLowerCase();
const klasa = action.includes('seal') ? 'b-seal' :
action.includes('create') ? 'b-create' :
action.includes('update') ? 'b-update' :
action.includes('delete') ? 'b-delete' : 'b-update';
const tx = item.tx_hash || item.polygon_tx || '';
const txLink = tx ? `<a href="https://polygonscan.com/tx/${tx}" target="_blank" class="tx-link">${tx.substring(0,16)}...</a>` : '<span style="color:#5a6072">—</span>';
const ts = new Date(item.created_at || item.timestamp).toLocaleString('hr-HR');
const details = item.details || item.diff || item.message || '';
const detStr = typeof details === 'object' ? JSON.stringify(details).substring(0,80)+'...' : String(details).substring(0,80);
return `<tr>
<td>${ts}</td>
<td>${item.user_email || item.user_name || item.actor || '—'}</td>
<td><span class="badge ${klasa}">${(item.action || '').toUpperCase()}</span></td>
<td>${item.resource_type || item.resource || item.target || '—'}</td>
<td>${detStr}</td>
<td>${txLink}</td>
</tr>`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="6" class="empty">⚠ Greška: ${e.message}</td></tr>`;
}
// Stats
try {
const sr = await fetch('/sport/api/audit/stats');
if (sr.ok) {
const s = await sr.json();
document.getElementById('s-total').textContent = s.total || '—';
document.getElementById('s-today').textContent = s.today || '—';
document.getElementById('s-sealed').textContent = s.sealed || '—';
document.getElementById('s-users').textContent = s.users || '—';
}
} catch(e) {}
}
load();
setInterval(load, 30000);
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>RINET KPI Dashboard</title>
<style>
body { font-family: -apple-system, sans-serif; background: #0a0e1a; color: #d0d8e8; margin: 0; padding: 20px; }
h1 { color: #4af; margin: 0 0 20px; font-size: 24px; }
h2 { color: #6cf; margin: 20px 0 8px; font-size: 16px; border-bottom: 1px solid #2a3a4a; padding-bottom: 4px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-bottom: 16px; }
.card { background: #14192a; padding: 12px 16px; border-radius: 6px; border-left: 3px solid #4af; }
.card .label { color: #88a; font-size: 11px; text-transform: uppercase; }
.card .value { color: #fff; font-size: 22px; font-weight: bold; margin: 4px 0; }
.card .sub { color: #aab; font-size: 12px; }
.card.good { border-left-color: #4f4; }
.card.warn { border-left-color: #fa4; }
.card.bad { border-left-color: #f44; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #2a3a4a; font-size: 12px; }
th { color: #6cf; font-weight: normal; text-transform: uppercase; font-size: 10px; }
tr:hover { background: #1a2030; }
.updated { color: #678; font-size: 11px; }
.refresh { background: #4af; color: #fff; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; }
body{padding:20px}
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="kpi"></script>
</head>
<body>
<h1>RINET KPI Dashboard <span class="updated" id="updated"></span> <button class="refresh" onclick="load()">↻</button></h1>
<div id="root">Loading...</div>
<script>
async function load() {
document.getElementById('updated').textContent = '...';
try {
const r = await fetch('/admin/api/kpi');
const d = await r.json();
if (d.error) {
document.getElementById('root').innerHTML = '<div class="card bad">Error: ' + d.error + '</div>';
return;
}
const haluClass = d.queries.halu_pct > 5 ? 'bad' : d.queries.halu_pct > 1 ? 'warn' : 'good';
const clusterTotal = Object.values(d.cluster).reduce((a,b)=>a+b, 0);
const clusterUnhealthy = Object.entries(d.cluster).filter(([s,n]) => !['healthy','skipped'].includes(s)).reduce((a,[s,n])=>a+n, 0);
const clusterClass = clusterUnhealthy > 0 ? 'bad' : 'good';
const incClass = d.open_incidents > 0 ? 'warn' : 'good';
const embClass = d.knowledge.embed_pct >= 99 ? 'good' : d.knowledge.embed_pct >= 95 ? 'warn' : 'bad';
let html = `
<h2>Queries (Production)</h2>
<div class="grid">
<div class="card good"><div class="label">Last 1h</div><div class="value">${d.queries.h1}</div></div>
<div class="card good"><div class="label">Last 24h</div><div class="value">${d.queries.h24}</div></div>
<div class="card ${haluClass}"><div class="label">Halucinacije 24h</div><div class="value">${d.queries.halucinacije_h24}</div><div class="sub">${d.queries.halu_pct}%</div></div>
<div class="card good"><div class="label">Avg latency</div><div class="value">${d.queries.avg_latency_sec}s</div></div>
<div class="card good"><div class="label">Avg confidence</div><div class="value">${d.queries.avg_confidence}</div></div>
</div>
<h2>Knowledge Base</h2>
<div class="grid">
<div class="card good"><div class="label">Total facts</div><div class="value">${d.knowledge.total.toLocaleString()}</div></div>
<div class="card good"><div class="label">Added 1h / 24h</div><div class="value">+${d.knowledge.added_h1} / +${d.knowledge.added_h24}</div></div>
<div class="card ${embClass}"><div class="label">Embed coverage</div><div class="value">${d.knowledge.embed_pct}%</div><div class="sub">${d.knowledge.embed_pending} pending</div></div>
<div class="card good"><div class="label">Training Q&A</div><div class="value">${d.training.total.toLocaleString()}</div><div class="sub">+${d.training.added_h24} / 24h, ${d.training.from_capture} from capture</div></div>
</div>
<h2>Cluster Health</h2>
<div class="grid">
<div class="card ${clusterClass}"><div class="label">Healthy</div><div class="value">${d.cluster.healthy || 0} / ${clusterTotal}</div></div>
<div class="card ${incClass}"><div class="label">Open incidents</div><div class="value">${d.open_incidents}</div></div>
<div class="card good"><div class="label">Skipped</div><div class="value">${d.cluster.skipped || 0}</div><div class="sub">PG/Redis/cold by design</div></div>
<div class="card ${clusterUnhealthy>0?'bad':'good'}"><div class="label">Unhealthy</div><div class="value">${clusterUnhealthy}</div></div>
</div>
<h2>Top Sources (24h scrape)</h2>
<table>
<tr><th>Source</th><th>Count</th></tr>
${d.top_sources_h24.map(s => `<tr><td>${s.source}</td><td>${s.count.toLocaleString()}</td></tr>`).join('')}
</table>
<h2>Top Models (24h)</h2>
<table>
<tr><th>Model</th><th>Calls</th><th>Avg latency</th></tr>
${d.top_models_h24.map(m => `<tr><td>${m.model || '-'}</td><td>${m.count}</td><td>${m.avg_latency}s</td></tr>`).join('')}
</table>
`;
document.getElementById('root').innerHTML = html;
document.getElementById('updated').textContent = new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('root').innerHTML = '<div class="card bad">Network error: ' + e.message + '</div>';
}
}
load();
setInterval(load, 30000); // 30s refresh
</script>
</body>
</html>
+564
View File
@@ -0,0 +1,564 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · Prijava</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>P</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #06080d;
--bg-2: #0d1117;
--bg-3: #161b22;
--border: #1f2937;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #6e7681;
--accent: #00f0ff;
--accent-2: #00b8d4;
--green: #56d364;
--red: #f85149;
--yellow: #d29922;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
body {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100vh;
}
@media (max-width: 900px) {
body { grid-template-columns: 1fr; }
.left { display: none; }
}
.left {
background:
radial-gradient(ellipse at 30% 20%, rgba(0,240,255,0.08), transparent 60%),
radial-gradient(ellipse at 70% 80%, rgba(188,140,255,0.05), transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
border-right: 1px solid var(--border);
padding: 56px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
}
.left::before {
content: '';
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(0,240,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,240,255,0.04) 1px, transparent 1px);
background-size: 40px 40px;
mask: radial-gradient(ellipse at center, black 30%, transparent 80%);
pointer-events: none;
}
.brand {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 14px;
}
.brand-mark {
width: 48px; height: 48px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
color: var(--bg);
font-weight: 700; font-size: 22px;
font-family: 'JetBrains Mono', monospace;
box-shadow: 0 0 24px rgba(0,240,255,0.3);
}
.brand-text h1 {
font-size: 20px; font-weight: 700; letter-spacing: 0.5px;
}
.brand-text .sub {
font-size: 12px; color: var(--text-3);
font-family: 'JetBrains Mono', monospace;
}
.hero { position: relative; z-index: 1; max-width: 460px; }
.hero h2 {
font-size: 36px; font-weight: 700;
line-height: 1.15;
margin-bottom: 18px;
letter-spacing: -0.5px;
}
.hero h2 span { color: var(--accent); }
.hero p {
color: var(--text-2);
font-size: 15px;
line-height: 1.6;
margin-bottom: 28px;
}
.features {
display: grid; gap: 12px;
}
.feat {
display: flex; gap: 12px;
font-size: 13px; color: var(--text-2);
}
.feat .ico {
width: 22px; height: 22px;
border-radius: 4px;
background: rgba(0,240,255,0.1);
color: var(--accent);
display: grid; place-items: center;
font-size: 12px; font-weight: 700;
flex-shrink: 0;
}
.footer-left {
position: relative; z-index: 1;
font-size: 11px; color: var(--text-3);
font-family: 'JetBrains Mono', monospace;
}
.right {
display: flex; align-items: center; justify-content: center;
padding: 40px;
}
.card {
width: 100%;
max-width: 380px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 36px 32px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.card h3 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
}
.card .lead {
color: var(--text-3);
font-size: 13px;
margin-bottom: 28px;
}
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 11px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.7px;
margin-bottom: 6px;
font-weight: 600;
}
.field input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 12px 14px;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0,240,255,0.12);
}
.row {
display: flex; justify-content: space-between; align-items: center;
margin: 14px 0 22px;
font-size: 12px;
}
.row label {
display: flex; align-items: center; gap: 6px;
color: var(--text-2);
cursor: pointer;
}
.row label input { accent-color: var(--accent); }
.row a { color: var(--accent); text-decoration: none; }
.row a:hover { text-decoration: underline; }
.btn {
width: 100%;
background: var(--accent);
color: var(--bg);
border: 0;
padding: 12px;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.3px;
transition: background 0.15s, transform 0.05s;
}
.btn:hover:not(:disabled) { background: var(--accent-2); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn .spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(0,0,0,0.25);
border-top-color: var(--bg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: -3px;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.alert {
background: rgba(248,81,73,0.1);
border: 1px solid rgba(248,81,73,0.4);
color: #ffb4af;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 16px;
display: none;
}
.alert.show { display: block; }
.alert.success {
background: rgba(86,211,100,0.1);
border-color: rgba(86,211,100,0.4);
color: #b6f0bd;
}
.divider {
display: flex; align-items: center; gap: 12px;
margin: 18px 0;
color: var(--text-3);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.demo {
background: var(--bg-3);
border: 1px dashed var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 11px;
color: var(--text-2);
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
transition: border-color 0.15s;
}
.demo:hover { border-color: var(--accent); color: var(--text); }
.demo strong { color: var(--accent); }
.footer-right {
text-align: center;
margin-top: 22px;
font-size: 11px;
color: var(--text-3);
}
.footer-right a {
color: var(--text-2);
text-decoration: none;
margin: 0 6px;
}
.footer-right a:hover { color: var(--accent); }
/* Cookie banner */
.cookie {
position: fixed;
bottom: 16px; left: 16px; right: 16px;
max-width: 600px;
margin: 0 auto;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 20px;
display: none;
z-index: 1000;
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
}
.cookie.show { display: block; }
.cookie h4 { font-size: 14px; margin-bottom: 6px; }
.cookie p { font-size: 12px; color: var(--text-2); margin-bottom: 12px; }
.cookie-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.cookie-actions button {
background: transparent;
border: 1px solid var(--border);
color: var(--text-2);
padding: 6px 14px;
border-radius: 5px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.cookie-actions button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
font-weight: 600;
}
.cookie-actions button:hover { color: var(--text); border-color: var(--accent); }
.cookie a { color: var(--accent); text-decoration: none; }
</style>
<link rel="stylesheet" href="/static/shared/sidebar.css">
<script src="/static/shared/sidebar.js" defer data-active="login"></script>
</head>
<body>
<div class="left">
<div class="brand">
<div class="brand-mark">P</div>
<div class="brand-text">
<h1>PGŽ Sport</h1>
<div class="sub">ERP/CRM Platforma</div>
</div>
</div>
<div class="hero">
<h2>Operativna platforma <span>za sport</span> u Primorsko-goranskoj županiji.</h2>
<p>Jedinstvena baza klubova, saveza i sportaša. Računovodstvo, članarine, liječnički pregledi, sufinanciranja — sve na jednom mjestu.</p>
<div class="features">
<div class="feat"><div class="ico">✓</div><div>Multi-tenant arhitektura — PGŽ, savezi, klubovi sa svojim view-om</div></div>
<div class="feat"><div class="ico">✓</div><div>OCR za račune, automatska ekstrakcija polja, putni nalozi</div></div>
<div class="feat"><div class="ico">✓</div><div>Članarine s HUB-3 uplatnicama i blockchain audit log</div></div>
<div class="feat"><div class="ico">✓</div><div>GDPR-compliant (Art. 17, 20) · 2FA · audit svih akcija</div></div>
</div>
</div>
<div class="footer-left">
PGŽ ODJEL ZA SPORT · v3.0 · 2026
</div>
</div>
<div class="right">
<div class="card">
<h3>Prijava</h3>
<div class="lead">Unesite svoje podatke za pristup platformi.</div>
<div id="alert" class="alert"></div>
<form id="loginForm" autocomplete="on">
<div class="field">
<label for="email">E-mail</label>
<input type="email" id="email" name="email" required autocomplete="username" placeholder="ime.prezime@pgz.hr">
</div>
<div class="field">
<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>
</div>
<button type="submit" class="btn" id="submitBtn">Prijavi se</button>
</form>
<div class="divider">Demo računi</div>
<div style="display:grid;gap:8px">
<div class="demo" data-email="damir@pgz.hr" data-pwd="PGZ2026!">
<strong>PGŽ admin</strong> · damir@pgz.hr / PGZ2026!
</div>
<div class="demo" data-email="pero@atletika.pgz.hr" data-pwd="PGZ2026!">
<strong>Savez admin</strong> · pero@atletika.pgz.hr
</div>
<div class="demo" data-email="ana@akkvarner.hr" data-pwd="PGZ2026!">
<strong>Klub admin</strong> · ana@akkvarner.hr
</div>
</div>
<div class="footer-right">
<a href="/sport2.html">Javni portal</a>
·
<a href="#" id="privacyLink">Politika privatnosti</a>
·
<a href="#" id="cookieLink">Kolačići</a>
</div>
</div>
</div>
<!-- GDPR cookie consent -->
<div id="cookie" class="cookie">
<h4>🍪 Kolačići</h4>
<p>Koristimo nužne kolačiće za prijavu i sigurnost sesije. Po vašem odobrenju koristimo i analitičke kolačiće za poboljšanje platforme. <a href="#" id="cookieMore">Više…</a></p>
<div class="cookie-actions">
<button class="primary" id="cookieAccept">Prihvati sve</button>
<button id="cookieNecessary">Samo nužni</button>
<button id="cookieReject">Odbij sve</button>
</div>
</div>
<script>
const API = '/api';
const $ = s => document.querySelector(s);
// ---------- Login ----------
function showAlert(msg, type) {
const a = $('#alert');
a.textContent = msg;
a.className = 'alert show' + (type === 'success' ? ' success' : '');
if (type === 'success') {
setTimeout(() => a.classList.remove('show'), 3000);
}
}
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(body)
});
const data = await r.json();
if (!r.ok) {
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;
}
// Store tokens
const store = $('#remember').checked ? localStorage : sessionStorage;
store.setItem('pgz_access', data.access_token);
store.setItem('pgz_refresh', data.refresh_token);
store.setItem('pgz_user', JSON.stringify(data.user));
showAlert('Prijava uspješna. Preusmjeravam…', 'success');
// Redirect by role
setTimeout(() => {
const role = (data.user.role || '').toLowerCase();
if (['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz',
'savez_admin','savez_user','klub_admin','klub_user','klub_trener'].includes(role)) {
// Smart redirect po roli
const role = data.user.role;
const redirectMap = {
'pgz_admin': '/app',
'savez_admin': '/app',
'klub_admin': '/app',
'super_admin': '/admin'
};
location.href = redirectMap[role] || '/app';
} else {
location.href = '/';
}
}, 600);
} catch (e) {
showAlert('Greška mreže: ' + e.message);
btn.disabled = false;
btn.textContent = 'Prijavi se';
}
}
$('#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, totp);
});
document.querySelectorAll('.demo').forEach(el => {
el.addEventListener('click', () => {
$('#email').value = el.dataset.email;
$('#password').value = el.dataset.pwd;
$('#email').focus();
});
});
$('#forgotLink').addEventListener('click', async e => {
e.preventDefault();
const email = ($('#email').value || prompt('Unesite e-mail:') || '').trim().toLowerCase();
if (!email) return;
try {
const r = await fetch(API + '/auth/password/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await r.json();
showAlert(data.message || 'Zahtjev poslan administratoru.', 'success');
} catch (err) {
showAlert('Greška: ' + err.message);
}
});
// ---------- Cookie consent ----------
const consentKey = 'pgz_consent';
function showConsent() {
if (!localStorage.getItem(consentKey)) {
$('#cookie').classList.add('show');
}
}
async function saveConsent(necessary, analytics, marketing) {
const session_id = localStorage.getItem('pgz_session_id') ||
(() => { const s = crypto.randomUUID(); localStorage.setItem('pgz_session_id', s); return s; })();
localStorage.setItem(consentKey, JSON.stringify({ necessary, analytics, marketing, ts: Date.now() }));
$('#cookie').classList.remove('show');
try {
await fetch(API + '/gdpr/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ necessary, analytics, marketing, session_id })
});
} catch {}
}
$('#cookieAccept').addEventListener('click', () => saveConsent(true, true, true));
$('#cookieNecessary').addEventListener('click', () => saveConsent(true, false, false));
$('#cookieReject').addEventListener('click', () => saveConsent(true, false, false));
$('#cookieLink').addEventListener('click', e => { e.preventDefault(); localStorage.removeItem(consentKey); showConsent(); });
$('#privacyLink').addEventListener('click', async e => {
e.preventDefault();
try {
const r = await fetch(API + '/gdpr/policy');
const d = await r.json();
alert('PGŽ Sport — Politika privatnosti v' + d.version +
'\n\nKontroler: ' + d.controller +
'\nKontakt: ' + d.contact +
'\nDPO: ' + d.dpo +
'\n\nVaša prava:\n' + d.rights.join('\n'));
} catch {}
});
$('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privacyLink').click(); });
// Skip login if already authenticated
(async () => {
const tok = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access');
if (tok) {
try {
const r = await fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + tok }});
if (r.ok) {
location.href = '/app';
return;
}
} catch {}
}
showConsent();
$('#email').focus();
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,772 @@
#!/usr/bin/env python3
# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025).
from __future__ import annotations
import json
from datetime import datetime, date, timedelta
from typing import Optional, Any
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, Body, HTTPException, Query, Header
try:
from erp.permissions import (
can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog,
can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions,
audit_putni, fetch_audit, is_pgz_admin,
)
except Exception:
def can_view_putni_nalog(u, p): return True
def can_edit_putni_nalog(u, p): return True
def can_submit_putni_nalog(u, p): return True
def can_approve_putni_nalog(u, p): return True
def can_pay_putni_nalog(u, p): return True
def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False}
def audit_putni(u, pid, op, field=None, old=None, new=None): pass
def fetch_audit(t, r, limit=50): return []
def is_pgz_admin(u): return False
try:
from auth.auth_v2 import get_current_user as _auth_user
except Exception:
_auth_user = None
try:
from erp.notifications import (
notify_pn_submitted, notify_pn_approved, notify_pn_rejected, notify_pn_paid,
)
except Exception:
def notify_pn_submitted(*a, **k): return {}
def notify_pn_approved(*a, **k): return {}
def notify_pn_rejected(*a, **k): return {}
def notify_pn_paid(*a, **k): return {}
ADMIN_TOKEN = "admin-pgz-2026"
def _resolve_user(authorization):
if _auth_user:
try:
u = _auth_user(authorization)
if u: return u
except Exception:
pass
if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN:
return {"id": 0, "email": "admin@token", "user_type": "pgz_admin",
"klub_id": None, "savez_id": None, "_synthetic": True}
return None
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
# === HR pravilnik 2025 — dnevnice ===
# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h.
# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €).
DNEVNICA_DOM_FULL = 26.54 # EUR
DNEVNICA_DOM_HALF = 13.27 # EUR
KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil)
# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo).
DNEVNICE_INO = {
"Italija": 35.00,
"Italy": 35.00,
"Slovenija": 30.00,
"Slovenia": 30.00,
"Austrija": 35.00,
"Austria": 35.00,
"Mađarska": 30.00,
"Madarska": 30.00,
"Hungary": 30.00,
"Bosna i Hercegovina": 30.00,
"BiH": 30.00,
"Bosnia": 30.00,
"Srbija": 30.00,
"Serbia": 30.00,
"Crna Gora": 30.00,
"Montenegro": 30.00,
"Njemačka": 50.00,
"Germany": 50.00,
"Francuska": 50.00,
"France": 50.00,
"Švicarska": 60.00,
"Switzerland": 60.00,
"SAD": 70.00,
"USA": 70.00,
}
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _parse_dt(v) -> Optional[datetime]:
if v is None or v == "":
return None
if isinstance(v, datetime):
return v
s = str(v).strip().replace("Z", "+00:00")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt)
except Exception:
continue
try:
return datetime.fromisoformat(s)
except Exception:
return None
def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict:
"""
Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]}
Pravila (HR pravilnik 2025, neoporeziv iznos):
- Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna.
- Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €.
- Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima.
Implementacija (jednostavna, transparentna):
1) ukupne sate računaj kao razliku.
2) full_segments = sati // 24
3) ostatak_sati = sati - full_segments*24
4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0.
5) puna dnevnica = pun_iznos po zemlji; pola = polovica.
"""
df = _parse_dt(date_from)
dt = _parse_dt(date_to)
if not df or not dt or dt < df:
return {"error": "neispravni datumi", "hours": 0,
"days_full": 0, "days_half": 0,
"dnevnica_amount_total": 0.0, "breakdown": []}
delta = dt - df
hours = round(delta.total_seconds() / 3600, 2)
full_segments = int(delta.total_seconds() // (24 * 3600))
remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0
days_full = full_segments
days_half = 0.0
if remainder_h >= 8:
days_full += 1
elif remainder_h >= 5:
days_half += 1
# else: 0
is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr")
if is_domestic:
full_amt = DNEVNICA_DOM_FULL
half_amt = DNEVNICA_DOM_HALF
else:
full_amt = DNEVNICE_INO.get(country.strip(), 50.00)
half_amt = full_amt / 2.0
total = round(days_full * full_amt + days_half * half_amt, 2)
return {
"hours": hours,
"days_full": days_full,
"days_half": days_half,
"country": country,
"rate_full": full_amt,
"rate_half": half_amt,
"dnevnica_amount_total": total,
"breakdown": [
f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €",
f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "",
],
}
def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float:
try:
return round(float(km or 0) * float(km_rate or 0), 2)
except Exception:
return 0.0
# === Endpoints ===
@router.get("/putni-nalog/dnevnice/preview")
def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska",
km: float = 0.0, km_rate: float = KM_RATE_DEFAULT):
"""Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview."""
d = compute_dnevnice(date_from, date_to, country)
km_amt = compute_kilometrina(km, km_rate)
d["km_amount"] = km_amt
d["km_driven"] = km
d["km_rate"] = km_rate
d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2)
return {"ok": True, "preview": d}
@router.get("/putni-nalog")
def list_putni_nalozi(klub_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = Query(100, le=500),
offset: int = 0):
sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv,
er.user_id, er.clan_id, er.report_type, er.report_no,
er.destination, er.purpose,
er.date_from, er.date_to,
er.vehicle_type, er.vehicle_plate,
er.km_driven, er.km_rate,
er.cost_transport, er.cost_lodging, er.cost_meals,
er.cost_other, er.cost_total,
er.dnevnice_count, er.dnevnice_amount,
er.status, er.approved_at, er.paid_at,
er.created_at, er.tenant_id, er.notes
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.report_type='putni_nalog'"""
args: list = []
if klub_id is not None:
sql += " AND er.klub_id=%s"; args.append(klub_id)
if status:
sql += " AND er.status=%s"; args.append(status)
sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s"
args += [limit, offset]
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows, "count": len(rows)}
@router.get("/putni-nalog/{nalog_id}")
def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, row):
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
# Vezani računi iz m2m tablice
cur.execute(
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category,
pnr.kategorija AS attached_kategorija, pnr.attached_at
FROM pgz_sport.putni_nalog_racuni pnr
JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id
WHERE pnr.putni_nalog_id=%s
ORDER BY i.invoice_date DESC""", (nalog_id,))
invoices = cur.fetchall()
# Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani
cur.execute(
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.putni_nalog_racuni pnr
ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s
WHERE i.klub_id=%s
AND i.invoice_date BETWEEN %s AND %s
AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo')
AND pnr.id IS NULL
ORDER BY i.invoice_date DESC LIMIT 50""",
(nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")),
)
suggested = cur.fetchall()
# Payments za ovaj putni nalog
cur.execute(
"""SELECT id, payment_date, amount, currency, payment_method, iban_from,
iban_to, reference, bank_transaction_id, matched_status, created_at
FROM pgz_sport.payments WHERE expense_report_id=%s
ORDER BY payment_date DESC""", (nalog_id,))
payments = cur.fetchall()
audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50)
actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False}
return {"ok": True, "putni_nalog": row, "invoices": invoices,
"suggested_invoices": suggested,
"payments": payments, "audit": audit, "actions": actions}
@router.post("/putni-nalog/{nalog_id}/attach-invoice")
def attach_invoice(nalog_id: int, body: dict = Body(...),
authorization: Optional[str] = Header(None)):
"""Veži postojeći račun na putni nalog (m2m)."""
user = _resolve_user(authorization)
inv_id = body.get("invoice_id")
kategorija = body.get("kategorija") or body.get("category")
if not inv_id:
raise HTTPException(400, "invoice_id je obavezan")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
raise HTTPException(403, "Nemate ovlasti za vezivanje računa")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.putni_nalog_racuni
(putni_nalog_id, invoice_id, kategorija, attached_by)
VALUES (%s,%s,%s,%s)
ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija
RETURNING id, attached_at""",
(nalog_id, inv_id, kategorija, (user.get("id") if user else None)),
)
link = cur.fetchone()
audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id)
return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]}
@router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}")
def detach_invoice(nalog_id: int, invoice_id: int,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
raise HTTPException(403, "Nemate ovlasti")
with _db() as c:
cur = c.cursor()
cur.execute(
"DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s",
(nalog_id, invoice_id),
)
audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id)
return {"ok": True}
@router.post("/putni-nalog/{nalog_id}/posalji")
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
"""Voditelj/klub_admin šalje draft → poslan."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_submit_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti slanja na odobrenje")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW()
WHERE id=%s RETURNING id, status""", (nalog_id,))
row = cur.fetchone()
audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan")
notif = notify_pn_submitted({**pn, "status": "poslan"})
return {"ok": True, "putni_nalog": row, "notification": notif}
@router.post("/putni-nalog/{nalog_id}/odbij")
def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Klub_admin/pgz_admin odbija s razlogom."""
user = _resolve_user(authorization)
razlog = (body.get("razlog") or body.get("reason") or "").strip()
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_approve_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti odbiti")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW()
WHERE id=%s RETURNING id, status, notes""",
(razlog or "(bez razloga)", nalog_id),
)
row = cur.fetchone()
audit_putni(user, nalog_id, "reject", field="status",
old=pn.get("status"), new=f"odbijen: {razlog}")
notif = notify_pn_rejected({**pn, "status": "odbijen"}, razlog=razlog)
return {"ok": True, "putni_nalog": row, "notification": notif}
@router.post("/putni-nalog/{nalog_id}/isplati")
def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Isplata putnog naloga (odobren/zatvoren → isplaćen).
Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}"""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_pay_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti za isplatu")
paid_date = body.get("paid_date") or date.today().isoformat()
iban_to = body.get("iban_to")
iban_from = body.get("iban_from")
amount = body.get("amount") or pn.get("cost_total")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
payment_method = body.get("payment_method") or "transfer"
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='isplacen', paid_at=%s, updated_at=NOW()
WHERE id=%s RETURNING id, status, paid_at, cost_total""",
(paid_date, nalog_id),
)
row = cur.fetchone()
cur.execute(
"""INSERT INTO pgz_sport.payments
(klub_id, expense_report_id, payment_date, amount, currency,
payment_method, iban_from, iban_to, reference, bank_transaction_id,
matched_status)
VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched')
RETURNING id""",
(pn.get("klub_id"), nalog_id, paid_date, amount, payment_method,
iban_from, iban_to, reference, tx_id),
)
pay = cur.fetchone()
audit_putni(user, nalog_id, "pay", field="status",
old=pn.get("status"), new="isplacen")
notif = notify_pn_paid(
{**pn, **(row or {}), "id": nalog_id},
{"iban_to": iban_to, "iban_from": iban_from, "amount": amount,
"reference": reference, "payment_date": paid_date},
)
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None,
"notification": notif}
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
def putni_hub3(nalog_id: int, iban: Optional[str] = None,
authorization: Optional[str] = Header(None)):
"""HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti")
try:
from crm.payments import build_hub3_pdf
except Exception as e:
raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}")
from fastapi.responses import Response
att = pn.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
voditelj = att.get("voditelj") or "Voditelj putovanja"
iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000"
iznos = float(pn.get("cost_total") or 0)
if iznos <= 0:
raise HTTPException(400, "Iznos isplate mora biti veći od 0")
poziv = f"{nalog_id:08d}"
opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}{pn.get('date_to')})"[:140]
pdf = build_hub3_pdf(
platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub",
platitelj_adresa=pn.get("klub_adresa") or "—",
primatelj_naziv=voditelj,
primatelj_adresa="—",
iban=iban_to,
amount_eur=iznos,
model="HR99",
poziv_na_broj=poziv,
opis=opis,
sifra_namjene="SALA",
datum=date.today(),
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'})
@router.get("/putni-nalog/{nalog_id}/audit")
def putni_audit(nalog_id: int, limit: int = 100,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti")
return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)}
@router.post("/putni-nalog")
def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Kreiraj putni nalog.
Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[],
svrha (purpose), od_grada, do_grada (destination),
datum_polaska (date_from), datum_povratka (date_to),
registracija_vozila (vehicle_plate), vehicle_type,
kilometara (km_driven), km_rate,
predviđeni_troškovi (cost_estimate), country, notes."""
df = body.get("date_from") or body.get("datum_polaska")
dt = body.get("date_to") or body.get("datum_povratka")
if not df or not dt:
raise HTTPException(400, "Datum polaska i povratka su obavezni")
klub_id = body.get("klub_id")
if not klub_id:
raise HTTPException(400, "klub_id je obavezan")
user = _resolve_user(authorization)
# Permission: pgz_admin uvijek; klub_admin/klub_user samo za vlastiti klub
if user and not is_pgz_admin(user):
if user.get("user_type") not in ("klub_admin", "klub_user") or user.get("klub_id") != klub_id:
raise HTTPException(403, "Nemate ovlasti kreirati putni nalog za ovaj klub")
country = body.get("country", "Hrvatska")
km = body.get("km_driven", body.get("kilometara", 0)) or 0
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
dnv = compute_dnevnice(df, dt, country)
dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0)
dnevnice_amount = dnv.get("dnevnica_amount_total") or 0
cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0)
od = body.get("od_grada") or body.get("from_city")
do = body.get("do_grada") or body.get("to_city") or body.get("destination")
destination = " → ".join([x for x in [od, do] if x]) or do
putnici = body.get("putnici") or []
voditelj = body.get("voditelj_ime") or body.get("voditelj")
purpose = body.get("svrha") or body.get("purpose") or ""
meta = {
"voditelj": voditelj,
"putnici": putnici,
"from_city": od, "to_city": do,
"country": country,
"dnevnice_calc": dnv,
"predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [],
}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.expense_reports
(klub_id, user_id, clan_id, report_type, report_no, destination, purpose,
date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate,
cost_transport, cost_lodging, cost_meals, cost_other,
dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id)
VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, COALESCE(%s,'draft'), %s, %s, %s)
RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount,
cost_transport, date_from, date_to, destination""",
(
klub_id, body.get("user_id"), body.get("clan_id"),
body.get("report_no"), destination, purpose,
df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"),
float(km or 0), float(km_rate or 0),
cost_transport,
body.get("cost_lodging") or 0, body.get("cost_meals") or 0,
body.get("cost_other") or 0,
dnevnice_count, dnevnice_amount,
body.get("status"),
json.dumps(meta, ensure_ascii=False, default=str),
body.get("notes"),
body.get("tenant_id", 1),
),
)
row = cur.fetchone()
# cost_total via trigger maybe; recompute here
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s
RETURNING cost_total""", (row["id"],),
)
ct = cur.fetchone()
if ct:
row["cost_total"] = ct["cost_total"]
audit_putni(user, row["id"], "create", field="status",
new=f"draft (€{row.get('cost_total')})")
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
@router.put("/putni-nalog/{nalog_id}")
def update_putni_nalog(nalog_id: int, body: dict = Body(...)):
"""Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe)."""
cols = []
args: list = []
for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type",
"vehicle_plate", "km_driven", "km_rate", "cost_transport",
"cost_lodging", "cost_meals", "cost_other", "notes",
"dnevnice_count", "dnevnice_amount"):
if col in body:
cols.append(f"{col}=%s"); args.append(body[col])
# Recompute dnevnice if dates provided
if "date_from" in body or "date_to" in body or "country" in body:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,))
cur_row = cur.fetchone()
if cur_row:
df = body.get("date_from") or cur_row["date_from"]
dt = body.get("date_to") or cur_row["date_to"]
country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska")
d = compute_dnevnice(df, dt, country)
cols += ["dnevnice_count=%s", "dnevnice_amount=%s"]
args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0),
d.get("dnevnica_amount_total") or 0]
if not cols:
raise HTTPException(400, "Nema polja za izmjenu")
cols.append("updated_at=NOW()")
args.append(nalog_id)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args)
row = cur.fetchone()
if row:
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s""", (nalog_id,),
)
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/odobriti")
def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
approved_by = body.get("approved_by") or (user.get("id") if user else None)
if approved_by == 0 or (user and user.get("_synthetic")):
approved_by = None # admin token nema realnog user_id u DB
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_approve_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti odobriti")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW()
WHERE id=%s AND report_type='putni_nalog'
RETURNING id, status, approved_at""", (approved_by, nalog_id),
)
row = cur.fetchone()
audit_putni(user, nalog_id, "approve", field="status",
old=pn.get("status"), new="odobren")
notif = notify_pn_approved({**pn, "status": "odobren"})
return {"ok": True, "putni_nalog": row, "notification": notif}
# R6.2 — PUT alias za simetriju s briefom
@router.put("/putni-nalog/{nalog_id}/odobri")
def odobri_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
return odobriti_putni_nalog(nalog_id, body, authorization)
@router.put("/putni-nalog/{nalog_id}/odbij")
def odbij_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
return odbij_putni_nalog(nalog_id, body, authorization)
@router.put("/putni-nalog/{nalog_id}/isplati")
def isplati_putni_nalog_put(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
return isplati_putni_nalog(nalog_id, body, authorization)
@router.post("/putni-nalog/{nalog_id}/zatvori")
def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})):
"""Zatvori putni nalog: priloži račune i konačan obračun."""
invoice_ids = body.get("invoice_ids") or []
cost_lodging = body.get("cost_lodging")
cost_meals = body.get("cost_meals")
cost_other = body.get("cost_other")
notes = body.get("notes")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
cur_row = cur.fetchone()
if not cur_row:
raise HTTPException(404, "Putni nalog ne postoji")
# Aggregiraj iznose iz računa (ako su poslani)
if invoice_ids:
cur.execute(
"SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)",
(invoice_ids,),
)
invs_total = float(cur.fetchone()["total"] or 0)
else:
invs_total = None
sets = ["status='zatvoren'", "updated_at=NOW()"]
args: list = []
if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging)
if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals)
if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other)
if notes: sets.append("notes=%s"); args.append(notes)
# Pohrani povezane račune u attachments
atts = cur_row["attachments"] or {}
if isinstance(atts, str):
try: atts = json.loads(atts)
except Exception: atts = {}
atts["invoice_ids"] = invoice_ids
if invs_total is not None:
atts["invoices_total"] = invs_total
sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str))
args.append(nalog_id)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s RETURNING cost_total""", (nalog_id,),
)
ct = cur.fetchone()
if ct: row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row}
File diff suppressed because it is too large Load Diff