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

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

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

Note: existing admin.html (CC4 ERP/OCR work) preserved intact;
admin_users.html is dedicated user-mgmt page linked from sidebar.
This commit is contained in:
Damir Radulić
2026-05-05 00:20:03 +02:00
parent cef4d2575b
commit 8fe2478b84
17 changed files with 10013 additions and 37 deletions
+577
View File
@@ -0,0 +1,577 @@
<!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: 240px 1fr; min-height: 100vh; }
.sidebar {
background: var(--bg-2);
border-right: 1px solid var(--border);
padding: 20px 0;
display: flex; flex-direction: column;
}
.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>
</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>
</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 + DeepSeek V3 izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke</div>
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
</div>
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Izdavatelj</label><input id="oc_vendor_name" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">OIB izdavatelja</label><input id="oc_vendor_oib" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Broj računa</label><input id="oc_invoice_no" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Datum</label><input id="oc_invoice_date" type="date" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Iznos neto</label><input id="oc_amount_net" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">PDV</label><input id="oc_amount_vat" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Brutto (UKUPNO)</label><input id="oc_amount_gross" type="number" step="0.01" class="search" style="max-width:none;width:100%;border-color:var(--accent)"></div>
<div><label style="font-size:11px;color:var(--text-3)">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">IBAN</label><input id="oc_iban" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Vrsta troška</label>
<select id="oc_kind" class="search" style="max-width:none;width:100%">
<option value="gorivo">Gorivo</option>
<option value="cestarina">Cestarina</option>
<option value="hotel">Hotel</option>
<option value="restoran">Restoran</option>
<option value="oprema">Oprema</option>
<option value="ostalo" selected>Ostalo</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Klub</label>
<select id="oc_klub" class="search" style="max-width:none;width:100%"></select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Valuta</label>
<select id="oc_currency" class="search" style="max-width:none;width:100%"><option>EUR</option><option>HRK</option></select>
</div>
</div>
<div style="margin-top:10px"><label style="font-size:11px;color:var(--text-3)">Opis</label><input id="oc_description" class="search" style="max-width:none;width:100%"></div>
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
</details>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<button id="ocSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">💾 Spremi račun</button>
<button id="ocCancel" style="padding:8px 14px;background:var(--bg-3);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
<!-- M6: Putni nalozi creation form -->
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Klub</label><select id="pn_klub" class="search" style="max-width:none;width:100%"></select></div>
<div><label style="font-size:11px;color:var(--text-3)">Voditelj</label><input id="pn_voditelj" class="search" style="max-width:none;width:100%" placeholder="Ime Prezime"></div>
<div><label style="font-size:11px;color:var(--text-3)">Putnici (zarezom razdvojeno)</label><input id="pn_putnici" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Svrha</label><input id="pn_svrha" class="search" style="max-width:none;width:100%" placeholder="Natjecanje, treninzi…"></div>
<div><label style="font-size:11px;color:var(--text-3)">Od grada</label><input id="pn_od" class="search" style="max-width:none;width:100%" value="Rijeka"></div>
<div><label style="font-size:11px;color:var(--text-3)">Do grada</label><input id="pn_do" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Polazak</label><input id="pn_from" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Povratak</label><input id="pn_to" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Zemlja</label><input id="pn_country" class="search" style="max-width:none;width:100%" value="Hrvatska"></div>
<div><label style="font-size:11px;color:var(--text-3)">Tip vozila</label>
<select id="pn_vehicle" class="search" style="max-width:none;width:100%">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Registracija</label><input id="pn_plate" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Kilometara</label><input id="pn_km" type="number" step="1" class="search" style="max-width:none;width:100%" value="0"></div>
<div><label style="font-size:11px;color:var(--text-3)">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="search" style="max-width:none;width:100%" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px">
<button id="pnSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
<div class="section">
<h3>Računi</h3>
<table id="invTable"><thead><tr><th>Broj</th><th>Dobavljač</th><th>Klub</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</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();
}
// 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>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,569 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/lijecnicki_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/lijecnicki_router.py
# Svrha: M8 — CRM Liječnički pregledi + ZZJZ PGŽ scheduling integracija
# ═══════════════════════════════════════════════════════════════════
"""M8 Liječnički router.
Endpointi (montirani na /api/crm):
GET /lijecnicki → lista (filteri)
POST /lijecnicki → novi pregled
GET /lijecnicki/{id} → detalji
PUT /lijecnicki/{id} → update
DELETE /lijecnicki/{id} → brisanje
GET /lijecnicki/uskoro-isticu → istekao + idućih 30 dana
POST /lijecnicki/{id}/zakazi → zakaži termin (ZZJZ PGŽ mock)
GET /zzjz/termini → dostupni termini ZZJZ PGŽ (mock + scrape stub)
"""
from __future__ import annotations
import sys
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional, List
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-lijecnicki"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
ZZJZ_BASE = "https://zzjzpgz.hr"
ZZJZ_INFO = {
"naziv": "Nastavni zavod za javno zdravstvo PGŽ",
"adresa": "Krešimirova 52a, 51000 Rijeka",
"telefon": "+385 51 358 770",
"email": "info@zzjzpgz.hr",
"web": ZZJZ_BASE,
# Najbliži postojeći odjel — sportski liječnički ide preko adolescentne medicine
"url_sportska_medicina": f"{ZZJZ_BASE}/zavod/odjeli/odjel-za-skolsku-i-adolescentnu-medicinu/",
}
def _conn():
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
def _conv(v):
if isinstance(v, (date, datetime)):
return v.isoformat()
if isinstance(v, Decimal):
return float(v)
return v
def _row(d):
return {k: _conv(v) for k, v in dict(d).items()}
# ───────────── modeli ─────────────
class LijecnickiIn(BaseModel):
clan_id: int
klub_id: Optional[int] = None
datum_pregleda: date
vrijedi_do: Optional[date] = None
vrsta_pregleda: Optional[str] = "temeljni"
ustanova: Optional[str] = "ZZJZ PGŽ"
lijecnik: Optional[str] = None
spreman_za_natjecanje: Optional[bool] = True
ekg: Optional[bool] = False
krv: Optional[bool] = False
spirometrija: Optional[bool] = False
nalaz: Optional[str] = None
komentar_lijecnika: Optional[str] = None
preporuke: Optional[str] = None
iznos: Optional[float] = 0
iznos_zzjz: Optional[float] = 0
iznos_klub: Optional[float] = 0
iznos_clan: Optional[float] = 0
datum_placanja: Optional[date] = None
placeno: Optional[bool] = False
racun_broj: Optional[str] = None
nacin_placanja: Optional[str] = None
napomena: Optional[str] = None
class LijecnickiPatch(BaseModel):
klub_id: Optional[int] = None
datum_pregleda: Optional[date] = None
vrijedi_do: Optional[date] = None
vrsta_pregleda: Optional[str] = None
ustanova: Optional[str] = None
lijecnik: Optional[str] = None
spreman_za_natjecanje: Optional[bool] = None
ekg: Optional[bool] = None
krv: Optional[bool] = None
spirometrija: Optional[bool] = None
nalaz: Optional[str] = None
komentar_lijecnika: Optional[str] = None
preporuke: Optional[str] = None
iznos: Optional[float] = None
iznos_zzjz: Optional[float] = None
iznos_klub: Optional[float] = None
iznos_clan: Optional[float] = None
datum_placanja: Optional[date] = None
placeno: Optional[bool] = None
racun_broj: Optional[str] = None
nacin_placanja: Optional[str] = None
napomena: Optional[str] = None
class ZakaziIn(BaseModel):
datum: date
vrijeme: Optional[str] = "09:00"
ustanova: Optional[str] = "ZZJZ PGŽ"
napomena: Optional[str] = None
# ───────────── lista ─────────────
@router.get("/lijecnicki")
def list_lijecnicki(
klub_id: Optional[int] = Query(None),
clan_id: Optional[int] = Query(None),
status: Optional[str] = Query(None,
description="vazeci|uskoro|istekao"),
placeno: Optional[bool] = Query(None),
sort: str = Query("vrijedi_do"),
order: str = Query("asc"),
limit: int = Query(500, le=2000),
):
where, params = [], []
if klub_id:
where.append("l.klub_id = %s"); params.append(klub_id)
if clan_id:
where.append("l.clan_id = %s"); params.append(clan_id)
if placeno is not None:
where.append("l.placeno = %s"); params.append(placeno)
# status_calc: vazeci = >30d, uskoro = 0..30d, istekao = <0
if status == "vazeci":
where.append("l.vrijedi_do > (CURRENT_DATE + INTERVAL '30 days')")
elif status == "uskoro":
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + INTERVAL '30 days')")
elif status == "istekao":
where.append("l.vrijedi_do < CURRENT_DATE")
sort_map = {
"vrijedi_do": "l.vrijedi_do",
"datum_pregleda": "l.datum_pregleda",
"klub": "k.naziv",
"clan": "cl.prezime",
"iznos": "l.iznos",
}
sort_col = sort_map.get(sort, "l.vrijedi_do")
order_sql = "DESC" if order.lower() == "desc" else "ASC"
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
params.append(limit)
sql = f"""
SELECT l.*,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib, cl.email AS clan_email,
k.naziv AS klub, k.oib AS klub_oib,
CASE
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
ELSE 'vazeci'
END AS status_calc,
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
{where_sql}
ORDER BY {sort_col} {order_sql} NULLS LAST
LIMIT %s
"""
sum_sql = f"""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE l.vrijedi_do < CURRENT_DATE) AS istekli,
COUNT(*) FILTER (WHERE l.vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro,
COUNT(*) FILTER (WHERE l.vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci,
COUNT(*) FILTER (WHERE l.placeno IS TRUE) AS placeni,
COALESCE(SUM(l.iznos), 0)::numeric(10,2) AS total_iznos
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
{where_sql}
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(sum_sql, params[:-1])
summary = _row(cur.fetchone() or {})
return {"count": len(rows), "rows": rows, "summary": summary}
# ───────────── uskoro isticu (30 dana + istekli) ─────────────
@router.get("/lijecnicki/uskoro-isticu")
def list_uskoro_isticu(
klub_id: Optional[int] = Query(None),
days: int = Query(30, ge=1, le=180),
include_expired: bool = Query(True),
):
where = ["l.vrijedi_do IS NOT NULL"]
params: list = []
if include_expired:
where.append("l.vrijedi_do <= (CURRENT_DATE + (%s || ' days')::interval)")
else:
where.append("l.vrijedi_do BETWEEN CURRENT_DATE AND (CURRENT_DATE + (%s || ' days')::interval)")
params.append(str(days))
if klub_id:
where.append("l.klub_id = %s"); params.append(klub_id)
sql = f"""
SELECT l.id, l.clan_id, l.klub_id, l.datum_pregleda, l.vrijedi_do,
l.vrsta_pregleda, l.ustanova, l.lijecnik, l.placeno,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, cl.telefon AS clan_telefon,
k.naziv AS klub, k.oib AS klub_oib,
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka,
CASE
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
ELSE 'uskoro'
END AS status_calc
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE {' AND '.join(where)}
ORDER BY l.vrijedi_do ASC
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
n_istekli = sum(1 for r in rows if (r.get("dana_do_isteka") or 0) < 0)
n_uskoro = len(rows) - n_istekli
return {"count": len(rows), "istekli": n_istekli, "uskoro": n_uskoro,
"days_window": days, "rows": rows}
# ───────────── detalji ─────────────
@router.get("/lijecnicki/{lid}")
def get_lijecnicki(lid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT l.*,
cl.ime || ' ' || cl.prezime AS clan,
cl.oib AS clan_oib, cl.email AS clan_email,
cl.telefon AS clan_telefon,
k.naziv AS klub, k.oib AS klub_oib,
CASE
WHEN l.vrijedi_do IS NULL THEN 'nepoznato'
WHEN l.vrijedi_do < CURRENT_DATE THEN 'istekao'
WHEN l.vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
ELSE 'vazeci'
END AS status_calc,
(l.vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.id = %s
""", (lid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
return _row(r)
# ───────────── kreiraj ─────────────
@router.post("/lijecnicki")
def create_lijecnicki(body: LijecnickiIn):
klub_id = body.klub_id
with _conn() as conn, conn.cursor() as cur:
if not klub_id:
cur.execute("SELECT klub_id FROM pgz_sport.clanovi WHERE id=%s", (body.clan_id,))
r = cur.fetchone()
klub_id = r["klub_id"] if r else None
# default vrijedi_do = +1 godina ako nije postavljeno
vrijedi_do = body.vrijedi_do
if vrijedi_do is None:
vrijedi_do = body.datum_pregleda + timedelta(days=365)
cur.execute("""
INSERT INTO pgz_sport.lijecnicki_pregledi
(clan_id, klub_id, datum_pregleda, vrijedi_do, vrsta_pregleda,
ustanova, lijecnik, spreman_za_natjecanje, ekg, krv, spirometrija,
nalaz, komentar_lijecnika, preporuke, iznos, iznos_zzjz,
iznos_klub, iznos_clan, datum_placanja, placeno, racun_broj,
nacin_placanja, napomena)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
RETURNING *
""", (body.clan_id, klub_id, body.datum_pregleda, vrijedi_do,
body.vrsta_pregleda, body.ustanova, body.lijecnik,
body.spreman_za_natjecanje, body.ekg, body.krv, body.spirometrija,
body.nalaz, body.komentar_lijecnika, body.preporuke,
body.iznos, body.iznos_zzjz, body.iznos_klub, body.iznos_clan,
body.datum_placanja, body.placeno, body.racun_broj,
body.nacin_placanja, body.napomena))
r = cur.fetchone()
conn.commit()
return _row(r)
# ───────────── update / delete ─────────────
@router.put("/lijecnicki/{lid}")
def update_lijecnicki(lid: int, patch: LijecnickiPatch):
fields, params = [], []
for f in ("klub_id", "datum_pregleda", "vrijedi_do", "vrsta_pregleda",
"ustanova", "lijecnik", "spreman_za_natjecanje",
"ekg", "krv", "spirometrija", "nalaz", "komentar_lijecnika",
"preporuke", "iznos", "iznos_zzjz", "iznos_klub", "iznos_clan",
"datum_placanja", "placeno", "racun_broj", "nacin_placanja",
"napomena"):
v = getattr(patch, f)
if v is not None:
fields.append(f"{f} = %s"); params.append(v)
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at = now()")
params.append(lid)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE pgz_sport.lijecnicki_pregledi SET {', '.join(fields)} WHERE id=%s RETURNING *",
params)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
conn.commit()
return _row(r)
@router.delete("/lijecnicki/{lid}")
def delete_lijecnicki(lid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("DELETE FROM pgz_sport.lijecnicki_pregledi WHERE id=%s RETURNING id", (lid,))
r = cur.fetchone()
conn.commit()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
return {"ok": True, "id": lid, "deleted": True}
# ───────────── ZZJZ PGŽ scheduling ─────────────
def _mock_zzjz_termini(week_start: date) -> list[dict]:
"""
Mock dostupnih termina za sportsku medicinu.
TODO: zamijeniti realnim scrapeom iz https://zzjzpgz.hr/djelatnosti/sportska-medicina/
Format termina: po danu (pon-pet), 09:00-15:00 svakih 30 min.
"""
out = []
times = ["08:00", "08:30", "09:00", "09:30", "10:00", "10:30",
"11:00", "11:30", "12:30", "13:00", "13:30", "14:00", "14:30"]
for d in range(5):
day = week_start + timedelta(days=d)
if day.weekday() >= 5:
continue
for t in times:
# pseudo-availability deterministic by day*hour
h = int(t.split(":")[0])
available = ((day.day + h) % 3) != 0
out.append({
"datum": day.isoformat(),
"vrijeme": t,
"doktor": "Dr. Sportska medicina",
"ustanova": "ZZJZ PGŽ",
"available": available,
"iznos_eur": 25.00,
})
return out
@router.get("/zzjz/info")
def zzjz_info():
"""Vraća kontakt + provjerava ima li online termin sustav (best-effort scrape)."""
online_booking = _detect_zzjz_booking()
return {**ZZJZ_INFO, "online_booking": online_booking}
def _detect_zzjz_booking() -> dict:
"""
Best-effort detekcija da li ZZJZ PGŽ ima online termin formu na stranici.
Vraća: {available: bool, url: str|None, kind: 'iframe'|'link'|'email'}
Ne baca iznimku — uvijek vrati strukturu (fallback je email).
"""
try:
import urllib.request
import re as _re
req = urllib.request.Request(ZZJZ_INFO["url_sportska_medicina"],
headers={"User-Agent": "PGZSport/1.0"})
with urllib.request.urlopen(req, timeout=4) as resp:
html = resp.read(200_000).decode("utf-8", errors="ignore")
# tražimo standardne oznake online booking sustava
patterns = [
r'(https?://[^"\']*(?:doktor|booking|narucivanje|naruci|termin)[^"\']*)',
r'<iframe[^>]+src="([^"]+)"',
]
for p in patterns:
m = _re.search(p, html, _re.IGNORECASE)
if m:
url = m.group(1)
if "iframe" in p:
return {"available": True, "url": url, "kind": "iframe"}
return {"available": True, "url": url, "kind": "link"}
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
"kind": "email",
"fallback_email": ZZJZ_INFO["email"],
"note": "Nije pronađen online sustav — koristi e-mail kontakt."}
except Exception as e:
return {"available": False, "url": ZZJZ_INFO["url_sportska_medicina"],
"kind": "email",
"fallback_email": ZZJZ_INFO["email"],
"error": str(e)[:120],
"note": "Detekcija nije uspjela — fallback na e-mail."}
@router.get("/zzjz/termini")
def zzjz_termini(
od: Optional[date] = Query(None,
description="Početak tjedna; default = ovaj tjedan"),
):
"""
Vraća dostupne termine za sportsku medicinu pri ZZJZ PGŽ.
Trenutno: mock (deterministička dostupnost). Stvarna integracija
čeka API ili scraping form-e na zzjzpgz.hr.
"""
if od is None:
today = date.today()
od = today - timedelta(days=today.weekday())
termini = _mock_zzjz_termini(od)
return {
"ustanova": ZZJZ_INFO,
"week_start": od.isoformat(),
"count": len(termini),
"available": sum(1 for t in termini if t["available"]),
"termini": termini,
"note": "Mock podaci. Realni termini čekaju ZZJZ PGŽ API ili authorizirani scraper.",
}
@router.post("/lijecnicki/{lid}/zakazi")
def zakazi_termin(lid: int, body: ZakaziIn):
"""
Zakazuje termin za pregled.
- Ako ZZJZ PGŽ ima online booking → vraća iframe/deeplink URL.
- Ako nema → vraća mailto: deeplink za zahtjev e-mailom.
Status pregleda u DB se ažurira (ustanova + napomena).
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT l.id, l.clan_id, l.ustanova,
cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email,
k.naziv AS klub
FROM pgz_sport.lijecnicki_pregledi l
LEFT JOIN pgz_sport.clanovi cl ON cl.id = l.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = l.klub_id
WHERE l.id=%s
""", (lid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Liječnički pregled ne postoji")
new_napomena = (
f"Termin zakazan: {body.datum.isoformat()} {body.vrijeme} @ "
f"{body.ustanova}. {body.napomena or ''}"
).strip()
cur.execute("""
UPDATE pgz_sport.lijecnicki_pregledi
SET ustanova = COALESCE(%s, ustanova),
napomena = %s,
updated_at = now()
WHERE id = %s
RETURNING *
""", (body.ustanova, new_napomena, lid))
upd = cur.fetchone()
conn.commit()
booking = _detect_zzjz_booking()
from urllib.parse import quote as _q
subj = _q(f"Zahtjev za termin sportske medicine — {r.get('clan') or '(sportaš)'}")
body_email = _q(
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
f"Sportaš: {r.get('clan') or ''}\n"
f"Klub: {r.get('klub') or ''}\n"
f"Željeni datum: {body.datum.isoformat()} oko {body.vrijeme}\n"
f"Kontakt: {r.get('clan_email') or '(nepoznato)'}\n\n"
f"Lijep pozdrav,\nPGŽ Sport platforma"
)
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
return {
"ok": True,
"id": lid,
"zakazano_za": f"{body.datum.isoformat()} {body.vrijeme}",
"ustanova": body.ustanova,
"zzjz": ZZJZ_INFO,
"booking": booking,
"mailto": mailto,
"note": (
"Online booking detektiran — koristi 'booking.url' za iframe/redirect."
if booking.get("available") else
"Online booking nije pronađen — fallback: koristi 'mailto' za zahtjev e-mailom."
),
"pregled": _row(upd),
}
class ZakaziEmailIn(BaseModel):
klub_id: Optional[int] = None
clan_id: int
zeljeni_datum: Optional[date] = None
zeljeno_vrijeme: Optional[str] = "09:00"
napomena: Optional[str] = None
@router.post("/lijecnicki/zakazi-email")
def zakazi_email(body: ZakaziEmailIn):
"""
Bez postojećeg pregleda — generira mailto: link s pred-popunjenim
podacima sportaša/kluba za slanje zahtjeva ZZJZ PGŽ.
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT cl.id, cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, cl.telefon AS clan_telefon,
cl.datum_rodenja, cl.oib AS clan_oib,
k.naziv AS klub, k.oib AS klub_oib
FROM pgz_sport.clanovi cl
LEFT JOIN pgz_sport.klubovi k ON k.id = cl.klub_id
WHERE cl.id=%s
""", (body.clan_id,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Član ne postoji")
from urllib.parse import quote as _q
when = (body.zeljeni_datum.isoformat() if body.zeljeni_datum else "po dogovoru")
subj = _q(f"Zahtjev za termin sportske medicine — {r['clan']}")
body_email = _q(
f"Poštovani,\n\nMolim Vas termin za sportski liječnički pregled.\n\n"
f"Sportaš: {r['clan']}\n"
f"OIB: {r['clan_oib'] or '—'}\n"
f"Datum rođenja: {r['datum_rodenja'] or '—'}\n"
f"Klub: {r['klub'] or '—'}\n"
f"Željeni termin: {when} oko {body.zeljeno_vrijeme}\n"
f"Kontakt: {r['clan_email'] or '—'} / {r['clan_telefon'] or '—'}\n\n"
f"Napomena: {body.napomena or '—'}\n\n"
f"Lijep pozdrav,\nPGŽ Sport platforma"
)
mailto = f"mailto:{ZZJZ_INFO['email']}?subject={subj}&body={body_email}"
booking = _detect_zzjz_booking()
return {
"ok": True,
"clan": r["clan"],
"zzjz": ZZJZ_INFO,
"booking": booking,
"mailto": mailto,
}
@@ -0,0 +1,757 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/obrasci_router.py | v1.0.0 | 04.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/obrasci_router.py
# Svrha: M9 — Obrasci za sufinanciranje (form_templates + form_submissions)
# + autopopulacija polja iz baze + digitalni potpis (sha256)
# ═══════════════════════════════════════════════════════════════════
"""M9 Obrasci router.
Endpointi (montirani na /api/crm):
GET /forms → katalog form_templates
GET /forms/{code_or_id} → schema + ui hints
GET /forms/{code_or_id}/prefill → autopopulirane vrijednosti za klub/člana
GET /forms/submissions → lista submissiona (filter: status, klub, code)
POST /forms/submissions → kreira draft submission
GET /forms/submissions/{id} → detalji
POST /forms/submissions/{id}/submit → potpis + status submitted
POST /forms/submissions/{id}/approve
POST /forms/submissions/{id}/reject
POST /forms/{code_or_id}/submit → kompatibilni shortcut: kreiraj+submit u jednom POST
"""
from __future__ import annotations
import json
import hashlib
import sys
from datetime import date, datetime
from decimal import Decimal
from typing import Optional, Any
import uuid as _uuid
import psycopg2
from psycopg2.extras import RealDictCursor, Json
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-obrasci"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
def _conn():
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
def _conv(v):
if isinstance(v, (date, datetime)):
return v.isoformat()
if isinstance(v, Decimal):
return float(v)
if isinstance(v, _uuid.UUID):
return str(v)
return v
def _row(d):
return {k: _conv(v) for k, v in dict(d).items()}
def _resolve_template(code_or_id: str, cur) -> dict:
"""Akceptira numerički ID ili code string."""
if str(code_or_id).isdigit():
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s AND active=TRUE",
(int(code_or_id),))
else:
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s AND active=TRUE",
(code_or_id,))
r = cur.fetchone()
if not r:
raise HTTPException(404, f"Form template '{code_or_id}' ne postoji")
return r
# ───────────── modeli ─────────────
class SubmissionIn(BaseModel):
template_code: Optional[str] = None
template_id: Optional[int] = None
klub_id: Optional[int] = None
user_id: Optional[int] = None
clan_id: Optional[int] = None
data: dict = {}
attachments: Optional[list] = None
status: Optional[str] = "draft"
class SubmitIn(BaseModel):
user_id: Optional[int] = None
full_name: Optional[str] = None
data: Optional[dict] = None
confirm: bool = True
class ApproveIn(BaseModel):
user_id: Optional[int] = None
note: Optional[str] = None
class RejectIn(BaseModel):
user_id: Optional[int] = None
reason: str
# ───────────── katalog templata ─────────────
@router.get("/forms/templates")
def list_form_templates_alias(
kategorija: Optional[str] = Query(None),
q: Optional[str] = Query(None),
active_only: bool = Query(True),
):
"""Alias za /forms — kompatibilnost s /sport/api/forms/templates."""
return list_forms(kategorija=kategorija, q=q, active_only=active_only)
@router.get("/forms")
def list_forms(
kategorija: Optional[str] = Query(None),
q: Optional[str] = Query(None),
active_only: bool = Query(True),
):
where, params = [], []
if active_only:
where.append("active = TRUE")
if kategorija:
where.append("kategorija = %s"); params.append(kategorija)
if q:
where.append("(naziv ILIKE %s OR opis ILIKE %s OR code ILIKE %s)")
params += [f"%{q}%"] * 3
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT id, code, naziv, kategorija, opis, required_role,
jsonb_array_length(COALESCE(schema_json->'fields', '[]'::jsonb)) AS field_count,
active, created_at
FROM pgz_sport.form_templates
{where_sql}
ORDER BY kategorija NULLS LAST, naziv
""", params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute("SELECT DISTINCT kategorija FROM pgz_sport.form_templates WHERE kategorija IS NOT NULL ORDER BY 1")
kats = [r["kategorija"] for r in cur.fetchall()]
return {"count": len(rows), "kategorije": kats, "forms": rows}
# NOTE: /forms/submissions* moraju biti registrirani PRIJE /forms/{code_or_id}
# jer FastAPI prvo provjerava redom registracije, a "submissions" bi
# inače bilo uhvaćeno kao code_or_id.
# ───────────── autopopulacija polja iz baze (mora prije /{code_or_id} catch-all) ─────────────
@router.get("/forms/{code_or_id}/prefill")
def prefill_form(code_or_id: str,
klub_id: Optional[int] = Query(None),
clan_id: Optional[int] = Query(None),
user_id: Optional[int] = Query(None)):
"""
Vraća inicijalne vrijednosti za polja obrasca, popunjene iz baze.
Mapiranje polja → izvor:
klub_naziv, klub_oib, klub_iban, klub_adresa, klub_grad, klub_email, klub_telefon,
predsjednik, tajnik, sport, savez_naziv → pgz_sport.klubovi
ime, prezime, oib_clan, datum_rodenja, kategorija → pgz_sport.clanovi
iban, naziv (kad se odnose na klub) → klub
*_godina → tekuća godina
Polja koja schema_json nema, neće biti vraćena.
"""
with _conn() as conn, conn.cursor() as cur:
t = _resolve_template(code_or_id, cur)
schema = t.get("schema_json") or {}
fields = schema.get("fields") or []
field_names = {f.get("name") for f in fields if isinstance(f, dict)}
klub = {}
savez = {}
if klub_id:
cur.execute("""
SELECT k.*, s.naziv AS savez_naziv
FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
WHERE k.id = %s
""", (klub_id,))
r = cur.fetchone()
if r:
klub = _row(r)
clan = {}
if clan_id:
cur.execute("SELECT * FROM pgz_sport.clanovi WHERE id=%s", (clan_id,))
r = cur.fetchone()
if r:
clan = _row(r)
# ako klub_id nije eksplicitno, izvuci iz člana
if not klub and clan.get("klub_id"):
cur.execute("""
SELECT k.*, s.naziv AS savez_naziv
FROM pgz_sport.klubovi k
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
WHERE k.id = %s
""", (clan["klub_id"],))
rr = cur.fetchone()
if rr:
klub = _row(rr)
user = {}
if user_id:
cur.execute("SELECT id, email, full_name, ime, prezime, oib, telefon, klub_id, savez_id, user_type FROM pgz_sport.users WHERE id=%s",
(user_id,))
r = cur.fetchone()
if r:
user = _row(r)
# Mapiranje
prefill: dict = {}
today = date.today()
def put(name: str, value: Any):
if name in field_names and value not in (None, ""):
prefill[name] = value
# KLUB → polja
if klub:
put("klub_naziv", klub.get("naziv"))
put("naziv_kluba", klub.get("naziv"))
put("naziv", klub.get("naziv"))
put("klub_oib", klub.get("oib"))
put("oib", klub.get("oib"))
put("oib_kluba", klub.get("oib"))
put("klub_iban", klub.get("iban"))
put("iban", klub.get("iban"))
put("adresa", klub.get("adresa"))
put("klub_adresa", klub.get("adresa"))
put("grad", klub.get("grad"))
put("klub_grad", klub.get("grad"))
put("klub_email", klub.get("email"))
put("email", klub.get("email"))
put("klub_telefon", klub.get("telefon"))
put("telefon", klub.get("telefon"))
put("predsjednik", klub.get("predsjednik"))
put("tajnik", klub.get("tajnik"))
put("sport", klub.get("sport"))
put("savez_naziv", klub.get("savez_naziv"))
put("godina_osnutka", klub.get("godina_osnutka"))
put("matični_broj", klub.get("matični_broj"))
put("reg_broj", klub.get("reg_broj"))
# ČLAN → polja
if clan:
put("ime", clan.get("ime"))
put("prezime", clan.get("prezime"))
put("ime_prezime", f"{clan.get('ime','')} {clan.get('prezime','')}".strip())
put("oib_clan", clan.get("oib"))
put("oib_sportasa", clan.get("oib"))
put("datum_rodenja", clan.get("datum_rodenja"))
put("kategorija", clan.get("kategorija"))
put("podkategorija", clan.get("podkategorija"))
put("pozicija", clan.get("pozicija"))
put("clan_email", clan.get("email"))
put("clan_telefon", clan.get("telefon"))
put("clan_adresa", clan.get("adresa"))
put("spol", clan.get("spol"))
put("licenca_broj", clan.get("licenca_broj"))
# USER → polja
if user:
put("podnositelj_ime", (user.get("full_name") or
f"{user.get('ime','')} {user.get('prezime','')}".strip()))
put("podnositelj_email", user.get("email"))
put("podnositelj_telefon", user.get("telefon"))
# TEKUĆA GODINA / DATUM
put("program_godina", today.year)
put("godina", today.year)
put("datum", today.isoformat())
put("datum_predaje", today.isoformat())
return {
"template_code": t["code"],
"template_id": t["id"],
"naziv": t["naziv"],
"prefill": prefill,
"missing_fields": sorted(field_names - set(prefill.keys())),
"applied_fields": sorted(prefill.keys()),
"sources": {"klub": bool(klub), "clan": bool(clan), "user": bool(user)},
}
# ───────────── submissions ─────────────
@router.get("/forms/submissions")
def list_submissions(
klub_id: Optional[int] = Query(None),
template_code: Optional[str] = Query(None),
status: Optional[str] = Query(None),
user_id: Optional[int] = Query(None),
limit: int = Query(200, le=1000),
):
where, params = [], []
if klub_id:
where.append("s.klub_id=%s"); params.append(klub_id)
if template_code:
where.append("s.template_code=%s"); params.append(template_code)
if status:
where.append("s.status=%s"); params.append(status)
if user_id:
where.append("s.user_id=%s"); params.append(user_id)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
params.append(limit)
sql = f"""
SELECT s.id, s.template_id, s.template_code, s.klub_id, s.user_id,
s.clan_id, s.status, s.reference_no, s.submitted_at,
s.reviewed_at, s.approved_at, s.rejected_reason, s.created_at,
t.naziv AS template_naziv, t.kategorija,
k.naziv AS klub_naziv,
cl.ime || ' ' || cl.prezime AS clan_naziv,
COALESCE(s.data->>'__signature_sha256', NULL) AS signature_sha256
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
{where_sql}
ORDER BY s.created_at DESC
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(f"""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE s.status='draft') AS draft,
COUNT(*) FILTER (WHERE s.status='submitted') AS submitted,
COUNT(*) FILTER (WHERE s.status='approved') AS approved,
COUNT(*) FILTER (WHERE s.status='rejected') AS rejected
FROM pgz_sport.form_submissions s
{where_sql}
""", params[:-1])
summary = _row(cur.fetchone() or {})
return {"count": len(rows), "rows": rows, "summary": summary}
@router.get("/forms/submissions/{sid}")
def get_submission(sid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
cl.ime || ' ' || cl.prezime AS clan_naziv
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
WHERE s.id = %s
""", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
return _row(r)
@router.post("/forms/submissions")
def create_submission(body: SubmissionIn):
if not (body.template_code or body.template_id):
raise HTTPException(400, "template_code ili template_id obavezan")
with _conn() as conn, conn.cursor() as cur:
if body.template_id:
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE id=%s", (body.template_id,))
else:
cur.execute("SELECT * FROM pgz_sport.form_templates WHERE code=%s", (body.template_code,))
t = cur.fetchone()
if not t:
raise HTTPException(404, "Template ne postoji")
# generiraj reference_no: TPL-YYYY-XXXXXXXX
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
cur.execute("""
INSERT INTO pgz_sport.form_submissions
(template_id, template_code, klub_id, user_id, clan_id, data,
attachments, status, reference_no)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s)
RETURNING *
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
json.dumps(body.data or {}), json.dumps(body.attachments or []),
body.status or "draft", ref))
s = cur.fetchone()
conn.commit()
return _row(s)
# ───────────── digitalni potpis (sha256) i submit ─────────────
def _sign_payload(data: dict, signer: Optional[str]) -> dict:
"""
Deterministički sha256 nad sortiranim JSON-om + timestamp.
Vraća meta polja koja se ubacuju u data:
__signature_sha256, __signed_at, __signed_by
"""
canon = json.dumps(data, sort_keys=True, ensure_ascii=False, default=str)
sha = hashlib.sha256(canon.encode("utf-8")).hexdigest()
return {
"__signature_sha256": sha,
"__signed_at": datetime.utcnow().isoformat() + "Z",
"__signed_by": signer or "unknown",
}
@router.post("/forms/submissions/{sid}/submit")
def submit_submission(sid: int, body: SubmitIn):
if not body.confirm:
raise HTTPException(400, "Potrebna potvrda (confirm=true)")
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
if r["status"] not in ("draft", "rejected"):
raise HTTPException(400, f"Submission je u statusu '{r['status']}', ne može se submitati")
merged = dict(r["data"] or {})
if body.data:
merged.update(body.data)
# ukloni stari potpis prije računanja novog
for k in list(merged.keys()):
if k.startswith("__signature") or k.startswith("__signed"):
merged.pop(k, None)
signer = body.full_name or (str(body.user_id) if body.user_id else None)
sig = _sign_payload(merged, signer)
merged.update(sig)
cur.execute("""
UPDATE pgz_sport.form_submissions
SET data = %s::jsonb,
status = 'submitted',
user_id = COALESCE(%s, user_id),
submitted_at = now(),
updated_at = now()
WHERE id = %s
RETURNING *
""", (json.dumps(merged), body.user_id, sid))
s = cur.fetchone()
conn.commit()
return {
"ok": True,
"id": sid,
"status": "submitted",
"signature_sha256": sig["__signature_sha256"],
"signed_at": sig["__signed_at"],
"signed_by": sig["__signed_by"],
"submission": _row(s),
}
@router.post("/forms/submissions/{sid}/approve")
def approve_submission(sid: int, body: ApproveIn):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='approved',
approved_by=%s, approved_at=now(),
reviewed_by=%s, reviewed_at=now(),
updated_at=now()
WHERE id=%s AND status IN ('submitted','draft')
RETURNING *
""", (body.user_id, body.user_id, sid))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
conn.commit()
return {"ok": True, "id": sid, "status": "approved", "submission": _row(r)}
@router.post("/forms/submissions/{sid}/reject")
def reject_submission(sid: int, body: RejectIn):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
UPDATE pgz_sport.form_submissions
SET status='rejected',
reviewed_by=%s, reviewed_at=now(),
rejected_reason=%s,
updated_at=now()
WHERE id=%s AND status IN ('submitted','draft')
RETURNING *
""", (body.user_id, body.reason, sid))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji ili nije u submitted statusu")
conn.commit()
return {"ok": True, "id": sid, "status": "rejected",
"reason": body.reason, "submission": _row(r)}
# ───────────── potpisivanje + PDF izvoz submissiona ─────────────
class SignIn(BaseModel):
user_id: Optional[int] = None
full_name: Optional[str] = None
@router.post("/forms/submissions/{sid}/sign")
def sign_submission(sid: int, body: SignIn):
"""
Digitalni potpis postojećeg submissiona — sha256 nad sortiranim JSON-om.
Može se pozvati i na već submitanom (re-sign) i na draftu (samo potpisuje,
ne mijenja status).
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT * FROM pgz_sport.form_submissions WHERE id=%s", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
merged = dict(r["data"] or {})
# ukloni stari potpis
for k in list(merged.keys()):
if k.startswith("__signature") or k.startswith("__signed"):
merged.pop(k, None)
signer = body.full_name or (str(body.user_id) if body.user_id else "anonymous")
sig = _sign_payload(merged, signer)
merged.update(sig)
cur.execute("""
UPDATE pgz_sport.form_submissions
SET data = %s::jsonb,
user_id = COALESCE(%s, user_id),
updated_at = now()
WHERE id = %s
RETURNING *
""", (json.dumps(merged), body.user_id, sid))
s = cur.fetchone()
conn.commit()
return {
"ok": True,
"id": sid,
"signature_sha256": sig["__signature_sha256"],
"signed_at": sig["__signed_at"],
"signed_by": sig["__signed_by"],
"submission": _row(s),
}
@router.get("/forms/submissions/{sid}/pdf")
def submission_pdf(sid: int):
"""Generira PDF s sadržajem submissiona, statusom i potpisom (sha256)."""
from fastapi.responses import Response
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
import io as _io
# font za HR diakritike
font_reg, font_bold = "Helvetica", "Helvetica-Bold"
try:
if "DejaVu" not in pdfmetrics.getRegisteredFontNames():
for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/dejavu/DejaVuSans.ttf"):
try:
pdfmetrics.registerFont(TTFont("DejaVu", path))
pdfmetrics.registerFont(TTFont("DejaVu-Bold",
path.replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf")))
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
break
except Exception:
continue
else:
font_reg, font_bold = "DejaVu", "DejaVu-Bold"
except Exception:
pass
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT s.*, t.naziv AS template_naziv, t.kategorija, t.schema_json,
k.naziv AS klub_naziv, k.oib AS klub_oib, k.iban AS klub_iban,
cl.ime || ' ' || cl.prezime AS clan_naziv
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
LEFT JOIN pgz_sport.klubovi k ON k.id = s.klub_id
LEFT JOIN pgz_sport.clanovi cl ON cl.id = s.clan_id
WHERE s.id = %s
""", (sid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Submission ne postoji")
s = _row(r)
schema = s.get("schema_json") or {}
fields = schema.get("fields") or []
data = s.get("data") or {}
sig_sha = data.get("__signature_sha256")
sig_at = data.get("__signed_at")
sig_by = data.get("__signed_by")
buf = _io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
W, H = A4
y = H - 18 * mm
# Header bar
c.setFillColorRGB(0.13, 0.20, 0.32)
c.rect(0, H - 22 * mm, W, 22 * mm, fill=1, stroke=0)
c.setFillColorRGB(1, 1, 1)
c.setFont(font_bold, 14)
c.drawString(15 * mm, H - 12 * mm, "PGŽ SPORT — OBRAZAC")
c.setFont(font_reg, 10)
c.drawString(15 * mm, H - 18 * mm, str(s.get("template_naziv") or s.get("template_code") or ""))
c.drawRightString(W - 15 * mm, H - 12 * mm, f"REF: {s.get('reference_no') or ''}")
c.drawRightString(W - 15 * mm, H - 18 * mm,
f"Status: {s.get('status','').upper()}")
y = H - 30 * mm
c.setFillColorRGB(0, 0, 0)
# Meta
def line(label, value, bold=False):
nonlocal y
if y < 25 * mm:
c.showPage()
y = H - 20 * mm
c.setFillColorRGB(0, 0, 0)
c.setFont(font_reg, 8)
c.setFillColorRGB(0.45, 0.45, 0.45)
c.drawString(15 * mm, y, label)
c.setFont(font_bold if bold else font_reg, 10)
c.setFillColorRGB(0, 0, 0)
v = "" if value is None else str(value)
# wrap
max_w = W - 30 * mm
while v:
chunk = v
while pdfmetrics.stringWidth(chunk, font_bold if bold else font_reg, 10) > max_w and len(chunk) > 5:
chunk = chunk[:-2]
c.drawString(15 * mm, y - 4 * mm, chunk)
v = v[len(chunk):].lstrip() if len(chunk) < len(v) else ""
y -= 5 * mm
if v:
if y < 25 * mm:
c.showPage(); y = H - 20 * mm
y -= 3 * mm
line("KLUB", s.get("klub_naziv"), bold=True)
line("OIB KLUBA", s.get("klub_oib"))
line("IBAN KLUBA", s.get("klub_iban"))
if s.get("clan_naziv"):
line("ČLAN/SPORTAŠ", s.get("clan_naziv"))
line("DATUM PREDAJE", s.get("submitted_at") or s.get("created_at"))
line("STATUS", s.get("status"), bold=True)
# Section divider
y -= 4 * mm
c.setStrokeColorRGB(0.13, 0.20, 0.32)
c.setLineWidth(0.6)
c.line(15 * mm, y, W - 15 * mm, y)
y -= 6 * mm
c.setFont(font_bold, 11)
c.setFillColorRGB(0.13, 0.20, 0.32)
c.drawString(15 * mm, y, "SADRŽAJ OBRASCA")
y -= 8 * mm
c.setFillColorRGB(0, 0, 0)
# Polja iz schema_json (skip meta __keys)
if fields:
for f in fields:
name = f.get("name")
if not name or name.startswith("__"):
continue
label = f.get("label") or name
val = data.get(name)
line(label, val)
else:
# fallback — sve ključeve iz data
for k, v in data.items():
if k.startswith("__"):
continue
line(k, v)
# Potpis
y -= 6 * mm
if y < 50 * mm:
c.showPage(); y = H - 20 * mm
c.setFillColorRGB(0.13, 0.20, 0.32)
c.setStrokeColorRGB(0.13, 0.20, 0.32)
c.setLineWidth(0.6)
c.line(15 * mm, y, W - 15 * mm, y)
y -= 6 * mm
c.setFont(font_bold, 11)
c.drawString(15 * mm, y, "DIGITALNI POTPIS")
y -= 8 * mm
c.setFillColorRGB(0, 0, 0)
if sig_sha:
line("Potpisao", sig_by or "")
line("Vrijeme potpisa (UTC)", sig_at or "")
line("SHA-256 hash sadržaja", sig_sha)
line("Verifikacija",
"PGŽ Sport ERP/CRM — hash izračunat nad sortiranim JSON sadržajem (bez __* polja).")
else:
c.setFont(font_reg, 9)
c.setFillColorRGB(0.7, 0.3, 0.3)
c.drawString(15 * mm, y, "Obrazac NIJE digitalno potpisan.")
y -= 6 * mm
# Footer
c.setFont(font_reg, 7)
c.setFillColorRGB(0.55, 0.55, 0.55)
c.drawString(15 * mm, 10 * mm,
f"PGŽ Sport ERP/CRM • Generirano {datetime.now().strftime('%d.%m.%Y. %H:%M')} • REF {s.get('reference_no') or sid}")
c.save()
pdf = buf.getvalue()
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition":
f"inline; filename=obrazac-{sid}.pdf"})
# ───────────── /forms/{code_or_id} (catch-all GET — mora biti POSLIJE submissions!) ─────────────
@router.get("/forms/{code_or_id}")
def get_form(code_or_id: str):
with _conn() as conn, conn.cursor() as cur:
t = _resolve_template(code_or_id, cur)
return _row(t)
# ───────────── shortcut: kreiraj+submit u jednom ─────────────
@router.post("/forms/{code_or_id}/submit")
def quick_submit(code_or_id: str, body: SubmissionIn):
"""Kompatibilni shortcut — kreira draft + odmah submita s potpisom."""
with _conn() as conn, conn.cursor() as cur:
t = _resolve_template(code_or_id, cur)
ref = f"{t['code'][:8].upper()}-{date.today().year}-{_uuid.uuid4().hex[:8].upper()}"
merged = dict(body.data or {})
signer = str(body.user_id) if body.user_id else "anonymous"
sig = _sign_payload(merged, signer)
merged.update(sig)
cur.execute("""
INSERT INTO pgz_sport.form_submissions
(template_id, template_code, klub_id, user_id, clan_id, data,
attachments, status, reference_no, submitted_at)
VALUES (%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,'submitted',%s, now())
RETURNING *
""", (t["id"], t["code"], body.klub_id, body.user_id, body.clan_id,
json.dumps(merged), json.dumps(body.attachments or []), ref))
s = cur.fetchone()
conn.commit()
return {
"ok": True,
"id": s["id"],
"reference_no": s["reference_no"],
"status": "submitted",
"signature_sha256": sig["__signature_sha256"],
"signed_at": sig["__signed_at"],
"submission": _row(s),
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -81,6 +81,9 @@ def main():
print("== Users ==") print("== Users ==")
users = [ users = [
("damir@pgz.hr", "PGZ2026!", "Damir Radulić", "Damir", "Radulić", "pgz_admin", None, None), ("damir@pgz.hr", "PGZ2026!", "Damir Radulić", "Damir", "Radulić", "pgz_admin", None, None),
("tajnik@atletski.pgz.hr", "Atl2026!", "Tajnik Atletski","Tajnik","Atletski", "savez_admin", None, atletski_savez),
("admin@ak-kvarner.hr", "Kvarner2026!", "Admin Kvarner", "Admin", "Kvarner", "klub_admin", ak_klub, atletski_savez),
# Extra demos
("pero@atletika.pgz.hr", "PGZ2026!", "Pero Perić", "Pero", "Perić", "savez_admin", None, atletski_savez), ("pero@atletika.pgz.hr", "PGZ2026!", "Pero Perić", "Pero", "Perić", "savez_admin", None, atletski_savez),
("ana@akkvarner.hr", "PGZ2026!", "Ana Anić", "Ana", "Anić", "klub_admin", ak_klub, atletski_savez), ("ana@akkvarner.hr", "PGZ2026!", "Ana Anić", "Ana", "Anić", "klub_admin", ak_klub, atletski_savez),
("sportas@akkvarner.hr", "PGZ2026!", "Marko Marković", "Marko", "Marković", "klub_clan", ak_klub, atletski_savez), ("sportas@akkvarner.hr", "PGZ2026!", "Marko Marković", "Marko", "Marković", "klub_clan", ak_klub, atletski_savez),
@@ -90,7 +93,8 @@ def main():
print(f" [{action}] {email} (id={uid}, type={ut}, klub_id={kid}, savez_id={sid})") print(f" [{action}] {email} (id={uid}, type={ut}, klub_id={kid}, savez_id={sid})")
print("\n== Sanity check ==") print("\n== Sanity check ==")
for email in ["damir@pgz.hr","pero@atletika.pgz.hr","ana@akkvarner.hr","sportas@akkvarner.hr"]: for email in ["damir@pgz.hr","tajnik@atletski.pgz.hr","admin@ak-kvarner.hr",
"pero@atletika.pgz.hr","ana@akkvarner.hr","sportas@akkvarner.hr"]:
u = db_one("SELECT id, email, user_type, klub_id, savez_id, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", (email,)) u = db_one("SELECT id, email, user_type, klub_id, savez_id, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s", (email,))
print(f" {email}: {u}") print(f" {email}: {u}")
+11 -2
View File
@@ -63,9 +63,18 @@ POLYGON_WALLET = os.environ.get(
POLYGON_PRIVKEY = os.environ.get("POLYGON_PRIVKEY", "").strip() POLYGON_PRIVKEY = os.environ.get("POLYGON_PRIVKEY", "").strip()
POLYGONSCAN_BASE = os.environ.get("POLYGONSCAN_BASE", "https://polygonscan.com") POLYGONSCAN_BASE = os.environ.get("POLYGONSCAN_BASE", "https://polygonscan.com")
_pgh = os.environ.get("PG_HOST", "10.10.0.2")
_pgp = int(os.environ.get("PG_PORT", "6432"))
# pgz-sport.service inherits PG_HOST=localhost:5432 from /opt/.env.rinet which is
# stale (local PG was decommissioned). Honour the DB_HOST/DB_PORT override that
# points at canonical Server B (10.10.0.2:6432).
if _pgh in ("localhost", "127.0.0.1"):
_pgh = os.environ.get("DB_HOST", "10.10.0.2")
_pgp = int(os.environ.get("DB_PORT", "6432"))
DB = dict( DB = dict(
host=os.environ.get("PG_HOST", "10.10.0.2"), host=_pgh,
port=int(os.environ.get("PG_PORT", "6432")), port=_pgp,
dbname=os.environ.get("PG_DB", "rinet_v3"), dbname=os.environ.get("PG_DB", "rinet_v3"),
user=os.environ.get("PG_USER", "rinet"), user=os.environ.get("PG_USER", "rinet"),
password=os.environ.get("PG_PASS", "R1net2026!SecureDB#v7"), password=os.environ.get("PG_PASS", "R1net2026!SecureDB#v7"),
+30 -28
View File
@@ -20,36 +20,38 @@ DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7") password="R1net2026!SecureDB#v7")
# === HR pravilnik 2025 — dnevnice === # === HR pravilnik 2025 — dnevnice ===
# Domaće: 30 € (puna) za put preko 8h, 15 € za 58h, 0 € za <5h # 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). # Izvor: NN Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €).
# (Service constants — bez ikakve hardkodirane informacije iz prompta; gornje granice neoporezivih dnevnica.) DNEVNICA_DOM_FULL = 26.54 # EUR
DNEVNICA_DOM_FULL = 30.00 # EUR DNEVNICA_DOM_HALF = 13.27 # EUR
DNEVNICA_DOM_HALF = 15.00 # EUR KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil)
KM_RATE_DEFAULT = 0.50 # EUR/km (uvjet: vlastiti automobil; granica neopor. 2025)
# Inozemne dnevnice (gornja granica neoporezivog iznosa po Uredbi o izdacima službenih putovanja) # Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo).
# Izvor: Uredba o izdacima za službena putovanja u inozemstvo (HR, 2024 ažurirano)
DNEVNICE_INO = { DNEVNICE_INO = {
"Slovenija": 70.00, "Italija": 35.00,
"Italija": 70.00, "Italy": 35.00,
"Austrija": 70.00, "Slovenija": 30.00,
"Mađarska": 50.00, "Slovenia": 30.00,
"Hungary": 50.00, "Austrija": 35.00,
"Bosna i Hercegovina": 50.00, "Austria": 35.00,
"Srbija": 50.00, "Mađarska": 30.00,
"Crna Gora": 50.00, "Madarska": 30.00,
"Njemačka": 80.00, "Hungary": 30.00,
"Germany": 80.00, "Bosna i Hercegovina": 30.00,
"Francuska": 80.00, "BiH": 30.00,
"France": 80.00, "Bosnia": 30.00,
"Belgija": 80.00, "Srbija": 30.00,
"Nizozemska": 80.00, "Serbia": 30.00,
"Velika Britanija": 90.00, "Crna Gora": 30.00,
"UK": 90.00, "Montenegro": 30.00,
"Švicarska": 100.00, "Njemačka": 50.00,
"Switzerland": 100.00, "Germany": 50.00,
"SAD": 100.00, "Francuska": 50.00,
"USA": 100.00, "France": 50.00,
"Švicarska": 60.00,
"Switzerland": 60.00,
"SAD": 70.00,
"USA": 70.00,
} }
+26
View File
@@ -1449,6 +1449,32 @@ def serve_admin():
return FileResponse(p) return FileResponse(p)
return {"error": "admin.html not found"} return {"error": "admin.html not found"}
@app.get("/erp")
@app.get("/erp/")
@app.get("/app/erp")
@app.get("/app/erp/")
def serve_erp():
p = HTML_DIR / "erp.html"
if p.exists():
return FileResponse(p)
return {"error": "erp.html not found"}
@app.get("/login")
@app.get("/login/")
def serve_login():
p = HTML_DIR / "login.html"
if p.exists():
return FileResponse(p)
return {"error": "login.html not found"}
@app.get("/admin/users")
@app.get("/admin/users/")
def serve_admin_users():
p = HTML_DIR / "admin_users.html"
if p.exists():
return FileResponse(p)
return {"error": "admin_users.html not found"}
@app.get("/api/sportski-objekti") @app.get("/api/sportski-objekti")
def list_sportski_objekti(q=None,tip=None,grad=None): def list_sportski_objekti(q=None,tip=None,grad=None):
+96
View File
@@ -0,0 +1,96 @@
"""
audit_seal_router.py — HTTP surface for the Polygon PoS sealing module
Author: Damir Radulić (damir@rinet.one) / dradulic@outlook.com
Date: 2026-05-04
Endpoints (all under /sport/api):
POST /audit/seal
body: {
action: "sufinanciranje.approved",
ref_type: "sufinanciranje", (optional)
ref_id: "2026-001", (string or number)
payload: { ... }, (sha256 computed server-side)
data_hash: "abc..." (optional — if you already have the hash)
}
returns the seal record (seal_id, tx_hash, polygonscan_url, status, ...).
GET /audit/seal/list?action=&ref_type=&ref_id=&limit=
Recent seals for the audit-log UI.
GET /audit/seal/{seal_id}
Single seal with on-chain receipt cross-check (if web3 wired up).
The legacy hash-chain audit endpoints (/api/admin/audit-chain*) live in
pgz_sport_api.py and remain unchanged.
"""
from __future__ import annotations
import sys, os
from typing import Any, Optional
from fastapi import APIRouter, Body, HTTPException, Header
# blockchain.seal lives at /opt/pgz-sport/blockchain/seal.py
sys.path.insert(0, '/opt/pgz-sport')
from blockchain import seal as seal_mod # noqa: E402
router = APIRouter()
@router.post("/audit/seal")
def audit_seal(body: dict = Body(...),
x_user_email: Optional[str] = Header(default=None),
x_user_id: Optional[int] = Header(default=None)):
"""Seal an audit event to Polygon PoS (or queue for later if no key)."""
if not isinstance(body, dict):
raise HTTPException(400, "JSON body required")
action = (body.get('action') or '').strip()
if not action:
raise HTTPException(400, "action is required")
payload = body.get('payload')
data_hash = (body.get('data_hash') or '').strip().lower().lstrip('0x')
if not data_hash:
if payload is None:
raise HTTPException(400, "either data_hash or payload required")
data_hash = seal_mod.hash_payload(payload)
ref_id = body.get('ref_id')
if ref_id is None:
raise HTTPException(400, "ref_id is required")
ref_type = body.get('ref_type')
try:
result = seal_mod.seal_to_polygon(
data_hash=data_hash,
ref_id=str(ref_id),
action=action,
ref_type=ref_type,
payload=payload,
user_id=x_user_id,
user_email=x_user_email,
)
except ValueError as e:
raise HTTPException(400, str(e))
return result
@router.get("/audit/seal/list")
def audit_seal_list(action: Optional[str] = None,
ref_type: Optional[str] = None,
ref_id: Optional[str] = None,
limit: int = 50):
rows = seal_mod.list_seals(action=action, ref_type=ref_type,
ref_id=ref_id, limit=limit)
return {'count': len(rows), 'rows': rows,
'wallet': seal_mod.POLYGON_WALLET,
'chain_id': seal_mod.POLYGON_CHAIN_ID,
'live': seal_mod.HAS_WEB3 and bool(seal_mod.POLYGON_PRIVKEY)}
@router.get("/audit/seal/{seal_id}")
def audit_seal_get(seal_id: str):
row = seal_mod.verify_seal(seal_id)
if not row:
raise HTTPException(404, "seal not found")
return row
+266
View File
@@ -224,6 +224,88 @@ td.num { font-family: 'JetBrains Mono', monospace; text-align: right; }
<!-- ERP --> <!-- ERP -->
<div class="tab-content" id="tab-erp"> <div class="tab-content" id="tab-erp">
<div class="kpi-grid" id="erpKpi"></div> <div class="kpi-grid" id="erpKpi"></div>
<!-- M5: OCR drag-and-drop upload -->
<div class="section">
<h3>📷 OCR — Skeniraj račun (gorivo, cestarina, hotel…)</h3>
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:30px;text-align:center;cursor:pointer;background:var(--bg-3);transition:.15s">
<div style="font-size:32px;color:var(--accent);margin-bottom:6px"></div>
<div style="font-size:14px;font-weight:600">Povuci PDF/JPG/PNG ovdje ili klikni za odabir</div>
<div style="font-size:11px;color:var(--text-3);margin-top:6px">Tesseract OCR + DeepSeek V3 izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke</div>
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
</div>
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Izdavatelj</label><input id="oc_vendor_name" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">OIB izdavatelja</label><input id="oc_vendor_oib" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Broj računa</label><input id="oc_invoice_no" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Datum</label><input id="oc_invoice_date" type="date" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Iznos neto</label><input id="oc_amount_net" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">PDV</label><input id="oc_amount_vat" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Brutto (UKUPNO)</label><input id="oc_amount_gross" type="number" step="0.01" class="search" style="max-width:none;width:100%;border-color:var(--accent)"></div>
<div><label style="font-size:11px;color:var(--text-3)">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">IBAN</label><input id="oc_iban" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Vrsta troška</label>
<select id="oc_kind" class="search" style="max-width:none;width:100%">
<option value="gorivo">Gorivo</option>
<option value="cestarina">Cestarina</option>
<option value="hotel">Hotel</option>
<option value="restoran">Restoran</option>
<option value="oprema">Oprema</option>
<option value="ostalo" selected>Ostalo</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Klub</label>
<select id="oc_klub" class="search" style="max-width:none;width:100%"></select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Valuta</label>
<select id="oc_currency" class="search" style="max-width:none;width:100%"><option>EUR</option><option>HRK</option></select>
</div>
</div>
<div style="margin-top:10px"><label style="font-size:11px;color:var(--text-3)">Opis</label><input id="oc_description" class="search" style="max-width:none;width:100%"></div>
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
</details>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<button id="ocSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">💾 Spremi račun</button>
<button id="ocCancel" style="padding:8px 14px;background:var(--bg-3);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
<!-- M6: Putni nalozi creation form -->
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Klub</label><select id="pn_klub" class="search" style="max-width:none;width:100%"></select></div>
<div><label style="font-size:11px;color:var(--text-3)">Voditelj</label><input id="pn_voditelj" class="search" style="max-width:none;width:100%" placeholder="Ime Prezime"></div>
<div><label style="font-size:11px;color:var(--text-3)">Putnici (zarezom razdvojeno)</label><input id="pn_putnici" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Svrha</label><input id="pn_svrha" class="search" style="max-width:none;width:100%" placeholder="Natjecanje, treninzi…"></div>
<div><label style="font-size:11px;color:var(--text-3)">Od grada</label><input id="pn_od" class="search" style="max-width:none;width:100%" value="Rijeka"></div>
<div><label style="font-size:11px;color:var(--text-3)">Do grada</label><input id="pn_do" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Polazak</label><input id="pn_from" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Povratak</label><input id="pn_to" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Zemlja</label><input id="pn_country" class="search" style="max-width:none;width:100%" value="Hrvatska"></div>
<div><label style="font-size:11px;color:var(--text-3)">Tip vozila</label>
<select id="pn_vehicle" class="search" style="max-width:none;width:100%">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Registracija</label><input id="pn_plate" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Kilometara</label><input id="pn_km" type="number" step="1" class="search" style="max-width:none;width:100%" value="0"></div>
<div><label style="font-size:11px;color:var(--text-3)">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="search" style="max-width:none;width:100%" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px">
<button id="pnSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
<div class="section"> <div class="section">
<h3>Računi</h3> <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> <table id="invTable"><thead><tr><th>Broj</th><th>Dobavljač</th><th>Klub</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
@@ -469,6 +551,190 @@ function activateTab(name) {
if (name === 'reports') loadReports(); 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 // Init
$$('.nav-item').forEach(n => n.addEventListener('click', () => activateTab(n.dataset.tab))); $$('.nav-item').forEach(n => n.addEventListener('click', () => activateTab(n.dataset.tab)));
+765
View File
@@ -0,0 +1,765 @@
<!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="/sport/admin"><span class="icon"></span><span class="sb-text">ERP / CRM / OCR</span></a>
<a class="nav-item" href="/sport/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>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 = '/sport/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 = '/sport/static/login.html'; return null; }
headers['Authorization'] = 'Bearer ' + newTok;
r = await fetch(API + path, Object.assign({}, opts, {headers}));
}
return r;
}
async function apiJson(path, opts) { const r = await api(path, opts); if (!r) return null; try { return await r.json(); } catch { return null; } }
function toast(msg, type='success') {
const t = $('#toast'); t.textContent = msg;
t.className = 'toast show ' + type;
setTimeout(() => t.classList.remove('show'), 3500);
}
function fmtDateTime(d) { if (!d) return '—'; try { return new Date(d).toLocaleString('hr-HR'); } catch { return d; } }
function escapeHtml(s) { if (s == null) return ''; return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c])); }
function roleBadge(r) {
const map = { super_admin:'red', pgz_admin:'cyan', pgz_user:'cyan', pgz_finance:'cyan', pgz_zzjz:'cyan',
savez_admin:'purple', savez_user:'purple', klub_admin:'green', klub_trener:'green', klub_user:'green', klub_clan:'green', viewer:'gray' };
return `<span class="badge ${map[r]||'gray'}">${escapeHtml(r||'—')}</span>`;
}
function statusBadge(active) { return active ? '<span class="badge green">Aktivan</span>' : '<span class="badge gray">Neaktivan</span>'; }
function openModal(name) { $('#'+name+'Bg').classList.add('show'); }
function closeModal(name) { $('#'+name+'Bg').classList.remove('show'); }
// Sidebar collapse
const sbState = localStorage.getItem('pgz_sidebar') || 'expanded';
if (sbState === 'collapsed') $('#appShell').classList.add('collapsed');
$('#sbToggle').textContent = $('#appShell').classList.contains('collapsed') ? '⮞' : '⮜';
$('#sbToggle').addEventListener('click', () => {
$('#appShell').classList.toggle('collapsed');
const c = $('#appShell').classList.contains('collapsed');
localStorage.setItem('pgz_sidebar', c ? 'collapsed' : 'expanded');
$('#sbToggle').textContent = c ? '⮞' : '⮜';
});
// Tabs
function activate(tab) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === tab));
$$('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
if (tab === 'overview') loadOverview();
if (tab === 'users') loadUsers();
if (tab === 'tenants') loadTenants();
if (tab === 'audit') loadAudit();
if (tab === 'security') loadSecurity();
if (tab === 'gdpr') loadGdpr();
history.replaceState(null, '', '#' + tab);
}
$$('.nav-item[data-tab]').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
// User dropdown
$('#userMenuBtn').addEventListener('click', e => { e.stopPropagation(); $('#userDropdown').classList.toggle('show'); });
document.addEventListener('click', () => $('#userDropdown').classList.remove('show'));
$('#userDropdown').addEventListener('click', e => e.stopPropagation());
$('#menuLogout').addEventListener('click', async () => {
await api('/auth/logout', {method:'POST'});
clearAuth();
location.href = '/sport/static/login.html';
});
$('#menuExport').addEventListener('click', async () => {
const r = await api('/gdpr/export'); if (!r) return;
const data = await r.json();
const blob = new Blob([JSON.stringify(data, null, 2)], {type: 'application/json'});
const u = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = u;
a.download = `pgz_data_export_${data.subject.id}_${Date.now()}.json`;
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>';
}
// 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 = '/sport/static/login.html'; return; }
const r = await api('/auth/me');
if (!r || !r.ok) { clearAuth(); location.href = '/sport/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>
+974
View File
@@ -0,0 +1,974 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport — CRM (Članarine • Liječnički • Obrasci)</title>
<style>
:root {
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a;
--bg:#0f1115; --bg2:#171a21; --bg3:#1f242d;
--rim:#293040; --t1:#e6e8ef; --t2:#9aa3b6; --t3:#6b748b;
--ok:#22c55e; --warn:#f59e0b; --err:#ef4444; --info:#3b82f6;
}
* { box-sizing: border-box; }
body { margin:0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background: var(--bg); color: var(--t1); font-size: 14px; }
.topbar {
height: 54px; background: linear-gradient(90deg, var(--pgz-blue2), var(--pgz-blue));
display: flex; align-items: center; padding: 0 18px; gap: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.topbar .logo { font-weight: 700; font-size: 16px; }
.topbar .sep { color: rgba(255,255,255,0.5); }
.topbar .title { font-size: 14px; opacity: 0.95; }
.topbar .right { margin-left: auto; display: flex; gap: 10px; align-items: center; font-size: 12px; }
.topbar a { color: #fff; text-decoration: none; opacity: 0.8; padding: 6px 10px; border-radius: 4px; }
.topbar a:hover { opacity: 1; background: rgba(255,255,255,0.1); }
.tabs { display: flex; background: var(--bg2); border-bottom: 1px solid var(--rim); padding: 0 18px; }
.tab { padding: 14px 20px; cursor: pointer; color: var(--t2); border-bottom: 2px solid transparent;
font-weight: 500; user-select: none; }
.tab:hover { color: var(--t1); }
.tab.active { color: var(--pgz-blue); border-bottom-color: var(--pgz-blue); background: var(--bg3); }
.tab .count { background: var(--bg3); color: var(--t2); padding: 2px 8px; border-radius: 10px;
font-size: 11px; margin-left: 6px; }
.tab.active .count { background: var(--pgz-blue); color: #fff; }
.container { padding: 18px; }
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
.toolbar input, .toolbar select {
background: var(--bg2); border: 1px solid var(--rim); color: var(--t1);
padding: 7px 11px; border-radius: 5px; font-size: 13px; min-width: 140px;
}
.toolbar input:focus, .toolbar select:focus { outline: none; border-color: var(--pgz-blue); }
.toolbar .grow { flex: 1; }
.btn { background: var(--bg3); color: var(--t1); border: 1px solid var(--rim);
padding: 7px 13px; border-radius: 5px; cursor: pointer; font-size: 13px;
font-family: inherit; }
.btn:hover { background: var(--bg2); border-color: var(--pgz-blue); }
.btn.primary { background: linear-gradient(135deg, var(--pgz-blue), var(--pgz-blue2)); border-color: var(--pgz-blue); color:#fff; }
.btn.primary:hover { filter: brightness(1.1); }
.btn.danger { color: var(--err); border-color: var(--err); }
.btn.sm { padding: 4px 8px; font-size: 12px; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px; margin-bottom: 14px; }
.kpi { background: var(--bg2); border: 1px solid var(--rim); padding: 12px 14px; border-radius: 8px; }
.kpi.g { border-left: 3px solid var(--ok); }
.kpi.r { border-left: 3px solid var(--err); }
.kpi.a { border-left: 3px solid var(--warn); }
.kpi.b { border-left: 3px solid var(--pgz-blue); }
.kpi-l { font-size: 11px; color: var(--t2); text-transform: uppercase; letter-spacing: 0.5px; }
.kpi-v { font-size: 22px; font-weight: 700; margin-top: 4px; }
.kpi-s { font-size: 11px; color: var(--t3); margin-top: 2px; }
.card { background: var(--bg2); border: 1px solid var(--rim); border-radius: 8px;
margin-bottom: 14px; overflow: hidden; }
.card-h { padding: 12px 16px; border-bottom: 1px solid var(--rim); display: flex; align-items: center;
justify-content: space-between; background: var(--bg3); }
.card-t { font-weight: 600; font-size: 14px; }
.card-b { padding: 14px 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
table th, table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--rim); }
table th { background: var(--bg3); color: var(--t2); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.4px; }
table tr:hover td { background: rgba(26, 115, 232, 0.05); }
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.tag.gr { background: rgba(34,197,94,0.2); color: var(--ok); }
.tag.am { background: rgba(245,158,11,0.2); color: var(--warn); }
.tag.rd { background: rgba(239,68,68,0.2); color: var(--err); }
.tag.bl { background: rgba(26,115,232,0.2); color: var(--pgz-blue); }
.tag.gy { background: rgba(154,163,182,0.2); color: var(--t2); }
.empty { text-align: center; padding: 40px; color: var(--t3); }
.loading { text-align: center; padding: 30px; color: var(--t2); }
.modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7);
display: none; justify-content: center; align-items: flex-start; padding-top: 5vh; z-index: 1000; }
.modal-bg.open { display: flex; }
.modal { background: var(--bg2); border: 1px solid var(--rim); border-radius: 8px;
width: 92%; max-width: 720px; max-height: 90vh; overflow-y: auto; }
.modal-h { padding: 14px 18px; border-bottom: 1px solid var(--rim); display: flex;
justify-content: space-between; align-items: center; background: var(--bg3); }
.modal-t { font-weight: 600; font-size: 15px; }
.modal-x { cursor: pointer; color: var(--t2); font-size: 22px; line-height: 1; padding: 0 4px; }
.modal-x:hover { color: var(--err); }
.modal-b { padding: 18px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--t2); margin-bottom: 4px;
text-transform: uppercase; letter-spacing: 0.3px; }
.field label.req::after { content: " *"; color: var(--err); }
.field input, .field select, .field textarea {
width: 100%; background: var(--bg); border: 1px solid var(--rim); color: var(--t1);
padding: 8px 12px; border-radius: 5px; font-size: 13px; font-family: inherit;
}
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--pgz-blue); }
.field textarea { min-height: 70px; resize: vertical; }
.field .help { font-size: 11px; color: var(--t3); margin-top: 3px; }
.payment-card { background: var(--bg); border: 1px solid var(--rim); border-radius: 6px;
padding: 14px; margin-top: 12px; }
.payment-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--rim); }
.payment-row:last-child { border-bottom: none; }
.payment-row .l { color: var(--t2); font-size: 12px; }
.payment-row .v { font-weight: 600; font-family: 'SF Mono', Consolas, monospace; }
.payment-row .v.big { font-size: 18px; color: var(--pgz-blue); }
.qr-box { display: flex; gap: 16px; align-items: center; margin: 14px 0; }
.qr-box img { width: 160px; height: 160px; background: #fff; padding: 8px; border-radius: 6px; }
.qr-box .qr-info { flex: 1; }
.signature-box { background: var(--bg); border: 1px solid var(--rim); border-radius: 6px;
padding: 14px; margin-top: 14px; font-family: 'SF Mono', Consolas, monospace;
font-size: 11px; word-break: break-all; }
.signature-box .sha { color: var(--ok); }
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--bg3); border: 1px solid var(--rim);
padding: 10px 16px; border-radius: 6px; font-size: 13px; z-index: 2000;
border-left: 3px solid var(--ok); transform: translateX(120%); transition: transform 0.3s; }
.toast.show { transform: translateX(0); }
.toast.err { border-left-color: var(--err); }
</style>
</head>
<body>
<div class="topbar">
<div class="logo">⬢ PGŽ SPORT</div>
<div class="sep">·</div>
<div class="title">CRM — Članarine • Liječnički • Obrasci</div>
<div class="right">
<span style="opacity:.7">Round 3 / CC5</span>
<a href="/sport/static/sport2.html">← portal</a>
<a href="/sport/static/app.html">app →</a>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="clanarine" onclick="setTab('clanarine')">€ Članarine <span class="count" id="cnt-clanarine"></span></div>
<div class="tab" data-tab="lijecnicki" onclick="setTab('lijecnicki')">⚕ Liječnički pregledi <span class="count" id="cnt-lijecnicki"></span></div>
<div class="tab" data-tab="obrasci" onclick="setTab('obrasci')">📝 Obrasci <span class="count" id="cnt-obrasci"></span></div>
</div>
<div class="container">
<div id="page-clanarine" class="page"></div>
<div id="page-lijecnicki" class="page" style="display:none"></div>
<div id="page-obrasci" class="page" style="display:none"></div>
</div>
<div id="modal-bg" class="modal-bg" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
<div id="toast" class="toast"></div>
<script>
// ────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────
const API = '/sport/api/crm';
const $ = (s, root=document) => root.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtEur = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' €';
const fmt = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR');
const fmtDate = d => !d ? '—' : new Date(d).toLocaleDateString('hr-HR');
async function api(path, opts={}) {
const o = Object.assign({headers: {'Content-Type':'application/json'}}, opts);
if (o.body && typeof o.body !== 'string') o.body = JSON.stringify(o.body);
const r = await fetch(API + path, o);
if (!r.ok) {
const msg = await r.text().catch(()=>r.statusText);
throw new Error(`HTTP ${r.status}: ${msg.substring(0,200)}`);
}
return r.json();
}
function toast(msg, isErr=false) {
const t = $('#toast');
t.textContent = msg;
t.classList.toggle('err', isErr);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3500);
}
function openModal(html) {
$('#modal').innerHTML = html;
$('#modal-bg').classList.add('open');
}
function closeModal() {
$('#modal-bg').classList.remove('open');
$('#modal').innerHTML = '';
}
function setTab(name) {
$$('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
$$('.page').forEach(p => p.style.display = (p.id === 'page-' + name) ? 'block' : 'none');
if (name === 'clanarine') loadClanarine();
if (name === 'lijecnicki') loadLijecnicki();
if (name === 'obrasci') loadObrasci();
}
// ════════════════════════════════════════════════════
// MODUL 1 — ČLANARINE (M7)
// ════════════════════════════════════════════════════
async function loadClanarine() {
const root = $('#page-clanarine');
root.innerHTML = '<div class="loading">Učitavanje članarina…</div>';
let data;
try {
data = await api('/clanarine?limit=200');
} catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-clanarine').textContent = data.count;
const s = data.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Ukupno zaduženja</div><div class="kpi-v">${fmt(s.total)}</div></div>
<div class="kpi g"><div class="kpi-l">Naplaćeno</div><div class="kpi-v">${fmtEur(s.total_placen)}</div></div>
<div class="kpi r"><div class="kpi-l">Dug</div><div class="kpi-v">${fmtEur(s.total_dug)}</div></div>
<div class="kpi a"><div class="kpi-l">Nepodmireno</div><div class="kpi-v">${fmt(s.n_nepodmireno)}</div></div>
</div>`;
const tools = `
<div class="toolbar">
<select id="cl-status" onchange="loadClanarineFiltered()">
<option value="">Svi statusi</option>
<option value="nepodmireno">Nepodmireno</option>
<option value="djelomicno">Djelomično</option>
<option value="podmireno">Podmireno</option>
<option value="storno">Storno</option>
</select>
<input id="cl-godina" type="number" placeholder="Godina" min="2020" max="2030" onchange="loadClanarineFiltered()">
<input id="cl-klub" type="number" placeholder="Klub ID" onchange="loadClanarineFiltered()">
<div class="grow"></div>
<button class="btn primary" onclick="bulkNotify()">📧 Notify dužnike</button>
<button class="btn" onclick="newClanarinaModal()">+ Novo zaduženje</button>
</div>`;
const rows = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${esc(r.godina)}</td>
<td>${esc(r.razdoblje || '')}</td>
<td>${fmtEur(r.iznos_propisan)}</td>
<td>${fmtEur(r.iznos_placen)}</td>
<td><b style="color:${r.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(r.dug)}</b></td>
<td><span class="tag ${statusTag(r.status)}">${esc(r.status)}</span></td>
<td>
<button class="btn sm" onclick="openPayment(${r.id})" title="Pregled plaćanja">💳</button>
<button class="btn sm" onclick="openUplata(${r.id})" title="Registriraj uplatu">+€</button>
<a class="btn sm" href="${API}/clanarine/${r.id}/uplatnica.pdf" target="_blank" title="HUB-3 PDF">📄</a>
</td>
</tr>`).join('');
root.innerHTML = kpi + tools + `
<div class="card">
<div class="card-h"><div class="card-t">Lista članarina (${data.count})</div></div>
<table>
<thead><tr><th>Sportaš/Klub</th><th>God.</th><th>Razdoblje</th><th>Propisan</th><th>Plaćeno</th><th>Dug</th><th>Status</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>'}</tbody>
</table>
</div>`;
}
function statusTag(s) {
return ({nepodmireno:'rd', djelomicno:'am', podmireno:'gr', storno:'gy'})[s] || 'gy';
}
async function loadClanarineFiltered() {
const status = $('#cl-status').value;
const godina = $('#cl-godina').value;
const klub = $('#cl-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (godina) params.append('godina', godina);
if (klub) params.append('klub_id', klub);
const data = await api('/clanarine?' + params);
const tbody = $('#page-clanarine table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${esc(r.godina)}</td>
<td>${esc(r.razdoblje || '')}</td>
<td>${fmtEur(r.iznos_propisan)}</td>
<td>${fmtEur(r.iznos_placen)}</td>
<td><b style="color:${r.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(r.dug)}</b></td>
<td><span class="tag ${statusTag(r.status)}">${esc(r.status)}</span></td>
<td>
<button class="btn sm" onclick="openPayment(${r.id})">💳</button>
<button class="btn sm" onclick="openUplata(${r.id})">+€</button>
<a class="btn sm" href="${API}/clanarine/${r.id}/uplatnica.pdf" target="_blank">📄</a>
</td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>';
}
async function openPayment(id) {
let info;
try { info = await api('/clanarine/' + id + '/payment-info'); }
catch (e) { return toast('Greška: ' + e.message, true); }
openModal(`
<div class="modal-h">
<div class="modal-t">💳 Podaci za plaćanje #${id}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="qr-box">
<img src="${API}/clanarine/${id}/qr.png" alt="EPC QR">
<div class="qr-info">
<p style="margin:0 0 8px;color:var(--t2);font-size:12px">Skenirajte QR mobilnom bankom (Zaba / PBZ / Erste / OTP / RBA) — popunit će sve podatke za uplatu.</p>
<a class="btn primary" href="${API}/clanarine/${id}/uplatnica.pdf" target="_blank">📄 HUB-3 PDF (uplatnica)</a>
</div>
</div>
<div class="payment-card">
<div class="payment-row"><div class="l">Iznos za uplatu</div><div class="v big">${fmtEur(info.iznos_eur)}</div></div>
<div class="payment-row"><div class="l">Primatelj</div><div class="v">${esc(info.primatelj)}</div></div>
<div class="payment-row"><div class="l">IBAN</div><div class="v">${esc(info.iban)}</div></div>
<div class="payment-row"><div class="l">Model</div><div class="v">${esc(info.model)}</div></div>
<div class="payment-row"><div class="l">Poziv na broj</div><div class="v">${esc(info.poziv_na_broj)}</div></div>
<div class="payment-row"><div class="l">Opis</div><div class="v">${esc(info.opis)}</div></div>
</div>
<details style="margin-top:14px">
<summary style="cursor:pointer;color:var(--t2);font-size:12px">EPC QR payload (BCD/002 SCT)</summary>
<pre style="background:var(--bg);padding:10px;border-radius:5px;font-size:11px;overflow:auto;margin-top:6px">${esc(info.epc_payload)}</pre>
</details>
</div>`);
}
function openUplata(id) {
openModal(`
<div class="modal-h">
<div class="modal-t">+€ Registriraj uplatu (članarina #${id})</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitUplata(event, ${id})">
<div class="field"><label class="req">Iznos uplate (EUR)</label>
<input name="iznos" type="number" step="0.01" min="0.01" required></div>
<div class="field"><label>Datum uplate</label>
<input name="datum_uplate" type="date" value="${new Date().toISOString().slice(0,10)}"></div>
<div class="field"><label>Način uplate</label>
<select name="nacin_uplate">
<option value="transakcijski">Transakcijski račun</option>
<option value="gotovina">Gotovina</option>
<option value="kartica">Kartica</option>
</select></div>
<div class="field"><label>Referenca / broj naloga</label>
<input name="referenca" type="text"></div>
<div style="text-align:right;margin-top:14px">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Spremi uplatu</button>
</div>
</form>
</div>`);
}
async function submitUplata(e, id) {
e.preventDefault();
const f = e.target;
const body = {
iznos: parseFloat(f.iznos.value),
datum_uplate: f.datum_uplate.value || null,
nacin_uplate: f.nacin_uplate.value,
referenca: f.referenca.value || null,
};
try {
const r = await api('/clanarine/' + id + '/uplata', {method:'POST', body});
closeModal();
toast(`Uplata ${fmtEur(body.iznos)} registrirana. Status: ${r.status}`);
loadClanarine();
} catch (err) { toast('Greška: ' + err.message, true); }
}
function newClanarinaModal() {
openModal(`
<div class="modal-h">
<div class="modal-t">+ Novo zaduženje članarine</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitNewClanarina(event)">
<div class="field"><label class="req">Član ID</label>
<input name="clan_id" type="number" required></div>
<div class="field"><label>Klub ID (auto ako se ne unese)</label>
<input name="klub_id" type="number"></div>
<div class="field"><label class="req">Godina</label>
<input name="godina" type="number" required value="${new Date().getFullYear()}"></div>
<div class="field"><label>Razdoblje</label>
<input name="razdoblje" type="text" value="godišnja"></div>
<div class="field"><label class="req">Iznos propisan (EUR)</label>
<input name="iznos_propisan" type="number" step="0.01" required></div>
<div class="field"><label>Iznos plaćen (ako odmah)</label>
<input name="iznos_placen" type="number" step="0.01" value="0"></div>
<div class="field"><label>Napomena</label>
<textarea name="napomena"></textarea></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Kreiraj</button>
</div>
</form>
</div>`);
}
async function submitNewClanarina(e) {
e.preventDefault();
const f = e.target;
const body = {
clan_id: parseInt(f.clan_id.value),
klub_id: f.klub_id.value ? parseInt(f.klub_id.value) : null,
godina: parseInt(f.godina.value),
razdoblje: f.razdoblje.value,
iznos_propisan: parseFloat(f.iznos_propisan.value),
iznos_placen: parseFloat(f.iznos_placen.value || 0),
napomena: f.napomena.value || null,
};
try {
await api('/clanarine', {method:'POST', body});
closeModal();
toast('Članarina kreirana.');
loadClanarine();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function bulkNotify() {
if (!confirm('Pošalji notifikaciju svim dužnicima?')) return;
try {
const r = await api('/clanarine/notify-bulk', {method:'POST', body: {}});
toast(`Postavljeno ${r.queued} primatelja u red. (Mock — SMTP nije konfiguriran.)`);
} catch (err) { toast('Greška: ' + err.message, true); }
}
// ════════════════════════════════════════════════════
// MODUL 2 — LIJEČNIČKI PREGLEDI (M8)
// ════════════════════════════════════════════════════
async function loadLijecnicki() {
const root = $('#page-lijecnicki');
root.innerHTML = '<div class="loading">Učitavanje pregleda…</div>';
let data;
try { data = await api('/lijecnicki?limit=200'); }
catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-lijecnicki').textContent = data.count;
const s = data.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Ukupno pregleda</div><div class="kpi-v">${fmt(s.total)}</div></div>
<div class="kpi g"><div class="kpi-l">Važeći</div><div class="kpi-v">${fmt(s.vazeci)}</div></div>
<div class="kpi a"><div class="kpi-l">Uskoro istek (30d)</div><div class="kpi-v">${fmt(s.uskoro)}</div></div>
<div class="kpi r"><div class="kpi-l">Istekli</div><div class="kpi-v">${fmt(s.istekli)}</div></div>
</div>`;
const tools = `
<div class="toolbar">
<select id="lj-status" onchange="loadLijecnickiFiltered()">
<option value="">Svi statusi</option>
<option value="vazeci">Važeći</option>
<option value="uskoro">Uskoro istek</option>
<option value="istekao">Istekao</option>
</select>
<input id="lj-klub" type="number" placeholder="Klub ID" onchange="loadLijecnickiFiltered()">
<div class="grow"></div>
<button class="btn" onclick="loadZZJZ()">🏥 ZZJZ PGŽ termini</button>
<button class="btn" onclick="newLijecnickiModal()">+ Novi pregled</button>
</div>`;
const rows = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${fmtDate(r.datum_pregleda)}</td>
<td>${fmtDate(r.vrijedi_do)}</td>
<td><span class="tag ${({vazeci:'gr', uskoro:'am', istekao:'rd'})[r.status_calc]||'gy'}">
${r.status_calc}${r.dana_do_isteka != null ? ' ('+r.dana_do_isteka+'d)' : ''}</span></td>
<td>${esc(r.ustanova || '')}</td>
<td>${esc(r.lijecnik || '')}</td>
<td>${r.placeno ? '<span class="tag gr">DA</span>' : '<span class="tag rd">NE</span>'}</td>
<td>
<button class="btn sm" onclick="openZakaziModal(${r.id}, '${esc(r.clan)}')" title="Zakaži termin">📅</button>
<button class="btn sm" onclick="openLijecnickiDetalji(${r.id})" title="Detalji">👁</button>
</td>
</tr>`).join('');
root.innerHTML = kpi + tools + `
<div class="card">
<div class="card-h"><div class="card-t">Lista pregleda (${data.count})</div></div>
<table>
<thead><tr><th>Sportaš/Klub</th><th>Datum pregleda</th><th>Vrijedi do</th><th>Status</th><th>Ustanova</th><th>Liječnik</th><th>Plaćeno</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>'}</tbody>
</table>
</div>`;
}
async function loadLijecnickiFiltered() {
const status = $('#lj-status').value;
const klub = $('#lj-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (klub) params.append('klub_id', klub);
const data = await api('/lijecnicki?' + params);
const tbody = $('#page-lijecnicki table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${fmtDate(r.datum_pregleda)}</td>
<td>${fmtDate(r.vrijedi_do)}</td>
<td><span class="tag ${({vazeci:'gr', uskoro:'am', istekao:'rd'})[r.status_calc]||'gy'}">
${r.status_calc}${r.dana_do_isteka != null ? ' ('+r.dana_do_isteka+'d)' : ''}</span></td>
<td>${esc(r.ustanova || '')}</td>
<td>${esc(r.lijecnik || '')}</td>
<td>${r.placeno ? '<span class="tag gr">DA</span>' : '<span class="tag rd">NE</span>'}</td>
<td>
<button class="btn sm" onclick="openZakaziModal(${r.id}, '${esc(r.clan)}')">📅</button>
<button class="btn sm" onclick="openLijecnickiDetalji(${r.id})">👁</button>
</td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>';
}
async function loadZZJZ() {
let info, termini;
try {
info = await api('/zzjz/info');
termini = await api('/zzjz/termini');
} catch (e) { return toast('Greška: ' + e.message, true); }
const booking = info.online_booking || {};
const bookingHtml = booking.available
? `<a class="btn primary" target="_blank" href="${esc(booking.url)}">🔗 Otvori online sustav (${esc(booking.kind)})</a>`
: `<div class="tag am">Online sustav nije pronađen — koristi e-mail kontakt</div>
<div style="margin-top:8px"><a class="btn primary" href="mailto:${esc(info.email)}">✉ E-mail: ${esc(info.email)}</a></div>`;
const termHtml = (termini.termini || []).slice(0, 30).map(t => `
<tr>
<td>${esc(t.datum)}</td><td>${esc(t.vrijeme)}</td>
<td>${esc(t.doktor)}</td>
<td>${t.available ? '<span class="tag gr">slobodno</span>' : '<span class="tag rd">zauzeto</span>'}</td>
<td>${fmtEur(t.iznos_eur)}</td>
</tr>`).join('');
openModal(`
<div class="modal-h">
<div class="modal-t">🏥 ZZJZ PGŽ — Sportska medicina</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Naziv</div><div class="v">${esc(info.naziv)}</div></div>
<div class="payment-row"><div class="l">Adresa</div><div class="v">${esc(info.adresa)}</div></div>
<div class="payment-row"><div class="l">Telefon</div><div class="v">${esc(info.telefon)}</div></div>
<div class="payment-row"><div class="l">E-mail</div><div class="v">${esc(info.email)}</div></div>
<div class="payment-row"><div class="l">Web</div><div class="v"><a href="${esc(info.url_sportska_medicina)}" target="_blank" style="color:var(--pgz-blue)">${esc(info.url_sportska_medicina)}</a></div></div>
</div>
<div style="margin:14px 0">${bookingHtml}</div>
<div class="card-h" style="background:transparent;border:none;padding:8px 0">
<div class="card-t">Dostupni termini (mock — tjedan ${esc(termini.week_start)})</div>
<div style="font-size:11px;color:var(--t3)">${termini.available} slobodno / ${termini.count} ukupno</div>
</div>
<table>
<thead><tr><th>Datum</th><th>Vrijeme</th><th>Doktor</th><th>Status</th><th>Iznos</th></tr></thead>
<tbody>${termHtml || '<tr><td colspan="5" class="empty">Nema termina.</td></tr>'}</tbody>
</table>
</div>`);
}
function openZakaziModal(lid, clan) {
openModal(`
<div class="modal-h">
<div class="modal-t">📅 Zakaži pregled — ${esc(clan)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<p style="color:var(--t2);font-size:13px;margin-top:0">Sustav će zakazati termin u ZZJZ PGŽ. Ako online sustav nije dostupan, otvorit će mailto: link.</p>
<form onsubmit="submitZakazi(event, ${lid})">
<div class="field"><label class="req">Datum</label>
<input name="datum" type="date" required value="${new Date(Date.now()+7*86400000).toISOString().slice(0,10)}"></div>
<div class="field"><label>Vrijeme</label>
<input name="vrijeme" type="time" value="09:00"></div>
<div class="field"><label>Ustanova</label>
<input name="ustanova" type="text" value="ZZJZ PGŽ"></div>
<div class="field"><label>Napomena</label>
<textarea name="napomena"></textarea></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">📅 Zakaži</button>
</div>
</form>
</div>`);
}
async function submitZakazi(e, lid) {
e.preventDefault();
const f = e.target;
const body = {
datum: f.datum.value, vrijeme: f.vrijeme.value,
ustanova: f.ustanova.value, napomena: f.napomena.value || null,
};
try {
const r = await api('/lijecnicki/' + lid + '/zakazi', {method:'POST', body});
closeModal();
toast('Termin zakazan: ' + r.zakazano_za);
if (r.booking && r.booking.available) {
window.open(r.booking.url, '_blank');
} else if (r.mailto) {
window.location.href = r.mailto;
}
loadLijecnicki();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function openLijecnickiDetalji(lid) {
let l;
try { l = await api('/lijecnicki/' + lid); }
catch (e) { return toast('Greška: ' + e.message, true); }
openModal(`
<div class="modal-h">
<div class="modal-t">⚕ Pregled #${l.id}${esc(l.clan)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Sportaš</div><div class="v">${esc(l.clan)}</div></div>
<div class="payment-row"><div class="l">Klub</div><div class="v">${esc(l.klub || '')}</div></div>
<div class="payment-row"><div class="l">Datum pregleda</div><div class="v">${fmtDate(l.datum_pregleda)}</div></div>
<div class="payment-row"><div class="l">Vrijedi do</div><div class="v">${fmtDate(l.vrijedi_do)}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag ${({vazeci:'gr',uskoro:'am',istekao:'rd'})[l.status_calc]||'gy'}">${l.status_calc} (${l.dana_do_isteka}d)</span></div></div>
<div class="payment-row"><div class="l">Vrsta</div><div class="v">${esc(l.vrsta_pregleda || '')}</div></div>
<div class="payment-row"><div class="l">Ustanova</div><div class="v">${esc(l.ustanova || '')}</div></div>
<div class="payment-row"><div class="l">Liječnik</div><div class="v">${esc(l.lijecnik || '')}</div></div>
<div class="payment-row"><div class="l">EKG / Krv / Spirometrija</div><div class="v">${l.ekg?'✓':'✗'} / ${l.krv?'✓':'✗'} / ${l.spirometrija?'✓':'✗'}</div></div>
<div class="payment-row"><div class="l">Spreman za natjecanje</div><div class="v">${l.spreman_za_natjecanje?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</div></div>
<div class="payment-row"><div class="l">Iznos / plaćeno</div><div class="v">${fmtEur(l.iznos)} ${l.placeno?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</div></div>
</div>
${l.komentar_lijecnika ? `<div style="margin-top:12px;padding:10px;background:var(--bg);border-left:3px solid var(--pgz-blue);border-radius:5px"><div style="font-size:11px;color:var(--t3);margin-bottom:4px">KOMENTAR LIJEČNIKA</div>${esc(l.komentar_lijecnika)}</div>` : ''}
${l.napomena ? `<div style="margin-top:8px;padding:10px;background:var(--bg);border-left:3px solid var(--warn);border-radius:5px"><div style="font-size:11px;color:var(--t3);margin-bottom:4px">NAPOMENA</div>${esc(l.napomena)}</div>` : ''}
<div style="text-align:right;margin-top:14px">
<button class="btn" onclick="openZakaziModal(${l.id}, '${esc(l.clan)}')">📅 Zakaži novi termin</button>
</div>
</div>`);
}
function newLijecnickiModal() {
openModal(`
<div class="modal-h">
<div class="modal-t">+ Novi liječnički pregled</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitNewLijecnicki(event)">
<div class="field"><label class="req">Član ID</label>
<input name="clan_id" type="number" required></div>
<div class="field"><label class="req">Datum pregleda</label>
<input name="datum_pregleda" type="date" required value="${new Date().toISOString().slice(0,10)}"></div>
<div class="field"><label>Vrijedi do (auto +1 god)</label>
<input name="vrijedi_do" type="date"></div>
<div class="field"><label>Vrsta pregleda</label>
<select name="vrsta_pregleda">
<option value="temeljni">Temeljni</option>
<option value="kontrolni">Kontrolni</option>
<option value="izvanredni">Izvanredni</option>
</select></div>
<div class="field"><label>Ustanova</label>
<input name="ustanova" type="text" value="ZZJZ PGŽ"></div>
<div class="field"><label>Liječnik</label>
<input name="lijecnik" type="text"></div>
<div class="field"><label>Iznos (EUR)</label>
<input name="iznos" type="number" step="0.01" value="60"></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Spremi pregled</button>
</div>
</form>
</div>`);
}
async function submitNewLijecnicki(e) {
e.preventDefault();
const f = e.target;
const body = {
clan_id: parseInt(f.clan_id.value),
datum_pregleda: f.datum_pregleda.value,
vrijedi_do: f.vrijedi_do.value || null,
vrsta_pregleda: f.vrsta_pregleda.value,
ustanova: f.ustanova.value,
lijecnik: f.lijecnik.value || null,
iznos: parseFloat(f.iznos.value || 0),
};
try {
await api('/lijecnicki', {method:'POST', body});
closeModal();
toast('Pregled spremljen.');
loadLijecnicki();
} catch (err) { toast('Greška: ' + err.message, true); }
}
// ════════════════════════════════════════════════════
// MODUL 3 — OBRASCI (M9)
// ════════════════════════════════════════════════════
async function loadObrasci() {
const root = $('#page-obrasci');
root.innerHTML = '<div class="loading">Učitavanje obrazaca…</div>';
let templates, submissions;
try {
templates = await api('/forms');
submissions = await api('/forms/submissions?limit=50');
} catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-obrasci').textContent = templates.count;
const ss = submissions.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Templati</div><div class="kpi-v">${fmt(templates.count)}</div></div>
<div class="kpi g"><div class="kpi-l">Predani</div><div class="kpi-v">${fmt(ss.submitted)}</div></div>
<div class="kpi a"><div class="kpi-l">Draft</div><div class="kpi-v">${fmt(ss.draft)}</div></div>
<div class="kpi b"><div class="kpi-l">Odobreni</div><div class="kpi-v">${fmt(ss.approved)}</div></div>
</div>`;
const cards = (templates.forms || []).map(f => `
<div class="card" style="margin-bottom:10px">
<div class="card-b" style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:600">${esc(f.naziv)}</div>
<div style="font-size:11px;color:var(--t3);margin-top:3px">${esc(f.code)} · ${esc(f.kategorija || '—')} · ${f.field_count} polja${f.opis ? ' · ' + esc(f.opis.substring(0,80)) : ''}</div>
</div>
<button class="btn primary" onclick="openFormFill('${esc(f.code)}')">📝 Otvori obrazac</button>
</div>
</div>`).join('');
const subRows = (submissions.rows || []).map(s => `
<tr>
<td><b>${esc(s.template_naziv || s.template_code)}</b><div style="font-size:11px;color:var(--t3)">${esc(s.reference_no || '')}</div></td>
<td>${esc(s.klub_naziv || '—')}</td>
<td>${fmtDate(s.created_at)}</td>
<td><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[s.status]||'gy'}">${esc(s.status)}</span></td>
<td><code style="font-size:10px;color:var(--ok)">${esc((s.signature_sha256 || '').substring(0,12))}${s.signature_sha256?'…':''}</code></td>
<td>
<button class="btn sm" onclick="openSubmissionDetalji(${s.id})" title="Detalji">👁</button>
<a class="btn sm" href="${API}/forms/submissions/${s.id}/pdf" target="_blank" title="PDF">📄</a>
</td>
</tr>`).join('');
root.innerHTML = kpi + `
<div class="row" style="display:grid;grid-template-columns:1fr 1.4fr;gap:14px">
<div>
<div class="card-h" style="border-radius:8px 8px 0 0;background:var(--bg2);border:1px solid var(--rim);border-bottom:none">
<div class="card-t">📋 Dostupni obrasci (${templates.count})</div>
</div>
<div style="background:var(--bg2);border:1px solid var(--rim);border-top:none;border-radius:0 0 8px 8px;padding:12px;max-height:600px;overflow-y:auto">${cards}</div>
</div>
<div>
<div class="card">
<div class="card-h"><div class="card-t">Predani obrasci (${submissions.count})</div></div>
<table>
<thead><tr><th>Obrazac</th><th>Klub</th><th>Datum</th><th>Status</th><th>SHA-256</th><th></th></tr></thead>
<tbody>${subRows || '<tr><td colspan="6" class="empty">Nema predanih obrazaca.</td></tr>'}</tbody>
</table>
</div>
</div>
</div>`;
}
async function openFormFill(code) {
let tpl, prefill;
try {
tpl = await api('/forms/' + code);
// prefill bez klub_id pretpostavlja prazan
prefill = await api(`/forms/${code}/prefill`);
} catch (e) { return toast('Greška: ' + e.message, true); }
const fields = (tpl.schema_json && tpl.schema_json.fields) || [];
const pre = prefill.prefill || {};
const fieldsHtml = fields.map(f => {
const v = pre[f.name] != null ? pre[f.name] : '';
const reqClass = f.required ? 'req' : '';
let inp = '';
if (f.type === 'textarea') {
inp = `<textarea name="${esc(f.name)}">${esc(v)}</textarea>`;
} else if (f.type === 'select' && Array.isArray(f.options)) {
inp = `<select name="${esc(f.name)}"><option value=""></option>${f.options.map(o => `<option ${o===v?'selected':''}>${esc(o)}</option>`).join('')}</select>`;
} else if (f.type === 'date') {
inp = `<input type="date" name="${esc(f.name)}" value="${esc(v)}">`;
} else if (f.type === 'number') {
inp = `<input type="number" name="${esc(f.name)}" value="${esc(v)}" ${f.required?'required':''}>`;
} else if (f.type === 'file') {
inp = `<input type="text" name="${esc(f.name)}" placeholder="(file upload — TODO)">`;
} else {
inp = `<input type="text" name="${esc(f.name)}" value="${esc(v)}" ${f.required?'required':''}>`;
}
return `<div class="field"><label class="${reqClass}">${esc(f.label || f.name)}</label>${inp}${f.help ? `<div class="help">${esc(f.help)}</div>` : ''}</div>`;
}).join('');
openModal(`
<div class="modal-h">
<div class="modal-t">📝 ${esc(tpl.naziv)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<p style="color:var(--t2);font-size:12px;margin-top:0">${esc(tpl.opis || '')} <br><span style="color:var(--t3)">Polja označena * su obavezna. Submit = digitalni potpis (sha256) + status "submitted".</span></p>
<form onsubmit="submitFormFill(event, '${esc(code)}')">
<div class="field"><label>Klub ID (opcionalno — za bolju autopopulaciju)</label>
<input id="fill-klub" type="number" placeholder="npr. 10" onchange="reloadPrefill('${esc(code)}', this.value)"></div>
${fieldsHtml}
<div class="field"><label>Vaše ime/prezime (digitalni potpis)</label>
<input name="__signer" type="text" placeholder="npr. Damir Radulić" required></div>
<div style="text-align:right;margin-top:14px">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="button" class="btn" onclick="saveFormDraft(event, '${esc(code)}', this)">💾 Spremi draft</button>
<button type="submit" class="btn primary">✍ Potpiši i predaj</button>
</div>
</form>
</div>`);
}
async function reloadPrefill(code, klubId) {
if (!klubId) return;
try {
const data = await api(`/forms/${code}/prefill?klub_id=${parseInt(klubId)}`);
Object.entries(data.prefill || {}).forEach(([k, v]) => {
const el = document.querySelector(`[name="${k}"]`);
if (el && !el.value) el.value = v;
});
toast(`Autopopulirano ${data.applied_fields.length} polja iz kluba ${klubId}`);
} catch (err) { toast('Prefill greška: ' + err.message, true); }
}
function _collectFormData(form) {
const data = {};
let signer = null;
let klubId = null;
Array.from(form.elements).forEach(el => {
if (!el.name) return;
if (el.name === '__signer') { signer = el.value; return; }
if (el.id === 'fill-klub') { klubId = el.value ? parseInt(el.value) : null; return; }
data[el.name] = el.value;
});
return {data, signer, klubId};
}
async function submitFormFill(e, code) {
e.preventDefault();
const {data, signer, klubId} = _collectFormData(e.target);
try {
// create draft
const draft = await api('/forms/submissions', {method:'POST', body: {
template_code: code, klub_id: klubId, data,
}});
// submit + sign
const signed = await api('/forms/submissions/' + draft.id + '/submit', {method:'POST', body: {
full_name: signer, confirm: true,
}});
closeModal();
toast('Obrazac potpisan i predan. SHA-256: ' + signed.signature_sha256.substring(0,12) + '…');
showSignatureConfirm(signed);
loadObrasci();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function saveFormDraft(e, code, btn) {
const form = btn.closest('form');
const {data, klubId} = _collectFormData(form);
try {
const draft = await api('/forms/submissions', {method:'POST', body: {
template_code: code, klub_id: klubId, data,
}});
closeModal();
toast('Spremljen draft #' + draft.id + ' (REF ' + draft.reference_no + ')');
loadObrasci();
} catch (err) { toast('Greška: ' + err.message, true); }
}
function showSignatureConfirm(signed) {
setTimeout(() => openModal(`
<div class="modal-h">
<div class="modal-t">✓ Obrazac digitalno potpisan</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Submission ID</div><div class="v">#${signed.id}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag am">${esc(signed.status)}</span></div></div>
<div class="payment-row"><div class="l">Potpisao</div><div class="v">${esc(signed.signed_by)}</div></div>
<div class="payment-row"><div class="l">Vrijeme</div><div class="v" style="font-size:11px">${esc(signed.signed_at)}</div></div>
</div>
<div class="signature-box">
<div style="color:var(--t2);margin-bottom:6px">DIGITALNI POTPIS — SHA-256</div>
<div class="sha">${esc(signed.signature_sha256)}</div>
</div>
<div style="text-align:right;margin-top:14px">
<a class="btn primary" href="${API}/forms/submissions/${signed.id}/pdf" target="_blank">📄 Preuzmi PDF</a>
</div>
</div>`), 200);
}
async function openSubmissionDetalji(sid) {
let s;
try { s = await api('/forms/submissions/' + sid); }
catch (e) { return toast('Greška: ' + e.message, true); }
const data = s.data || {};
const fields = (s.schema_json && s.schema_json.fields) || [];
const fieldsHtml = fields.filter(f => !f.name.startsWith('__')).map(f => {
const v = data[f.name];
if (v == null || v === '') return '';
return `<div class="payment-row"><div class="l">${esc(f.label || f.name)}</div><div class="v">${esc(v).substring(0,200)}</div></div>`;
}).join('');
const sig = data.__signature_sha256;
openModal(`
<div class="modal-h">
<div class="modal-t">📋 Submission #${s.id}${esc(s.template_naziv)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Reference</div><div class="v">${esc(s.reference_no || '')}</div></div>
<div class="payment-row"><div class="l">Klub</div><div class="v">${esc(s.klub_naziv || '—')}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[s.status]||'gy'}">${esc(s.status)}</span></div></div>
<div class="payment-row"><div class="l">Predano</div><div class="v">${fmtDate(s.submitted_at)}</div></div>
</div>
<div class="card-h" style="background:transparent;border:none;padding:8px 0;margin-top:14px"><div class="card-t">Sadržaj</div></div>
<div class="payment-card">${fieldsHtml || '<div style="color:var(--t3)">Prazno.</div>'}</div>
${sig ? `<div class="signature-box"><div style="color:var(--t2);margin-bottom:6px">DIGITALNI POTPIS — SHA-256</div><div class="sha">${esc(sig)}</div><div style="margin-top:6px;color:var(--t3)">Potpisao: ${esc(data.__signed_by||'')}${esc(data.__signed_at||'')}</div></div>` : '<div style="color:var(--err);margin-top:10px;font-size:12px">⚠ Nije digitalno potpisan</div>'}
<div style="text-align:right;margin-top:14px;display:flex;gap:8px;justify-content:flex-end">
${s.status === 'submitted' ? `
<button class="btn" onclick="approveSub(${s.id})">✓ Odobri</button>
<button class="btn danger" onclick="rejectSub(${s.id})">✗ Odbij</button>
` : ''}
<button class="btn" onclick="reSign(${s.id})">✍ Potpiši ponovno</button>
<a class="btn primary" href="${API}/forms/submissions/${s.id}/pdf" target="_blank">📄 PDF</a>
</div>
</div>`);
}
async function approveSub(sid) {
if (!confirm('Odobri submission #' + sid + '?')) return;
try {
await api('/forms/submissions/' + sid + '/approve', {method:'POST', body: {user_id: 1}});
closeModal(); toast('Submission #' + sid + ' odobren.'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function rejectSub(sid) {
const reason = prompt('Razlog odbijanja:');
if (!reason) return;
try {
await api('/forms/submissions/' + sid + '/reject', {method:'POST', body: {user_id: 1, reason}});
closeModal(); toast('Submission #' + sid + ' odbijen.'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function reSign(sid) {
const name = prompt('Vaše ime za potpis:');
if (!name) return;
try {
const r = await api('/forms/submissions/' + sid + '/sign', {method:'POST', body: {full_name: name, user_id: 1}});
closeModal(); toast('Potpisano. SHA-256: ' + r.signature_sha256.substring(0,12) + '…'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
// ────────────────────────────────────────────────────
// init
// ────────────────────────────────────────────────────
loadClanarine();
// preload counts
(async () => {
try {
const lj = await api('/lijecnicki?limit=1');
$('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?';
const fm = await api('/forms');
$('#cnt-obrasci').textContent = fm.count;
} catch (e) {}
})();
</script>
</body>
</html>
+386
View File
@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · ERP — OCR + Putni nalozi</title>
<!--
erp.html — PGŽ Sport ERP UI (M5 OCR + M6 Putni nalozi)
Author: dradulic@outlook.com / damir@rinet.one — 2026-05-04
Real backend: /api/erp/ocr/upload, /parse, /invoices, /putni-nalog
-->
<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'>€</text></svg>">
<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; --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; }
.app { display:grid; grid-template-columns:230px 1fr; min-height:100vh; }
.sidebar { background:var(--bg-2); border-right:1px solid var(--border); padding:20px 0; }
.brand { padding:0 20px 18px; border-bottom:1px solid var(--border); margin-bottom:10px; }
.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; gap:10px; padding:10px 20px; cursor:pointer; color:var(--text-2); font-size:13px; border-left:3px solid transparent; align-items:center; }
.nav-item:hover { background:var(--bg-3); color:var(--text); }
.nav-item.active { color:var(--accent); background:rgba(0,240,255,.05); border-left-color:var(--accent); }
.main { padding:24px 30px; overflow-y:auto; }
.header { display:flex; justify-content:space-between; padding-bottom:14px; border-bottom:1px solid var(--border); margin-bottom:18px; align-items:center; }
.header h2 { font-size:22px; font-weight:700; }
.header .meta { color:var(--text-3); font-size:12px; font-family:'JetBrains Mono',monospace; }
.section { background:var(--bg-2); border:1px solid var(--border); border-radius:8px; padding:18px; margin-bottom:16px; }
.section h3 { font-size:14px; font-weight:600; color:var(--accent); margin-bottom:12px; }
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:.5px; border-bottom:1px solid var(--border); }
td { padding:10px; border-bottom:1px solid var(--border); }
td.num { font-family:'JetBrains Mono',monospace; text-align:right; }
tr:hover { background:var(--bg-3); }
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.badge.green { background:rgba(86,211,100,.15); color:var(--green); }
.badge.yellow { background:rgba(210,153,34,.15); color:var(--yellow); }
.badge.red { background:rgba(248,81,73,.15); color:var(--red); }
.badge.gray { background:rgba(110,118,129,.15); color:var(--text-3); }
input.fld, select.fld { width:100%; background:var(--bg); border:1px solid var(--border); padding:8px 10px; border-radius:4px; color:var(--text); font-family:inherit; font-size:13px; }
input.fld:focus, select.fld:focus { outline:none; border-color:var(--accent); }
label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4px; text-transform:uppercase; letter-spacing:.5px; }
.btn { padding:9px 18px; background:var(--accent); color:var(--bg); border:0; border-radius:4px; cursor:pointer; font-weight:600; font-family:inherit; font-size:13px; }
.btn.sec { background:var(--bg-3); color:var(--text); border:1px solid var(--border); }
.tab { display:none; }
.tab.active { display:block; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><h1>PGŽ ERP</h1><div class="sub">M5 OCR + M6 Putni nalozi</div></div>
<div class="nav-item active" data-tab="ocr"><span>📷</span><span>Skeniraj račun</span></div>
<div class="nav-item" data-tab="invoices"><span></span><span>Računi</span></div>
<div class="nav-item" data-tab="putni"><span>🚗</span><span>Novi putni nalog</span></div>
<div class="nav-item" data-tab="putni-list"><span>📋</span><span>Lista putnih naloga</span></div>
</aside>
<main class="main">
<div class="header">
<h2 id="pageTitle">Skeniraj račun (OCR)</h2>
<span class="meta" id="metaInfo">Tesseract + DeepSeek V3 · /api/erp</span>
</div>
<!-- OCR -->
<div class="tab active" id="tab-ocr">
<div class="section">
<h3>📷 Drag-and-drop OCR (PDF / JPG / PNG)</h3>
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:34px;text-align:center;cursor:pointer;background:var(--bg-3)">
<div style="font-size:36px;color:var(--accent);margin-bottom:6px"></div>
<div style="font-size:14px;font-weight:600">Povuci datoteku ovdje ili klikni za odabir</div>
<div style="font-size:11px;color:var(--text-3);margin-top:6px">Tesseract OCR (hrv+eng) + DeepSeek V3 LLM ekstrakcija polja</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 class="grid2" style="font-size:13px">
<div><label class="lbl">Izdavatelj</label><input id="oc_vendor_name" class="fld"></div>
<div><label class="lbl">OIB izdavatelja</label><input id="oc_vendor_oib" class="fld"></div>
<div><label class="lbl">Broj računa</label><input id="oc_invoice_no" class="fld"></div>
<div><label class="lbl">Datum</label><input id="oc_invoice_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos neto (€)</label><input id="oc_amount_net" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">PDV (€)</label><input id="oc_amount_vat" type="number" step="0.01" class="fld"></div>
<div><label class="lbl" style="color:var(--accent)">Brutto / UKUPNO (€)</label><input id="oc_amount_gross" type="number" step="0.01" class="fld" style="border-color:var(--accent)"></div>
<div><label class="lbl">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">IBAN</label><input id="oc_iban" class="fld"></div>
<div><label class="lbl">Valuta</label><select id="oc_currency" class="fld"><option>EUR</option><option>HRK</option></select></div>
<div><label class="lbl">Vrsta troška</label>
<select id="oc_kind" class="fld">
<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 class="lbl">Klub</label><select id="oc_klub" class="fld"></select></div>
</div>
<div style="margin-top:10px"><label class="lbl">Opis</label><input id="oc_description" class="fld"></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" class="btn">💾 Spremi račun</button>
<button id="ocCancel" class="btn sec">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
</div>
<!-- Invoices list -->
<div class="tab" id="tab-invoices">
<div class="section">
<h3>Računi (svi klubovi)</h3>
<table id="invTable"><thead><tr><th>#</th><th>Vrsta</th><th>Broj</th><th>Dobavljač</th><th>OIB</th><th>Klub</th><th class="num">Brutto</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Putni nalog form -->
<div class="tab" id="tab-putni">
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div class="grid3" style="font-size:13px">
<div><label class="lbl">Klub</label><select id="pn_klub" class="fld"></select></div>
<div><label class="lbl">Voditelj</label><input id="pn_voditelj" class="fld" placeholder="Ime Prezime"></div>
<div><label class="lbl">Putnici (zarez)</label><input id="pn_putnici" class="fld"></div>
<div style="grid-column:span 3"><label class="lbl">Svrha putovanja</label><input id="pn_svrha" class="fld" placeholder="Natjecanje, treninzi, edukacija…"></div>
<div><label class="lbl">Od grada</label><input id="pn_od" class="fld" value="Rijeka"></div>
<div><label class="lbl">Do grada</label><input id="pn_do" class="fld"></div>
<div><label class="lbl">Zemlja</label><input id="pn_country" class="fld" value="Hrvatska"></div>
<div><label class="lbl">Polazak</label><input id="pn_from" type="datetime-local" class="fld"></div>
<div><label class="lbl">Povratak</label><input id="pn_to" type="datetime-local" class="fld"></div>
<div><label class="lbl">Tip vozila</label>
<select id="pn_vehicle" class="fld">
<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 class="lbl">Registracija</label><input id="pn_plate" class="fld"></div>
<div><label class="lbl">Kilometara</label><input id="pn_km" type="number" step="1" class="fld" value="0"></div>
<div><label class="lbl">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="fld" 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;align-items:center">
<button id="pnSave" class="btn">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
<p style="margin-top:14px;font-size:11px;color:var(--text-3);line-height:1.6">
<b>HR pravilnik 2025:</b> domaće 26.54 € (>8h), 13.27 € (58h), 0 € (&lt;5h). Inozemne dnevnice po zemlji
(Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km.
</p>
</div>
</div>
<!-- Putni nalozi list -->
<div class="tab" id="tab-putni-list">
<div class="section">
<h3>Lista putnih naloga</h3>
<table id="pnTable"><thead><tr><th>#</th><th>Klub</th><th>Destinacija</th><th>Polazak</th><th>Povratak</th><th class="num">Dnevnice</th><th class="num">Transport</th><th class="num">Total</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<script>
const ERP_API = '/api/erp';
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
const fmt = n => n == null ? '—' : new Intl.NumberFormat('hr-HR').format(n);
const fmtEur = n => n != null ? '€' + fmt(Math.round(n*100)/100) : '—';
const fmtDate = d => d ? d.substring(0,10) : '—';
function badge(t,c) { return `<span class="badge ${c}">${t||'—'}</span>`; }
function sBadge(s) {
if (!s) return badge('—','gray');
const x = s.toLowerCase();
if (['paid','approved','active','odobren','zatvoren'].includes(x)) return badge(s,'green');
if (['pending','draft','submitted','open','unpaid'].includes(x)) return badge(s,'yellow');
if (['overdue','rejected','cancelled','failed'].includes(x)) return badge(s,'red');
return badge(s,'gray');
}
async function loadKlubovi() {
const r = await fetch('/api/klubovi?limit=400').then(r=>r.json()).catch(()=>null);
if (!r) return;
const arr = Array.isArray(r) ? r : (r.rows || r.items || []);
const opts = '<option value="">— odaberi klub —</option>' + arr
.map(k => ({id: k.id, naziv: (k.naziv || k.klub || k.sport || '#'+k.id).toString().trim()}))
.filter(k => k.naziv)
.sort((a,b) => a.naziv.localeCompare(b.naziv,'hr'))
.map(k => `<option value="${k.id}">${k.naziv.replace(/"/g,'&quot;')}</option>`).join('');
['oc_klub','pn_klub'].forEach(id => { const e=$('#'+id); if (e) e.innerHTML=opts; });
}
let ocrUploadId = null, ocrParsed = null;
function ocrSet(m,c) { const e=$('#ocrStatus'); if(e){e.textContent=m||''; e.style.color=c||'var(--text-2)';} }
async function ocrHandle(file) {
if (!file) return;
ocrSet('⏳ Učitavam datoteku…','var(--yellow)');
const klubVal = $('#oc_klub')?.value || '';
const fd = new FormData();
fd.append('file', file);
if (klubVal) fd.append('klub_id', klubVal);
fd.append('tenant_id', 1);
fd.append('invoice_kind', $('#oc_kind')?.value || 'ostalo');
let r = await fetch(`${ERP_API}/ocr/upload`, {method:'POST',body:fd});
if (!r.ok) { ocrSet('❌ Upload pao: '+r.status,'var(--red)'); return; }
const j = await r.json();
ocrUploadId = j.upload_id;
ocrSet(`✓ Uploaded #${ocrUploadId} (${j.size} B). Pokrećem OCR + DeepSeek V3 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});
const p = await r.json();
if (!p.ok) { ocrSet('❌ '+(p.error||'Parse fail'),'var(--red)'); return; }
ocrParsed = p.extracted || {};
$('#oc_vendor_name').value = ocrParsed.vendor_name || '';
$('#oc_vendor_oib').value = ocrParsed.vendor_oib || '';
$('#oc_invoice_no').value = ocrParsed.invoice_no || '';
$('#oc_invoice_date').value = ocrParsed.invoice_date|| '';
$('#oc_amount_net').value = ocrParsed.amount_net ?? '';
$('#oc_amount_vat').value = ocrParsed.amount_vat ?? '';
$('#oc_amount_gross').value = ocrParsed.amount_gross?? '';
$('#oc_vat_rate').value = ocrParsed.vat_rate ?? '';
$('#oc_iban').value = ocrParsed.iban || '';
$('#oc_kind').value = ocrParsed.category || 'ostalo';
$('#oc_currency').value = ocrParsed.currency || 'EUR';
$('#oc_description').value = ocrParsed.description|| '';
$('#oc_raw').textContent = (p.raw_text_preview||'').slice(0,4000);
$('#ocrResult').style.display = 'block';
ocrSet(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja → "Spremi račun".`,'var(--green)');
}
function ocrInit() {
const drop = $('#ocrDrop'), inp = $('#ocrFile');
drop.addEventListener('click', () => inp.click());
inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandle(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) ocrHandle(f); });
$('#ocCancel').addEventListener('click', () => { $('#ocrResult').style.display='none'; ocrUploadId=null; ocrParsed=null; ocrSet(''); inp.value=''; });
$('#ocSave').addEventListener('click', async () => {
const klub = $('#oc_klub').value;
if (!klub) { $('#ocSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1, upload_id: ocrUploadId,
invoice_kind: $('#oc_kind').value || 'ostalo',
invoice_no: $('#oc_invoice_no').value, vendor_name: $('#oc_vendor_name').value,
vendor_oib: $('#oc_vendor_oib').value, invoice_date: $('#oc_invoice_date').value,
amount_net: parseFloat($('#oc_amount_net').value)||null,
amount_vat: parseFloat($('#oc_amount_vat').value)||null,
amount_gross: parseFloat($('#oc_amount_gross').value),
vat_rate: parseFloat($('#oc_vat_rate').value)||null,
iban_to: $('#oc_iban').value || null,
currency: $('#oc_currency').value || 'EUR',
category: $('#oc_kind').value || 'ostalo',
description: $('#oc_description').value || null,
};
$('#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) {
$('#ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`;
$('#ocSaveStatus').style.color = 'var(--green)';
setTimeout(() => { $('#ocrResult').style.display='none'; loadInvoices(); }, 1500);
} else {
$('#ocSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#ocSaveStatus').style.color = 'var(--red)';
}
});
}
let pnTimer = null;
async function pnPreview() {
const df = $('#pn_from').value, dt = $('#pn_to').value;
const country = $('#pn_country').value || 'Hrvatska';
const km = parseFloat($('#pn_km').value || 0);
const kr = parseFloat($('#pn_kmrate').value || 0.5);
const tgt = $('#pn_preview');
if (!df || !dt) { tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; }
const r = await fetch(`${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${kr}`).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { tgt.textContent='⚠ Neuspješan obračun'; return; }
const d = r.preview;
tgt.innerHTML = `
<div class="grid4">
<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:16px;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:16px;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 = $('#'+id); if (el) el.addEventListener('input', () => { clearTimeout(pnTimer); pnTimer = setTimeout(pnPreview, 250); });
});
$('#pnSave').addEventListener('click', async () => {
const klub = $('#pn_klub').value;
if (!klub) { $('#pnSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1,
voditelj_ime: $('#pn_voditelj').value,
putnici: ($('#pn_putnici').value||'').split(',').map(s=>s.trim()).filter(Boolean),
svrha: $('#pn_svrha').value,
od_grada: $('#pn_od').value, do_grada: $('#pn_do').value,
datum_polaska: $('#pn_from').value, datum_povratka: $('#pn_to').value,
country: $('#pn_country').value,
vehicle_type: $('#pn_vehicle').value,
registracija_vozila: $('#pn_plate').value,
kilometara: parseFloat($('#pn_km').value)||0,
km_rate: parseFloat($('#pn_kmrate').value)||0.5,
};
$('#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) {
$('#pnSaveStatus').innerHTML = `✓ Putni nalog #${j.putni_nalog.id} kreiran (€${j.putni_nalog.cost_total})`;
$('#pnSaveStatus').style.color = 'var(--green)';
loadPutni();
} else {
$('#pnSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#pnSaveStatus').style.color = 'var(--red)';
}
});
}
async function loadInvoices() {
const r = await fetch(`${ERP_API}/invoices?limit=50`).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>`
<tr><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td>
<td>${i.vendor_name||'—'}</td><td style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
<td>${i.klub_naziv||'—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
async function loadPutni() {
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>`
<tr><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td>
<td>${fmtDate(p.date_from)}</td><td>${fmtDate(p.date_to)}</td>
<td class="num">${fmtEur(p.dnevnice_amount)}</td>
<td class="num">${fmtEur(p.cost_transport)}</td>
<td class="num"><strong>${fmtEur(p.cost_total)}</strong></td>
<td>${sBadge(p.status)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
function activate(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
const titles = {ocr:'Skeniraj račun (OCR)',invoices:'Računi',putni:'Novi putni nalog','putni-list':'Lista putnih naloga'};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'invoices') loadInvoices();
if (name === 'putni-list') loadPutni();
}
$$('.nav-item').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
(async () => {
await loadKlubovi();
ocrInit();
pnInit();
})();
</script>
</body>
</html>
+538
View File
@@ -0,0 +1,538 @@
<!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>
</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="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="/sport/static/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 = '/sport/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) {
const btn = $('#submitBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Prijavljujem…';
try {
const r = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password })
});
const data = await r.json();
if (!r.ok) {
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)) {
location.href = '/sport/static/admin_users.html';
} else {
location.href = '/sport/';
}
}, 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;
if (!email || !pwd) return;
doLogin(email, pwd);
});
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 = '/sport/static/admin_users.html';
return;
}
} catch {}
}
showConsent();
$('#email').focus();
})();
</script>
</body>
</html>
+5 -2
View File
@@ -255,6 +255,7 @@ a.tag:hover,.tag[onclick]:hover{transform:translateY(-1px);filter:brightness(1.1
<section id="pg-manifestacije" class="section"></section> <section id="pg-manifestacije" class="section"></section>
<section id="pg-mreza" class="section"></section> <section id="pg-mreza" class="section"></section>
<section id="pg-forenzika" class="section"></section> <section id="pg-forenzika" class="section"></section>
<section id="pg-audit" class="section"></section>
</div> </div>
</main> </main>
</div> </div>
@@ -280,7 +281,8 @@ const NAV_ITEMS = [
{id:'objekti', ic:'\u{1F4CD}', label:'Objekti'}, {id:'objekti', ic:'\u{1F4CD}', label:'Objekti'},
{id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'}, {id:'manifestacije', ic:'\u{1F4C5}', label:'Manifestacije'},
{id:'mreza', ic:'\u{1F578}', label:'Mreža'}, {id:'mreza', ic:'\u{1F578}', label:'Mreža'},
{id:'forenzika', ic:'⚠', label:'Forenzika'} {id:'forenzika', ic:'⚠', label:'Forenzika'},
{id:'audit', ic:'\u{1F512}', label:'Audit log'}
]; ];
const SECTION_TITLES = { const SECTION_TITLES = {
dashboard: ['Dashboard', 'Pregled stanja PGŽ Sporta'], dashboard: ['Dashboard', 'Pregled stanja PGŽ Sporta'],
@@ -291,7 +293,8 @@ const SECTION_TITLES = {
objekti: ['Sportski objekti', 'Geocodirana infrastruktura'], objekti: ['Sportski objekti', 'Geocodirana infrastruktura'],
manifestacije: ['Manifestacije', 'Sportski događaji'], manifestacije: ['Manifestacije', 'Sportski događaji'],
mreza: ['Mreža', 'Force-directed graf entiteta i veza'], mreza: ['Mreža', 'Force-directed graf entiteta i veza'],
forenzika: ['Forenzika', 'Kritični nalazi i alarmi'] forenzika: ['Forenzika', 'Kritični nalazi i alarmi'],
audit: ['Audit log', 'Polygon PoS pečaćenje ključnih akcija']
}; };
const _cache = {savezi:null, klubovi:null, clanovi:null, objekti:null, manifestacije:null, sufin:null, dash:null}; const _cache = {savezi:null, klubovi:null, clanovi:null, objekti:null, manifestacije:null, sufin:null, dash:null};