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:
@@ -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
Reference in New Issue
Block a user