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:
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@@ -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
@@ -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>
|
||||
@@ -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
Reference in New Issue
Block a user