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