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
Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 327 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 560 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

+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
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+65
View File
@@ -231,6 +231,71 @@ def dashboard():
WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""") WHERE godina = 2025 ORDER BY iznos DESC LIMIT 10""")
return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji} return {**d, "top_savezi": top, "proracun_trend": proracun_trend, "nositelji_2025": nositelji}
@app.get("/api/kpi")
def api_kpi():
"""CC6 analitika — single-payload KPI for /kpi page.
Returns top-level counts (savezi, klubovi, sportasi, members),
proracun current/trend, top10 sufinanciranje, sport distribution,
drill-down hooks, and a heartbeat so the page can refresh.
"""
counts_row = fetch("""SELECT
(SELECT COUNT(*) FROM pgz_sport.savezi) AS savezi,
(SELECT COUNT(*) FROM pgz_sport.klubovi WHERE aktivan) AS klubovi,
(SELECT COUNT(*) FROM pgz_sport.clanovi WHERE aktivan) AS sportasi,
(SELECT COUNT(*) FROM pgz_sport.clanovi WHERE aktivan AND reprezentativac) AS reprezentativci,
(SELECT COUNT(*) FROM pgz_sport.sportski_objekti WHERE aktivan) AS objekti,
(SELECT COUNT(*) FROM pgz_sport.manifestacije) AS manifestacije
""")
counts = counts_row[0] if counts_row else {}
proracun_trend = fetch("SELECT godina, ukupno FROM pgz_sport.proracun ORDER BY godina")
proracun_2026 = next((r['ukupno'] for r in proracun_trend if r.get('godina') == 2026), None)
top_sufin = fetch("""SELECT naziv_kluba, godina, iznos
FROM pgz_sport.potpore_nositelji
WHERE godina = 2025
ORDER BY iznos DESC NULLS LAST
LIMIT 10""")
by_sport = fetch("""SELECT sport, COUNT(*)::int AS broj
FROM pgz_sport.klubovi
WHERE aktivan AND sport IS NOT NULL
GROUP BY sport
ORDER BY COUNT(*) DESC
LIMIT 15""")
by_region = fetch("""SELECT COALESCE(region, 'N/A') AS region, COUNT(*)::int AS broj
FROM pgz_sport.klubovi
WHERE aktivan
GROUP BY region
ORDER BY COUNT(*) DESC""")
# Liječnički expiring (next 30d) — ops widget
lijec_expiring = fetch("""SELECT COUNT(*)::int AS n
FROM pgz_sport.lijecnicki_pregledi
WHERE vrijedi_do BETWEEN CURRENT_DATE
AND CURRENT_DATE + INTERVAL '30 days'""")
lijec_expiring_n = lijec_expiring[0]['n'] if lijec_expiring else 0
return {
"as_of": datetime.now().isoformat(timespec='seconds'),
"counts": counts,
"proracun_2026": proracun_2026,
"proracun_trend": proracun_trend,
"top_sufinanciranje_2025": top_sufin,
"klubovi_by_sport": by_sport,
"klubovi_by_region": by_region,
"lijecnicki_expiring_30d": lijec_expiring_n,
"drill_down": {
"savezi": "/api/v2/savezi",
"klubovi": "/api/klubovi",
"sportasi": "/api/clanovi-full",
"objekti": "/api/sportski-objekti",
},
}
@app.get("/api/dashboard/ekosustav") @app.get("/api/dashboard/ekosustav")
def dashboard_ekosustav(): def dashboard_ekosustav():
"""Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra.""" """Sport ekosustav PGŽ — coverage stats za enrichment iz FINA registra."""
+18 -2
View File
@@ -42,7 +42,15 @@ def proxy_image(u: str):
try: try:
r = requests.get(u, timeout=10, headers={"User-Agent": "RiNET-Civic/1.0"}) r = requests.get(u, timeout=10, headers={"User-Agent": "RiNET-Civic/1.0"})
if r.status_code != 200: if r.status_code != 200:
raise HTTPException(r.status_code, f"Origin returned {r.status_code}") # Graceful fallback: return 1x1 transparent PNG (avoids cascading <img onerror> noise)
import base64
TRANS_PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=")
return Response(content=TRANS_PNG, media_type="image/png", headers={
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=3600",
"X-Proxy-Cache": "ORIGIN_4XX",
"X-Origin-Status": str(r.status_code),
})
ct = r.headers.get('content-type', 'image/jpeg') ct = r.headers.get('content-type', 'image/jpeg')
# Save to cache # Save to cache
with open(cf, 'wb') as f: f.write(r.content) with open(cf, 'wb') as f: f.write(r.content)
@@ -53,4 +61,12 @@ def proxy_image(u: str):
"X-Proxy-Cache": "MISS" "X-Proxy-Cache": "MISS"
}) })
except requests.RequestException as e: except requests.RequestException as e:
raise HTTPException(502, f"Origin fetch failed: {e}") # Network error: return 1x1 transparent PNG instead of 502
import base64
TRANS_PNG = base64.b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=")
return Response(content=TRANS_PNG, media_type="image/png", headers={
"Access-Control-Allow-Origin": "*",
"Cache-Control": "public, max-age=300",
"X-Proxy-Cache": "ORIGIN_NET_ERROR",
"X-Origin-Error": str(e)[:100],
})
+1 -1
View File
@@ -165,7 +165,7 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
<aside class="sidebar"> <aside class="sidebar">
<div class="brand"> <div class="brand">
<h1>PGŽ SPORT</h1> <h1><a href="/" style="text-decoration:none;color:inherit" title="Početna">PGŽ SPORT</a></h1>
<div class="sub">Admin Dashboard v1.1</div> <div class="sub">Admin Dashboard v1.1</div>
</div> </div>
+1 -1
View File
@@ -149,7 +149,7 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
<div class="brand"> <div class="brand">
<div class="brand-mark">P</div> <div class="brand-mark">P</div>
<div class="brand-text"> <div class="brand-text">
<h1>PGŽ SPORT</h1> <h1><a href="/" style="text-decoration:none;color:inherit" title="Početna">PGŽ SPORT</a></h1>
<div class="sub">Admin · Auth v3.0</div> <div class="sub">Admin · Auth v3.0</div>
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More