CC2 R5: defense-in-depth JWT + invite/reset token flows + audit

#1 JWT middleware:
- pgz_sport_api.py: starlette middleware require_jwt_on_admin runs before
  every /api/admin/* route. Even routes that lack Depends(require_user)
  cannot be reached without a valid Bearer token (verifies signature,
  exp, typ='access', revocation via user_sessions). OPTIONS passes for CORS.

#2 Invitation flow:
- pgz_sport.user_action_tokens table (token_hash, user_id, kind, expires_at,
  used_at, created_by, ip, meta). Single-use, raw token never persisted.
- POST /api/admin/users/{id}/invite — issues 'invite' token (TTL 7d),
  marks must_change_pwd, revokes existing sessions, returns invite_link.
- GET  /api/auth/setup-password?token=X — preflight (no consume).
- POST /api/auth/setup-password — consumes token, sets password, sets
  email_verified=true.

#3 Password reset flow:
- POST /api/auth/forgot-password — generic 'ako račun postoji' response;
  issues 'reset' token (TTL 2h) only for active users. Token returned in
  response only on localhost or if PGZ_REVEAL_RESET_TOKEN=1.
- GET  /api/auth/reset-password?token=X — preflight.
- POST /api/auth/reset-password — consumes token, sets new password,
  revokes all active sessions.

#4 Audit coverage (auth events):
- login.ok, login.fail (with reason), login.locked, login.2fa_required,
  login.2fa_fail, logout, auth.refresh, password.change, password.reset.ok,
  password.reset.fail, password.forgot.issue, password.forgot.miss,
  invite.consume.ok, invite.consume.fail, user.invite, user.create,
  user.update, user.delete, user.role.change, user.suspend, user.unsuspend,
  user.password.reset, 2fa.verify.ok, 2fa.verify.fail, 2fa.disable.

#5 Live tests: 41/41 across 6 demo users (incl. fresh invited+deleted user).
   Phase 2 verifies 14 endpoints reject no-auth and accept valid Bearer.
This commit is contained in:
Damir Radulić
2026-05-05 01:28:29 +02:00
parent 8dce58c5f9
commit 0046b8d695
24 changed files with 15419 additions and 72 deletions
@@ -0,0 +1,761 @@
<!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 + Ri.NET AI Engine izvuče izdavatelja, OIB, datum, iznos, PDV, IBAN, stavke</div>
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
</div>
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Izdavatelj</label><input id="oc_vendor_name" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">OIB izdavatelja</label><input id="oc_vendor_oib" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Broj računa</label><input id="oc_invoice_no" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Datum</label><input id="oc_invoice_date" type="date" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Iznos neto</label><input id="oc_amount_net" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">PDV</label><input id="oc_amount_vat" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Brutto (UKUPNO)</label><input id="oc_amount_gross" type="number" step="0.01" class="search" style="max-width:none;width:100%;border-color:var(--accent)"></div>
<div><label style="font-size:11px;color:var(--text-3)">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">IBAN</label><input id="oc_iban" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Vrsta troška</label>
<select id="oc_kind" class="search" style="max-width:none;width:100%">
<option value="gorivo">Gorivo</option>
<option value="cestarina">Cestarina</option>
<option value="hotel">Hotel</option>
<option value="restoran">Restoran</option>
<option value="oprema">Oprema</option>
<option value="ostalo" selected>Ostalo</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Klub</label>
<select id="oc_klub" class="search" style="max-width:none;width:100%"></select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Valuta</label>
<select id="oc_currency" class="search" style="max-width:none;width:100%"><option>EUR</option><option>HRK</option></select>
</div>
</div>
<div style="margin-top:10px"><label style="font-size:11px;color:var(--text-3)">Opis</label><input id="oc_description" class="search" style="max-width:none;width:100%"></div>
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
</details>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<button id="ocSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">💾 Spremi račun</button>
<button id="ocCancel" style="padding:8px 14px;background:var(--bg-3);color:var(--text);border:1px solid var(--border);border-radius:4px;cursor:pointer">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
<!-- M6: Putni nalozi creation form -->
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:10px;font-size:13px">
<div><label style="font-size:11px;color:var(--text-3)">Klub</label><select id="pn_klub" class="search" style="max-width:none;width:100%"></select></div>
<div><label style="font-size:11px;color:var(--text-3)">Voditelj</label><input id="pn_voditelj" class="search" style="max-width:none;width:100%" placeholder="Ime Prezime"></div>
<div><label style="font-size:11px;color:var(--text-3)">Putnici (zarezom razdvojeno)</label><input id="pn_putnici" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Svrha</label><input id="pn_svrha" class="search" style="max-width:none;width:100%" placeholder="Natjecanje, treninzi…"></div>
<div><label style="font-size:11px;color:var(--text-3)">Od grada</label><input id="pn_od" class="search" style="max-width:none;width:100%" value="Rijeka"></div>
<div><label style="font-size:11px;color:var(--text-3)">Do grada</label><input id="pn_do" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Polazak</label><input id="pn_from" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Povratak</label><input id="pn_to" type="datetime-local" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Zemlja</label><input id="pn_country" class="search" style="max-width:none;width:100%" value="Hrvatska"></div>
<div><label style="font-size:11px;color:var(--text-3)">Tip vozila</label>
<select id="pn_vehicle" class="search" style="max-width:none;width:100%">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label style="font-size:11px;color:var(--text-3)">Registracija</label><input id="pn_plate" class="search" style="max-width:none;width:100%"></div>
<div><label style="font-size:11px;color:var(--text-3)">Kilometara</label><input id="pn_km" type="number" step="1" class="search" style="max-width:none;width:100%" value="0"></div>
<div><label style="font-size:11px;color:var(--text-3)">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="search" style="max-width:none;width:100%" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px">
<button id="pnSave" style="padding:8px 18px;background:var(--accent);color:var(--bg);border:0;border-radius:4px;cursor:pointer;font-weight:600">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
<div class="section">
<h3>Računi</h3>
<table id="invTable"><thead><tr><th>Broj</th><th>Dobavljač</th><th>Klub</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
<div class="section">
<h3>Putni nalozi / izdaci</h3>
<table id="expTable"><thead><tr><th>Broj</th><th>Klub</th><th>Destinacija</th><th class="num">Iznos</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- CRM klubovi -->
<div class="tab-content" id="tab-crm">
<input type="text" class="search" id="klubSearch" placeholder="Traži klub po imenu, OIB-u, gradu, sportu...">
<div class="section" style="margin-top: 14px;">
<h3>Klubovi</h3>
<table id="klubTable"><thead><tr><th>Naziv</th><th>OIB</th><th>Sport</th><th>Grad</th><th>Email</th><th class="num">Članovi</th><th class="num">Računi</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Osobe -->
<div class="tab-content" id="tab-osobe">
<input type="text" class="search" id="osobaSearch" placeholder="Traži po imenu, prezimenu, OIB-u...">
<div class="section" style="margin-top: 14px;">
<h3>Kontakti / Članovi</h3>
<table id="osobeTable"><thead><tr><th>Ime</th><th>Prezime</th><th>OIB</th><th>Klub</th><th>Pozicija</th><th>Email</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- 3D Graph -->
<div class="tab-content" id="tab-graph3d">
<div class="section">
<h3>3D Sport Graph</h3>
<p style="color: var(--text-3); margin-bottom: 12px;">Interaktivni 3D prikaz svih klubova, saveza i osoba s drill-down na detalje.</p>
<div class="iframe-wrap">
<iframe id="graph3dIframe" loading="lazy"></iframe>
</div>
</div>
</div>
<!-- Tenants -->
<div class="tab-content" id="tab-tenants">
<div class="section">
<h3>Multi-tenant Management</h3>
<p style="color: var(--text-3); margin-bottom: 16px;">Tenants u sustavu. Svaki tenant ima vlastiti scope klubova, financija i konfiguracije.</p>
<div class="tenants-grid" id="tenantsGrid"></div>
</div>
</div>
<!-- Reports -->
<div class="tab-content" id="tab-reports">
<div class="section">
<h3>Top 10 Klubova (po dokumentima i računima)</h3>
<table id="repTable"><thead><tr><th>Naziv</th><th>Sport</th><th>Grad</th><th class="num">Računi</th><th class="num">Članovi</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<script>
const API = '/admin/api';
let currentTenant = 1;
let dashboardData = null;
let tenantsList = [];
const $ = sel => document.querySelector(sel);
const $$ = sel => document.querySelectorAll(sel);
async function fetchJSON(url) {
try {
const r = await fetch(url);
if (!r.ok) throw new Error(r.status);
return await r.json();
} catch (e) { console.error('Fetch fail:', url, e); return null; }
}
function fmt(n) {
if (n == null) return '—';
if (typeof n !== 'number') return n;
return new Intl.NumberFormat('hr-HR').format(n);
}
function fmtEur(n) { return n != null ? '€' + fmt(Math.round(n)) : '—'; }
function fmtDate(d) { return d ? d.substring(0, 10) : '—'; }
function badge(text, color) { return '<span class="badge ' + color + '">' + (text || '—') + '</span>'; }
function statusBadge(s) {
if (!s) return badge('—', 'gray');
const s2 = s.toLowerCase();
if (['paid', 'approved', 'active', 'completed'].includes(s2)) return badge(s, 'green');
if (['pending', 'submitted', 'draft', 'open'].includes(s2)) return badge(s, 'yellow');
if (['overdue', 'rejected', 'cancelled', 'failed'].includes(s2)) return badge(s, 'red');
return badge(s, 'gray');
}
async function loadDashboard() {
const d = await fetchJSON(`${API}/dashboard?tenant_id=${currentTenant}`);
if (!d) return;
dashboardData = d;
const k = d.kpi;
$('#kpiGrid').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Klubovi</div><div class="kpi-value">${fmt(k.klubovi_total)}</div><div class="kpi-sub">${fmt(k.klubovi_aktivni_90d)} aktivnih /90d</div></div>
<div class="kpi-card green"><div class="kpi-label">Osobe</div><div class="kpi-value">${fmt(k.osobe)}</div><div class="kpi-sub">članovi i kontakti</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Računi</div><div class="kpi-value">${fmt(k.invoices)}</div><div class="kpi-sub">${fmtEur(k.invoices_total_eur)}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(k.expenses)}</div><div class="kpi-sub">${fmtEur(k.expenses_total_eur)}</div></div>
<div class="kpi-card"><div class="kpi-label">Aktivnost</div><div class="kpi-value">${fmt(k.activity_30d)}</div><div class="kpi-sub">audit eventova /30d</div></div>
<div class="kpi-card green"><div class="kpi-label">Dokumenti</div><div class="kpi-value">${fmt(k.dokumenti_7d)}</div><div class="kpi-sub">novih /7d</div></div>
`;
// Top klubovi
const top = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=8`);
if (top && top.top_klubovi) {
$('#topKlubovi tbody').innerHTML = top.top_klubovi.map(k => `
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td class="num">${fmt(k.clanovi)}</td><td class="num">${fmt(k.invoices)}</td></tr>
`).join('');
}
$('#metaInfo').textContent = `Tenant: ${d.tenant.display_name} · OIB: ${d.tenant.oib || '—'} · ${new Date().toLocaleString('hr-HR')}`;
}
async function loadERP() {
const s = await fetchJSON(`${API}/erp/summary?tenant_id=${currentTenant}`);
if (s) {
$('#erpKpi').innerHTML = `
<div class="kpi-card"><div class="kpi-label">Računi total</div><div class="kpi-value">${fmt(s.invoices.total)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_total)}</div></div>
<div class="kpi-card green"><div class="kpi-label">Plaćeno</div><div class="kpi-value">${fmt(s.invoices.paid)}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_paid)}</div></div>
<div class="kpi-card yellow"><div class="kpi-label">Neplaćeno</div><div class="kpi-value">${fmt(s.invoices.pending + s.invoices.overdue + (s.invoices.other||0))}</div><div class="kpi-sub">${fmtEur(s.invoices.sum_unpaid)}</div></div>
<div class="kpi-card purple"><div class="kpi-label">Putni nalozi</div><div class="kpi-value">${fmt(s.expenses.total)}</div><div class="kpi-sub">${fmtEur(s.expenses.sum_total)}</div></div>
<div class="kpi-card"><div class="kpi-label">Plaćanja /90d</div><div class="kpi-value">${fmt(s.payments_90d.total)}</div><div class="kpi-sub">${fmtEur(s.payments_90d.sum_total)}</div></div>
<div class="kpi-card green"><div class="kpi-label">Proračun</div><div class="kpi-value">${fmtEur(s.proracun.sum_planirano)}</div><div class="kpi-sub">${s.proracun.n} godina · izvršeno: ${fmtEur(s.proracun.sum_izvrseno)}</div></div>
`;
}
const inv = await fetchJSON(`${API}/erp/invoices?tenant_id=${currentTenant}&limit=20`);
if (inv && inv.invoices) {
$('#invTable tbody').innerHTML = inv.invoices.length ? inv.invoices.map(i => `
<tr><td>${i.invoice_no || '—'}</td><td>${i.vendor_name || '—'}</td>
<td>${i.klub_naziv || '—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${statusBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
const exp = await fetchJSON(`${API}/erp/expenses?tenant_id=${currentTenant}&limit=20`);
if (exp && exp.expenses) {
$('#expTable tbody').innerHTML = exp.expenses.length ? exp.expenses.map(e => `
<tr><td>${e.report_no || '—'}</td><td>${e.klub_naziv || '—'}</td>
<td>${e.destination || '—'}</td><td class="num">${fmtEur(e.cost_total)}</td>
<td>${statusBadge(e.status)}</td><td>${fmtDate(e.created_at)}</td></tr>
`).join('') : '<tr><td colspan="6" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
}
async function loadCRM(q='') {
const url = `${API}/crm/klubovi?tenant_id=${currentTenant}&limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
const d = await fetchJSON(url);
if (d && d.klubovi) {
$('#klubTable tbody').innerHTML = d.klubovi.map(k => `
<tr><td><strong>${k.naziv}</strong></td><td>${k.oib || '—'}</td>
<td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td>${k.email || '—'}</td><td class="num">${fmt(k.clanovi)}</td>
<td class="num">${fmt(k.invoices_count)}</td></tr>
`).join('');
}
}
async function loadOsobe(q='') {
const url = `${API}/crm/osobe?limit=50${q ? '&q=' + encodeURIComponent(q) : ''}`;
const d = await fetchJSON(url);
if (d && d.osobe) {
$('#osobeTable tbody').innerHTML = d.osobe.map(o => `
<tr><td>${o.ime}</td><td><strong>${o.prezime}</strong></td>
<td>${o.oib || '—'}</td><td>${o.klub_naziv || '—'}</td>
<td>${o.pozicija || '—'}</td><td>${o.email || '—'}</td>
<td>${o.aktivan ? badge('Aktivan', 'green') : badge('Neaktivan', 'gray')}</td></tr>
`).join('');
}
}
async function loadTenants() {
const d = await fetchJSON(`${API}/tenants`);
if (d && d.tenants) {
$('#tenantsGrid').innerHTML = d.tenants.map(t => `
<div class="tenant-card">
<div class="name">${t.display_name}</div>
<div class="slug">@${t.slug} · ${t.type} · ${t.oib || 'no OIB'}</div>
<div class="stats">
<div class="stat"><strong>${fmt(t.klubovi_count || 0)}</strong>klubovi</div>
<div class="stat"><strong>${statusBadge(t.status).match(/>([^<]+)</)[1]}</strong>status</div>
</div>
</div>
`).join('');
}
}
async function loadReports() {
const d = await fetchJSON(`${API}/reports/top_klubovi?tenant_id=${currentTenant}&limit=20`);
if (d && d.top_klubovi) {
$('#repTable tbody').innerHTML = d.top_klubovi.map(k => `
<tr><td>${k.naziv}</td><td>${k.sport || '—'}</td><td>${k.grad || '—'}</td>
<td class="num">${fmt(k.invoices)}</td><td class="num">${fmt(k.clanovi)}</td></tr>
`).join('');
}
}
function load3D() {
const f = $('#graph3dIframe');
if (!f.src) f.src = '/3d';
}
async function loadTenantSelector() {
const d = await fetchJSON(`${API}/tenants`);
if (d && d.tenants) {
tenantsList = d.tenants;
$('#tenantSel').innerHTML = d.tenants.map(t =>
`<option value="${t.id}" ${t.id === currentTenant ? 'selected' : ''}>${t.display_name}</option>`
).join('');
}
}
function activateTab(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + name));
const titles = {
dashboard: 'Dashboard',
erp: 'ERP — Financije',
crm: 'CRM — Klubovi',
osobe: 'Kontakti',
graph3d: '3D Graf',
tenants: 'Multi-tenant',
reports: 'Reports'
};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'dashboard') loadDashboard();
if (name === 'erp') loadERP();
if (name === 'crm') loadCRM();
if (name === 'osobe') loadOsobe();
if (name === 'graph3d') load3D();
if (name === 'tenants') loadTenants();
if (name === 'reports') loadReports();
}
// === M5: OCR upload (drag-and-drop) ===
const ERP_API = '/api/erp';
async function ocrLoadKlubSelectors() {
const sels = [document.getElementById('oc_klub'), document.getElementById('pn_klub')].filter(Boolean);
if (!sels.length) return;
// Use main API for klubovi list (admin-scoped)
const d = await fetch(`/api/klubovi?limit=400`).then(r => r.json()).catch(() => null);
if (!d) return;
const arr = Array.isArray(d) ? d : (d.rows || d.items || []);
const opts = '<option value="">— odaberi klub —</option>' + arr.map(k => `<option value="${k.id}">${k.naziv}</option>`).join('');
sels.forEach(s => { if (s) s.innerHTML = opts; });
}
let ocrParsed = null;
let ocrUploadId = null;
function ocrSetStatus(msg, color) {
const el = document.getElementById('ocrStatus');
if (el) { el.textContent = msg || ''; el.style.color = color || 'var(--text-2)'; }
}
async function ocrHandleFile(file) {
if (!file) return;
ocrSetStatus('⏳ Učitavam datoteku…', 'var(--yellow)');
const klubVal = document.getElementById('oc_klub')?.value || '';
const fd = new FormData();
fd.append('file', file);
if (klubVal) fd.append('klub_id', klubVal);
fd.append('tenant_id', currentTenant || 1);
fd.append('invoice_kind', document.getElementById('oc_kind')?.value || 'ostalo');
let r = await fetch(`${ERP_API}/ocr/upload`, {method: 'POST', body: fd});
if (!r.ok) { ocrSetStatus('❌ Upload pao: ' + r.status, 'var(--red)'); return; }
const j = await r.json();
ocrUploadId = j.upload_id;
ocrSetStatus(`✓ Uploaded (id=${ocrUploadId}, ${j.size} B). Pokrećem OCR + LLM ekstrakciju…`, 'var(--accent)');
const fd2 = new FormData();
fd2.append('upload_id', ocrUploadId);
fd2.append('use_llm', 'true');
r = await fetch(`${ERP_API}/ocr/parse`, {method: 'POST', body: fd2});
if (!r.ok) { ocrSetStatus('❌ Parse pao: ' + r.status, 'var(--red)'); return; }
const p = await r.json();
if (!p.ok) { ocrSetStatus('❌ ' + (p.error || 'Parse fail'), 'var(--red)'); return; }
ocrParsed = p.extracted || {};
document.getElementById('oc_vendor_name').value = ocrParsed.vendor_name || '';
document.getElementById('oc_vendor_oib').value = ocrParsed.vendor_oib || '';
document.getElementById('oc_invoice_no').value = ocrParsed.invoice_no || '';
document.getElementById('oc_invoice_date').value = ocrParsed.invoice_date || '';
document.getElementById('oc_amount_net').value = ocrParsed.amount_net ?? '';
document.getElementById('oc_amount_vat').value = ocrParsed.amount_vat ?? '';
document.getElementById('oc_amount_gross').value = ocrParsed.amount_gross ?? '';
document.getElementById('oc_vat_rate').value = ocrParsed.vat_rate ?? '';
document.getElementById('oc_iban').value = ocrParsed.iban || '';
document.getElementById('oc_kind').value = ocrParsed.category || 'ostalo';
document.getElementById('oc_currency').value = ocrParsed.currency || 'EUR';
document.getElementById('oc_description').value = ocrParsed.description || '';
document.getElementById('oc_raw').textContent = (p.raw_text_preview || '').slice(0, 4000);
document.getElementById('ocrResult').style.display = 'block';
ocrSetStatus(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja i klikni "Spremi račun".`, 'var(--green)');
}
function ocrInitDrop() {
const drop = document.getElementById('ocrDrop');
const inp = document.getElementById('ocrFile');
if (!drop || !inp) return;
drop.addEventListener('click', () => inp.click());
inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandleFile(e.target.files[0]); });
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--accent)'; }));
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor = 'var(--border)'; }));
drop.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) ocrHandleFile(f); });
document.getElementById('ocCancel')?.addEventListener('click', () => {
document.getElementById('ocrResult').style.display = 'none';
ocrParsed = null; ocrUploadId = null; ocrSetStatus('');
inp.value = '';
});
document.getElementById('ocSave')?.addEventListener('click', async () => {
const klub = document.getElementById('oc_klub').value;
if (!klub) { document.getElementById('ocSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub),
tenant_id: currentTenant || 1,
upload_id: ocrUploadId,
invoice_kind: document.getElementById('oc_kind').value || 'ostalo',
invoice_no: document.getElementById('oc_invoice_no').value,
vendor_name: document.getElementById('oc_vendor_name').value,
vendor_oib: document.getElementById('oc_vendor_oib').value,
invoice_date: document.getElementById('oc_invoice_date').value,
amount_net: parseFloat(document.getElementById('oc_amount_net').value) || null,
amount_vat: parseFloat(document.getElementById('oc_amount_vat').value) || null,
amount_gross: parseFloat(document.getElementById('oc_amount_gross').value),
vat_rate: parseFloat(document.getElementById('oc_vat_rate').value) || null,
iban_to: document.getElementById('oc_iban').value || null,
currency: document.getElementById('oc_currency').value || 'EUR',
category: document.getElementById('oc_kind').value || 'ostalo',
description: document.getElementById('oc_description').value || null,
};
document.getElementById('ocSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
document.getElementById('ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`;
document.getElementById('ocSaveStatus').style.color = 'var(--green)';
setTimeout(() => { document.getElementById('ocrResult').style.display = 'none'; loadERP(); }, 1500);
} else {
document.getElementById('ocSaveStatus').textContent = '❌ ' + (j.detail || 'Greška');
document.getElementById('ocSaveStatus').style.color = 'var(--red)';
}
});
}
// === M6: Putni nalog form with live dnevnice preview ===
let pnPreviewTimer = null;
async function pnRefreshPreview() {
const df = document.getElementById('pn_from')?.value;
const dt = document.getElementById('pn_to')?.value;
const country = document.getElementById('pn_country')?.value || 'Hrvatska';
const km = parseFloat(document.getElementById('pn_km')?.value || 0);
const km_rate = parseFloat(document.getElementById('pn_kmrate')?.value || 0.5);
const tgt = document.getElementById('pn_preview');
if (!df || !dt) { if (tgt) tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; }
const url = `${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${km_rate}`;
const r = await fetch(url).then(r => r.json()).catch(() => null);
if (!r || !r.ok) { tgt.textContent = '⚠ Neuspješan obračun'; return; }
const d = r.preview;
tgt.innerHTML = `
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:14px">
<div><div style="color:var(--text-3);font-size:11px">Sati</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.hours}h</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pune dnevnice</div><div style="font-size:18px;color:var(--accent);font-family:'JetBrains Mono'">${d.days_full} × €${d.rate_full}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pola dnevnica</div><div style="font-size:18px;color:var(--yellow);font-family:'JetBrains Mono'">${d.days_half} × €${d.rate_half}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Dnevnice ukupno</div><div style="font-size:18px;color:var(--green);font-family:'JetBrains Mono'">€${d.dnevnica_amount_total}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometara</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.km_driven} km</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometrina</div><div style="font-size:18px;font-family:'JetBrains Mono'">€${d.km_amount}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Zemlja</div><div style="font-size:14px;font-family:'JetBrains Mono'">${d.country}</div></div>
<div><div style="color:var(--text-3);font-size:11px">PROCJENA UKUPNO</div><div style="font-size:22px;color:var(--accent);font-family:'JetBrains Mono';font-weight:700">€${d.total_estimated}</div></div>
</div>`;
}
function pnInit() {
['pn_from','pn_to','pn_country','pn_km','pn_kmrate'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', () => {
clearTimeout(pnPreviewTimer);
pnPreviewTimer = setTimeout(pnRefreshPreview, 250);
});
});
document.getElementById('pnSave')?.addEventListener('click', async () => {
const klub = document.getElementById('pn_klub').value;
if (!klub) { document.getElementById('pnSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub),
tenant_id: currentTenant || 1,
voditelj_ime: document.getElementById('pn_voditelj').value,
putnici: (document.getElementById('pn_putnici').value || '').split(',').map(s => s.trim()).filter(Boolean),
svrha: document.getElementById('pn_svrha').value,
od_grada: document.getElementById('pn_od').value,
do_grada: document.getElementById('pn_do').value,
datum_polaska: document.getElementById('pn_from').value,
datum_povratka: document.getElementById('pn_to').value,
country: document.getElementById('pn_country').value,
vehicle_type: document.getElementById('pn_vehicle').value,
registracija_vozila: document.getElementById('pn_plate').value,
kilometara: parseFloat(document.getElementById('pn_km').value) || 0,
km_rate: parseFloat(document.getElementById('pn_kmrate').value) || 0.5,
};
document.getElementById('pnSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/putni-nalog`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
const pn = j.putni_nalog;
document.getElementById('pnSaveStatus').innerHTML = `✓ Putni nalog #${pn.id} kreiran (€${pn.cost_total})`;
document.getElementById('pnSaveStatus').style.color = 'var(--green)';
loadERP();
} else {
document.getElementById('pnSaveStatus').textContent = '❌ ' + (j.detail || 'Greška');
document.getElementById('pnSaveStatus').style.color = 'var(--red)';
}
});
}
ocrInitDrop();
pnInit();
ocrLoadKlubSelectors();
// Init
$$('.nav-item').forEach(n => n.addEventListener('click', () => activateTab(n.dataset.tab)));
let searchTimeout;
$('#klubSearch').addEventListener('input', e => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadCRM(e.target.value), 300);
});
$('#osobaSearch').addEventListener('input', e => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => loadOsobe(e.target.value), 300);
});
$('#tenantSel').addEventListener('change', e => {
currentTenant = parseInt(e.target.value);
activateTab($('.nav-item.active').dataset.tab);
});
(async () => {
await loadTenantSelector();
await loadDashboard();
})();
</script>
</body>
</html>
+446
View File
@@ -0,0 +1,446 @@
#!/usr/bin/env python3
# admin_users.py — /api/admin/users CRUD endpoints
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
"""
GET /api/admin/users?tenant_type=&tenant_id=&q=
POST /api/admin/users
PUT /api/admin/users/{id}
DELETE /api/admin/users/{id}
POST /api/admin/users/{id}/invite
POST /api/admin/users/{id}/role
POST /api/admin/users/{id}/suspend
GET /api/admin/audit?user_id=&action=&limit=
GET /api/admin/tenants
POST /api/admin/users/bulk-csv
"""
import csv, io, secrets, json
from typing import Optional, List, Dict, Any
from datetime import datetime
from fastapi import APIRouter, HTTPException, Depends, Request, Body, UploadFile, File
from pydantic import BaseModel
from .auth_v2 import (
db_query, db_one, db_exec, hash_password,
require_user, audit, _client,
_resolve_tenant, _tier_for,
PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
VALID_USER_TYPES = (PGZ_USER_TYPES | SAVEZ_USER_TYPES | KLUB_USER_TYPES |
{"viewer", "guest"})
# ─────────────────────────── Permission helpers ───────────────────────────
def _is_pgz_admin(u: Dict) -> bool:
return (u.get("user_type") or "").lower() in ("super_admin", "pgz_admin")
def _is_savez_admin(u: Dict) -> bool:
return (u.get("user_type") or "").lower() == "savez_admin"
def _is_klub_admin(u: Dict) -> bool:
return (u.get("user_type") or "").lower() == "klub_admin"
def _can_manage(actor: Dict, target_user_type: str,
target_klub_id: Optional[int], target_savez_id: Optional[int]) -> bool:
"""Hierarchical management:
- super_admin / pgz_admin → manage everyone
- savez_admin → manage savez_*, klub_admin in their savez
- klub_admin → manage klub_user/klub_trener/klub_clan in their klub
"""
if _is_pgz_admin(actor): return True
tut = (target_user_type or "").lower()
if _is_savez_admin(actor):
if tut in PGZ_USER_TYPES: return False
if tut in SAVEZ_USER_TYPES and (target_savez_id == actor.get("savez_id")): return True
if tut == "klub_admin" and target_savez_id and target_savez_id == actor.get("savez_id"):
return True
# any klub user that belongs to this savez
if tut in KLUB_USER_TYPES and target_savez_id == actor.get("savez_id"):
return True
return False
if _is_klub_admin(actor):
if tut not in {"klub_user", "klub_trener", "klub_clan", "viewer"}:
return False
return target_klub_id and target_klub_id == actor.get("klub_id")
return False
def _scoped_where(actor: Dict) -> tuple:
"""Filter user list by actor's scope."""
if _is_pgz_admin(actor): return ("", [])
if _is_savez_admin(actor):
sid = actor.get("savez_id")
if not sid: return ("AND 1=0", [])
return ("AND (u.savez_id=%s OR u.klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s))",
[sid, sid])
if _is_klub_admin(actor):
kid = actor.get("klub_id")
if not kid: return ("AND 1=0", [])
return ("AND u.klub_id=%s", [kid])
return ("AND u.id=%s", [actor["id"]])
# ─────────────────────────── List / read ───────────────────────────
@router.get("/users")
def list_users(
q: Optional[str] = None,
user_type: Optional[str] = None,
tenant_type: Optional[str] = None,
tenant_id: Optional[int] = None,
klub_id: Optional[int] = None,
savez_id: Optional[int] = None,
aktivan: Optional[bool] = None,
limit: int = 100,
offset: int = 0,
actor = Depends(require_user),
):
if not (_is_pgz_admin(actor) or _is_savez_admin(actor) or _is_klub_admin(actor)):
raise HTTPException(403, "Forbidden — admin required")
where = ["1=1"]; args: List[Any] = []
sw, sp = _scoped_where(actor)
if sw: where.append(sw.replace("AND ", "")); args.extend(sp)
if q:
where.append("(LOWER(u.email) LIKE %s OR LOWER(u.full_name) LIKE %s OR LOWER(COALESCE(u.ime,'')) LIKE %s OR LOWER(COALESCE(u.prezime,'')) LIKE %s)")
like = f"%{q.lower()}%"; args.extend([like]*4)
if user_type: where.append("u.user_type=%s"); args.append(user_type)
if klub_id: where.append("u.klub_id=%s"); args.append(klub_id)
if savez_id: where.append("u.savez_id=%s"); args.append(savez_id)
if aktivan is not None: where.append("u.aktivan=%s"); args.append(aktivan)
if tenant_type and tenant_id is not None:
if tenant_type == "klub": where.append("u.klub_id=%s"); args.append(tenant_id)
elif tenant_type == "savez": where.append("u.savez_id=%s"); args.append(tenant_id)
base_args = list(args)
args.extend([limit, offset])
rows = db_query(f"""SELECT u.id, u.email, u.full_name, u.ime, u.prezime, u.user_type,
u.klub_id, u.savez_id, u.aktivan, u.status, u.must_change_pwd,
u.last_login, u.locked_until, u.failed_login_count, u.telefon,
u.created_at, u.gdpr_consent_at,
k.naziv AS klub_naziv, s.naziv AS savez_naziv
FROM pgz_sport.users u
LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id
LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id
WHERE {' AND '.join(where)}
ORDER BY u.id DESC LIMIT %s OFFSET %s""", tuple(args))
total = db_one(f"SELECT COUNT(*) AS c FROM pgz_sport.users u WHERE {' AND '.join(where)}",
tuple(base_args))["c"]
return {"count": len(rows), "total": total, "results": rows}
@router.get("/users/{uid}")
def get_user(uid: int, actor = Depends(require_user)):
u = db_one("""SELECT u.*, k.naziv AS klub_naziv, s.naziv AS savez_naziv
FROM pgz_sport.users u
LEFT JOIN pgz_sport.klubovi k ON k.id=u.klub_id
LEFT JOIN pgz_sport.savezi s ON s.id=u.savez_id
WHERE u.id=%s""", (uid,))
if not u: raise HTTPException(404, "User not found")
if not (_is_pgz_admin(actor) or
_can_manage(actor, u.get("user_type"), u.get("klub_id"), u.get("savez_id")) or
actor["id"] == uid):
raise HTTPException(403, "Forbidden")
# Strip sensitive
u.pop("password_hash", None)
u.pop("two_factor_secret", None)
return u
# ─────────────────────────── Create ───────────────────────────
class CreateUserReq(BaseModel):
email: str
full_name: Optional[str] = None
ime: Optional[str] = None
prezime: Optional[str] = None
user_type: str = "klub_user"
klub_id: Optional[int] = None
savez_id: Optional[int] = None
telefon: Optional[str] = None
oib: Optional[str] = None
password: Optional[str] = None # if absent → temp pwd + must_change
@router.post("/users")
def create_user(req: CreateUserReq, request: Request, actor = Depends(require_user)):
if req.user_type not in VALID_USER_TYPES:
raise HTTPException(400, f"Invalid user_type: {req.user_type}")
if not _can_manage(actor, req.user_type, req.klub_id, req.savez_id):
raise HTTPException(403, "Forbidden — out of management scope")
full_name = req.full_name or ((req.ime or "") + " " + (req.prezime or "")).strip() or req.email
pwd = req.password or ("PGZ-" + secrets.token_hex(4))
must_change = not bool(req.password)
try:
new_id = db_one("""INSERT INTO pgz_sport.users
(email, password_hash, full_name, ime, prezime, user_type, klub_id, savez_id,
telefon, oib, must_change_pwd, aktivan, status, auth_provider)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,'active','local')
RETURNING id""",
(req.email.lower().strip(), hash_password(pwd), full_name,
req.ime, req.prezime, req.user_type, req.klub_id, req.savez_id,
req.telefon, req.oib, must_change))["id"]
except Exception as e:
if "duplicate" in str(e).lower() or "unique" in str(e).lower():
raise HTTPException(409, f"Email već postoji: {req.email}")
raise HTTPException(400, str(e))
ip, ua = _client(request)
audit(actor["id"], "user.create", "user", new_id,
{"email": req.email, "user_type": req.user_type,
"klub_id": req.klub_id, "savez_id": req.savez_id}, ip, ua)
return {"id": new_id, "email": req.email, "user_type": req.user_type,
"must_change_pwd": must_change,
"temporary_password": pwd if must_change else None}
# ─────────────────────────── Update ───────────────────────────
class UpdateUserReq(BaseModel):
full_name: Optional[str] = None
ime: Optional[str] = None
prezime: Optional[str] = None
user_type: Optional[str] = None
klub_id: Optional[int] = None
savez_id: Optional[int] = None
telefon: Optional[str] = None
oib: Optional[str] = None
aktivan: Optional[bool] = None
@router.put("/users/{uid}")
def update_user(uid: int, req: UpdateUserReq, request: Request,
actor = Depends(require_user)):
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
(uid,))
if not target: raise HTTPException(404, "User not found")
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
raise HTTPException(403, "Forbidden")
fields, args = [], []
for f in ["full_name","ime","prezime","user_type","klub_id","savez_id","telefon","oib","aktivan"]:
v = getattr(req, f)
if v is not None:
if f == "user_type" and v not in VALID_USER_TYPES:
raise HTTPException(400, f"Invalid user_type: {v}")
fields.append(f"{f}=%s"); args.append(v)
if not fields:
return {"status": "nothing_to_update"}
fields.append("updated_at=now()")
args.append(uid)
db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)} WHERE id=%s", tuple(args))
ip, ua = _client(request)
audit(actor["id"], "user.update", "user", uid,
req.dict(exclude_none=True), ip, ua)
return {"status": "ok", "id": uid}
# ─────────────────────────── Delete (soft) ───────────────────────────
@router.delete("/users/{uid}")
def delete_user(uid: int, request: Request, actor = Depends(require_user)):
if uid == actor["id"]:
raise HTTPException(400, "Ne možete obrisati svoj račun")
target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s",
(uid,))
if not target: raise HTTPException(404, "User not found")
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
raise HTTPException(403, "Forbidden")
db_exec("""UPDATE pgz_sport.users SET aktivan=false, status='deleted',
updated_at=now() WHERE id=%s""", (uid,))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
ip, ua = _client(request)
audit(actor["id"], "user.delete", "user", uid, {"email": target["email"]}, ip, ua)
return {"status": "ok", "id": uid}
# ─────────────────────────── Invite ───────────────────────────
class InviteReq(BaseModel):
send_email: bool = False # placeholder — wired to mailer in M11
note: Optional[str] = None
@router.post("/users/{uid}/invite")
def invite_user(uid: int, req: InviteReq, request: Request,
actor = Depends(require_user)):
target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
(uid,))
if not target: raise HTTPException(404, "User not found")
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
raise HTTPException(403, "Forbidden")
new_temp = "PGZ-" + secrets.token_hex(4)
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=true,
failed_login_count=0, locked_until=NULL, updated_at=now()
WHERE id=%s""", (hash_password(new_temp), uid))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
ip, ua = _client(request)
audit(actor["id"], "user.invite", "user", uid,
{"email": target["email"], "send_email": req.send_email}, ip, ua)
invite_link = f"https://api.rinet.one/sport/login?email={target['email']}"
return {"status": "ok", "id": uid,
"temporary_password": new_temp,
"invite_link": invite_link,
"email_sent": False} # mailer wired later
# ─────────────────────────── Role change ───────────────────────────
class RoleReq(BaseModel):
user_type: str
@router.post("/users/{uid}/role")
def change_role(uid: int, req: RoleReq, request: Request,
actor = Depends(require_user)):
if not _is_pgz_admin(actor):
raise HTTPException(403, "Samo PGŽ admin može mijenjati role")
if req.user_type not in VALID_USER_TYPES:
raise HTTPException(400, f"Invalid user_type: {req.user_type}")
target = db_one("SELECT user_type FROM pgz_sport.users WHERE id=%s", (uid,))
if not target: raise HTTPException(404, "User not found")
db_exec("UPDATE pgz_sport.users SET user_type=%s, updated_at=now() WHERE id=%s",
(req.user_type, uid))
ip, ua = _client(request)
audit(actor["id"], "user.role.change", "user", uid,
{"from": target["user_type"], "to": req.user_type}, ip, ua)
return {"status": "ok", "id": uid, "user_type": req.user_type}
# ─────────────────────────── Suspend / unsuspend ───────────────────────────
class SuspendReq(BaseModel):
reason: Optional[str] = None
minutes: Optional[int] = None # null → indefinite
@router.post("/users/{uid}/suspend")
def suspend_user(uid: int, req: SuspendReq, request: Request,
actor = Depends(require_user)):
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
(uid,))
if not target: raise HTTPException(404, "User not found")
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
raise HTTPException(403, "Forbidden")
if req.minutes:
db_exec("""UPDATE pgz_sport.users
SET locked_until = now() + (interval '1 minute' * %s),
updated_at = now() WHERE id=%s""", (req.minutes, uid))
else:
db_exec("UPDATE pgz_sport.users SET aktivan=false, updated_at=now() WHERE id=%s",
(uid,))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
ip, ua = _client(request)
audit(actor["id"], "user.suspend", "user", uid,
{"reason": req.reason, "minutes": req.minutes}, ip, ua)
return {"status": "ok", "id": uid}
@router.post("/users/{uid}/unsuspend")
def unsuspend_user(uid: int, request: Request, actor = Depends(require_user)):
target = db_one("SELECT user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
(uid,))
if not target: raise HTTPException(404, "User not found")
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
raise HTTPException(403, "Forbidden")
db_exec("""UPDATE pgz_sport.users
SET aktivan=true, locked_until=NULL, failed_login_count=0,
updated_at=now() WHERE id=%s""", (uid,))
ip, ua = _client(request)
audit(actor["id"], "user.unsuspend", "user", uid, None, ip, ua)
return {"status": "ok", "id": uid}
# ─────────────────────────── Reset password (admin) ───────────────────────────
@router.post("/users/{uid}/reset-password")
def admin_reset_password(uid: int, request: Request, actor = Depends(require_user)):
target = db_one("SELECT user_type, klub_id, savez_id, email FROM pgz_sport.users WHERE id=%s",
(uid,))
if not target: raise HTTPException(404, "User not found")
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
raise HTTPException(403, "Forbidden")
new_temp = "PGZ-" + secrets.token_hex(4)
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=true,
failed_login_count=0, locked_until=NULL, updated_at=now()
WHERE id=%s""", (hash_password(new_temp), uid))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
ip, ua = _client(request)
audit(actor["id"], "user.password.reset", "user", uid,
{"email": target["email"]}, ip, ua)
return {"status": "ok", "temporary_password": new_temp}
# ─────────────────────────── Audit log ───────────────────────────
@router.get("/audit")
def audit_log(user_id: Optional[int] = None,
action: Optional[str] = None,
resource_type: Optional[str] = None,
limit: int = 100,
offset: int = 0,
actor = Depends(require_user)):
if not _is_pgz_admin(actor):
# savez/klub admins see only their scope
if not (_is_savez_admin(actor) or _is_klub_admin(actor)):
raise HTTPException(403, "Forbidden")
where = ["1=1"]; args: List[Any] = []
if user_id: where.append("a.user_id=%s"); args.append(user_id)
if action: where.append("a.action LIKE %s"); args.append(f"%{action}%")
if resource_type: where.append("a.resource_type=%s"); args.append(resource_type)
if not _is_pgz_admin(actor):
# restrict to own user's actions or resources within scope
if _is_savez_admin(actor):
where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE savez_id=%s OR klub_id IN (SELECT id FROM pgz_sport.klubovi WHERE savez_id=%s)))")
args.extend([actor.get("savez_id"), actor.get("savez_id")])
elif _is_klub_admin(actor):
where.append("(a.user_id IN (SELECT id FROM pgz_sport.users WHERE klub_id=%s))")
args.append(actor.get("klub_id"))
args.extend([limit, offset])
rows = db_query(f"""SELECT a.id, a.action, a.resource_type, a.resource_id,
a.user_id, a.ts AS created_at, a.meta, a.ip_address, a.user_agent,
u.email AS actor_email, u.full_name AS actor_name
FROM pgz_sport.audit_events a
LEFT JOIN pgz_sport.users u ON u.id=a.user_id
WHERE {' AND '.join(where)}
ORDER BY a.id DESC LIMIT %s OFFSET %s""", tuple(args))
return {"count": len(rows), "results": rows}
# ─────────────────────────── Tenants list ───────────────────────────
@router.get("/tenants")
def list_tenants(actor = Depends(require_user)):
"""Combined view: tenants table + savezi + klubovi."""
tenants = db_query("""SELECT id, slug, display_name, type, status, oib, created_at
FROM pgz_sport.tenants ORDER BY id""")
if _is_pgz_admin(actor):
savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik
FROM pgz_sport.savezi WHERE aktivan=true ORDER BY naziv LIMIT 200""")
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
FROM pgz_sport.klubovi WHERE aktivan=true ORDER BY naziv LIMIT 500""")
elif _is_savez_admin(actor):
sid = actor.get("savez_id")
savezi = db_query("""SELECT id, naziv, sport, oib, predsjednik, tajnik
FROM pgz_sport.savezi WHERE id=%s""", (sid,))
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
FROM pgz_sport.klubovi WHERE savez_id=%s AND aktivan=true ORDER BY naziv""", (sid,))
else:
kid = actor.get("klub_id")
savezi = []
klubovi = db_query("""SELECT id, naziv, sport, grad, oib, savez_id
FROM pgz_sport.klubovi WHERE id=%s""", (kid,))
return {"tenants": tenants, "savezi": savezi, "klubovi": klubovi}
# ─────────────────────────── Bulk CSV import ───────────────────────────
@router.post("/users/bulk-csv")
async def bulk_csv(file: UploadFile = File(...),
default_user_type: str = "klub_clan",
default_klub_id: Optional[int] = None,
default_savez_id: Optional[int] = None,
request: Request = None,
actor = Depends(require_user)):
"""CSV columns (header required): email,ime,prezime,user_type,klub_id,savez_id,telefon,oib"""
if not _is_pgz_admin(actor):
raise HTTPException(403, "Samo PGŽ admin može masovno uvoziti")
raw = (await file.read()).decode("utf-8", errors="replace")
rdr = csv.DictReader(io.StringIO(raw))
created, skipped, errors = 0, 0, []
for i, row in enumerate(rdr, 1):
email = (row.get("email") or "").lower().strip()
if not email:
skipped += 1; continue
try:
ut = row.get("user_type") or default_user_type
if ut not in VALID_USER_TYPES:
errors.append(f"row {i}: invalid user_type {ut}"); skipped += 1; continue
kid = int(row["klub_id"]) if row.get("klub_id") else default_klub_id
sid = int(row["savez_id"]) if row.get("savez_id") else default_savez_id
full_name = (row.get("ime","") + " " + row.get("prezime","")).strip() or email
temp_pwd = "PGZ-" + secrets.token_hex(4)
new_id = db_one("""INSERT INTO pgz_sport.users
(email, password_hash, ime, prezime, full_name, user_type, klub_id, savez_id,
telefon, oib, must_change_pwd, aktivan, status, auth_provider)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,true,true,'active','local')
ON CONFLICT (email) DO NOTHING RETURNING id""",
(email, hash_password(temp_pwd), row.get("ime"), row.get("prezime"),
full_name, ut, kid, sid, row.get("telefon"), row.get("oib")))
if new_id and new_id.get("id"):
created += 1
else:
skipped += 1
except Exception as e:
errors.append(f"row {i}: {e}"); skipped += 1
audit(actor["id"], "user.bulk_csv", meta={"created": created, "skipped": skipped})
return {"created": created, "skipped": skipped, "errors": errors[:20]}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>Audit Log — PGŽ Sport</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<style>
:root { --bg0:#08090e; --bg1:#11141d; --bg2:#1a1f2c; --txt:#e6e9ef; --muted:#7a8294;
--pgz-blue:#003087; --pgz-gold:#F4C430; --green:#1a8754; --red:#dc3545; --orange:#fd7e14; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg0); color:var(--txt); padding:24px; line-height:1.5; }
h1 { color:var(--pgz-gold); margin-bottom:6px; }
.sub { color:var(--muted); margin-bottom:24px; }
.toolbar { display:flex; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
.btn { background:var(--pgz-blue); color:white; border:none; padding:9px 16px; border-radius:6px; cursor:pointer; font-weight:500; }
.btn:hover { background:#0040b8; }
.btn.secondary { background:var(--bg2); }
input,select { background:var(--bg2); color:var(--txt); border:1px solid #2a3144; padding:9px 12px; border-radius:6px; min-width:160px; }
table { width:100%; border-collapse:collapse; background:var(--bg1); border-radius:8px; overflow:hidden; margin-top:8px; }
th { background:var(--bg2); padding:12px; text-align:left; color:var(--pgz-gold); font-size:0.85rem; text-transform:uppercase; }
td { padding:11px 12px; border-top:1px solid #1a1f2c; font-size:0.92rem; }
tr:hover { background:#13182a; }
.badge { padding:3px 9px; border-radius:11px; font-size:0.75rem; font-weight:600; }
.b-create { background:rgba(26,135,84,0.2); color:#7fdca5; }
.b-update { background:rgba(253,126,20,0.2); color:#ffaa66; }
.b-delete { background:rgba(220,53,69,0.2); color:#ff7e85; }
.b-seal { background:rgba(244,196,48,0.2); color:var(--pgz-gold); }
.tx-link { color:#5fa8d3; text-decoration:none; font-family:monospace; font-size:0.85rem; }
.tx-link:hover { text-decoration:underline; }
.empty { padding:60px; text-align:center; color:var(--muted); }
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
.stat { background:var(--bg1); padding:14px; border-radius:8px; border-left:3px solid var(--pgz-blue); }
.stat .v { font-size:1.6rem; font-weight:700; color:var(--pgz-gold); }
.stat .l { color:var(--muted); font-size:0.82rem; text-transform:uppercase; margin-top:3px; }
</style>
</head>
<body>
<h1>📜 Audit Log</h1>
<div class="sub">Kompletna povijest izmjena s blockchain pečatima na Polygon PoS</div>
<div class="stats" id="stats">
<div class="stat"><div class="v" id="s-total">—</div><div class="l">Ukupno akcija</div></div>
<div class="stat"><div class="v" id="s-today">—</div><div class="l">Danas</div></div>
<div class="stat"><div class="v" id="s-sealed">—</div><div class="l">Polygon zapečaćeno</div></div>
<div class="stat"><div class="v" id="s-users">—</div><div class="l">Aktivni korisnici</div></div>
</div>
<div class="toolbar">
<input id="f-q" placeholder="🔍 Pretraži..." />
<select id="f-action">
<option value="">Sve akcije</option>
<option value="create">CREATE</option>
<option value="update">UPDATE</option>
<option value="delete">DELETE</option>
<option value="seal">SEAL</option>
</select>
<select id="f-resource">
<option value="">Svi resursi</option>
<option value="users">Korisnici</option>
<option value="klubovi">Klubovi</option>
<option value="invoices">Računi</option>
<option value="putni_nalozi">Putni nalozi</option>
<option value="sufinanciranje">Sufinanciranje</option>
</select>
<button class="btn" onclick="load()">Filtriraj</button>
<button class="btn secondary" onclick="window.location.href='/app'">← Natrag na app</button>
</div>
<table id="tbl">
<thead>
<tr>
<th>Vrijeme</th>
<th>Korisnik</th>
<th>Akcija</th>
<th>Resurs</th>
<th>Detalji</th>
<th>Polygon Tx</th>
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="6" class="empty">⏳ Učitavam...</td></tr>
</tbody>
</table>
<script>
async function load() {
const q = document.getElementById('f-q').value;
const action = document.getElementById('f-action').value;
const resource = document.getElementById('f-resource').value;
const tbody = document.getElementById('tbody');
let url = '/sport/api/audit/log?limit=200';
if (q) url += '&q=' + encodeURIComponent(q);
if (action) url += '&action=' + action;
if (resource) url += '&resource=' + resource;
try {
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
const items = data.items || data.entries || data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty">📭 Nema zapisa</td></tr>';
return;
}
tbody.innerHTML = items.map(item => {
const action = (item.action || 'unknown').toLowerCase();
const klasa = action.includes('seal') ? 'b-seal' :
action.includes('create') ? 'b-create' :
action.includes('update') ? 'b-update' :
action.includes('delete') ? 'b-delete' : 'b-update';
const tx = item.tx_hash || item.polygon_tx || '';
const txLink = tx ? `<a href="https://polygonscan.com/tx/${tx}" target="_blank" class="tx-link">${tx.substring(0,16)}...</a>` : '<span style="color:#5a6072">—</span>';
const ts = new Date(item.created_at || item.timestamp).toLocaleString('hr-HR');
const details = item.details || item.diff || item.message || '';
const detStr = typeof details === 'object' ? JSON.stringify(details).substring(0,80)+'...' : String(details).substring(0,80);
return `<tr>
<td>${ts}</td>
<td>${item.user_email || item.user_name || item.actor || '—'}</td>
<td><span class="badge ${klasa}">${(item.action || '').toUpperCase()}</span></td>
<td>${item.resource_type || item.resource || item.target || '—'}</td>
<td>${detStr}</td>
<td>${txLink}</td>
</tr>`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="6" class="empty">⚠ Greška: ${e.message}</td></tr>`;
}
// Stats
try {
const sr = await fetch('/sport/api/audit/stats');
if (sr.ok) {
const s = await sr.json();
document.getElementById('s-total').textContent = s.total || '—';
document.getElementById('s-today').textContent = s.today || '—';
document.getElementById('s-sealed').textContent = s.sealed || '—';
document.getElementById('s-users').textContent = s.users || '—';
}
} catch(e) {}
}
load();
setInterval(load, 30000);
</script>
</body>
</html>
+666
View File
@@ -0,0 +1,666 @@
#!/usr/bin/env python3
# auth_v2.py — JWT auth backend with tenant_id, role, tier claims
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout,
# /api/auth/me, /api/auth/password/change, /api/auth/password/reset
"""
JWT claims:
sub int user id
email str
name str
tenant_id int|null pgz_sport.tenants.id (or null for super_admin)
tenant_type str pgz | savez | klub | global
tenant_scope dict {"klub_id": ..., "savez_id": ...}
role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...)
tier int 0 = PGŽ, 1 = savez, 2 = klub
jti str token id (revocable via user_sessions)
iat / exp / nbf
"""
import os, hashlib, secrets, json, time
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, List, Any
import jwt as _jwt
import psycopg2, psycopg2.extras
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body
from pydantic import BaseModel, EmailStr
try:
from passlib.hash import bcrypt as _bcrypt
HAS_BCRYPT = True
except Exception:
HAS_BCRYPT = False
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3',
user='rinet', password='R1net2026!SecureDB#v7')
# Persistent JWT secret — read from env, else stable file, else generated.
def _load_secret() -> str:
env_secret = os.environ.get("PGZ_JWT_SECRET")
if env_secret and len(env_secret) >= 32:
return env_secret
secret_file = "/opt/pgz-sport/auth/.jwt_secret"
try:
if os.path.exists(secret_file):
with open(secret_file) as f:
s = f.read().strip()
if len(s) >= 32:
return s
s = "rinet-pgz-" + secrets.token_urlsafe(48)
with open(secret_file, "w") as f:
f.write(s)
os.chmod(secret_file, 0o600)
return s
except Exception:
return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest()
JWT_SECRET = _load_secret()
JWT_ALG = "HS256"
ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30")))
REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7")))
router = APIRouter(prefix="/api/auth", tags=["auth_v2"])
# ─────────────────────────── DB helpers ───────────────────────────
def _conn():
return psycopg2.connect(**DB)
def db_query(sql: str, params=()):
with _conn() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
if cur.description: return cur.fetchall()
return []
def db_one(sql: str, params=()):
rows = db_query(sql, params)
return rows[0] if rows else None
def db_exec(sql: str, params=()):
with _conn() as c:
cur = c.cursor()
cur.execute(sql, params)
if cur.description:
r = cur.fetchone()
return r[0] if r else None
c.commit()
# ─────────────────────────── Password helpers ───────────────────────────
def _sha256(pw: str) -> str:
return hashlib.sha256(pw.encode()).hexdigest()
def hash_password(pw: str) -> str:
if HAS_BCRYPT:
return _bcrypt.using(rounds=12).hash(pw)
return _sha256(pw)
def verify_password(pw: str, hashed: Optional[str]) -> bool:
if not hashed: return False
h = hashed.strip()
if h.startswith("$2") and HAS_BCRYPT:
try:
return _bcrypt.verify(pw, h)
except Exception:
return False
return h == _sha256(pw)
def needs_rehash(hashed: Optional[str]) -> bool:
if not hashed: return True
return HAS_BCRYPT and not hashed.startswith("$2")
# ─────────────────────────── Tenant resolution ───────────────────────────
PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"}
SAVEZ_USER_TYPES = {"savez_admin", "savez_user"}
KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"}
def _tier_for(user_type: str) -> int:
ut = (user_type or "").lower()
if ut in PGZ_USER_TYPES: return 0
if ut in SAVEZ_USER_TYPES: return 1
if ut in KLUB_USER_TYPES: return 2
return 9 # unknown / viewer / guest
def _resolve_tenant(u: Dict) -> Dict:
"""Resolve tenant_id + tenant_type from a user row."""
ut = (u.get("user_type") or "").lower()
klub_id = u.get("klub_id")
savez_id = u.get("savez_id")
if ut in PGZ_USER_TYPES:
row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1")
return {
"tenant_id": row["id"] if row else None,
"tenant_type": "pgz",
"tenant_name": row["display_name"] if row else "PGŽ",
"tenant_scope": {"klub_id": None, "savez_id": None},
}
if ut in SAVEZ_USER_TYPES and savez_id:
return {
"tenant_id": savez_id,
"tenant_type": "savez",
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"),
"tenant_scope": {"klub_id": None, "savez_id": savez_id},
}
if ut in KLUB_USER_TYPES and klub_id:
return {
"tenant_id": klub_id,
"tenant_type": "klub",
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"),
"tenant_scope": {"klub_id": klub_id, "savez_id": savez_id},
}
# super_admin without context
if ut == "super_admin":
return {"tenant_id": None, "tenant_type": "global",
"tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}}
return {"tenant_id": None, "tenant_type": "viewer",
"tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}}
# ─────────────────────────── JWT issue / verify ───────────────────────────
def _now() -> datetime: return datetime.now(timezone.utc)
def _new_jti() -> str: return secrets.token_urlsafe(16)
def make_access_token(u: Dict, jti: str) -> str:
tenant = _resolve_tenant(u)
tier = _tier_for(u.get("user_type") or "")
now = _now()
payload = {
"sub": str(u["id"]),
"uid": u["id"],
"email": u["email"],
"name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"],
"tenant_id": tenant["tenant_id"],
"tenant_type": tenant["tenant_type"],
"tenant_name": tenant["tenant_name"],
"tenant_scope": tenant["tenant_scope"],
"role": u.get("user_type") or "viewer",
"tier": tier,
"jti": jti,
"typ": "access",
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"exp": int((now + ACCESS_TTL).timestamp()),
}
return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
def make_refresh_token(uid: int, jti: str) -> str:
now = _now()
return _jwt.encode({
"sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh",
"iat": int(now.timestamp()),
"exp": int((now + REFRESH_TTL).timestamp()),
}, JWT_SECRET, algorithm=JWT_ALG)
def decode_token(token: str) -> Dict:
try:
return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
except _jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except Exception as e:
raise HTTPException(401, f"Invalid token: {e}")
def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None):
th = hashlib.sha256(jti.encode()).hexdigest()
db_exec("""INSERT INTO pgz_sport.user_sessions
(user_id, token_hash, device_info, ip_address, expires_at, revoked)
VALUES (%s,%s,%s,%s::inet,%s,false)
ON CONFLICT (token_hash) DO NOTHING""",
(uid, th, ua, ip, expires))
def _is_revoked(jti: str) -> bool:
th = hashlib.sha256(jti.encode()).hexdigest()
r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,))
if not r: return False
return bool(r.get("revoked"))
def _revoke_jti(jti: str):
th = hashlib.sha256(jti.encode()).hexdigest()
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,))
# ─────────────────────────── current_user dep ───────────────────────────
def _extract_token(authorization: Optional[str]) -> Optional[str]:
if not authorization: return None
return authorization.replace("Bearer ", "").strip() or None
def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]:
token = _extract_token(authorization)
if not token: return None
try:
payload = decode_token(token)
except HTTPException:
return None
if payload.get("typ") not in (None, "access"):
return None
if _is_revoked(payload.get("jti","")):
return None
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
klub_id, savez_id, status, aktivan, must_change_pwd
FROM pgz_sport.users WHERE id=%s""", (uid,))
if not u or u.get("status") != "active" or not u.get("aktivan", True):
return None
u["_jwt"] = payload
u["_token"] = token
return u
def require_user(user = Depends(get_current_user)) -> Dict:
if not user:
raise HTTPException(401, "Authentication required")
return user
def require_role(roles: List[str]):
def dep(user = Depends(require_user)):
if user.get("user_type") not in roles:
raise HTTPException(403, f"Forbidden — required: {','.join(roles)}")
return user
return dep
# ─────────────────────────── Audit ───────────────────────────
def audit(user_id: Optional[int], action: str, resource_type: str = None,
resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None):
try:
db_exec("""INSERT INTO pgz_sport.audit_events
(user_id, action, resource_type, resource_id, meta, ip_address, user_agent)
VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""",
(user_id, action, resource_type, resource_id,
json.dumps(meta or {}), ip, ua))
except Exception as e:
print(f"[AUDIT WARN] {e}")
def _client(req: Request):
ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None
ua = req.headers.get("user-agent")
return ip, ua
# ─────────────────────────── Schemas ───────────────────────────
class LoginReq(BaseModel):
email: str
password: str
totp: Optional[str] = None # 6-digit TOTP if 2FA enabled (or recovery code)
class RefreshReq(BaseModel):
refresh_token: str
class ChangePwdReq(BaseModel):
old_password: Optional[str] = None
new_password: str
class ResetPwdReq(BaseModel):
email: str
# ─────────────────────────── Endpoints ───────────────────────────
@router.post("/login")
def login(req: LoginReq, request: Request):
ip, ua = _client(request)
email = (req.email or "").lower().strip()
if not email or not req.password:
raise HTTPException(400, "Email i lozinka obavezni")
u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status,
user_type, klub_id, savez_id, aktivan, must_change_pwd,
failed_login_count, locked_until
FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,))
if not u:
audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua)
raise HTTPException(401, "Neispravni podaci")
if u.get("locked_until"):
lu = u["locked_until"]
if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc)
if lu > _now():
audit(u["id"], "login.locked", ip=ip, ua=ua)
raise HTTPException(423, "Račun privremeno zaključan")
if u.get("status") != "active" or not u.get("aktivan", True):
audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua)
raise HTTPException(403, "Račun nije aktivan")
if not verify_password(req.password, u.get("password_hash")):
db_exec("""UPDATE pgz_sport.users
SET failed_login_count = COALESCE(failed_login_count,0)+1,
locked_until = CASE WHEN COALESCE(failed_login_count,0)+1>=5
THEN now()+interval '15 minutes' ELSE locked_until END
WHERE id=%s""", (u["id"],))
audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua)
raise HTTPException(401, "Neispravni podaci")
# opportunistic rehash to bcrypt
if needs_rehash(u.get("password_hash")):
try:
db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s",
(hash_password(req.password), u["id"]))
except Exception: pass
# 2FA gate — if user has enabled 2FA, demand a valid TOTP / recovery code
twofa_row = None
try:
twofa_row = db_one("SELECT secret, enabled, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s",
(u["id"],))
except Exception: pass
if twofa_row and twofa_row.get("enabled"):
code = (req.totp or "").strip().replace(" ", "")
if not code:
audit(u["id"], "login.2fa_required", ip=ip, ua=ua)
raise HTTPException(401, "2FA_REQUIRED")
ok = False
if code.isdigit() and len(code) in (6, 8) and HAS_PYOTP:
ok = _pyotp.TOTP(twofa_row["secret"]).verify(code, valid_window=1)
if not ok and twofa_row.get("recovery_codes"):
up = code.upper()
if up in (twofa_row["recovery_codes"] or []):
ok = True
# consume the recovery code so it can't be reused
remaining = [c for c in twofa_row["recovery_codes"] if c != up]
db_exec("UPDATE pgz_sport.user_2fa SET recovery_codes=%s, updated_at=now() WHERE user_id=%s",
(remaining, u["id"]))
if not ok:
audit(u["id"], "login.2fa_fail", ip=ip, ua=ua)
raise HTTPException(401, "Neispravan 2FA kod")
db_exec("""UPDATE pgz_sport.users
SET failed_login_count=0, locked_until=NULL, last_login=now()
WHERE id=%s""", (u["id"],))
jti = _new_jti()
rjti = _new_jti()
access = make_access_token(u, jti)
refresh = make_refresh_token(u["id"], rjti)
_record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
_record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]")
audit(u["id"], "login.ok", ip=ip, ua=ua)
tenant = _resolve_tenant(u)
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "Bearer",
"expires_in": int(ACCESS_TTL.total_seconds()),
"user": {
"id": u["id"], "email": u["email"],
"full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(),
"role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""),
"must_change_pwd": bool(u.get("must_change_pwd")),
**tenant,
},
}
@router.post("/refresh")
def refresh(req: RefreshReq, request: Request):
payload = decode_token(req.refresh_token)
if payload.get("typ") != "refresh":
raise HTTPException(401, "Invalid refresh token")
if _is_revoked(payload.get("jti","")):
raise HTTPException(401, "Refresh token revoked")
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
klub_id, savez_id, status, aktivan, must_change_pwd
FROM pgz_sport.users WHERE id=%s""", (uid,))
if not u or u.get("status") != "active" or not u.get("aktivan", True):
raise HTTPException(401, "User inactive")
ip, ua = _client(request)
new_jti = _new_jti()
access = make_access_token(u, new_jti)
_record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
audit(u["id"], "auth.refresh", ip=ip, ua=ua)
return {"access_token": access, "token_type": "Bearer",
"expires_in": int(ACCESS_TTL.total_seconds())}
@router.post("/logout")
def logout(request: Request, user = Depends(require_user)):
jti = (user.get("_jwt") or {}).get("jti")
if jti: _revoke_jti(jti)
# Also revoke refresh tokens for this user (best-effort)
db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true
WHERE user_id=%s AND device_info LIKE %s""",
(user["id"], "%[refresh]%"))
ip, ua = _client(request)
audit(user["id"], "logout", ip=ip, ua=ua)
return {"status": "ok"}
@router.get("/me")
def me(user = Depends(require_user)):
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
klub_id, savez_id, must_change_pwd, aktivan, status,
last_login, oib, telefon, phone, preferred_language, created_at,
avatar_url, gdpr_consent_at, google_picture
FROM pgz_sport.users WHERE id=%s""", (user["id"],))
if not enriched:
raise HTTPException(404, "User not found")
tenant = _resolve_tenant(enriched)
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=%s AND ur.active=true""", (user["id"],))
try:
twofa = db_one("SELECT secret IS NOT NULL AS enabled FROM pgz_sport.user_2fa WHERE user_id=%s",
(user["id"],)) or {"enabled": False}
except Exception:
twofa = {"enabled": False}
return {**enriched,
"tier": _tier_for(enriched.get("user_type") or ""),
"must_change_pwd": bool(enriched.get("must_change_pwd")),
"two_factor_enabled": bool(twofa.get("enabled")),
**tenant, "roles": roles}
class UpdateMeReq(BaseModel):
ime: Optional[str] = None
prezime: Optional[str] = None
full_name: Optional[str] = None
telefon: Optional[str] = None
phone: Optional[str] = None
preferred_language: Optional[str] = None
oib: Optional[str] = None
@router.put("/me")
def update_me(req: UpdateMeReq, request: Request, user = Depends(require_user)):
fields = []
vals: List[Any] = []
for k in ("ime","prezime","full_name","telefon","phone","preferred_language","oib"):
v = getattr(req, k)
if v is not None:
fields.append(f"{k}=%s")
vals.append(v.strip() if isinstance(v, str) else v)
if not fields:
raise HTTPException(400, "Nema polja za ažuriranje")
vals.append(user["id"])
db_exec(f"UPDATE pgz_sport.users SET {', '.join(fields)}, updated_at=now() WHERE id=%s", tuple(vals))
ip, ua = _client(request)
audit(user["id"], "profile.update", meta={"fields": [f.split("=")[0] for f in fields]}, ip=ip, ua=ua)
return me(user)
# ─────────────────────────── AVATAR UPLOAD ───────────────────────────
import shutil, pathlib
from fastapi import UploadFile, File
UPLOAD_ROOT = pathlib.Path("/opt/pgz-sport/uploads")
AVATAR_DIR = UPLOAD_ROOT / "avatars"
AVATAR_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_AVATAR_MIME = {"image/jpeg","image/jpg","image/png","image/webp"}
ALLOWED_AVATAR_EXT = {".jpg",".jpeg",".png",".webp"}
MAX_AVATAR_BYTES = 5 * 1024 * 1024 # 5 MB
@router.post("/me/avatar")
async def upload_my_avatar(request: Request, file: UploadFile = File(...), user = Depends(require_user)):
ct = (file.content_type or "").lower()
if ct not in ALLOWED_AVATAR_MIME:
raise HTTPException(400, f"Nedozvoljen tip slike: {ct} — jpeg/png/webp")
ext = pathlib.Path(file.filename or "").suffix.lower()
if ext not in ALLOWED_AVATAR_EXT:
ext = {"image/jpeg":".jpg","image/jpg":".jpg","image/png":".png","image/webp":".webp"}.get(ct, ".jpg")
data = await file.read()
if len(data) > MAX_AVATAR_BYTES:
raise HTTPException(413, f"Slika prevelika ({len(data)} B > {MAX_AVATAR_BYTES})")
if len(data) < 32:
raise HTTPException(400, "Slika prazna ili neispravna")
safe_name = f"{int(user['id'])}_{int(time.time())}{ext}"
target = AVATAR_DIR / safe_name
with open(target, "wb") as f:
f.write(data)
try: os.chmod(target, 0o644)
except Exception: pass
avatar_url = f"/uploads/avatars/{safe_name}"
db_exec("UPDATE pgz_sport.users SET avatar_url=%s, updated_at=now() WHERE id=%s",
(avatar_url, user["id"]))
ip, ua = _client(request)
audit(user["id"], "profile.avatar_upload",
meta={"file": safe_name, "size": len(data), "mime": ct}, ip=ip, ua=ua)
return {"status":"ok", "avatar_url": avatar_url, "size": len(data), "mime": ct}
@router.delete("/me/avatar")
def delete_my_avatar(request: Request, user = Depends(require_user)):
cur = db_one("SELECT avatar_url FROM pgz_sport.users WHERE id=%s", (user["id"],))
if cur and cur.get("avatar_url"):
p = AVATAR_DIR / pathlib.Path(cur["avatar_url"]).name
try:
if p.exists() and p.is_relative_to(AVATAR_DIR): p.unlink()
except Exception: pass
db_exec("UPDATE pgz_sport.users SET avatar_url=NULL, updated_at=now() WHERE id=%s", (user["id"],))
ip, ua = _client(request)
audit(user["id"], "profile.avatar_delete", ip=ip, ua=ua)
return {"status": "ok"}
@router.post("/password/change")
def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)):
if len(req.new_password) < 8:
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s",
(user["id"],))
if not cur: raise HTTPException(404, "User not found")
if not cur.get("must_change_pwd"):
if not req.old_password:
raise HTTPException(400, "old_password obavezan")
if not verify_password(req.old_password, cur.get("password_hash")):
raise HTTPException(401, "Stara lozinka netočna")
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=false, updated_at=now()
WHERE id=%s""", (hash_password(req.new_password), user["id"]))
ip, ua = _client(request)
audit(user["id"], "password.change", ip=ip, ua=ua)
return {"status": "ok"}
@router.post("/password/reset")
def password_reset(req: ResetPwdReq, request: Request):
"""Issue a temporary password (admin-equivalent self-reset; logged)."""
email = (req.email or "").lower().strip()
u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s",
(email,))
ip, ua = _client(request)
audit(u["id"] if u else None, "password.reset.request",
meta={"email": email, "found": bool(u)}, ip=ip, ua=ua)
# Generic response — do not leak which emails exist
return {"status": "ok",
"message": "Ako račun postoji, administrator će vam poslati instrukcije."}
# ─────────────────────────── 2FA — real TOTP (RFC 6238) ───────────────────────────
try:
import pyotp as _pyotp
HAS_PYOTP = True
except Exception:
HAS_PYOTP = False
def _ensure_2fa_table():
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_2fa (
user_id INTEGER PRIMARY KEY REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
secret TEXT NOT NULL,
enabled BOOLEAN DEFAULT false,
verified_at TIMESTAMPTZ,
recovery_codes TEXT[],
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
)""")
_ensure_2fa_table()
def _build_qr_png(otpauth_url: str) -> str:
"""Return a data: URL containing a base64 PNG of the QR code."""
try:
import qrcode, io, base64
img = qrcode.make(otpauth_url)
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception as e:
return ""
def _gen_recovery_codes(n: int = 8) -> List[str]:
return [secrets.token_hex(4).upper() for _ in range(n)]
@router.post("/2fa/setup")
def twofa_setup(user = Depends(require_user)):
"""Generate a TOTP secret, store unverified, and return otpauth URL + QR + recovery codes.
The 2FA stays disabled until /2fa/verify confirms a valid TOTP code."""
if not HAS_PYOTP:
raise HTTPException(503, "pyotp not installed on server")
secret = _pyotp.random_base32() # 32-char base32, RFC 4648 — what authenticator apps expect
recovery = _gen_recovery_codes()
db_exec("""INSERT INTO pgz_sport.user_2fa (user_id, secret, enabled, recovery_codes, updated_at)
VALUES (%s,%s,false,%s,now())
ON CONFLICT (user_id) DO UPDATE SET
secret=EXCLUDED.secret, enabled=false,
recovery_codes=EXCLUDED.recovery_codes, updated_at=now()""",
(user["id"], secret, recovery))
issuer = "PGŽ Sport"
otpauth = _pyotp.TOTP(secret).provisioning_uri(name=user["email"], issuer_name=issuer)
return {
"secret": secret,
"otpauth_url": otpauth,
"qr_png": _build_qr_png(otpauth),
"issuer": issuer,
"account": user["email"],
"recovery_codes": recovery,
"enabled": False,
"instructions": "Skenirajte QR u Google Authenticator / Authy / 1Password, zatim potvrdite kod kroz POST /api/auth/2fa/verify",
}
class TwoFAVerifyReq(BaseModel):
code: str
@router.post("/2fa/verify")
def twofa_verify(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)):
"""Verify TOTP code; on success, mark 2FA enabled."""
if not HAS_PYOTP:
raise HTTPException(503, "pyotp not installed on server")
row = db_one("SELECT secret, enabled FROM pgz_sport.user_2fa WHERE user_id=%s",
(user["id"],))
if not row:
raise HTTPException(400, "2FA nije postavljen — pozovite /2fa/setup prvo")
code = (req.code or "").strip().replace(" ", "")
if not code or not code.isdigit() or len(code) not in (6, 8):
raise HTTPException(400, "Neispravan format koda (6-8 znamenki)")
totp = _pyotp.TOTP(row["secret"])
# valid_window=1 → tolerate ±30s drift
if not totp.verify(code, valid_window=1):
ip, ua = _client(request)
audit(user["id"], "2fa.verify.fail", ip=ip, ua=ua)
raise HTTPException(401, "Neispravan TOTP kod")
db_exec("""UPDATE pgz_sport.user_2fa
SET enabled=true, verified_at=now(), updated_at=now()
WHERE user_id=%s""", (user["id"],))
ip, ua = _client(request)
audit(user["id"], "2fa.verify.ok", ip=ip, ua=ua)
return {"status": "ok", "enabled": True}
@router.post("/2fa/disable")
def twofa_disable(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)):
"""Disable 2FA — must verify a current TOTP code (or recovery code)."""
if not HAS_PYOTP:
raise HTTPException(503, "pyotp not installed on server")
row = db_one("SELECT secret, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s",
(user["id"],))
if not row:
raise HTTPException(404, "2FA nije postavljen")
code = (req.code or "").strip().replace(" ", "").upper()
valid = False
if code.isdigit() and len(code) in (6, 8):
valid = _pyotp.TOTP(row["secret"]).verify(code, valid_window=1)
elif row.get("recovery_codes") and code in (row["recovery_codes"] or []):
valid = True
if not valid:
raise HTTPException(401, "Neispravan kod")
db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_id=%s", (user["id"],))
ip, ua = _client(request)
audit(user["id"], "2fa.disable", ip=ip, ua=ua)
return {"status": "ok", "enabled": False}
@router.get("/2fa/status")
def twofa_status(user = Depends(require_user)):
row = db_one("SELECT enabled, verified_at, created_at FROM pgz_sport.user_2fa WHERE user_id=%s",
(user["id"],))
return {"enabled": bool(row and row.get("enabled")),
"configured": bool(row),
"verified_at": row.get("verified_at") if row else None}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,848 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · ERP — OCR + Putni nalozi</title>
<!--
erp.html — PGŽ Sport ERP UI (M5 OCR + M6 Putni nalozi)
Author: dradulic@outlook.com / damir@rinet.one — 2026-05-04
Real backend: /api/erp/ocr/upload, /parse, /invoices, /putni-nalog
-->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>€</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg:#06080d; --bg-2:#0d1117; --bg-3:#161b22; --border:#1f2937;
--text:#e6edf3; --text-2:#8b949e; --text-3:#6e7681;
--accent:#00f0ff; --green:#56d364; --yellow:#d29922; --red:#f85149; --purple:#bc8cff;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; font-size:14px; }
.app { display:grid; grid-template-columns:230px 1fr; min-height:100vh; }
.sidebar { background:var(--bg-2); border-right:1px solid var(--border); padding:20px 0; }
.brand { padding:0 20px 18px; border-bottom:1px solid var(--border); margin-bottom:10px; }
.brand h1 { font-size:16px; font-weight:700; color:var(--accent); font-family:'JetBrains Mono',monospace; }
.brand .sub { font-size:11px; color:var(--text-3); margin-top:2px; }
.nav-item { display:flex; gap:10px; padding:10px 20px; cursor:pointer; color:var(--text-2); font-size:13px; border-left:3px solid transparent; align-items:center; }
.nav-item:hover { background:var(--bg-3); color:var(--text); }
.nav-item.active { color:var(--accent); background:rgba(0,240,255,.05); border-left-color:var(--accent); }
.main { padding:24px 30px; overflow-y:auto; }
.header { display:flex; justify-content:space-between; padding-bottom:14px; border-bottom:1px solid var(--border); margin-bottom:18px; align-items:center; }
.header h2 { font-size:22px; font-weight:700; }
.header .meta { color:var(--text-3); font-size:12px; font-family:'JetBrains Mono',monospace; }
.section { background:var(--bg-2); border:1px solid var(--border); border-radius:8px; padding:18px; margin-bottom:16px; }
.section h3 { font-size:14px; font-weight:600; color:var(--accent); margin-bottom:12px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; padding:8px 10px; color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; border-bottom:1px solid var(--border); }
td { padding:10px; border-bottom:1px solid var(--border); }
td.num { font-family:'JetBrains Mono',monospace; text-align:right; }
tr:hover { background:var(--bg-3); }
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.badge.green { background:rgba(86,211,100,.15); color:var(--green); }
.badge.yellow { background:rgba(210,153,34,.15); color:var(--yellow); }
.badge.red { background:rgba(248,81,73,.15); color:var(--red); }
.badge.gray { background:rgba(110,118,129,.15); color:var(--text-3); }
input.fld, select.fld { width:100%; background:var(--bg); border:1px solid var(--border); padding:8px 10px; border-radius:4px; color:var(--text); font-family:inherit; font-size:13px; }
input.fld:focus, select.fld:focus { outline:none; border-color:var(--accent); }
label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4px; text-transform:uppercase; letter-spacing:.5px; }
.btn { padding:9px 18px; background:var(--accent); color:var(--bg); border:0; border-radius:4px; cursor:pointer; font-weight:600; font-family:inherit; font-size:13px; }
.btn.sec { background:var(--bg-3); color:var(--text); border:1px solid var(--border); }
.tab { display:none; }
.tab.active { display:block; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
tr.clickable { cursor:pointer; }
tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--accent); }
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; display:none; align-items:flex-start; justify-content:center; padding:30px; overflow-y:auto; }
.modal-bg.show { display:flex; }
.modal { background:var(--bg-2); border:1px solid var(--border); border-radius:10px; max-width:1100px; width:100%; padding:0; box-shadow:0 12px 48px rgba(0,0,0,.6); }
.modal-h { display:flex; justify-content:space-between; align-items:center; padding:16px 22px; border-bottom:1px solid var(--border); }
.modal-h h3 { color:var(--accent); font-size:16px; }
.modal-h .x { background:transparent; border:0; color:var(--text-2); font-size:22px; cursor:pointer; }
.modal-h .x:hover { color:var(--red); }
.modal-body { padding:18px 22px; max-height:80vh; overflow-y:auto; }
.col2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
.kv { display:grid; grid-template-columns:140px 1fr; gap:6px 12px; font-size:13px; }
.kv > div:nth-child(odd) { color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; align-self:center; }
.kv > div:nth-child(even) { font-family:'JetBrains Mono',monospace; }
.preview-img { max-width:100%; max-height:480px; border:1px solid var(--border); border-radius:6px; background:var(--bg); }
.audit-row { display:grid; grid-template-columns:140px 110px 130px 1fr; gap:8px; padding:6px 0; border-bottom:1px dashed var(--border); font-size:12px; }
.audit-row:last-child { border-bottom:0; }
.audit-row .ts { color:var(--text-3); font-family:'JetBrains Mono',monospace; font-size:11px; }
.audit-row .op { color:var(--accent); font-weight:600; }
.audit-row .who { color:var(--text-2); }
.btn.green { background:var(--green); color:var(--bg); }
.btn.red { background:var(--red); color:#fff; }
.btn.yellow { background:var(--yellow); color:var(--bg); }
.actions-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; padding-top:14px; border-top:1px solid var(--border); }
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } .col2 { grid-template-columns:1fr; } .audit-row { grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><h1>PGŽ ERP</h1><div class="sub">M5 OCR + M6 Putni nalozi</div></div>
<div class="nav-item active" data-tab="ocr"><span>📷</span><span>Skeniraj račun</span></div>
<div class="nav-item" data-tab="invoices"><span>€</span><span>Računi</span></div>
<div class="nav-item" data-tab="putni"><span>🚗</span><span>Novi putni nalog</span></div>
<div class="nav-item" data-tab="putni-list"><span>📋</span><span>Lista putnih naloga</span></div>
</aside>
<main class="main">
<div class="header">
<h2 id="pageTitle">Skeniraj račun (OCR)</h2>
<span class="meta" id="metaInfo">Tesseract + Ri.NET AI Engine · /api/erp</span>
</div>
<!-- OCR -->
<div class="tab active" id="tab-ocr">
<div class="section">
<h3>📷 Drag-and-drop OCR (PDF / JPG / PNG)</h3>
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:34px;text-align:center;cursor:pointer;background:var(--bg-3)">
<div style="font-size:36px;color:var(--accent);margin-bottom:6px">⤓</div>
<div style="font-size:14px;font-weight:600">Povuci datoteku ovdje ili klikni za odabir</div>
<div style="font-size:11px;color:var(--text-3);margin-top:6px">Tesseract OCR (hrv+eng) + Ri.NET AI Engine LLM ekstrakcija polja</div>
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
</div>
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div class="grid2" style="font-size:13px">
<div><label class="lbl">Izdavatelj</label><input id="oc_vendor_name" class="fld"></div>
<div><label class="lbl">OIB izdavatelja</label><input id="oc_vendor_oib" class="fld"></div>
<div><label class="lbl">Broj računa</label><input id="oc_invoice_no" class="fld"></div>
<div><label class="lbl">Datum</label><input id="oc_invoice_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos neto (€)</label><input id="oc_amount_net" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">PDV (€)</label><input id="oc_amount_vat" type="number" step="0.01" class="fld"></div>
<div><label class="lbl" style="color:var(--accent)">Brutto / UKUPNO (€)</label><input id="oc_amount_gross" type="number" step="0.01" class="fld" style="border-color:var(--accent)"></div>
<div><label class="lbl">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">IBAN</label><input id="oc_iban" class="fld"></div>
<div><label class="lbl">Valuta</label><select id="oc_currency" class="fld"><option>EUR</option><option>HRK</option></select></div>
<div><label class="lbl">Vrsta troška</label>
<select id="oc_kind" class="fld">
<option value="gorivo">Gorivo</option><option value="cestarina">Cestarina</option>
<option value="hotel">Hotel</option><option value="restoran">Restoran</option>
<option value="oprema">Oprema</option><option value="ostalo" selected>Ostalo</option>
</select>
</div>
<div><label class="lbl">Klub</label><select id="oc_klub" class="fld"></select></div>
</div>
<div style="margin-top:10px"><label class="lbl">Opis</label><input id="oc_description" class="fld"></div>
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
</details>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<button id="ocSave" class="btn">💾 Spremi račun</button>
<button id="ocCancel" class="btn sec">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
</div>
<!-- Invoices list -->
<div class="tab" id="tab-invoices">
<div class="section">
<h3>Računi (svi klubovi)</h3>
<table id="invTable"><thead><tr><th>#</th><th>Vrsta</th><th>Broj</th><th>Dobavljač</th><th>OIB</th><th>Klub</th><th class="num">Brutto</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Putni nalog form -->
<div class="tab" id="tab-putni">
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div class="grid3" style="font-size:13px">
<div><label class="lbl">Klub</label><select id="pn_klub" class="fld"></select></div>
<div><label class="lbl">Voditelj</label><input id="pn_voditelj" class="fld" placeholder="Ime Prezime"></div>
<div><label class="lbl">Putnici (zarez)</label><input id="pn_putnici" class="fld"></div>
<div style="grid-column:span 3"><label class="lbl">Svrha putovanja</label><input id="pn_svrha" class="fld" placeholder="Natjecanje, treninzi, edukacija…"></div>
<div><label class="lbl">Od grada</label><input id="pn_od" class="fld" value="Rijeka"></div>
<div><label class="lbl">Do grada</label><input id="pn_do" class="fld"></div>
<div><label class="lbl">Zemlja</label><input id="pn_country" class="fld" value="Hrvatska"></div>
<div><label class="lbl">Polazak</label><input id="pn_from" type="datetime-local" class="fld"></div>
<div><label class="lbl">Povratak</label><input id="pn_to" type="datetime-local" class="fld"></div>
<div><label class="lbl">Tip vozila</label>
<select id="pn_vehicle" class="fld">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label class="lbl">Registracija</label><input id="pn_plate" class="fld"></div>
<div><label class="lbl">Kilometara</label><input id="pn_km" type="number" step="1" class="fld" value="0"></div>
<div><label class="lbl">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="fld" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button id="pnSave" class="btn">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
<p style="margin-top:14px;font-size:11px;color:var(--text-3);line-height:1.6">
<b>HR pravilnik 2025:</b> domaće 26.54 € (>8h), 13.27 € (58h), 0 € (&lt;5h). Inozemne dnevnice po zemlji
(Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km.
</p>
</div>
</div>
<!-- Putni nalozi list -->
<div class="tab" id="tab-putni-list">
<div class="section">
<h3>Lista putnih naloga</h3>
<table id="pnTable"><thead><tr><th>#</th><th>Klub</th><th>Destinacija</th><th>Polazak</th><th>Povratak</th><th class="num">Dnevnice</th><th class="num">Transport</th><th class="num">Total</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<!-- ============ INVOICE DETAIL MODAL (M5.5) ============ -->
<div id="invModal" class="modal-bg" onclick="if(event.target===this)closeModal('invModal')">
<div class="modal">
<div class="modal-h">
<h3 id="invModalTitle">Račun</h3>
<button class="x" onclick="closeModal('invModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Skenirana datoteka</h4>
<div id="inv_preview" style="text-align:center"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Podaci računa</h4>
<div class="kv" id="inv_kv"></div>
<div id="inv_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div class="actions-row" id="inv_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="inv_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY INVOICE MODAL (M5.5) ============ -->
<div id="payModal" class="modal-bg" onclick="if(event.target===this)closeModal('payModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Označi kao plaćen</h3>
<button class="x" onclick="closeModal('payModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="pay_iban_to" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">IBAN platitelja</label><input id="pay_iban_from" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">Datum uplate</label><input id="pay_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="pay_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Poziv na broj / referenca</label><input id="pay_ref" class="fld" placeholder="HR00 12345-67890"></div>
<div><label class="lbl">Tx ID (banka)</label><input id="pay_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="payConfirm">✓ Potvrdi plaćanje</button>
<button class="btn sec" onclick="closeModal('payModal')">Odustani</button>
<span id="payStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ COMMENT MODAL (M5.5) ============ -->
<div id="commentModal" class="modal-bg" onclick="if(event.target===this)closeModal('commentModal')">
<div class="modal" style="max-width:520px">
<div class="modal-h">
<h3>💬 Komentar (savez/admin)</h3>
<button class="x" onclick="closeModal('commentModal')">×</button>
</div>
<div class="modal-body">
<textarea id="commentText" class="fld" rows="5" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn" id="commentSave">Spremi komentar</button>
<button class="btn sec" onclick="closeModal('commentModal')">Odustani</button>
<span id="commentStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ PUTNI NALOG DETAIL MODAL (M6.3) ============ -->
<div id="pnModal" class="modal-bg" onclick="if(event.target===this)closeModal('pnModal')">
<div class="modal">
<div class="modal-h">
<h3 id="pnModalTitle">Putni nalog</h3>
<button class="x" onclick="closeModal('pnModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Voditelj + putnici, ruta, vozilo</h4>
<div class="kv" id="pn_kv"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Obračun (HR pravilnik 2025)</h4>
<div class="kv" id="pn_obracun"></div>
<div id="pn_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">📎 Vezani računi (gorivo, cestarina, hotel...)</h4>
<table id="pn_invoices_table"><thead><tr><th>#</th><th>Vrsta</th><th>Dobavljač</th><th>OIB</th><th>Datum</th><th class="num">Brutto</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
<div class="actions-row" id="pn_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="pn_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY PUTNI NALOG MODAL ============ -->
<div id="payPnModal" class="modal-bg" onclick="if(event.target===this)closeModal('payPnModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Isplata putnog naloga</h3>
<button class="x" onclick="closeModal('payPnModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="ppn_iban_to" class="fld"></div>
<div><label class="lbl">IBAN platitelja</label><input id="ppn_iban_from" class="fld"></div>
<div><label class="lbl">Datum uplate</label><input id="ppn_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="ppn_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Referenca</label><input id="ppn_ref" class="fld"></div>
<div><label class="lbl">Tx ID</label><input id="ppn_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="ppnConfirm">✓ Potvrdi isplatu</button>
<button class="btn sec" onclick="closeModal('payPnModal')">Odustani</button>
<span id="ppnStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ REJECT PUTNI NALOG MODAL ============ -->
<div id="rejectModal" class="modal-bg" onclick="if(event.target===this)closeModal('rejectModal')">
<div class="modal" style="max-width:480px">
<div class="modal-h">
<h3>❌ Odbij putni nalog</h3>
<button class="x" onclick="closeModal('rejectModal')">×</button>
</div>
<div class="modal-body">
<label class="lbl">Razlog odbijanja</label>
<textarea id="rejectText" class="fld" rows="4" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn red" id="rejectConfirm">Odbij</button>
<button class="btn sec" onclick="closeModal('rejectModal')">Odustani</button>
<span id="rejectStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<script>
const ERP_API = '/api/erp';
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
const fmt = n => n == null ? '—' : new Intl.NumberFormat('hr-HR').format(n);
const fmtEur = n => n != null ? '€' + fmt(Math.round(n*100)/100) : '—';
const fmtDate = d => d ? d.substring(0,10) : '—';
function badge(t,c) { return `<span class="badge ${c}">${t||'—'}</span>`; }
function sBadge(s) {
if (!s) return badge('—','gray');
const x = s.toLowerCase();
if (['paid','approved','active','odobren','zatvoren'].includes(x)) return badge(s,'green');
if (['pending','draft','submitted','open','unpaid'].includes(x)) return badge(s,'yellow');
if (['overdue','rejected','cancelled','failed'].includes(x)) return badge(s,'red');
return badge(s,'gray');
}
async function loadKlubovi() {
const r = await fetch('/api/klubovi?limit=400').then(r=>r.json()).catch(()=>null);
if (!r) return;
const arr = Array.isArray(r) ? r : (r.rows || r.items || []);
const opts = '<option value="">— odaberi klub —</option>' + arr
.map(k => ({id: k.id, naziv: (k.naziv || k.klub || k.sport || '#'+k.id).toString().trim()}))
.filter(k => k.naziv)
.sort((a,b) => a.naziv.localeCompare(b.naziv,'hr'))
.map(k => `<option value="${k.id}">${k.naziv.replace(/"/g,'&quot;')}</option>`).join('');
['oc_klub','pn_klub'].forEach(id => { const e=$('#'+id); if (e) e.innerHTML=opts; });
}
let ocrUploadId = null, ocrParsed = null;
function ocrSet(m,c) { const e=$('#ocrStatus'); if(e){e.textContent=m||''; e.style.color=c||'var(--text-2)';} }
async function ocrHandle(file) {
if (!file) return;
ocrSet('⏳ Učitavam datoteku…','var(--yellow)');
const klubVal = $('#oc_klub')?.value || '';
const fd = new FormData();
fd.append('file', file);
if (klubVal) fd.append('klub_id', klubVal);
fd.append('tenant_id', 1);
fd.append('invoice_kind', $('#oc_kind')?.value || 'ostalo');
let r = await fetch(`${ERP_API}/ocr/upload`, {method:'POST',body:fd});
if (!r.ok) { ocrSet('❌ Upload pao: '+r.status,'var(--red)'); return; }
const j = await r.json();
ocrUploadId = j.upload_id;
ocrSet(`✓ Uploaded #${ocrUploadId} (${j.size} B). Pokrećem OCR + Ri.NET AI Engine ekstrakciju…`,'var(--accent)');
const fd2 = new FormData();
fd2.append('upload_id', ocrUploadId);
fd2.append('use_llm', 'true');
r = await fetch(`${ERP_API}/ocr/parse`, {method:'POST',body:fd2});
const p = await r.json();
if (!p.ok) { ocrSet('❌ '+(p.error||'Parse fail'),'var(--red)'); return; }
ocrParsed = p.extracted || {};
$('#oc_vendor_name').value = ocrParsed.vendor_name || '';
$('#oc_vendor_oib').value = ocrParsed.vendor_oib || '';
$('#oc_invoice_no').value = ocrParsed.invoice_no || '';
$('#oc_invoice_date').value = ocrParsed.invoice_date|| '';
$('#oc_amount_net').value = ocrParsed.amount_net ?? '';
$('#oc_amount_vat').value = ocrParsed.amount_vat ?? '';
$('#oc_amount_gross').value = ocrParsed.amount_gross?? '';
$('#oc_vat_rate').value = ocrParsed.vat_rate ?? '';
$('#oc_iban').value = ocrParsed.iban || '';
$('#oc_kind').value = ocrParsed.category || 'ostalo';
$('#oc_currency').value = ocrParsed.currency || 'EUR';
$('#oc_description').value = ocrParsed.description|| '';
$('#oc_raw').textContent = (p.raw_text_preview||'').slice(0,4000);
$('#ocrResult').style.display = 'block';
ocrSet(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja → "Spremi račun".`,'var(--green)');
}
function ocrInit() {
const drop = $('#ocrDrop'), inp = $('#ocrFile');
drop.addEventListener('click', () => inp.click());
inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandle(e.target.files[0]); });
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor='var(--accent)'; }));
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor='var(--border)'; }));
drop.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) ocrHandle(f); });
$('#ocCancel').addEventListener('click', () => { $('#ocrResult').style.display='none'; ocrUploadId=null; ocrParsed=null; ocrSet(''); inp.value=''; });
$('#ocSave').addEventListener('click', async () => {
const klub = $('#oc_klub').value;
if (!klub) { $('#ocSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1, upload_id: ocrUploadId,
invoice_kind: $('#oc_kind').value || 'ostalo',
invoice_no: $('#oc_invoice_no').value, vendor_name: $('#oc_vendor_name').value,
vendor_oib: $('#oc_vendor_oib').value, invoice_date: $('#oc_invoice_date').value,
amount_net: parseFloat($('#oc_amount_net').value)||null,
amount_vat: parseFloat($('#oc_amount_vat').value)||null,
amount_gross: parseFloat($('#oc_amount_gross').value),
vat_rate: parseFloat($('#oc_vat_rate').value)||null,
iban_to: $('#oc_iban').value || null,
currency: $('#oc_currency').value || 'EUR',
category: $('#oc_kind').value || 'ostalo',
description: $('#oc_description').value || null,
};
$('#ocSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
$('#ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`;
$('#ocSaveStatus').style.color = 'var(--green)';
setTimeout(() => { $('#ocrResult').style.display='none'; loadInvoices(); }, 1500);
} else {
$('#ocSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#ocSaveStatus').style.color = 'var(--red)';
}
});
}
let pnTimer = null;
async function pnPreview() {
const df = $('#pn_from').value, dt = $('#pn_to').value;
const country = $('#pn_country').value || 'Hrvatska';
const km = parseFloat($('#pn_km').value || 0);
const kr = parseFloat($('#pn_kmrate').value || 0.5);
const tgt = $('#pn_preview');
if (!df || !dt) { tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; }
const r = await fetch(`${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${kr}`).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { tgt.textContent='⚠ Neuspješan obračun'; return; }
const d = r.preview;
tgt.innerHTML = `
<div class="grid4">
<div><div style="color:var(--text-3);font-size:11px">Sati</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.hours}h</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pune dnevnice</div><div style="font-size:18px;color:var(--accent);font-family:'JetBrains Mono'">${d.days_full} × €${d.rate_full}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pola dnevnica</div><div style="font-size:18px;color:var(--yellow);font-family:'JetBrains Mono'">${d.days_half} × €${d.rate_half}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Dnevnice ukupno</div><div style="font-size:18px;color:var(--green);font-family:'JetBrains Mono'">€${d.dnevnica_amount_total}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometara</div><div style="font-size:16px;font-family:'JetBrains Mono'">${d.km_driven} km</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometrina</div><div style="font-size:16px;font-family:'JetBrains Mono'">€${d.km_amount}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Zemlja</div><div style="font-size:14px;font-family:'JetBrains Mono'">${d.country}</div></div>
<div><div style="color:var(--text-3);font-size:11px">PROCJENA UKUPNO</div><div style="font-size:22px;color:var(--accent);font-family:'JetBrains Mono';font-weight:700">€${d.total_estimated}</div></div>
</div>`;
}
function pnInit() {
['pn_from','pn_to','pn_country','pn_km','pn_kmrate'].forEach(id => {
const el = $('#'+id); if (el) el.addEventListener('input', () => { clearTimeout(pnTimer); pnTimer = setTimeout(pnPreview, 250); });
});
$('#pnSave').addEventListener('click', async () => {
const klub = $('#pn_klub').value;
if (!klub) { $('#pnSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1,
voditelj_ime: $('#pn_voditelj').value,
putnici: ($('#pn_putnici').value||'').split(',').map(s=>s.trim()).filter(Boolean),
svrha: $('#pn_svrha').value,
od_grada: $('#pn_od').value, do_grada: $('#pn_do').value,
datum_polaska: $('#pn_from').value, datum_povratka: $('#pn_to').value,
country: $('#pn_country').value,
vehicle_type: $('#pn_vehicle').value,
registracija_vozila: $('#pn_plate').value,
kilometara: parseFloat($('#pn_km').value)||0,
km_rate: parseFloat($('#pn_kmrate').value)||0.5,
};
$('#pnSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/putni-nalog`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
$('#pnSaveStatus').innerHTML = `✓ Putni nalog #${j.putni_nalog.id} kreiran (€${j.putni_nalog.cost_total})`;
$('#pnSaveStatus').style.color = 'var(--green)';
loadPutni();
} else {
$('#pnSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#pnSaveStatus').style.color = 'var(--red)';
}
});
}
async function loadInvoices() {
const r = await fetch(`${ERP_API}/invoices?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>`
<tr class="clickable" onclick="openInvoice(${i.id})"><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td>
<td>${i.vendor_name||'—'}</td><td style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
<td>${i.klub_naziv||'—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
async function loadPutni() {
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>`
<tr class="clickable" onclick="openPutni(${p.id})"><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td>
<td>${fmtDate(p.date_from)}</td><td>${fmtDate(p.date_to)}</td>
<td class="num">${fmtEur(p.dnevnice_amount)}</td>
<td class="num">${fmtEur(p.cost_transport)}</td>
<td class="num"><strong>${fmtEur(p.cost_total)}</strong></td>
<td>${sBadge(p.status)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
// ===== AUTH (JWT iz localStorage ili admin token fallback) =====
function AUTH_HDR(extra) {
const h = Object.assign({}, extra || {});
let t = null;
try { t = localStorage.getItem('jwt') || sessionStorage.getItem('jwt'); } catch(e){}
if (!t) t = 'admin-pgz-2026';
h['Authorization'] = 'Bearer ' + t;
return h;
}
function AUTH_HDR_JSON() { return AUTH_HDR({'Content-Type': 'application/json'}); }
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderAudit(audit) {
if (!audit || !audit.length) return '<div style="color:var(--text-3);font-size:12px">Nema audit zapisa.</div>';
return audit.map(a => `
<div class="audit-row">
<div class="ts">${(a.timestamp||'').replace('T',' ').substring(0,19)}</div>
<div class="op">${escHtml(a.operacija)}</div>
<div class="who">${escHtml(a.korisnik||'—')}</div>
<div>${escHtml(a.promijenjeno_polje||'')}: <span style="color:var(--text-3)">${escHtml(a.stara_vrijednost||'∅')}</span> → <span style="color:var(--green)">${escHtml(a.nova_vrijednost||'∅')}</span></div>
</div>`).join('');
}
// ===== INVOICE DETAIL =====
let _currentInvoice = null;
async function openInvoice(id) {
const r = await fetch(`${ERP_API}/invoices/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju računa #' + id); return; }
_currentInvoice = r;
const i = r.invoice;
$('#invModalTitle').textContent = `Račun #${i.id} · ${i.invoice_no || '—'}`;
// Preview slike
const pv = $('#inv_preview');
if (r.uploads && r.uploads.length) {
const up = r.uploads[0];
const fileUrl = `${ERP_API}/invoices/${id}/file`;
const isPdf = (up.mime || '').includes('pdf') || (up.file_name || '').toLowerCase().endsWith('.pdf');
if (isPdf) {
pv.innerHTML = `<embed src="${fileUrl}" type="application/pdf" style="width:100%;height:480px;border:1px solid var(--border);border-radius:6px"><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · ${escHtml(up.mime||'')}</div>`;
} else {
pv.innerHTML = `<a href="${fileUrl}" target="_blank"><img class="preview-img" src="${fileUrl}" alt="skena"></a><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · OCR ${escHtml(up.ocr_engine||up.ocr_status||'')}</div>`;
}
} else {
pv.innerHTML = '<div style="padding:60px;background:var(--bg-3);border-radius:6px;color:var(--text-3);font-size:12px">Bez priložene datoteke</div>';
}
// KV polja
$('#inv_kv').innerHTML = `
<div>Izdavatelj</div><div>${escHtml(i.vendor_name||'—')}</div>
<div>OIB izdavatelja</div><div>${escHtml(i.vendor_oib||'—')}</div>
<div>Broj računa</div><div>${escHtml(i.invoice_no||'—')}</div>
<div>Datum</div><div>${fmtDate(i.invoice_date)}</div>
<div>Klub</div><div>${escHtml(i.klub_naziv||'—')}</div>
<div>Vrsta</div><div>${escHtml(i.invoice_kind||'—')}</div>
<div>Iznos neto</div><div>${fmtEur(i.amount_net)}</div>
<div>PDV (${i.vat_rate||'—'}%)</div><div>${fmtEur(i.amount_vat)}</div>
<div>Brutto</div><div style="color:var(--accent);font-weight:700">${fmtEur(i.amount_gross)}</div>
<div>Valuta</div><div>${escHtml(i.currency||'EUR')}</div>
<div>Opis</div><div>${escHtml(i.description||'—')}</div>
`;
// Status block
const status = (i.payment_status||'unpaid').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(i.payment_status)}</div>`;
if (status === 'paid') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to || i.iban_to || '—')}</span></div>
<div><span style="color:var(--text-3)">IBAN platitelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_from || i.iban_from || '—')}</span></div>
<div><span style="color:var(--text-3)">Datum uplate:</span> ${fmtDate(i.paid_date) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos uplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount || i.amount_gross)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'cancelled' || status === 'otkazan') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">Račun je otkazan.</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">Neplaćen — čeka uplatu.</div>`;
}
$('#inv_status_block').innerHTML = sb;
// Actions po permission-ima
const a = r.actions || {};
const acts = [];
if (a.pay && status !== 'paid') acts.push(`<button class="btn green" onclick="openPayModal(${id})">💰 Označi kao plaćen</button>`);
if (a.edit && status !== 'paid') acts.push(`<button class="btn yellow" onclick="alert('Edit u UI: koristi M5 OCR formu — ovaj panel je read-only za prikaz')">✏ Korekcija polja</button>`);
if (a.comment) acts.push(`<button class="btn sec" onclick="openCommentModal(${id})">💬 Komentar</button>`);
if (r.uploads && r.uploads.length) acts.push(`<a href="${ERP_API}/invoices/${id}/file" target="_blank" class="btn sec" style="text-decoration:none">📥 Preuzmi sken</a>`);
if (a.delete) acts.push(`<button class="btn red" onclick="if(confirm('Obrisati račun #${id}?')){alert('Brisanje: TODO endpoint')}">🗑 Obriši</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#inv_actions').innerHTML = acts.join('');
$('#inv_audit').innerHTML = renderAudit(r.audit);
openModal('invModal');
}
function openPayModal(id) {
const inv = _currentInvoice && _currentInvoice.invoice;
if (inv) {
$('#pay_iban_to').value = inv.iban_to || '';
$('#pay_amount').value = inv.amount_gross || '';
}
$('#pay_date').value = new Date().toISOString().substring(0,10);
$('#payStatus').textContent = '';
openModal('payModal');
$('#payConfirm').onclick = async () => {
const body = {
iban_to: $('#pay_iban_to').value.trim(),
iban_from: $('#pay_iban_from').value.trim(),
paid_date: $('#pay_date').value,
amount: parseFloat($('#pay_amount').value) || undefined,
reference: $('#pay_ref').value.trim(),
bank_transaction_id: $('#pay_tx').value.trim(),
payment_method: 'transfer',
};
$('#payStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices/${id}/pay`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#payStatus').textContent = '✓ Plaćeno';
$('#payStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('payModal'); openInvoice(id); loadInvoices(); }, 700);
} else {
$('#payStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#payStatus').style.color = 'var(--red)';
}
};
}
function openCommentModal(id) {
$('#commentText').value = '';
$('#commentStatus').textContent = '';
openModal('commentModal');
$('#commentSave').onclick = async () => {
const txt = $('#commentText').value.trim();
if (!txt) { $('#commentStatus').textContent = 'Komentar je prazan'; return; }
$('#commentStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/invoices/${id}/comment`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({comment: txt})}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#commentStatus').textContent = '✓ Spremljeno';
$('#commentStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('commentModal'); openInvoice(id); }, 600);
} else {
$('#commentStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#commentStatus').style.color = 'var(--red)';
}
};
}
// ===== PUTNI NALOG DETAIL =====
let _currentPn = null;
async function openPutni(id) {
const r = await fetch(`${ERP_API}/putni-nalog/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju putnog naloga #' + id); return; }
_currentPn = r;
const p = r.putni_nalog;
$('#pnModalTitle').textContent = `Putni nalog #${p.id} · ${p.klub_naziv||'—'}`;
const att = p.attachments || {};
const dnv = att.dnevnice_calc || {};
const putnici = (att.putnici || []).join(', ');
const voditelj = att.voditelj || '—';
const country = att.country || '—';
const fromCity = att.from_city || '—', toCity = att.to_city || '—';
$('#pn_kv').innerHTML = `
<div>Voditelj</div><div>${escHtml(voditelj)}</div>
<div>Putnici</div><div>${escHtml(putnici||'—')}</div>
<div>Svrha</div><div>${escHtml(p.purpose||'—')}</div>
<div>Ruta</div><div>${escHtml(fromCity)} → ${escHtml(toCity)}</div>
<div>Zemlja</div><div>${escHtml(country)}</div>
<div>Polazak</div><div>${fmtDate(p.date_from)}</div>
<div>Povratak</div><div>${fmtDate(p.date_to)}</div>
<div>Vozilo</div><div>${escHtml(p.vehicle_type||'—')} ${escHtml(p.vehicle_plate||'')}</div>
<div>Kilometara</div><div>${p.km_driven||0} km × €${p.km_rate||0.5}</div>
`;
$('#pn_obracun').innerHTML = `
<div>Pune dnevnice</div><div style="color:var(--accent)">${dnv.days_full||0} × €${dnv.rate_full||0}</div>
<div>Pola dnevnica</div><div style="color:var(--yellow)">${dnv.days_half||0} × €${dnv.rate_half||0}</div>
<div>Dnevnice ukupno</div><div style="color:var(--green)">${fmtEur(p.dnevnice_amount)}</div>
<div>Kilometrina</div><div>${fmtEur(p.cost_transport)}</div>
<div>Smještaj</div><div>${fmtEur(p.cost_lodging)}</div>
<div>Hrana / ostalo</div><div>${fmtEur((p.cost_meals||0)+(p.cost_other||0))}</div>
<div style="font-weight:700">UKUPNO</div><div style="color:var(--accent);font-weight:700;font-size:18px">${fmtEur(p.cost_total)}</div>
`;
// Status block
const status = (p.status||'draft').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(p.status)}</div>`;
if (status === 'isplacen') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to||'—')}</span></div>
<div><span style="color:var(--text-3)">Datum isplate:</span> ${fmtDate(p.paid_at) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos isplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount||p.cost_total)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'odbijen') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">${escHtml(p.notes||'Odbijen').slice(-200)}</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">${status === 'odobren' || status === 'zatvoren' ? 'Čeka isplatu.' : status === 'poslan' ? 'Čeka odobrenje.' : 'Draft — još nije poslan na odobrenje.'}</div>`;
}
$('#pn_status_block').innerHTML = sb;
// Vezani računi
const invs = r.invoices || [];
$('#pn_invoices_table tbody').innerHTML = invs.length ? invs.map(i => `
<tr class="clickable" onclick="closeModal('pnModal'); setTimeout(()=>openInvoice(${i.id}), 100)">
<td>${i.id}</td><td>${escHtml(i.invoice_kind||'—')}</td><td>${escHtml(i.vendor_name||'—')}</td>
<td style="font-family:'JetBrains Mono'">${escHtml(i.vendor_oib||'—')}</td>
<td>${fmtDate(i.invoice_date)}</td>
<td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td>
</tr>`).join('') : '<tr><td colspan="7" style="color:var(--text-3);text-align:center;padding:14px">Nema vezanih računa</td></tr>';
// Actions
const a = r.actions || {};
const acts = [];
if (a.submit) acts.push(`<button class="btn yellow" onclick="submitPn(${id})">📤 Pošalji na odobrenje</button>`);
if (a.approve) acts.push(`<button class="btn green" onclick="approvePn(${id})">✓ Odobri</button>`);
if (a.reject) acts.push(`<button class="btn red" onclick="openRejectModal(${id})">✗ Odbij</button>`);
if (a.pay) acts.push(`<button class="btn green" onclick="openPayPnModal(${id})">💰 Isplati</button>`);
if (a.edit) acts.push(`<button class="btn sec" onclick="alert('Edit drafta — koristi M6 formu \\'Novi putni nalog\\' s prefilanim poljima (TODO UI)')">✏ Edit</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#pn_actions').innerHTML = acts.join('');
$('#pn_audit').innerHTML = renderAudit(r.audit);
openModal('pnModal');
}
async function submitPn(id) {
if (!confirm('Poslati putni nalog #' + id + ' na odobrenje?')) return;
const r = await fetch(`${ERP_API}/putni-nalog/${id}/posalji`, {method:'POST', headers: AUTH_HDR_JSON()}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
}
async function approvePn(id) {
if (!confirm('Odobriti putni nalog #' + id + '?')) return;
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odobriti`, {method:'POST', headers: AUTH_HDR_JSON(), body: '{}'}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
}
function openRejectModal(id) {
$('#rejectText').value = '';
$('#rejectStatus').textContent = '';
openModal('rejectModal');
$('#rejectConfirm').onclick = async () => {
const reason = $('#rejectText').value.trim();
if (!reason) { $('#rejectStatus').textContent = 'Razlog je obavezan'; return; }
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odbij`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({razlog: reason})}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { closeModal('rejectModal'); openPutni(id); loadPutni(); }
else $('#rejectStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
};
}
function openPayPnModal(id) {
const pn = _currentPn && _currentPn.putni_nalog;
if (pn) $('#ppn_amount').value = pn.cost_total || '';
$('#ppn_date').value = new Date().toISOString().substring(0,10);
$('#ppnStatus').textContent = '';
openModal('payPnModal');
$('#ppnConfirm').onclick = async () => {
const body = {
iban_to: $('#ppn_iban_to').value.trim(),
iban_from: $('#ppn_iban_from').value.trim(),
paid_date: $('#ppn_date').value,
amount: parseFloat($('#ppn_amount').value) || undefined,
reference: $('#ppn_ref').value.trim(),
bank_transaction_id: $('#ppn_tx').value.trim(),
};
$('#ppnStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/putni-nalog/${id}/isplati`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) {
$('#ppnStatus').textContent = '✓ Isplaćeno';
$('#ppnStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('payPnModal'); openPutni(id); loadPutni(); }, 700);
} else {
$('#ppnStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
$('#ppnStatus').style.color = 'var(--red)';
}
};
}
function activate(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
const titles = {ocr:'Skeniraj račun (OCR)',invoices:'Računi',putni:'Novi putni nalog','putni-list':'Lista putnih naloga'};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'invoices') loadInvoices();
if (name === 'putni-list') loadPutni();
}
$$('.nav-item').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
(async () => {
await loadKlubovi();
ocrInit();
pnInit();
})();
</script>
</body>
</html>
@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>RINET KPI Dashboard</title>
<style>
body { font-family: -apple-system, sans-serif; background: #0a0e1a; color: #d0d8e8; margin: 0; padding: 20px; }
h1 { color: #4af; margin: 0 0 20px; font-size: 24px; }
h2 { color: #6cf; margin: 20px 0 8px; font-size: 16px; border-bottom: 1px solid #2a3a4a; padding-bottom: 4px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; margin-bottom: 16px; }
.card { background: #14192a; padding: 12px 16px; border-radius: 6px; border-left: 3px solid #4af; }
.card .label { color: #88a; font-size: 11px; text-transform: uppercase; }
.card .value { color: #fff; font-size: 22px; font-weight: bold; margin: 4px 0; }
.card .sub { color: #aab; font-size: 12px; }
.card.good { border-left-color: #4f4; }
.card.warn { border-left-color: #fa4; }
.card.bad { border-left-color: #f44; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 6px 12px; border-bottom: 1px solid #2a3a4a; font-size: 12px; }
th { color: #6cf; font-weight: normal; text-transform: uppercase; font-size: 10px; }
tr:hover { background: #1a2030; }
.updated { color: #678; font-size: 11px; }
.refresh { background: #4af; color: #fff; border: none; padding: 4px 12px; border-radius: 4px; cursor: pointer; }
</style>
</head>
<body>
<h1>RINET KPI Dashboard <span class="updated" id="updated"></span> <button class="refresh" onclick="load()">↻</button></h1>
<div id="root">Loading...</div>
<script>
async function load() {
document.getElementById('updated').textContent = '...';
try {
const r = await fetch('/admin/api/kpi');
const d = await r.json();
if (d.error) {
document.getElementById('root').innerHTML = '<div class="card bad">Error: ' + d.error + '</div>';
return;
}
const haluClass = d.queries.halu_pct > 5 ? 'bad' : d.queries.halu_pct > 1 ? 'warn' : 'good';
const clusterTotal = Object.values(d.cluster).reduce((a,b)=>a+b, 0);
const clusterUnhealthy = Object.entries(d.cluster).filter(([s,n]) => !['healthy','skipped'].includes(s)).reduce((a,[s,n])=>a+n, 0);
const clusterClass = clusterUnhealthy > 0 ? 'bad' : 'good';
const incClass = d.open_incidents > 0 ? 'warn' : 'good';
const embClass = d.knowledge.embed_pct >= 99 ? 'good' : d.knowledge.embed_pct >= 95 ? 'warn' : 'bad';
let html = `
<h2>Queries (Production)</h2>
<div class="grid">
<div class="card good"><div class="label">Last 1h</div><div class="value">${d.queries.h1}</div></div>
<div class="card good"><div class="label">Last 24h</div><div class="value">${d.queries.h24}</div></div>
<div class="card ${haluClass}"><div class="label">Halucinacije 24h</div><div class="value">${d.queries.halucinacije_h24}</div><div class="sub">${d.queries.halu_pct}%</div></div>
<div class="card good"><div class="label">Avg latency</div><div class="value">${d.queries.avg_latency_sec}s</div></div>
<div class="card good"><div class="label">Avg confidence</div><div class="value">${d.queries.avg_confidence}</div></div>
</div>
<h2>Knowledge Base</h2>
<div class="grid">
<div class="card good"><div class="label">Total facts</div><div class="value">${d.knowledge.total.toLocaleString()}</div></div>
<div class="card good"><div class="label">Added 1h / 24h</div><div class="value">+${d.knowledge.added_h1} / +${d.knowledge.added_h24}</div></div>
<div class="card ${embClass}"><div class="label">Embed coverage</div><div class="value">${d.knowledge.embed_pct}%</div><div class="sub">${d.knowledge.embed_pending} pending</div></div>
<div class="card good"><div class="label">Training Q&A</div><div class="value">${d.training.total.toLocaleString()}</div><div class="sub">+${d.training.added_h24} / 24h, ${d.training.from_capture} from capture</div></div>
</div>
<h2>Cluster Health</h2>
<div class="grid">
<div class="card ${clusterClass}"><div class="label">Healthy</div><div class="value">${d.cluster.healthy || 0} / ${clusterTotal}</div></div>
<div class="card ${incClass}"><div class="label">Open incidents</div><div class="value">${d.open_incidents}</div></div>
<div class="card good"><div class="label">Skipped</div><div class="value">${d.cluster.skipped || 0}</div><div class="sub">PG/Redis/cold by design</div></div>
<div class="card ${clusterUnhealthy>0?'bad':'good'}"><div class="label">Unhealthy</div><div class="value">${clusterUnhealthy}</div></div>
</div>
<h2>Top Sources (24h scrape)</h2>
<table>
<tr><th>Source</th><th>Count</th></tr>
${d.top_sources_h24.map(s => `<tr><td>${s.source}</td><td>${s.count.toLocaleString()}</td></tr>`).join('')}
</table>
<h2>Top Models (24h)</h2>
<table>
<tr><th>Model</th><th>Calls</th><th>Avg latency</th></tr>
${d.top_models_h24.map(m => `<tr><td>${m.model || '-'}</td><td>${m.count}</td><td>${m.avg_latency}s</td></tr>`).join('')}
</table>
`;
document.getElementById('root').innerHTML = html;
document.getElementById('updated').textContent = new Date().toLocaleTimeString();
} catch (e) {
document.getElementById('root').innerHTML = '<div class="card bad">Network error: ' + e.message + '</div>';
}
}
load();
setInterval(load, 30000); // 30s refresh
</script>
</body>
</html>
@@ -0,0 +1,562 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · Prijava</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>P</text></svg>">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #06080d;
--bg-2: #0d1117;
--bg-3: #161b22;
--border: #1f2937;
--text: #e6edf3;
--text-2: #8b949e;
--text-3: #6e7681;
--accent: #00f0ff;
--accent-2: #00b8d4;
--green: #56d364;
--red: #f85149;
--yellow: #d29922;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
}
body {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 100vh;
}
@media (max-width: 900px) {
body { grid-template-columns: 1fr; }
.left { display: none; }
}
.left {
background:
radial-gradient(ellipse at 30% 20%, rgba(0,240,255,0.08), transparent 60%),
radial-gradient(ellipse at 70% 80%, rgba(188,140,255,0.05), transparent 60%),
linear-gradient(180deg, var(--bg) 0%, var(--bg-2) 100%);
border-right: 1px solid var(--border);
padding: 56px;
display: flex;
flex-direction: column;
justify-content: space-between;
position: relative;
overflow: hidden;
}
.left::before {
content: '';
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(0,240,255,0.04) 1px, transparent 1px),
linear-gradient(90deg, rgba(0,240,255,0.04) 1px, transparent 1px);
background-size: 40px 40px;
mask: radial-gradient(ellipse at center, black 30%, transparent 80%);
pointer-events: none;
}
.brand {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 14px;
}
.brand-mark {
width: 48px; height: 48px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
color: var(--bg);
font-weight: 700; font-size: 22px;
font-family: 'JetBrains Mono', monospace;
box-shadow: 0 0 24px rgba(0,240,255,0.3);
}
.brand-text h1 {
font-size: 20px; font-weight: 700; letter-spacing: 0.5px;
}
.brand-text .sub {
font-size: 12px; color: var(--text-3);
font-family: 'JetBrains Mono', monospace;
}
.hero { position: relative; z-index: 1; max-width: 460px; }
.hero h2 {
font-size: 36px; font-weight: 700;
line-height: 1.15;
margin-bottom: 18px;
letter-spacing: -0.5px;
}
.hero h2 span { color: var(--accent); }
.hero p {
color: var(--text-2);
font-size: 15px;
line-height: 1.6;
margin-bottom: 28px;
}
.features {
display: grid; gap: 12px;
}
.feat {
display: flex; gap: 12px;
font-size: 13px; color: var(--text-2);
}
.feat .ico {
width: 22px; height: 22px;
border-radius: 4px;
background: rgba(0,240,255,0.1);
color: var(--accent);
display: grid; place-items: center;
font-size: 12px; font-weight: 700;
flex-shrink: 0;
}
.footer-left {
position: relative; z-index: 1;
font-size: 11px; color: var(--text-3);
font-family: 'JetBrains Mono', monospace;
}
.right {
display: flex; align-items: center; justify-content: center;
padding: 40px;
}
.card {
width: 100%;
max-width: 380px;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 36px 32px;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
}
.card h3 {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
}
.card .lead {
color: var(--text-3);
font-size: 13px;
margin-bottom: 28px;
}
.field {
margin-bottom: 14px;
}
.field label {
display: block;
font-size: 11px;
color: var(--text-3);
text-transform: uppercase;
letter-spacing: 0.7px;
margin-bottom: 6px;
font-weight: 600;
}
.field input {
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
padding: 12px 14px;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.field input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(0,240,255,0.12);
}
.row {
display: flex; justify-content: space-between; align-items: center;
margin: 14px 0 22px;
font-size: 12px;
}
.row label {
display: flex; align-items: center; gap: 6px;
color: var(--text-2);
cursor: pointer;
}
.row label input { accent-color: var(--accent); }
.row a { color: var(--accent); text-decoration: none; }
.row a:hover { text-decoration: underline; }
.btn {
width: 100%;
background: var(--accent);
color: var(--bg);
border: 0;
padding: 12px;
border-radius: 6px;
font-family: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
letter-spacing: 0.3px;
transition: background 0.15s, transform 0.05s;
}
.btn:hover:not(:disabled) { background: var(--accent-2); }
.btn:active:not(:disabled) { transform: translateY(1px); }
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.btn .spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(0,0,0,0.25);
border-top-color: var(--bg);
border-radius: 50%;
animation: spin 0.8s linear infinite;
vertical-align: -3px;
margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.alert {
background: rgba(248,81,73,0.1);
border: 1px solid rgba(248,81,73,0.4);
color: #ffb4af;
padding: 10px 12px;
border-radius: 6px;
font-size: 13px;
margin-bottom: 16px;
display: none;
}
.alert.show { display: block; }
.alert.success {
background: rgba(86,211,100,0.1);
border-color: rgba(86,211,100,0.4);
color: #b6f0bd;
}
.divider {
display: flex; align-items: center; gap: 12px;
margin: 18px 0;
color: var(--text-3);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
}
.divider::before, .divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.demo {
background: var(--bg-3);
border: 1px dashed var(--border);
border-radius: 6px;
padding: 10px 12px;
font-size: 11px;
color: var(--text-2);
font-family: 'JetBrains Mono', monospace;
cursor: pointer;
transition: border-color 0.15s;
}
.demo:hover { border-color: var(--accent); color: var(--text); }
.demo strong { color: var(--accent); }
.footer-right {
text-align: center;
margin-top: 22px;
font-size: 11px;
color: var(--text-3);
}
.footer-right a {
color: var(--text-2);
text-decoration: none;
margin: 0 6px;
}
.footer-right a:hover { color: var(--accent); }
/* Cookie banner */
.cookie {
position: fixed;
bottom: 16px; left: 16px; right: 16px;
max-width: 600px;
margin: 0 auto;
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px 20px;
display: none;
z-index: 1000;
box-shadow: 0 12px 40px rgba(0,0,0,0.5);
}
.cookie.show { display: block; }
.cookie h4 { font-size: 14px; margin-bottom: 6px; }
.cookie p { font-size: 12px; color: var(--text-2); margin-bottom: 12px; }
.cookie-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.cookie-actions button {
background: transparent;
border: 1px solid var(--border);
color: var(--text-2);
padding: 6px 14px;
border-radius: 5px;
font-family: inherit;
font-size: 12px;
cursor: pointer;
}
.cookie-actions button.primary {
background: var(--accent);
border-color: var(--accent);
color: var(--bg);
font-weight: 600;
}
.cookie-actions button:hover { color: var(--text); border-color: var(--accent); }
.cookie a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<div class="left">
<div class="brand">
<div class="brand-mark">P</div>
<div class="brand-text">
<h1>PGŽ Sport</h1>
<div class="sub">ERP/CRM Platforma</div>
</div>
</div>
<div class="hero">
<h2>Operativna platforma <span>za sport</span> u Primorsko-goranskoj županiji.</h2>
<p>Jedinstvena baza klubova, saveza i sportaša. Računovodstvo, članarine, liječnički pregledi, sufinanciranja — sve na jednom mjestu.</p>
<div class="features">
<div class="feat"><div class="ico">✓</div><div>Multi-tenant arhitektura — PGŽ, savezi, klubovi sa svojim view-om</div></div>
<div class="feat"><div class="ico">✓</div><div>OCR za račune, automatska ekstrakcija polja, putni nalozi</div></div>
<div class="feat"><div class="ico">✓</div><div>Članarine s HUB-3 uplatnicama i blockchain audit log</div></div>
<div class="feat"><div class="ico">✓</div><div>GDPR-compliant (Art. 17, 20) · 2FA · audit svih akcija</div></div>
</div>
</div>
<div class="footer-left">
PGŽ ODJEL ZA SPORT · v3.0 · 2026
</div>
</div>
<div class="right">
<div class="card">
<h3>Prijava</h3>
<div class="lead">Unesite svoje podatke za pristup platformi.</div>
<div id="alert" class="alert"></div>
<form id="loginForm" autocomplete="on">
<div class="field">
<label for="email">E-mail</label>
<input type="email" id="email" name="email" required autocomplete="username" placeholder="ime.prezime@pgz.hr">
</div>
<div class="field">
<label for="password">Lozinka</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
</div>
<div class="field" id="totpField" style="display:none">
<label for="totp">Kod autentifikatora (2FA)</label>
<input type="text" id="totp" name="totp" inputmode="numeric" pattern="[0-9 ]*" autocomplete="one-time-code" placeholder="123456" maxlength="8" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
</div>
<div class="row">
<label><input type="checkbox" id="remember" checked> Zapamti me</label>
<a href="#" id="forgotLink">Zaboravljena lozinka?</a>
</div>
<button type="submit" class="btn" id="submitBtn">Prijavi se</button>
</form>
<div class="divider">Demo računi</div>
<div style="display:grid;gap:8px">
<div class="demo" data-email="damir@pgz.hr" data-pwd="PGZ2026!">
<strong>PGŽ admin</strong> · damir@pgz.hr / PGZ2026!
</div>
<div class="demo" data-email="pero@atletika.pgz.hr" data-pwd="PGZ2026!">
<strong>Savez admin</strong> · pero@atletika.pgz.hr
</div>
<div class="demo" data-email="ana@akkvarner.hr" data-pwd="PGZ2026!">
<strong>Klub admin</strong> · ana@akkvarner.hr
</div>
</div>
<div class="footer-right">
<a href="/sport2.html">Javni portal</a>
·
<a href="#" id="privacyLink">Politika privatnosti</a>
·
<a href="#" id="cookieLink">Kolačići</a>
</div>
</div>
</div>
<!-- GDPR cookie consent -->
<div id="cookie" class="cookie">
<h4>🍪 Kolačići</h4>
<p>Koristimo nužne kolačiće za prijavu i sigurnost sesije. Po vašem odobrenju koristimo i analitičke kolačiće za poboljšanje platforme. <a href="#" id="cookieMore">Više…</a></p>
<div class="cookie-actions">
<button class="primary" id="cookieAccept">Prihvati sve</button>
<button id="cookieNecessary">Samo nužni</button>
<button id="cookieReject">Odbij sve</button>
</div>
</div>
<script>
const API = '/api';
const $ = s => document.querySelector(s);
// ---------- Login ----------
function showAlert(msg, type) {
const a = $('#alert');
a.textContent = msg;
a.className = 'alert show' + (type === 'success' ? ' success' : '');
if (type === 'success') {
setTimeout(() => a.classList.remove('show'), 3000);
}
}
async function doLogin(email, password, totp) {
const btn = $('#submitBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Prijavljujem…';
try {
const body = { email, password };
if (totp) body.totp = totp;
const r = await fetch(API + '/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await r.json();
if (!r.ok) {
if (r.status === 401 && (data.detail === '2FA_REQUIRED' || /2FA/i.test(data.detail||''))) {
// Show TOTP field and stop
$('#totpField').style.display = '';
$('#totp').focus();
showAlert('Unesite kod iz autentifikatora.');
} else {
showAlert(data.detail || 'Neispravni podaci');
}
btn.disabled = false;
btn.textContent = 'Prijavi se';
return;
}
// Store tokens
const store = $('#remember').checked ? localStorage : sessionStorage;
store.setItem('pgz_access', data.access_token);
store.setItem('pgz_refresh', data.refresh_token);
store.setItem('pgz_user', JSON.stringify(data.user));
showAlert('Prijava uspješna. Preusmjeravam…', 'success');
// Redirect by role
setTimeout(() => {
const role = (data.user.role || '').toLowerCase();
if (['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz',
'savez_admin','savez_user','klub_admin','klub_user','klub_trener'].includes(role)) {
// Smart redirect po roli
const role = data.user.role;
const redirectMap = {
'pgz_admin': '/app',
'savez_admin': '/app',
'klub_admin': '/app',
'super_admin': '/admin'
};
location.href = redirectMap[role] || '/app';
} else {
location.href = '/';
}
}, 600);
} catch (e) {
showAlert('Greška mreže: ' + e.message);
btn.disabled = false;
btn.textContent = 'Prijavi se';
}
}
$('#loginForm').addEventListener('submit', e => {
e.preventDefault();
const email = $('#email').value.trim().toLowerCase();
const pwd = $('#password').value;
const totp = ($('#totp').value || '').trim().replace(/\s/g,'') || null;
if (!email || !pwd) return;
doLogin(email, pwd, totp);
});
document.querySelectorAll('.demo').forEach(el => {
el.addEventListener('click', () => {
$('#email').value = el.dataset.email;
$('#password').value = el.dataset.pwd;
$('#email').focus();
});
});
$('#forgotLink').addEventListener('click', async e => {
e.preventDefault();
const email = ($('#email').value || prompt('Unesite e-mail:') || '').trim().toLowerCase();
if (!email) return;
try {
const r = await fetch(API + '/auth/password/reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email })
});
const data = await r.json();
showAlert(data.message || 'Zahtjev poslan administratoru.', 'success');
} catch (err) {
showAlert('Greška: ' + err.message);
}
});
// ---------- Cookie consent ----------
const consentKey = 'pgz_consent';
function showConsent() {
if (!localStorage.getItem(consentKey)) {
$('#cookie').classList.add('show');
}
}
async function saveConsent(necessary, analytics, marketing) {
const session_id = localStorage.getItem('pgz_session_id') ||
(() => { const s = crypto.randomUUID(); localStorage.setItem('pgz_session_id', s); return s; })();
localStorage.setItem(consentKey, JSON.stringify({ necessary, analytics, marketing, ts: Date.now() }));
$('#cookie').classList.remove('show');
try {
await fetch(API + '/gdpr/consent', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ necessary, analytics, marketing, session_id })
});
} catch {}
}
$('#cookieAccept').addEventListener('click', () => saveConsent(true, true, true));
$('#cookieNecessary').addEventListener('click', () => saveConsent(true, false, false));
$('#cookieReject').addEventListener('click', () => saveConsent(true, false, false));
$('#cookieLink').addEventListener('click', e => { e.preventDefault(); localStorage.removeItem(consentKey); showConsent(); });
$('#privacyLink').addEventListener('click', async e => {
e.preventDefault();
try {
const r = await fetch(API + '/gdpr/policy');
const d = await r.json();
alert('PGŽ Sport — Politika privatnosti v' + d.version +
'\n\nKontroler: ' + d.controller +
'\nKontakt: ' + d.contact +
'\nDPO: ' + d.dpo +
'\n\nVaša prava:\n' + d.rights.join('\n'));
} catch {}
});
$('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privacyLink').click(); });
// Skip login if already authenticated
(async () => {
const tok = localStorage.getItem('pgz_access') || sessionStorage.getItem('pgz_access');
if (tok) {
try {
const r = await fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + tok }});
if (r.ok) {
location.href = '/app';
return;
}
} catch {}
}
showConsent();
$('#email').focus();
})();
</script>
</body>
</html>
+858
View File
@@ -0,0 +1,858 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · ERP — OCR + Putni nalozi</title>
<!--
erp.html — PGŽ Sport ERP UI (M5 OCR + M6 Putni nalozi)
Author: dradulic@outlook.com / damir@rinet.one — 2026-05-04
Real backend: /api/erp/ocr/upload, /parse, /invoices, /putni-nalog
-->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>€</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg:#06080d; --bg-2:#0d1117; --bg-3:#161b22; --border:#1f2937;
--text:#e6edf3; --text-2:#8b949e; --text-3:#6e7681;
--accent:#00f0ff; --green:#56d364; --yellow:#d29922; --red:#f85149; --purple:#bc8cff;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; font-size:14px; }
.app { display:grid; grid-template-columns:230px 1fr; min-height:100vh; }
.sidebar { background:var(--bg-2); border-right:1px solid var(--border); padding:20px 0; }
.brand { padding:0 20px 18px; border-bottom:1px solid var(--border); margin-bottom:10px; }
.brand h1 { font-size:16px; font-weight:700; color:var(--accent); font-family:'JetBrains Mono',monospace; }
.brand .sub { font-size:11px; color:var(--text-3); margin-top:2px; }
.nav-item { display:flex; gap:10px; padding:10px 20px; cursor:pointer; color:var(--text-2); font-size:13px; border-left:3px solid transparent; align-items:center; }
.nav-item:hover { background:var(--bg-3); color:var(--text); }
.nav-item.active { color:var(--accent); background:rgba(0,240,255,.05); border-left-color:var(--accent); }
.main { padding:24px 30px; overflow-y:auto; }
.header { display:flex; justify-content:space-between; padding-bottom:14px; border-bottom:1px solid var(--border); margin-bottom:18px; align-items:center; }
.header h2 { font-size:22px; font-weight:700; }
.header .meta { color:var(--text-3); font-size:12px; font-family:'JetBrains Mono',monospace; }
.section { background:var(--bg-2); border:1px solid var(--border); border-radius:8px; padding:18px; margin-bottom:16px; }
.section h3 { font-size:14px; font-weight:600; color:var(--accent); margin-bottom:12px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; padding:8px 10px; color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; border-bottom:1px solid var(--border); }
td { padding:10px; border-bottom:1px solid var(--border); }
td.num { font-family:'JetBrains Mono',monospace; text-align:right; }
tr:hover { background:var(--bg-3); }
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.badge.green { background:rgba(86,211,100,.15); color:var(--green); }
.badge.yellow { background:rgba(210,153,34,.15); color:var(--yellow); }
.badge.red { background:rgba(248,81,73,.15); color:var(--red); }
.badge.gray { background:rgba(110,118,129,.15); color:var(--text-3); }
input.fld, select.fld { width:100%; background:var(--bg); border:1px solid var(--border); padding:8px 10px; border-radius:4px; color:var(--text); font-family:inherit; font-size:13px; }
input.fld:focus, select.fld:focus { outline:none; border-color:var(--accent); }
label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4px; text-transform:uppercase; letter-spacing:.5px; }
.btn { padding:9px 18px; background:var(--accent); color:var(--bg); border:0; border-radius:4px; cursor:pointer; font-weight:600; font-family:inherit; font-size:13px; }
.btn.sec { background:var(--bg-3); color:var(--text); border:1px solid var(--border); }
.tab { display:none; }
.tab.active { display:block; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
tr.clickable { cursor:pointer; }
tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--accent); }
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; display:none; align-items:flex-start; justify-content:center; padding:30px; overflow-y:auto; }
.modal-bg.show { display:flex; }
.modal { background:var(--bg-2); border:1px solid var(--border); border-radius:10px; max-width:1100px; width:100%; padding:0; box-shadow:0 12px 48px rgba(0,0,0,.6); }
.modal-h { display:flex; justify-content:space-between; align-items:center; padding:16px 22px; border-bottom:1px solid var(--border); }
.modal-h h3 { color:var(--accent); font-size:16px; }
.modal-h .x { background:transparent; border:0; color:var(--text-2); font-size:22px; cursor:pointer; }
.modal-h .x:hover { color:var(--red); }
.modal-body { padding:18px 22px; max-height:80vh; overflow-y:auto; }
.col2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
.kv { display:grid; grid-template-columns:140px 1fr; gap:6px 12px; font-size:13px; }
.kv > div:nth-child(odd) { color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; align-self:center; }
.kv > div:nth-child(even) { font-family:'JetBrains Mono',monospace; }
.preview-img { max-width:100%; max-height:480px; border:1px solid var(--border); border-radius:6px; background:var(--bg); }
.audit-row { display:grid; grid-template-columns:140px 110px 130px 1fr; gap:8px; padding:6px 0; border-bottom:1px dashed var(--border); font-size:12px; }
.audit-row:last-child { border-bottom:0; }
.audit-row .ts { color:var(--text-3); font-family:'JetBrains Mono',monospace; font-size:11px; }
.audit-row .op { color:var(--accent); font-weight:600; }
.audit-row .who { color:var(--text-2); }
.btn.green { background:var(--green); color:var(--bg); }
.btn.red { background:var(--red); color:#fff; }
.btn.yellow { background:var(--yellow); color:var(--bg); }
.actions-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; padding-top:14px; border-top:1px solid var(--border); }
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } .col2 { grid-template-columns:1fr; } .audit-row { grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><h1>PGŽ ERP</h1><div class="sub">M5 OCR + M6 Putni nalozi</div></div>
<div class="nav-item active" data-tab="ocr"><span>📷</span><span>Skeniraj račun</span></div>
<div class="nav-item" data-tab="invoices"><span>€</span><span>Računi</span></div>
<div class="nav-item" data-tab="putni"><span>🚗</span><span>Novi putni nalog</span></div>
<div class="nav-item" data-tab="putni-list"><span>📋</span><span>Lista putnih naloga</span></div>
<div style="padding:14px 20px 4px;font-size:9.5px;color:var(--text-2);text-transform:uppercase;letter-spacing:1.2px;font-weight:700">Portali</div>
<a class="nav-item" href="/sport/login" style="text-decoration:none"><span>🔑</span><span>Prijava</span></a>
<a class="nav-item" href="/sport/app" style="text-decoration:none"><span>📱</span><span>Aplikacija</span></a>
<a class="nav-item" href="/sport/admin" style="text-decoration:none"><span>🛡</span><span>Administracija</span></a>
<a class="nav-item" href="/sport/crm" style="text-decoration:none"><span>👥</span><span>CRM</span></a>
<a class="nav-item active" href="/sport/erp" style="text-decoration:none"><span>💰</span><span>ERP</span></a>
<a class="nav-item" href="/sport/kpi" style="text-decoration:none"><span>📈</span><span>KPI</span></a>
<a class="nav-item" href="/sport/audit" style="text-decoration:none"><span>📋</span><span>Audit</span></a>
<a class="nav-item" href="/sport/static/sport2.html" target="_blank" style="text-decoration:none"><span>🌐</span><span>Public portal</span></a>
</aside>
<main class="main">
<div class="header">
<h2 id="pageTitle">Skeniraj račun (OCR)</h2>
<span class="meta" id="metaInfo">Tesseract + Ri.NET AI Engine · /api/erp</span>
</div>
<!-- OCR -->
<div class="tab active" id="tab-ocr">
<div class="section">
<h3>📷 Drag-and-drop OCR (PDF / JPG / PNG)</h3>
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:34px;text-align:center;cursor:pointer;background:var(--bg-3)">
<div style="font-size:36px;color:var(--accent);margin-bottom:6px">⤓</div>
<div style="font-size:14px;font-weight:600">Povuci datoteku ovdje ili klikni za odabir</div>
<div style="font-size:11px;color:var(--text-3);margin-top:6px">Tesseract OCR (hrv+eng) + Ri.NET AI Engine LLM ekstrakcija polja</div>
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
</div>
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div class="grid2" style="font-size:13px">
<div><label class="lbl">Izdavatelj</label><input id="oc_vendor_name" class="fld"></div>
<div><label class="lbl">OIB izdavatelja</label><input id="oc_vendor_oib" class="fld"></div>
<div><label class="lbl">Broj računa</label><input id="oc_invoice_no" class="fld"></div>
<div><label class="lbl">Datum</label><input id="oc_invoice_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos neto (€)</label><input id="oc_amount_net" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">PDV (€)</label><input id="oc_amount_vat" type="number" step="0.01" class="fld"></div>
<div><label class="lbl" style="color:var(--accent)">Brutto / UKUPNO (€)</label><input id="oc_amount_gross" type="number" step="0.01" class="fld" style="border-color:var(--accent)"></div>
<div><label class="lbl">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">IBAN</label><input id="oc_iban" class="fld"></div>
<div><label class="lbl">Valuta</label><select id="oc_currency" class="fld"><option>EUR</option><option>HRK</option></select></div>
<div><label class="lbl">Vrsta troška</label>
<select id="oc_kind" class="fld">
<option value="gorivo">Gorivo</option><option value="cestarina">Cestarina</option>
<option value="hotel">Hotel</option><option value="restoran">Restoran</option>
<option value="oprema">Oprema</option><option value="ostalo" selected>Ostalo</option>
</select>
</div>
<div><label class="lbl">Klub</label><select id="oc_klub" class="fld"></select></div>
</div>
<div style="margin-top:10px"><label class="lbl">Opis</label><input id="oc_description" class="fld"></div>
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
</details>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<button id="ocSave" class="btn">💾 Spremi račun</button>
<button id="ocCancel" class="btn sec">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
</div>
<!-- Invoices list -->
<div class="tab" id="tab-invoices">
<div class="section">
<h3>Računi (svi klubovi)</h3>
<table id="invTable"><thead><tr><th>#</th><th>Vrsta</th><th>Broj</th><th>Dobavljač</th><th>OIB</th><th>Klub</th><th class="num">Brutto</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Putni nalog form -->
<div class="tab" id="tab-putni">
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div class="grid3" style="font-size:13px">
<div><label class="lbl">Klub</label><select id="pn_klub" class="fld"></select></div>
<div><label class="lbl">Voditelj</label><input id="pn_voditelj" class="fld" placeholder="Ime Prezime"></div>
<div><label class="lbl">Putnici (zarez)</label><input id="pn_putnici" class="fld"></div>
<div style="grid-column:span 3"><label class="lbl">Svrha putovanja</label><input id="pn_svrha" class="fld" placeholder="Natjecanje, treninzi, edukacija…"></div>
<div><label class="lbl">Od grada</label><input id="pn_od" class="fld" value="Rijeka"></div>
<div><label class="lbl">Do grada</label><input id="pn_do" class="fld"></div>
<div><label class="lbl">Zemlja</label><input id="pn_country" class="fld" value="Hrvatska"></div>
<div><label class="lbl">Polazak</label><input id="pn_from" type="datetime-local" class="fld"></div>
<div><label class="lbl">Povratak</label><input id="pn_to" type="datetime-local" class="fld"></div>
<div><label class="lbl">Tip vozila</label>
<select id="pn_vehicle" class="fld">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label class="lbl">Registracija</label><input id="pn_plate" class="fld"></div>
<div><label class="lbl">Kilometara</label><input id="pn_km" type="number" step="1" class="fld" value="0"></div>
<div><label class="lbl">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="fld" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button id="pnSave" class="btn">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
<p style="margin-top:14px;font-size:11px;color:var(--text-3);line-height:1.6">
<b>HR pravilnik 2025:</b> domaće 26.54 € (>8h), 13.27 € (58h), 0 € (&lt;5h). Inozemne dnevnice po zemlji
(Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km.
</p>
</div>
</div>
<!-- Putni nalozi list -->
<div class="tab" id="tab-putni-list">
<div class="section">
<h3>Lista putnih naloga</h3>
<table id="pnTable"><thead><tr><th>#</th><th>Klub</th><th>Destinacija</th><th>Polazak</th><th>Povratak</th><th class="num">Dnevnice</th><th class="num">Transport</th><th class="num">Total</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<!-- ============ INVOICE DETAIL MODAL (M5.5) ============ -->
<div id="invModal" class="modal-bg" onclick="if(event.target===this)closeModal('invModal')">
<div class="modal">
<div class="modal-h">
<h3 id="invModalTitle">Račun</h3>
<button class="x" onclick="closeModal('invModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Skenirana datoteka</h4>
<div id="inv_preview" style="text-align:center"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Podaci računa</h4>
<div class="kv" id="inv_kv"></div>
<div id="inv_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div class="actions-row" id="inv_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="inv_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY INVOICE MODAL (M5.5) ============ -->
<div id="payModal" class="modal-bg" onclick="if(event.target===this)closeModal('payModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Označi kao plaćen</h3>
<button class="x" onclick="closeModal('payModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="pay_iban_to" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">IBAN platitelja</label><input id="pay_iban_from" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">Datum uplate</label><input id="pay_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="pay_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Poziv na broj / referenca</label><input id="pay_ref" class="fld" placeholder="HR00 12345-67890"></div>
<div><label class="lbl">Tx ID (banka)</label><input id="pay_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="payConfirm">✓ Potvrdi plaćanje</button>
<button class="btn sec" onclick="closeModal('payModal')">Odustani</button>
<span id="payStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ COMMENT MODAL (M5.5) ============ -->
<div id="commentModal" class="modal-bg" onclick="if(event.target===this)closeModal('commentModal')">
<div class="modal" style="max-width:520px">
<div class="modal-h">
<h3>💬 Komentar (savez/admin)</h3>
<button class="x" onclick="closeModal('commentModal')">×</button>
</div>
<div class="modal-body">
<textarea id="commentText" class="fld" rows="5" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn" id="commentSave">Spremi komentar</button>
<button class="btn sec" onclick="closeModal('commentModal')">Odustani</button>
<span id="commentStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ PUTNI NALOG DETAIL MODAL (M6.3) ============ -->
<div id="pnModal" class="modal-bg" onclick="if(event.target===this)closeModal('pnModal')">
<div class="modal">
<div class="modal-h">
<h3 id="pnModalTitle">Putni nalog</h3>
<button class="x" onclick="closeModal('pnModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Voditelj + putnici, ruta, vozilo</h4>
<div class="kv" id="pn_kv"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Obračun (HR pravilnik 2025)</h4>
<div class="kv" id="pn_obracun"></div>
<div id="pn_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">📎 Vezani računi (gorivo, cestarina, hotel...)</h4>
<table id="pn_invoices_table"><thead><tr><th>#</th><th>Vrsta</th><th>Dobavljač</th><th>OIB</th><th>Datum</th><th class="num">Brutto</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
<div class="actions-row" id="pn_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="pn_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY PUTNI NALOG MODAL ============ -->
<div id="payPnModal" class="modal-bg" onclick="if(event.target===this)closeModal('payPnModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Isplata putnog naloga</h3>
<button class="x" onclick="closeModal('payPnModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="ppn_iban_to" class="fld"></div>
<div><label class="lbl">IBAN platitelja</label><input id="ppn_iban_from" class="fld"></div>
<div><label class="lbl">Datum uplate</label><input id="ppn_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="ppn_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Referenca</label><input id="ppn_ref" class="fld"></div>
<div><label class="lbl">Tx ID</label><input id="ppn_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="ppnConfirm">✓ Potvrdi isplatu</button>
<button class="btn sec" onclick="closeModal('payPnModal')">Odustani</button>
<span id="ppnStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ REJECT PUTNI NALOG MODAL ============ -->
<div id="rejectModal" class="modal-bg" onclick="if(event.target===this)closeModal('rejectModal')">
<div class="modal" style="max-width:480px">
<div class="modal-h">
<h3>❌ Odbij putni nalog</h3>
<button class="x" onclick="closeModal('rejectModal')">×</button>
</div>
<div class="modal-body">
<label class="lbl">Razlog odbijanja</label>
<textarea id="rejectText" class="fld" rows="4" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn red" id="rejectConfirm">Odbij</button>
<button class="btn sec" onclick="closeModal('rejectModal')">Odustani</button>
<span id="rejectStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<script>
const ERP_API = '/api/erp';
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
const fmt = n => n == null ? '—' : new Intl.NumberFormat('hr-HR').format(n);
const fmtEur = n => n != null ? '€' + fmt(Math.round(n*100)/100) : '—';
const fmtDate = d => d ? d.substring(0,10) : '—';
function badge(t,c) { return `<span class="badge ${c}">${t||'—'}</span>`; }
function sBadge(s) {
if (!s) return badge('—','gray');
const x = s.toLowerCase();
if (['paid','approved','active','odobren','zatvoren'].includes(x)) return badge(s,'green');
if (['pending','draft','submitted','open','unpaid'].includes(x)) return badge(s,'yellow');
if (['overdue','rejected','cancelled','failed'].includes(x)) return badge(s,'red');
return badge(s,'gray');
}
async function loadKlubovi() {
const r = await fetch('/api/klubovi?limit=400').then(r=>r.json()).catch(()=>null);
if (!r) return;
const arr = Array.isArray(r) ? r : (r.rows || r.items || []);
const opts = '<option value="">— odaberi klub —</option>' + arr
.map(k => ({id: k.id, naziv: (k.naziv || k.klub || k.sport || '#'+k.id).toString().trim()}))
.filter(k => k.naziv)
.sort((a,b) => a.naziv.localeCompare(b.naziv,'hr'))
.map(k => `<option value="${k.id}">${k.naziv.replace(/"/g,'&quot;')}</option>`).join('');
['oc_klub','pn_klub'].forEach(id => { const e=$('#'+id); if (e) e.innerHTML=opts; });
}
let ocrUploadId = null, ocrParsed = null;
function ocrSet(m,c) { const e=$('#ocrStatus'); if(e){e.textContent=m||''; e.style.color=c||'var(--text-2)';} }
async function ocrHandle(file) {
if (!file) return;
ocrSet('⏳ Učitavam datoteku…','var(--yellow)');
const klubVal = $('#oc_klub')?.value || '';
const fd = new FormData();
fd.append('file', file);
if (klubVal) fd.append('klub_id', klubVal);
fd.append('tenant_id', 1);
fd.append('invoice_kind', $('#oc_kind')?.value || 'ostalo');
let r = await fetch(`${ERP_API}/ocr/upload`, {method:'POST',body:fd});
if (!r.ok) { ocrSet('❌ Upload pao: '+r.status,'var(--red)'); return; }
const j = await r.json();
ocrUploadId = j.upload_id;
ocrSet(`✓ Uploaded #${ocrUploadId} (${j.size} B). Pokrećem OCR + Ri.NET AI Engine ekstrakciju…`,'var(--accent)');
const fd2 = new FormData();
fd2.append('upload_id', ocrUploadId);
fd2.append('use_llm', 'true');
r = await fetch(`${ERP_API}/ocr/parse`, {method:'POST',body:fd2});
const p = await r.json();
if (!p.ok) { ocrSet('❌ '+(p.error||'Parse fail'),'var(--red)'); return; }
ocrParsed = p.extracted || {};
$('#oc_vendor_name').value = ocrParsed.vendor_name || '';
$('#oc_vendor_oib').value = ocrParsed.vendor_oib || '';
$('#oc_invoice_no').value = ocrParsed.invoice_no || '';
$('#oc_invoice_date').value = ocrParsed.invoice_date|| '';
$('#oc_amount_net').value = ocrParsed.amount_net ?? '';
$('#oc_amount_vat').value = ocrParsed.amount_vat ?? '';
$('#oc_amount_gross').value = ocrParsed.amount_gross?? '';
$('#oc_vat_rate').value = ocrParsed.vat_rate ?? '';
$('#oc_iban').value = ocrParsed.iban || '';
$('#oc_kind').value = ocrParsed.category || 'ostalo';
$('#oc_currency').value = ocrParsed.currency || 'EUR';
$('#oc_description').value = ocrParsed.description|| '';
$('#oc_raw').textContent = (p.raw_text_preview||'').slice(0,4000);
$('#ocrResult').style.display = 'block';
ocrSet(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja → "Spremi račun".`,'var(--green)');
}
function ocrInit() {
const drop = $('#ocrDrop'), inp = $('#ocrFile');
drop.addEventListener('click', () => inp.click());
inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandle(e.target.files[0]); });
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor='var(--accent)'; }));
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor='var(--border)'; }));
drop.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) ocrHandle(f); });
$('#ocCancel').addEventListener('click', () => { $('#ocrResult').style.display='none'; ocrUploadId=null; ocrParsed=null; ocrSet(''); inp.value=''; });
$('#ocSave').addEventListener('click', async () => {
const klub = $('#oc_klub').value;
if (!klub) { $('#ocSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1, upload_id: ocrUploadId,
invoice_kind: $('#oc_kind').value || 'ostalo',
invoice_no: $('#oc_invoice_no').value, vendor_name: $('#oc_vendor_name').value,
vendor_oib: $('#oc_vendor_oib').value, invoice_date: $('#oc_invoice_date').value,
amount_net: parseFloat($('#oc_amount_net').value)||null,
amount_vat: parseFloat($('#oc_amount_vat').value)||null,
amount_gross: parseFloat($('#oc_amount_gross').value),
vat_rate: parseFloat($('#oc_vat_rate').value)||null,
iban_to: $('#oc_iban').value || null,
currency: $('#oc_currency').value || 'EUR',
category: $('#oc_kind').value || 'ostalo',
description: $('#oc_description').value || null,
};
$('#ocSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
$('#ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`;
$('#ocSaveStatus').style.color = 'var(--green)';
setTimeout(() => { $('#ocrResult').style.display='none'; loadInvoices(); }, 1500);
} else {
$('#ocSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#ocSaveStatus').style.color = 'var(--red)';
}
});
}
let pnTimer = null;
async function pnPreview() {
const df = $('#pn_from').value, dt = $('#pn_to').value;
const country = $('#pn_country').value || 'Hrvatska';
const km = parseFloat($('#pn_km').value || 0);
const kr = parseFloat($('#pn_kmrate').value || 0.5);
const tgt = $('#pn_preview');
if (!df || !dt) { tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; }
const r = await fetch(`${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${kr}`).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { tgt.textContent='⚠ Neuspješan obračun'; return; }
const d = r.preview;
tgt.innerHTML = `
<div class="grid4">
<div><div style="color:var(--text-3);font-size:11px">Sati</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.hours}h</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pune dnevnice</div><div style="font-size:18px;color:var(--accent);font-family:'JetBrains Mono'">${d.days_full} × €${d.rate_full}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pola dnevnica</div><div style="font-size:18px;color:var(--yellow);font-family:'JetBrains Mono'">${d.days_half} × €${d.rate_half}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Dnevnice ukupno</div><div style="font-size:18px;color:var(--green);font-family:'JetBrains Mono'">€${d.dnevnica_amount_total}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometara</div><div style="font-size:16px;font-family:'JetBrains Mono'">${d.km_driven} km</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometrina</div><div style="font-size:16px;font-family:'JetBrains Mono'">€${d.km_amount}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Zemlja</div><div style="font-size:14px;font-family:'JetBrains Mono'">${d.country}</div></div>
<div><div style="color:var(--text-3);font-size:11px">PROCJENA UKUPNO</div><div style="font-size:22px;color:var(--accent);font-family:'JetBrains Mono';font-weight:700">€${d.total_estimated}</div></div>
</div>`;
}
function pnInit() {
['pn_from','pn_to','pn_country','pn_km','pn_kmrate'].forEach(id => {
const el = $('#'+id); if (el) el.addEventListener('input', () => { clearTimeout(pnTimer); pnTimer = setTimeout(pnPreview, 250); });
});
$('#pnSave').addEventListener('click', async () => {
const klub = $('#pn_klub').value;
if (!klub) { $('#pnSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1,
voditelj_ime: $('#pn_voditelj').value,
putnici: ($('#pn_putnici').value||'').split(',').map(s=>s.trim()).filter(Boolean),
svrha: $('#pn_svrha').value,
od_grada: $('#pn_od').value, do_grada: $('#pn_do').value,
datum_polaska: $('#pn_from').value, datum_povratka: $('#pn_to').value,
country: $('#pn_country').value,
vehicle_type: $('#pn_vehicle').value,
registracija_vozila: $('#pn_plate').value,
kilometara: parseFloat($('#pn_km').value)||0,
km_rate: parseFloat($('#pn_kmrate').value)||0.5,
};
$('#pnSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/putni-nalog`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
$('#pnSaveStatus').innerHTML = `✓ Putni nalog #${j.putni_nalog.id} kreiran (€${j.putni_nalog.cost_total})`;
$('#pnSaveStatus').style.color = 'var(--green)';
loadPutni();
} else {
$('#pnSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#pnSaveStatus').style.color = 'var(--red)';
}
});
}
async function loadInvoices() {
const r = await fetch(`${ERP_API}/invoices?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>`
<tr class="clickable" onclick="openInvoice(${i.id})"><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td>
<td>${i.vendor_name||'—'}</td><td style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
<td>${i.klub_naziv||'—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
async function loadPutni() {
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>`
<tr class="clickable" onclick="openPutni(${p.id})"><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td>
<td>${fmtDate(p.date_from)}</td><td>${fmtDate(p.date_to)}</td>
<td class="num">${fmtEur(p.dnevnice_amount)}</td>
<td class="num">${fmtEur(p.cost_transport)}</td>
<td class="num"><strong>${fmtEur(p.cost_total)}</strong></td>
<td>${sBadge(p.status)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
// ===== AUTH (JWT iz localStorage ili admin token fallback) =====
function AUTH_HDR(extra) {
const h = Object.assign({}, extra || {});
let t = null;
try { t = localStorage.getItem('jwt') || sessionStorage.getItem('jwt'); } catch(e){}
if (!t) t = 'admin-pgz-2026';
h['Authorization'] = 'Bearer ' + t;
return h;
}
function AUTH_HDR_JSON() { return AUTH_HDR({'Content-Type': 'application/json'}); }
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderAudit(audit) {
if (!audit || !audit.length) return '<div style="color:var(--text-3);font-size:12px">Nema audit zapisa.</div>';
return audit.map(a => `
<div class="audit-row">
<div class="ts">${(a.timestamp||'').replace('T',' ').substring(0,19)}</div>
<div class="op">${escHtml(a.operacija)}</div>
<div class="who">${escHtml(a.korisnik||'—')}</div>
<div>${escHtml(a.promijenjeno_polje||'')}: <span style="color:var(--text-3)">${escHtml(a.stara_vrijednost||'∅')}</span> → <span style="color:var(--green)">${escHtml(a.nova_vrijednost||'∅')}</span></div>
</div>`).join('');
}
// ===== INVOICE DETAIL =====
let _currentInvoice = null;
async function openInvoice(id) {
const r = await fetch(`${ERP_API}/invoices/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju računa #' + id); return; }
_currentInvoice = r;
const i = r.invoice;
$('#invModalTitle').textContent = `Račun #${i.id} · ${i.invoice_no || '—'}`;
// Preview slike
const pv = $('#inv_preview');
if (r.uploads && r.uploads.length) {
const up = r.uploads[0];
const fileUrl = `${ERP_API}/invoices/${id}/file`;
const isPdf = (up.mime || '').includes('pdf') || (up.file_name || '').toLowerCase().endsWith('.pdf');
if (isPdf) {
pv.innerHTML = `<embed src="${fileUrl}" type="application/pdf" style="width:100%;height:480px;border:1px solid var(--border);border-radius:6px"><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · ${escHtml(up.mime||'')}</div>`;
} else {
pv.innerHTML = `<a href="${fileUrl}" target="_blank"><img class="preview-img" src="${fileUrl}" alt="skena"></a><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · OCR ${escHtml(up.ocr_engine||up.ocr_status||'')}</div>`;
}
} else {
pv.innerHTML = '<div style="padding:60px;background:var(--bg-3);border-radius:6px;color:var(--text-3);font-size:12px">Bez priložene datoteke</div>';
}
// KV polja
$('#inv_kv').innerHTML = `
<div>Izdavatelj</div><div>${escHtml(i.vendor_name||'—')}</div>
<div>OIB izdavatelja</div><div>${escHtml(i.vendor_oib||'—')}</div>
<div>Broj računa</div><div>${escHtml(i.invoice_no||'—')}</div>
<div>Datum</div><div>${fmtDate(i.invoice_date)}</div>
<div>Klub</div><div>${escHtml(i.klub_naziv||'—')}</div>
<div>Vrsta</div><div>${escHtml(i.invoice_kind||'—')}</div>
<div>Iznos neto</div><div>${fmtEur(i.amount_net)}</div>
<div>PDV (${i.vat_rate||'—'}%)</div><div>${fmtEur(i.amount_vat)}</div>
<div>Brutto</div><div style="color:var(--accent);font-weight:700">${fmtEur(i.amount_gross)}</div>
<div>Valuta</div><div>${escHtml(i.currency||'EUR')}</div>
<div>Opis</div><div>${escHtml(i.description||'—')}</div>
`;
// Status block
const status = (i.payment_status||'unpaid').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(i.payment_status)}</div>`;
if (status === 'paid') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to || i.iban_to || '—')}</span></div>
<div><span style="color:var(--text-3)">IBAN platitelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_from || i.iban_from || '—')}</span></div>
<div><span style="color:var(--text-3)">Datum uplate:</span> ${fmtDate(i.paid_date) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos uplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount || i.amount_gross)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'cancelled' || status === 'otkazan') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">Račun je otkazan.</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">Neplaćen — čeka uplatu.</div>`;
}
$('#inv_status_block').innerHTML = sb;
// Actions po permission-ima
const a = r.actions || {};
const acts = [];
if (a.pay && status !== 'paid') acts.push(`<button class="btn green" onclick="openPayModal(${id})">💰 Označi kao plaćen</button>`);
if (a.edit && status !== 'paid') acts.push(`<button class="btn yellow" onclick="alert('Edit u UI: koristi M5 OCR formu — ovaj panel je read-only za prikaz')">✏ Korekcija polja</button>`);
if (a.comment) acts.push(`<button class="btn sec" onclick="openCommentModal(${id})">💬 Komentar</button>`);
if (r.uploads && r.uploads.length) acts.push(`<a href="${ERP_API}/invoices/${id}/file" target="_blank" class="btn sec" style="text-decoration:none">📥 Preuzmi sken</a>`);
if (a.delete) acts.push(`<button class="btn red" onclick="if(confirm('Obrisati račun #${id}?')){alert('Brisanje: TODO endpoint')}">🗑 Obriši</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#inv_actions').innerHTML = acts.join('');
$('#inv_audit').innerHTML = renderAudit(r.audit);
openModal('invModal');
}
function openPayModal(id) {
const inv = _currentInvoice && _currentInvoice.invoice;
if (inv) {
$('#pay_iban_to').value = inv.iban_to || '';
$('#pay_amount').value = inv.amount_gross || '';
}
$('#pay_date').value = new Date().toISOString().substring(0,10);
$('#payStatus').textContent = '';
openModal('payModal');
$('#payConfirm').onclick = async () => {
const body = {
iban_to: $('#pay_iban_to').value.trim(),
iban_from: $('#pay_iban_from').value.trim(),
paid_date: $('#pay_date').value,
amount: parseFloat($('#pay_amount').value) || undefined,
reference: $('#pay_ref').value.trim(),
bank_transaction_id: $('#pay_tx').value.trim(),
payment_method: 'transfer',
};
$('#payStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices/${id}/pay`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#payStatus').textContent = '✓ Plaćeno';
$('#payStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('payModal'); openInvoice(id); loadInvoices(); }, 700);
} else {
$('#payStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#payStatus').style.color = 'var(--red)';
}
};
}
function openCommentModal(id) {
$('#commentText').value = '';
$('#commentStatus').textContent = '';
openModal('commentModal');
$('#commentSave').onclick = async () => {
const txt = $('#commentText').value.trim();
if (!txt) { $('#commentStatus').textContent = 'Komentar je prazan'; return; }
$('#commentStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/invoices/${id}/comment`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({comment: txt})}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#commentStatus').textContent = '✓ Spremljeno';
$('#commentStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('commentModal'); openInvoice(id); }, 600);
} else {
$('#commentStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#commentStatus').style.color = 'var(--red)';
}
};
}
// ===== PUTNI NALOG DETAIL =====
let _currentPn = null;
async function openPutni(id) {
const r = await fetch(`${ERP_API}/putni-nalog/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju putnog naloga #' + id); return; }
_currentPn = r;
const p = r.putni_nalog;
$('#pnModalTitle').textContent = `Putni nalog #${p.id} · ${p.klub_naziv||'—'}`;
const att = p.attachments || {};
const dnv = att.dnevnice_calc || {};
const putnici = (att.putnici || []).join(', ');
const voditelj = att.voditelj || '—';
const country = att.country || '—';
const fromCity = att.from_city || '—', toCity = att.to_city || '—';
$('#pn_kv').innerHTML = `
<div>Voditelj</div><div>${escHtml(voditelj)}</div>
<div>Putnici</div><div>${escHtml(putnici||'—')}</div>
<div>Svrha</div><div>${escHtml(p.purpose||'—')}</div>
<div>Ruta</div><div>${escHtml(fromCity)} → ${escHtml(toCity)}</div>
<div>Zemlja</div><div>${escHtml(country)}</div>
<div>Polazak</div><div>${fmtDate(p.date_from)}</div>
<div>Povratak</div><div>${fmtDate(p.date_to)}</div>
<div>Vozilo</div><div>${escHtml(p.vehicle_type||'—')} ${escHtml(p.vehicle_plate||'')}</div>
<div>Kilometara</div><div>${p.km_driven||0} km × €${p.km_rate||0.5}</div>
`;
$('#pn_obracun').innerHTML = `
<div>Pune dnevnice</div><div style="color:var(--accent)">${dnv.days_full||0} × €${dnv.rate_full||0}</div>
<div>Pola dnevnica</div><div style="color:var(--yellow)">${dnv.days_half||0} × €${dnv.rate_half||0}</div>
<div>Dnevnice ukupno</div><div style="color:var(--green)">${fmtEur(p.dnevnice_amount)}</div>
<div>Kilometrina</div><div>${fmtEur(p.cost_transport)}</div>
<div>Smještaj</div><div>${fmtEur(p.cost_lodging)}</div>
<div>Hrana / ostalo</div><div>${fmtEur((p.cost_meals||0)+(p.cost_other||0))}</div>
<div style="font-weight:700">UKUPNO</div><div style="color:var(--accent);font-weight:700;font-size:18px">${fmtEur(p.cost_total)}</div>
`;
// Status block
const status = (p.status||'draft').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(p.status)}</div>`;
if (status === 'isplacen') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to||'—')}</span></div>
<div><span style="color:var(--text-3)">Datum isplate:</span> ${fmtDate(p.paid_at) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos isplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount||p.cost_total)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'odbijen') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">${escHtml(p.notes||'Odbijen').slice(-200)}</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">${status === 'odobren' || status === 'zatvoren' ? 'Čeka isplatu.' : status === 'poslan' ? 'Čeka odobrenje.' : 'Draft — još nije poslan na odobrenje.'}</div>`;
}
$('#pn_status_block').innerHTML = sb;
// Vezani računi
const invs = r.invoices || [];
$('#pn_invoices_table tbody').innerHTML = invs.length ? invs.map(i => `
<tr class="clickable" onclick="closeModal('pnModal'); setTimeout(()=>openInvoice(${i.id}), 100)">
<td>${i.id}</td><td>${escHtml(i.invoice_kind||'—')}</td><td>${escHtml(i.vendor_name||'—')}</td>
<td style="font-family:'JetBrains Mono'">${escHtml(i.vendor_oib||'—')}</td>
<td>${fmtDate(i.invoice_date)}</td>
<td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td>
</tr>`).join('') : '<tr><td colspan="7" style="color:var(--text-3);text-align:center;padding:14px">Nema vezanih računa</td></tr>';
// Actions
const a = r.actions || {};
const acts = [];
if (a.submit) acts.push(`<button class="btn yellow" onclick="submitPn(${id})">📤 Pošalji na odobrenje</button>`);
if (a.approve) acts.push(`<button class="btn green" onclick="approvePn(${id})">✓ Odobri</button>`);
if (a.reject) acts.push(`<button class="btn red" onclick="openRejectModal(${id})">✗ Odbij</button>`);
if (a.pay) acts.push(`<button class="btn green" onclick="openPayPnModal(${id})">💰 Isplati</button>`);
if (a.edit) acts.push(`<button class="btn sec" onclick="alert('Edit drafta — koristi M6 formu \\'Novi putni nalog\\' s prefilanim poljima (TODO UI)')">✏ Edit</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#pn_actions').innerHTML = acts.join('');
$('#pn_audit').innerHTML = renderAudit(r.audit);
openModal('pnModal');
}
async function submitPn(id) {
if (!confirm('Poslati putni nalog #' + id + ' na odobrenje?')) return;
const r = await fetch(`${ERP_API}/putni-nalog/${id}/posalji`, {method:'POST', headers: AUTH_HDR_JSON()}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
}
async function approvePn(id) {
if (!confirm('Odobriti putni nalog #' + id + '?')) return;
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odobriti`, {method:'POST', headers: AUTH_HDR_JSON(), body: '{}'}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
}
function openRejectModal(id) {
$('#rejectText').value = '';
$('#rejectStatus').textContent = '';
openModal('rejectModal');
$('#rejectConfirm').onclick = async () => {
const reason = $('#rejectText').value.trim();
if (!reason) { $('#rejectStatus').textContent = 'Razlog je obavezan'; return; }
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odbij`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({razlog: reason})}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { closeModal('rejectModal'); openPutni(id); loadPutni(); }
else $('#rejectStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
};
}
function openPayPnModal(id) {
const pn = _currentPn && _currentPn.putni_nalog;
if (pn) $('#ppn_amount').value = pn.cost_total || '';
$('#ppn_date').value = new Date().toISOString().substring(0,10);
$('#ppnStatus').textContent = '';
openModal('payPnModal');
$('#ppnConfirm').onclick = async () => {
const body = {
iban_to: $('#ppn_iban_to').value.trim(),
iban_from: $('#ppn_iban_from').value.trim(),
paid_date: $('#ppn_date').value,
amount: parseFloat($('#ppn_amount').value) || undefined,
reference: $('#ppn_ref').value.trim(),
bank_transaction_id: $('#ppn_tx').value.trim(),
};
$('#ppnStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/putni-nalog/${id}/isplati`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) {
$('#ppnStatus').textContent = '✓ Isplaćeno';
$('#ppnStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('payPnModal'); openPutni(id); loadPutni(); }, 700);
} else {
$('#ppnStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
$('#ppnStatus').style.color = 'var(--red)';
}
};
}
function activate(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
const titles = {ocr:'Skeniraj račun (OCR)',invoices:'Računi',putni:'Novi putni nalog','putni-list':'Lista putnih naloga'};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'invoices') loadInvoices();
if (name === 'putni-list') loadPutni();
}
$$('.nav-item').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
(async () => {
await loadKlubovi();
ocrInit();
pnInit();
})();
</script>
</body>
</html>
+835
View File
@@ -0,0 +1,835 @@
#!/usr/bin/env python3
# erp/ocr.py — PGŽ Sport ERP OCR router (M5)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: /api/erp/ocr/upload + /parse — Tesseract OCR + DeepSeek V3 LLM extraction
# Persists into pgz_sport.invoice_uploads, then offers structured invoice parse.
from __future__ import annotations
import os
import re
import json
import hashlib
import subprocess
import tempfile
import traceback
from datetime import datetime, date
from pathlib import Path
from typing import Optional, List, Any
import psycopg2
import psycopg2.extras
import requests
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body
from fastapi.responses import JSONResponse, FileResponse
try:
from erp.permissions import (
can_view_invoice, can_edit_invoice, can_pay_invoice, can_comment_invoice,
invoice_actions, audit_invoice, fetch_audit, is_pgz_admin,
)
except Exception:
# Fallback (always-allow) for unauth dev
def can_view_invoice(u, i): return True
def can_edit_invoice(u, i): return True
def can_pay_invoice(u, i): return True
def can_comment_invoice(u, i): return True
def invoice_actions(u, i): return {"view": True, "edit": True, "pay": True, "comment": True, "delete": False}
def audit_invoice(u, iid, op, field=None, old=None, new=None): pass
def fetch_audit(t, r, limit=50): return []
def is_pgz_admin(u): return False
try:
from auth.auth_v2 import get_current_user as _auth_user
except Exception:
_auth_user = None
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
# === Config ===
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7")
DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions"
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
MAX_BYTES = 12 * 1024 * 1024 # 12 MB
ADMIN_TOKEN = "admin-pgz-2026"
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _is_admin(authorization: Optional[str]) -> bool:
if not authorization:
return False
t = authorization.replace("Bearer ", "").strip()
return t == ADMIN_TOKEN
def _resolve_user(authorization: Optional[str]) -> Optional[dict]:
"""Resolve current user via auth_v2 JWT, fallback to admin token (returns synthetic pgz_admin)."""
if _auth_user:
try:
u = _auth_user(authorization)
if u: return u
except Exception:
pass
if _is_admin(authorization):
return {"id": 0, "email": "admin@token", "user_type": "pgz_admin",
"klub_id": None, "savez_id": None, "_synthetic": True}
return None
def _safe_filename(orig: str) -> str:
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120]
if not base:
base = "upload"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{ts}_{base}"
def _extract_text(path: Path) -> tuple[str, str]:
"""Return (text, method). Tries pdftotext first, falls back to tesseract."""
suf = path.suffix.lower()
if suf == ".pdf":
try:
r = subprocess.run(
["pdftotext", "-layout", "-q", str(path), "-"],
capture_output=True, timeout=45,
)
txt = r.stdout.decode("utf-8", "ignore")
if len(txt.strip()) > 80:
return txt, "pdftotext"
except Exception:
pass
# Rasterize + tesseract
try:
with tempfile.TemporaryDirectory(prefix="ocr_") as td:
subprocess.run(
["pdftoppm", "-r", "200", str(path), f"{td}/page"],
timeout=120, check=True,
)
chunks = []
for img in sorted(Path(td).glob("page-*.ppm"))[:5]:
r = subprocess.run(
["tesseract", str(img), "-", "-l", "hrv+eng", "--psm", "6"],
capture_output=True, timeout=90,
)
chunks.append(r.stdout.decode("utf-8", "ignore"))
return "\n".join(chunks), "tesseract"
except Exception as e:
return "", f"pdf_err:{e}"
if suf in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}:
try:
r = subprocess.run(
["tesseract", str(path), "-", "-l", "hrv+eng", "--psm", "6"],
capture_output=True, timeout=120,
)
return r.stdout.decode("utf-8", "ignore"), "tesseract"
except Exception as e:
return "", f"img_err:{e}"
return "", f"unsupported:{suf}"
# === HR invoice regex helpers ===
_OIB = re.compile(r"\b(\d{11})\b")
_IBAN = re.compile(r"\b(HR\d{19})\b")
_DATE_DOT = re.compile(r"\b(\d{1,2})[.\s\-/]+(\d{1,2})[.\s\-/]+(20\d{2})\b")
_DATE_ISO = re.compile(r"\b(20\d{2})[\-/](\d{1,2})[\-/](\d{1,2})\b")
_AMOUNT_TOTAL = re.compile(
r"(?i)(?:UKUPNO|TOTAL|SVEUKUPNO|ZA NAPLATU|ZA PLATITI|ZA UPLATU|IZNOS\s+UKUPNO)[\s:€]*([\d.\s]{1,12}[,.]\d{2})"
)
_AMOUNT_VAT = re.compile(r"(?i)(?:PDV|VAT)[\s:%]*?([\d.\s]{1,8}[,.]\d{2})")
_INVOICE_NO = re.compile(r"(?i)(?:ra[čc]un|invoice|broj|fakture|br\.)\s*[:#]?\s*([A-Z0-9\-/.]{3,30})")
def _parse_amount(s: str) -> Optional[float]:
if not s:
return None
s = s.replace(" ", "").replace("\xa0", "")
# Croatian style "1.234,56" → 1234.56
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".")
elif "," in s:
s = s.replace(",", ".")
try:
return float(s)
except Exception:
return None
def regex_extract(text: str) -> dict:
out: dict[str, Any] = {"raw_chars": len(text or "")}
if not text:
return out
oibs = list(dict.fromkeys(_OIB.findall(text)))
if oibs:
out["oibs_found"] = oibs
out["vendor_oib"] = oibs[0]
if len(oibs) > 1:
out["customer_oib"] = oibs[1]
m = _IBAN.search(text.replace(" ", ""))
if m:
out["iban"] = m.group(1)
m = _INVOICE_NO.search(text)
if m:
out["invoice_no"] = m.group(1).strip().rstrip(".,;")
for rx, order in [(_DATE_DOT, "dmy"), (_DATE_ISO, "ymd")]:
m = rx.search(text)
if m:
g = m.groups()
try:
if order == "dmy":
out["invoice_date"] = f"{g[2]}-{int(g[1]):02d}-{int(g[0]):02d}"
else:
out["invoice_date"] = f"{g[0]}-{int(g[1]):02d}-{int(g[2]):02d}"
# validate
date.fromisoformat(out["invoice_date"])
break
except Exception:
out.pop("invoice_date", None)
totals = [_parse_amount(x) for x in _AMOUNT_TOTAL.findall(text)]
totals = [t for t in totals if t and t > 0.01]
if totals:
out["amount_gross"] = max(totals)
out["amounts_found"] = totals[:6]
vats = [_parse_amount(x) for x in _AMOUNT_VAT.findall(text)]
vats = [v for v in vats if v and v > 0.01]
if vats:
# smallest plausible PDV (less than gross)
if "amount_gross" in out:
cand = [v for v in vats if v < out["amount_gross"]]
if cand:
out["amount_vat"] = max(cand)
else:
out["amount_vat"] = max(vats)
if "amount_gross" in out and "amount_vat" in out:
out["amount_net"] = round(out["amount_gross"] - out["amount_vat"], 2)
# Vendor name guess: first non-numeric, non-OIB line in header
for line in text.split("\n")[:12]:
ln = line.strip()
if 4 < len(ln) < 80 and not _OIB.search(ln) and not re.match(r"^[\d\s.,\-/€:]+$", ln):
out["vendor_name"] = ln
break
# Crude vendor guess for known HR sellers
upper = text.upper()
for keyword, label in [
("INA d.d.", "INA"), ("INA-MAZIVA", "INA"), ("TIFON", "TIFON"),
("PETROL", "PETROL"), ("HAC", "HAC"), ("BINA-ISTRA", "BINA-ISTRA"),
("HRVATSKE AUTOCESTE", "HAC"),
]:
if keyword in upper:
out.setdefault("vendor_brand", label)
break
return out
# === DeepSeek V3 LLM extraction ===
SYSTEM_PROMPT = (
"Ti si stručnjak za hrvatske račune (R-1, fiskalne, HUB-3). "
"Korisnik daje tekst računa izvučen OCR-om. Vrati ISKLJUČIVO valjani JSON, bez markdowna i komentara. "
"Ako neko polje nije sigurno - vrati null. Iznosi su brojevi (decimal s točkom). Datum je 'YYYY-MM-DD'."
)
LLM_SCHEMA_HINT = """{
"izdavatelj_naziv": str|null,
"izdavatelj_oib": str|null,
"izdavatelj_adresa": str|null,
"kupac_naziv": str|null,
"kupac_oib": str|null,
"datum": "YYYY-MM-DD"|null,
"broj_racuna": str|null,
"iznos_neto": float|null,
"iznos_pdv": float|null,
"iznos_brutto": float|null,
"stopa_pdv": float|null,
"valuta": "EUR"|"HRK"|null,
"nacin_placanja": str|null,
"IBAN": str|null,
"opis_svrhe": str|null,
"vrsta_troska": "gorivo"|"cestarina"|"hotel"|"restoran"|"oprema"|"ostalo"|null,
"stavke": [
{"opis": str, "kolicina": float, "jedinica": str, "cijena": float, "ukupno": float}
]
}"""
def deepseek_extract(text: str, hint: dict | None = None) -> dict:
"""Call DeepSeek chat completions for structured JSON extraction."""
if not DEEPSEEK_API_KEY:
return {"error": "no_api_key"}
if not text or len(text.strip()) < 20:
return {"error": "empty_text"}
user_msg = (
f"Iz teksta računa ispod izvuci polja po shemi:\n{LLM_SCHEMA_HINT}\n\n"
f"REGEX hint (može biti nepotpun ili netočan): {json.dumps(hint or {}, ensure_ascii=False)}\n\n"
f"--- TEKST RAČUNA ---\n{text[:8000]}\n--- KRAJ ---"
)
payload = {
"model": DEEPSEEK_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
"response_format": {"type": "json_object"},
"temperature": 0.0,
"max_tokens": 1200,
}
headers = {
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
"Content-Type": "application/json",
}
try:
r = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60)
except Exception as e:
return {"error": f"net:{e}"}
if r.status_code != 200:
return {"error": f"http_{r.status_code}", "detail": r.text[:300]}
try:
body = r.json()
content = body["choices"][0]["message"]["content"]
return json.loads(content)
except Exception as e:
return {"error": f"parse:{e}", "raw": (r.text[:500] if r else "")}
# === Endpoints ===
@router.post("/ocr/upload")
async def ocr_upload(
file: UploadFile = File(...),
klub_id: Optional[int] = Form(None),
tenant_id: int = Form(1),
invoice_kind: str = Form("ostalo"),
authorization: Optional[str] = Header(None),
):
"""Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads."""
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}")
raw = await file.read()
if not raw:
raise HTTPException(400, "Prazna datoteka")
if len(raw) > MAX_BYTES:
raise HTTPException(400, f"Datoteka prevelika ({len(raw)} > {MAX_BYTES} bajtova)")
sha256 = hashlib.sha256(raw).hexdigest()
fname = _safe_filename(file.filename or "upload")
if not fname.endswith(suffix):
fname += suffix
path = UPLOAD_DIR / fname
path.write_bytes(raw)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""
INSERT INTO pgz_sport.invoice_uploads
(klub_id, file_name, file_path, file_size, mime, sha256, ocr_status, meta)
VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s)
RETURNING id, klub_id, file_name, ocr_status, uploaded_at
""",
(klub_id, file.filename, str(path), len(raw), file.content_type or "",
sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})),
)
row = cur.fetchone()
return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"],
"size": len(raw), "sha256": sha256, "status": row["ocr_status"]}
@router.post("/ocr/parse")
async def ocr_parse(
upload_id: Optional[int] = Form(None),
file: Optional[UploadFile] = File(None),
use_llm: bool = Form(True),
authorization: Optional[str] = Header(None),
):
"""Run OCR + (optional) DeepSeek LLM extraction.
Either pass upload_id (parse a previously uploaded file) or send file directly (one-shot)."""
tmp_to_clean: Optional[Path] = None
upload_row = None
try:
if upload_id:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,))
upload_row = cur.fetchone()
if not upload_row:
raise HTTPException(404, f"Upload id={upload_id} ne postoji")
target = Path(upload_row["file_path"])
if not target.exists():
raise HTTPException(404, f"Datoteka ne postoji na disku: {target}")
elif file:
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}")
raw = await file.read()
if not raw:
raise HTTPException(400, "Prazna datoteka")
tmp = tempfile.NamedTemporaryFile(prefix="parse_", suffix=suffix, delete=False)
tmp.write(raw); tmp.close()
target = Path(tmp.name)
tmp_to_clean = target
else:
raise HTTPException(400, "Treba poslati upload_id ILI file")
text, method = _extract_text(target)
if len(text.strip()) < 20:
return {"ok": False, "ocr_method": method, "raw_chars": len(text),
"error": "OCR nije uspio izvući dovoljno teksta"}
regex_fields = regex_extract(text)
regex_fields["ocr_method"] = method
llm_fields: dict = {}
if use_llm:
llm_fields = deepseek_extract(text, hint=regex_fields)
# Merge: LLM overrides regex when valid
merged = dict(regex_fields)
for k in ("izdavatelj_naziv", "izdavatelj_oib", "kupac_oib", "datum",
"broj_racuna", "iznos_neto", "iznos_pdv", "iznos_brutto",
"stopa_pdv", "valuta", "IBAN", "opis_svrhe", "vrsta_troska",
"izdavatelj_adresa", "nacin_placanja"):
v = llm_fields.get(k) if isinstance(llm_fields, dict) else None
if v not in (None, "", "null"):
merged[k] = v
# Normalize aliases for UI / DB
if "izdavatelj_naziv" in merged: merged.setdefault("vendor_name", merged["izdavatelj_naziv"])
if "izdavatelj_oib" in merged: merged.setdefault("vendor_oib", merged["izdavatelj_oib"])
if "izdavatelj_adresa" in merged: merged.setdefault("vendor_address", merged["izdavatelj_adresa"])
if "kupac_oib" in merged: merged.setdefault("customer_oib", merged["kupac_oib"])
if "datum" in merged: merged.setdefault("invoice_date", merged["datum"])
if "broj_racuna" in merged: merged.setdefault("invoice_no", merged["broj_racuna"])
if "iznos_brutto" in merged: merged.setdefault("amount_gross", merged["iznos_brutto"])
if "iznos_neto" in merged: merged.setdefault("amount_net", merged["iznos_neto"])
if "iznos_pdv" in merged: merged.setdefault("amount_vat", merged["iznos_pdv"])
if "stopa_pdv" in merged: merged.setdefault("vat_rate", merged["stopa_pdv"])
if "valuta" in merged: merged.setdefault("currency", merged["valuta"])
if "IBAN" in merged: merged.setdefault("iban", merged["IBAN"])
if "opis_svrhe" in merged: merged.setdefault("description", merged["opis_svrhe"])
if "vrsta_troska" in merged: merged.setdefault("category", merged["vrsta_troska"])
# Persist back to invoice_uploads when we have upload_row
if upload_row:
try:
with _db() as c:
c.cursor().execute(
"""UPDATE pgz_sport.invoice_uploads
SET ocr_status='done', processed_at=NOW(),
ocr_engine=%s, ocr_text=%s,
ai_invoice_no=%s, ai_invoice_date=%s,
ai_vendor_name=%s, ai_vendor_oib=%s,
ai_amount_gross=%s, ai_currency=%s, ai_iban=%s,
ai_extracted=%s, ai_engine=%s
WHERE id=%s""",
(
method, text[:50000],
merged.get("invoice_no"),
merged.get("invoice_date") if isinstance(merged.get("invoice_date"), str) else None,
merged.get("vendor_name"),
merged.get("vendor_oib"),
merged.get("amount_gross"),
merged.get("currency", "EUR"),
merged.get("iban"),
json.dumps({"regex": regex_fields, "llm": llm_fields, "merged": merged},
ensure_ascii=False, default=str),
("deepseek-v3" if use_llm and "error" not in (llm_fields or {}) else "regex"),
upload_row["id"],
),
)
except Exception as e:
merged["_persist_warn"] = str(e)[:200]
return {
"ok": True,
"upload_id": (upload_row["id"] if upload_row else None),
"ocr_method": method,
"raw_chars": len(text),
"regex": regex_fields,
"llm": llm_fields,
"extracted": merged,
"raw_text_preview": text[:1500],
}
finally:
if tmp_to_clean and tmp_to_clean.exists():
try:
tmp_to_clean.unlink()
except Exception:
pass
# === Invoices CRUD (M5) ===
@router.get("/invoices")
def invoices_list(
tenant_id: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
kind: Optional[str] = Query(None),
limit: int = Query(100, le=500),
offset: int = Query(0),
):
sql = """SELECT i.id, i.klub_id, k.naziv AS klub_naziv,
i.invoice_kind, i.invoice_no, i.internal_no,
i.vendor_name, i.vendor_oib, i.customer_name, i.customer_oib,
i.invoice_date, i.due_date, i.paid_date, i.currency,
i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate,
i.payment_status, i.payment_method, i.iban_to,
i.description, i.category, i.tenant_id,
i.created_at, i.approved_at
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id
WHERE 1=1"""
args: list = []
if tenant_id is not None:
sql += " AND i.tenant_id=%s"; args.append(tenant_id)
if klub_id is not None:
sql += " AND i.klub_id=%s"; args.append(klub_id)
if status:
sql += " AND i.payment_status=%s"; args.append(status)
if kind:
sql += " AND i.invoice_kind=%s"; args.append(kind)
sql += " ORDER BY i.invoice_date DESC NULLS LAST, i.id DESC LIMIT %s OFFSET %s"
args += [limit, offset]
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows, "count": len(rows)}
@router.get("/invoices/{invoice_id}")
def invoices_get(invoice_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT i.*, k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id
WHERE i.id=%s""", (invoice_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
if user and not can_view_invoice(user, row):
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj račun")
cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id",
(invoice_id,))
lines = cur.fetchall()
cur.execute(
"""SELECT id, file_name, file_size, mime, sha256, ocr_status, ocr_engine,
ai_extracted, uploaded_at, processed_at
FROM pgz_sport.invoice_uploads WHERE invoice_id=%s
ORDER BY uploaded_at DESC""", (invoice_id,))
uploads = cur.fetchall()
cur.execute(
"""SELECT id, payment_date, amount, currency, payment_method, iban_from,
iban_to, reference, bank_transaction_id, matched_status, created_at
FROM pgz_sport.payments WHERE invoice_id=%s ORDER BY payment_date DESC""",
(invoice_id,))
payments = cur.fetchall()
audit = fetch_audit("pgz_sport.invoices", invoice_id, 50)
actions = invoice_actions(user, row) if user else {"view": True, "edit": False, "pay": False, "comment": False, "delete": False}
return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads,
"payments": payments, "audit": audit, "actions": actions}
@router.get("/invoices/{invoice_id}/file")
def invoices_file(invoice_id: int, authorization: Optional[str] = Header(None)):
"""Streamira originalnu datoteku skena/računa (slika ili PDF)."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_view_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti")
cur.execute(
"""SELECT file_path, file_name, mime FROM pgz_sport.invoice_uploads
WHERE invoice_id=%s ORDER BY uploaded_at DESC LIMIT 1""", (invoice_id,))
up = cur.fetchone()
if not up:
raise HTTPException(404, "Datoteka skena ne postoji za ovaj račun")
p = Path(up["file_path"])
if not p.exists():
raise HTTPException(404, f"Datoteka ne postoji na disku")
return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream",
filename=up.get("file_name") or p.name)
@router.get("/invoices/uploads/{upload_id}/file")
def upload_file(upload_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,))
up = cur.fetchone()
if not up:
raise HTTPException(404, "Upload ne postoji")
if user and not is_pgz_admin(user) and user.get("klub_id") != up.get("klub_id"):
raise HTTPException(403, "Nemate ovlasti")
p = Path(up["file_path"])
if not p.exists():
raise HTTPException(404, "Datoteka ne postoji")
return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream",
filename=up.get("file_name") or p.name)
@router.post("/invoices/{invoice_id}/comment")
def invoices_comment(invoice_id: int, body: dict = Body(...),
authorization: Optional[str] = Header(None)):
"""Savez admin / klub admin / pgz admin može dodati komentar (audit log entry)."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_comment_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti komentirati")
txt = (body.get("comment") or "").strip()
if not txt:
raise HTTPException(400, "Komentar je prazan")
audit_invoice(user, invoice_id, "comment", field="komentar", old=None, new=txt[:500])
return {"ok": True, "invoice_id": invoice_id, "comment": txt}
@router.get("/invoices/{invoice_id}/audit")
def invoices_audit(invoice_id: int, limit: int = 100,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_view_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti")
return {"ok": True, "audit": fetch_audit("pgz_sport.invoices", invoice_id, limit)}
@router.post("/invoices")
def invoices_create(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Create an invoice from parsed OCR result.
Body: {klub_id, tenant_id, invoice_kind, invoice_no, vendor_name, vendor_oib,
invoice_date, amount_gross, amount_net, amount_vat, vat_rate, currency,
iban_to, description, category, lines:[{...}], upload_id?}"""
required = ["invoice_kind", "invoice_no", "invoice_date", "amount_gross"]
for k in required:
if body.get(k) in (None, ""):
raise HTTPException(400, f"Nedostaje polje: {k}")
klub_id = body.get("klub_id")
tenant_id = body.get("tenant_id", 1)
upload_id = body.get("upload_id")
lines = body.get("lines") or []
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.invoices
(klub_id, invoice_kind, invoice_no, internal_no,
vendor_oib, vendor_name, vendor_address,
customer_oib, customer_name,
invoice_date, due_date, currency,
amount_net, amount_vat, amount_gross, vat_rate,
payment_status, payment_method, iban_to,
description, category, account_code, tenant_id, meta)
VALUES (%s,%s,%s,%s, %s,%s,%s, %s,%s,
%s,%s,COALESCE(%s,'EUR'),
%s,%s,%s,%s,
COALESCE(%s,'unpaid'),%s,%s,
%s,%s,%s,%s,%s)
ON CONFLICT (klub_id, invoice_kind, invoice_no, vendor_oib)
DO UPDATE SET amount_gross=EXCLUDED.amount_gross,
amount_net=EXCLUDED.amount_net,
amount_vat=EXCLUDED.amount_vat,
updated_at=NOW()
RETURNING id, invoice_no, amount_gross, payment_status""",
(
klub_id, body["invoice_kind"], body["invoice_no"], body.get("internal_no"),
body.get("vendor_oib"), body.get("vendor_name"), body.get("vendor_address"),
body.get("customer_oib"), body.get("customer_name"),
body["invoice_date"], body.get("due_date"), body.get("currency"),
body.get("amount_net"), body.get("amount_vat"), body["amount_gross"], body.get("vat_rate"),
body.get("payment_status"), body.get("payment_method"), body.get("iban_to"),
body.get("description"), body.get("category"), body.get("account_code"),
tenant_id, json.dumps(body.get("meta", {})),
),
)
inv = cur.fetchone()
inv_id = inv["id"]
# Replace lines
cur.execute("DELETE FROM pgz_sport.invoice_lines WHERE invoice_id=%s", (inv_id,))
for i, ln in enumerate(lines, start=1):
cur.execute(
"""INSERT INTO pgz_sport.invoice_lines
(invoice_id, line_no, description, quantity, unit, unit_price,
vat_rate, line_net, line_vat, line_gross, account_code, cost_center, meta)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(
inv_id, ln.get("line_no", i), ln.get("description") or ln.get("opis") or "",
ln.get("quantity") or ln.get("kolicina") or 1,
ln.get("unit") or ln.get("jedinica") or "kom",
ln.get("unit_price") or ln.get("cijena"),
ln.get("vat_rate", 25),
ln.get("line_net"), ln.get("line_vat"),
ln.get("line_gross") or ln.get("ukupno"),
ln.get("account_code"), ln.get("cost_center"),
json.dumps(ln.get("meta", {})),
),
)
# Link upload to invoice
if upload_id:
cur.execute(
"UPDATE pgz_sport.invoice_uploads SET invoice_id=%s WHERE id=%s",
(inv_id, upload_id),
)
return {"ok": True, "invoice": inv}
@router.put("/invoices/{invoice_id}")
def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Update / approve invoice. Body may include any of: payment_status, paid_date,
approved (bool), notes, category, account_code, due_date."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_edit_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti uređivati ovaj račun")
fields = []
args: list = []
changes = []
for col in ("payment_status", "paid_date", "due_date", "category",
"account_code", "notes", "vat_rate", "amount_net", "amount_vat",
"amount_gross", "payment_method", "iban_to"):
if col in body:
fields.append(f"{col}=%s")
args.append(body[col])
changes.append((col, inv.get(col), body[col]))
if body.get("approved"):
fields.append("approved_at=NOW()")
changes.append(("approved_at", inv.get("approved_at"), "now"))
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at=NOW()")
args.append(invoice_id)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
for f, o, n in changes:
audit_invoice(user, invoice_id, "update", field=f, old=o, new=n)
return {"ok": True, "invoice": row}
@router.post("/invoices/{invoice_id}/pay")
def invoices_pay(invoice_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Označi račun kao plaćen + insert payment record.
Body: {iban_to, iban_from, paid_date, reference, bank_transaction_id, payment_method, amount}
"""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_pay_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti označiti račun kao plaćen")
if (inv.get("payment_status") or "").lower() == "paid":
raise HTTPException(409, "Račun je već označen kao plaćen")
paid_date = body.get("paid_date") or date.today().isoformat()
payment_method = body.get("payment_method") or "transfer"
iban_from = body.get("iban_from")
iban_to = body.get("iban_to") or inv.get("iban_to")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
amount = body.get("amount") or inv.get("amount_gross")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.invoices
SET payment_status='paid', paid_date=%s,
payment_method=COALESCE(%s,payment_method),
iban_from=COALESCE(%s,iban_from),
iban_to=COALESCE(%s,iban_to),
updated_at=NOW()
WHERE id=%s
RETURNING id, invoice_no, paid_date, amount_gross, payment_status,
iban_from, iban_to, payment_method""",
(paid_date, payment_method, iban_from, iban_to, invoice_id),
)
row = cur.fetchone()
# Insert payment record
cur.execute(
"""INSERT INTO pgz_sport.payments
(klub_id, invoice_id, payment_date, amount, currency, payment_method,
iban_from, iban_to, reference, bank_transaction_id, matched_status)
VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')
RETURNING id""",
(inv.get("klub_id"), invoice_id, paid_date, amount,
inv.get("currency"), payment_method, iban_from, iban_to,
reference, tx_id),
)
pay = cur.fetchone()
audit_invoice(user, invoice_id, "pay", field="payment_status",
old=inv.get("payment_status"), new="paid")
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
@router.get("/invoices/uploads/list")
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,
ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib,
ai_amount_gross, ai_currency, invoice_id, uploaded_at, processed_at
FROM pgz_sport.invoice_uploads WHERE 1=1"""
args: list = []
if klub_id is not None:
sql += " AND klub_id=%s"; args.append(klub_id)
if status:
sql += " AND ocr_status=%s"; args.append(status)
sql += " ORDER BY uploaded_at DESC LIMIT %s"; args.append(limit)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows}
@@ -0,0 +1,615 @@
#!/usr/bin/env python3
# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025).
from __future__ import annotations
import json
from datetime import datetime, date, timedelta
from typing import Optional, Any
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, Body, HTTPException, Query, Header
try:
from erp.permissions import (
can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog,
can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions,
audit_putni, fetch_audit, is_pgz_admin,
)
except Exception:
def can_view_putni_nalog(u, p): return True
def can_edit_putni_nalog(u, p): return True
def can_submit_putni_nalog(u, p): return True
def can_approve_putni_nalog(u, p): return True
def can_pay_putni_nalog(u, p): return True
def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False}
def audit_putni(u, pid, op, field=None, old=None, new=None): pass
def fetch_audit(t, r, limit=50): return []
def is_pgz_admin(u): return False
try:
from auth.auth_v2 import get_current_user as _auth_user
except Exception:
_auth_user = None
ADMIN_TOKEN = "admin-pgz-2026"
def _resolve_user(authorization):
if _auth_user:
try:
u = _auth_user(authorization)
if u: return u
except Exception:
pass
if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN:
return {"id": 0, "email": "admin@token", "user_type": "pgz_admin",
"klub_id": None, "savez_id": None, "_synthetic": True}
return None
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
# === HR pravilnik 2025 — dnevnice ===
# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h.
# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €).
DNEVNICA_DOM_FULL = 26.54 # EUR
DNEVNICA_DOM_HALF = 13.27 # EUR
KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil)
# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo).
DNEVNICE_INO = {
"Italija": 35.00,
"Italy": 35.00,
"Slovenija": 30.00,
"Slovenia": 30.00,
"Austrija": 35.00,
"Austria": 35.00,
"Mađarska": 30.00,
"Madarska": 30.00,
"Hungary": 30.00,
"Bosna i Hercegovina": 30.00,
"BiH": 30.00,
"Bosnia": 30.00,
"Srbija": 30.00,
"Serbia": 30.00,
"Crna Gora": 30.00,
"Montenegro": 30.00,
"Njemačka": 50.00,
"Germany": 50.00,
"Francuska": 50.00,
"France": 50.00,
"Švicarska": 60.00,
"Switzerland": 60.00,
"SAD": 70.00,
"USA": 70.00,
}
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _parse_dt(v) -> Optional[datetime]:
if v is None or v == "":
return None
if isinstance(v, datetime):
return v
s = str(v).strip().replace("Z", "+00:00")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt)
except Exception:
continue
try:
return datetime.fromisoformat(s)
except Exception:
return None
def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict:
"""
Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]}
Pravila (HR pravilnik 2025, neoporeziv iznos):
- Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna.
- Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €.
- Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima.
Implementacija (jednostavna, transparentna):
1) ukupne sate računaj kao razliku.
2) full_segments = sati // 24
3) ostatak_sati = sati - full_segments*24
4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0.
5) puna dnevnica = pun_iznos po zemlji; pola = polovica.
"""
df = _parse_dt(date_from)
dt = _parse_dt(date_to)
if not df or not dt or dt < df:
return {"error": "neispravni datumi", "hours": 0,
"days_full": 0, "days_half": 0,
"dnevnica_amount_total": 0.0, "breakdown": []}
delta = dt - df
hours = round(delta.total_seconds() / 3600, 2)
full_segments = int(delta.total_seconds() // (24 * 3600))
remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0
days_full = full_segments
days_half = 0.0
if remainder_h >= 8:
days_full += 1
elif remainder_h >= 5:
days_half += 1
# else: 0
is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr")
if is_domestic:
full_amt = DNEVNICA_DOM_FULL
half_amt = DNEVNICA_DOM_HALF
else:
full_amt = DNEVNICE_INO.get(country.strip(), 50.00)
half_amt = full_amt / 2.0
total = round(days_full * full_amt + days_half * half_amt, 2)
return {
"hours": hours,
"days_full": days_full,
"days_half": days_half,
"country": country,
"rate_full": full_amt,
"rate_half": half_amt,
"dnevnica_amount_total": total,
"breakdown": [
f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €",
f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "",
],
}
def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float:
try:
return round(float(km or 0) * float(km_rate or 0), 2)
except Exception:
return 0.0
# === Endpoints ===
@router.get("/putni-nalog/dnevnice/preview")
def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska",
km: float = 0.0, km_rate: float = KM_RATE_DEFAULT):
"""Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview."""
d = compute_dnevnice(date_from, date_to, country)
km_amt = compute_kilometrina(km, km_rate)
d["km_amount"] = km_amt
d["km_driven"] = km
d["km_rate"] = km_rate
d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2)
return {"ok": True, "preview": d}
@router.get("/putni-nalog")
def list_putni_nalozi(klub_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = Query(100, le=500),
offset: int = 0):
sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv,
er.user_id, er.clan_id, er.report_type, er.report_no,
er.destination, er.purpose,
er.date_from, er.date_to,
er.vehicle_type, er.vehicle_plate,
er.km_driven, er.km_rate,
er.cost_transport, er.cost_lodging, er.cost_meals,
er.cost_other, er.cost_total,
er.dnevnice_count, er.dnevnice_amount,
er.status, er.approved_at, er.paid_at,
er.created_at, er.tenant_id, er.notes
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.report_type='putni_nalog'"""
args: list = []
if klub_id is not None:
sql += " AND er.klub_id=%s"; args.append(klub_id)
if status:
sql += " AND er.status=%s"; args.append(status)
sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s"
args += [limit, offset]
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows, "count": len(rows)}
@router.get("/putni-nalog/{nalog_id}")
def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""SELECT er.*, k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, row):
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
# Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments)
att = row.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
invoice_ids = att.get("invoice_ids") or []
invoices = []
if invoice_ids:
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices WHERE id = ANY(%s)
ORDER BY invoice_date DESC""", (invoice_ids,))
invoices = cur.fetchall()
else:
# Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices
WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s
AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo')
ORDER BY invoice_date DESC LIMIT 50""",
(row.get("klub_id"), row.get("date_from"), row.get("date_to")),
)
invoices = cur.fetchall()
# Payments za ovaj putni nalog
cur.execute(
"""SELECT id, payment_date, amount, currency, payment_method, iban_from,
iban_to, reference, bank_transaction_id, matched_status, created_at
FROM pgz_sport.payments WHERE expense_report_id=%s
ORDER BY payment_date DESC""", (nalog_id,))
payments = cur.fetchall()
audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50)
actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False}
return {"ok": True, "putni_nalog": row, "invoices": invoices,
"payments": payments, "audit": audit, "actions": actions}
@router.post("/putni-nalog/{nalog_id}/posalji")
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
"""Voditelj/klub_admin šalje draft → poslan."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_submit_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti slanja na odobrenje")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports SET status='poslan', updated_at=NOW()
WHERE id=%s RETURNING id, status""", (nalog_id,))
row = cur.fetchone()
audit_putni(user, nalog_id, "submit", field="status", old=pn.get("status"), new="poslan")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/odbij")
def odbij_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Klub_admin/pgz_admin odbija s razlogom."""
user = _resolve_user(authorization)
razlog = (body.get("razlog") or body.get("reason") or "").strip()
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_approve_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti odbiti")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='odbijen', notes=COALESCE(notes,'') || E'\n[ODBIJEN] ' || %s, updated_at=NOW()
WHERE id=%s RETURNING id, status, notes""",
(razlog or "(bez razloga)", nalog_id),
)
row = cur.fetchone()
audit_putni(user, nalog_id, "reject", field="status",
old=pn.get("status"), new=f"odbijen: {razlog}")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/isplati")
def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Isplata putnog naloga (odobren/zatvoren → isplaćen).
Body: {iban_to, iban_from, paid_date, amount, reference, bank_transaction_id}"""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_pay_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti za isplatu")
paid_date = body.get("paid_date") or date.today().isoformat()
iban_to = body.get("iban_to")
iban_from = body.get("iban_from")
amount = body.get("amount") or pn.get("cost_total")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
payment_method = body.get("payment_method") or "transfer"
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='isplacen', paid_at=%s, updated_at=NOW()
WHERE id=%s RETURNING id, status, paid_at, cost_total""",
(paid_date, nalog_id),
)
row = cur.fetchone()
cur.execute(
"""INSERT INTO pgz_sport.payments
(klub_id, expense_report_id, payment_date, amount, currency,
payment_method, iban_from, iban_to, reference, bank_transaction_id,
matched_status)
VALUES (%s,%s,%s,%s,'EUR',%s,%s,%s,%s,%s,'matched')
RETURNING id""",
(pn.get("klub_id"), nalog_id, paid_date, amount, payment_method,
iban_from, iban_to, reference, tx_id),
)
pay = cur.fetchone()
audit_putni(user, nalog_id, "pay", field="status",
old=pn.get("status"), new="isplacen")
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
@router.get("/putni-nalog/{nalog_id}/audit")
def putni_audit(nalog_id: int, limit: int = 100,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti")
return {"ok": True, "audit": fetch_audit("pgz_sport.expense_reports", nalog_id, limit)}
@router.post("/putni-nalog")
def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Kreiraj putni nalog.
Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[],
svrha (purpose), od_grada, do_grada (destination),
datum_polaska (date_from), datum_povratka (date_to),
registracija_vozila (vehicle_plate), vehicle_type,
kilometara (km_driven), km_rate,
predviđeni_troškovi (cost_estimate), country, notes."""
df = body.get("date_from") or body.get("datum_polaska")
dt = body.get("date_to") or body.get("datum_povratka")
if not df or not dt:
raise HTTPException(400, "Datum polaska i povratka su obavezni")
klub_id = body.get("klub_id")
if not klub_id:
raise HTTPException(400, "klub_id je obavezan")
country = body.get("country", "Hrvatska")
km = body.get("km_driven", body.get("kilometara", 0)) or 0
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
dnv = compute_dnevnice(df, dt, country)
dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0)
dnevnice_amount = dnv.get("dnevnica_amount_total") or 0
cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0)
od = body.get("od_grada") or body.get("from_city")
do = body.get("do_grada") or body.get("to_city") or body.get("destination")
destination = " → ".join([x for x in [od, do] if x]) or do
putnici = body.get("putnici") or []
voditelj = body.get("voditelj_ime") or body.get("voditelj")
purpose = body.get("svrha") or body.get("purpose") or ""
meta = {
"voditelj": voditelj,
"putnici": putnici,
"from_city": od, "to_city": do,
"country": country,
"dnevnice_calc": dnv,
"predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [],
}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.expense_reports
(klub_id, user_id, clan_id, report_type, report_no, destination, purpose,
date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate,
cost_transport, cost_lodging, cost_meals, cost_other,
dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id)
VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, COALESCE(%s,'draft'), %s, %s, %s)
RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount,
cost_transport, date_from, date_to, destination""",
(
klub_id, body.get("user_id"), body.get("clan_id"),
body.get("report_no"), destination, purpose,
df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"),
float(km or 0), float(km_rate or 0),
cost_transport,
body.get("cost_lodging") or 0, body.get("cost_meals") or 0,
body.get("cost_other") or 0,
dnevnice_count, dnevnice_amount,
body.get("status"),
json.dumps(meta, ensure_ascii=False, default=str),
body.get("notes"),
body.get("tenant_id", 1),
),
)
row = cur.fetchone()
# cost_total via trigger maybe; recompute here
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s
RETURNING cost_total""", (row["id"],),
)
ct = cur.fetchone()
if ct:
row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
@router.put("/putni-nalog/{nalog_id}")
def update_putni_nalog(nalog_id: int, body: dict = Body(...)):
"""Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe)."""
cols = []
args: list = []
for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type",
"vehicle_plate", "km_driven", "km_rate", "cost_transport",
"cost_lodging", "cost_meals", "cost_other", "notes",
"dnevnice_count", "dnevnice_amount"):
if col in body:
cols.append(f"{col}=%s"); args.append(body[col])
# Recompute dnevnice if dates provided
if "date_from" in body or "date_to" in body or "country" in body:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,))
cur_row = cur.fetchone()
if cur_row:
df = body.get("date_from") or cur_row["date_from"]
dt = body.get("date_to") or cur_row["date_to"]
country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska")
d = compute_dnevnice(df, dt, country)
cols += ["dnevnice_count=%s", "dnevnice_amount=%s"]
args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0),
d.get("dnevnica_amount_total") or 0]
if not cols:
raise HTTPException(400, "Nema polja za izmjenu")
cols.append("updated_at=NOW()")
args.append(nalog_id)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args)
row = cur.fetchone()
if row:
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s""", (nalog_id,),
)
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/odobriti")
def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
approved_by = body.get("approved_by") or (user.get("id") if user else None)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_approve_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti odobriti")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW()
WHERE id=%s AND report_type='putni_nalog'
RETURNING id, status, approved_at""", (approved_by, nalog_id),
)
row = cur.fetchone()
audit_putni(user, nalog_id, "approve", field="status",
old=pn.get("status"), new="odobren")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/zatvori")
def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})):
"""Zatvori putni nalog: priloži račune i konačan obračun."""
invoice_ids = body.get("invoice_ids") or []
cost_lodging = body.get("cost_lodging")
cost_meals = body.get("cost_meals")
cost_other = body.get("cost_other")
notes = body.get("notes")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
cur_row = cur.fetchone()
if not cur_row:
raise HTTPException(404, "Putni nalog ne postoji")
# Aggregiraj iznose iz računa (ako su poslani)
if invoice_ids:
cur.execute(
"SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)",
(invoice_ids,),
)
invs_total = float(cur.fetchone()["total"] or 0)
else:
invs_total = None
sets = ["status='zatvoren'", "updated_at=NOW()"]
args: list = []
if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging)
if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals)
if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other)
if notes: sets.append("notes=%s"); args.append(notes)
# Pohrani povezane račune u attachments
atts = cur_row["attachments"] or {}
if isinstance(atts, str):
try: atts = json.loads(atts)
except Exception: atts = {}
atts["invoice_ids"] = invoice_ids
if invs_total is not None:
atts["invoices_total"] = invs_total
sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str))
args.append(nalog_id)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s RETURNING cost_total""", (nalog_id,),
)
ct = cur.fetchone()
if ct: row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+20 -10
View File
@@ -24,6 +24,7 @@ from .auth_v2 import (
require_user, audit, _client,
_resolve_tenant, _tier_for,
PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES,
issue_action_token, INVITE_TTL, _build_link,
)
router = APIRouter(prefix="/api/admin", tags=["admin"])
@@ -246,25 +247,34 @@ class InviteReq(BaseModel):
@router.post("/users/{uid}/invite")
def invite_user(uid: int, req: InviteReq, request: Request,
actor = Depends(require_user)):
"""Generate a single-use invite token; the user clicks the emailed link
and lands on /login/setup-password?token=… to set their password."""
target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
(uid,))
if not target: raise HTTPException(404, "User not found")
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
raise HTTPException(403, "Forbidden")
new_temp = "PGZ-" + secrets.token_hex(4)
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=true,
failed_login_count=0, locked_until=NULL, updated_at=now()
WHERE id=%s""", (hash_password(new_temp), uid))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
ip, ua = _client(request)
# Mark must_change_pwd and revoke any existing sessions so old creds can't log in
db_exec("""UPDATE pgz_sport.users SET must_change_pwd=true, updated_at=now()
WHERE id=%s""", (uid,))
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s", (uid,))
raw_token = issue_action_token(uid, "invite", INVITE_TTL,
created_by=actor["id"], ip=ip,
meta={"email": target["email"], "note": req.note})
invite_link = _build_link("/static/login.html?setup=1", raw_token)
api_link = _build_link("/api/auth/setup-password", raw_token)
audit(actor["id"], "user.invite", "user", uid,
{"email": target["email"], "send_email": req.send_email}, ip, ua)
invite_link = f"https://api.rinet.one/sport/login?email={target['email']}"
{"email": target["email"], "send_email": req.send_email,
"ttl_days": INVITE_TTL.days}, ip, ua)
# NOTE: real deployment must e-mail invite_link via a mailer (M11);
# for now, the link is returned to the admin who triggered the invite.
return {"status": "ok", "id": uid,
"temporary_password": new_temp,
"email": target["email"],
"invite_link": invite_link,
"email_sent": False} # mailer wired later
"api_link": api_link,
"expires_in_seconds": int(INVITE_TTL.total_seconds()),
"email_sent": False}
# ─────────────────────────── Role change ───────────────────────────
class RoleReq(BaseModel):
+200
View File
@@ -547,6 +547,206 @@ def password_reset(req: ResetPwdReq, request: Request):
return {"status": "ok",
"message": "Ako račun postoji, administrator će vam poslati instrukcije."}
# ─────────────────────────── R5 #2+#3: invite & reset tokens ───────────────────────────
def _ensure_token_table():
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_action_tokens (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
kind TEXT NOT NULL, -- 'invite' | 'reset'
created_at TIMESTAMPTZ DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_by INTEGER REFERENCES pgz_sport.users(id),
ip TEXT,
meta JSONB
)""")
db_exec("""CREATE INDEX IF NOT EXISTS idx_action_tokens_user
ON pgz_sport.user_action_tokens (user_id, kind, used_at)""")
_ensure_token_table()
INVITE_TTL = timedelta(days=int(os.environ.get("PGZ_INVITE_TTL_DAYS", "7")))
RESET_TTL = timedelta(hours=int(os.environ.get("PGZ_RESET_TTL_HOURS", "2")))
def _make_action_token() -> str:
return secrets.token_urlsafe(32)
def _hash_action_token(t: str) -> str:
return hashlib.sha256(t.encode()).hexdigest()
def issue_action_token(user_id: int, kind: str, ttl: timedelta,
created_by: Optional[int] = None,
ip: Optional[str] = None,
meta: Optional[Dict] = None) -> str:
"""Create a one-time URL-safe token; only its sha256 is persisted."""
if kind not in ("invite", "reset"):
raise ValueError("kind must be invite|reset")
# Invalidate any prior unused tokens of same kind for this user
db_exec("""UPDATE pgz_sport.user_action_tokens SET used_at=now()
WHERE user_id=%s AND kind=%s AND used_at IS NULL""",
(user_id, kind))
raw = _make_action_token()
th = _hash_action_token(raw)
db_exec("""INSERT INTO pgz_sport.user_action_tokens
(token_hash, user_id, kind, expires_at, created_by, ip, meta)
VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb)""",
(th, user_id, kind, _now() + ttl, created_by, ip, json.dumps(meta or {})))
return raw
def consume_action_token(raw: str, kind: str) -> Optional[Dict]:
"""Validate (kind/expiry/unused) and atomically mark used_at. Returns row dict if OK."""
th = _hash_action_token(raw)
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, t.kind, t.meta,
u.email, u.aktivan, u.status
FROM pgz_sport.user_action_tokens t
JOIN pgz_sport.users u ON u.id = t.user_id
WHERE t.token_hash=%s AND t.kind=%s""", (th, kind))
if not row: return None
if row["used_at"] is not None: return None
exp = row["expires_at"]
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
if exp <= _now(): return None
db_exec("UPDATE pgz_sport.user_action_tokens SET used_at=now() WHERE token_hash=%s", (th,))
return row
def _build_link(path: str, token: str) -> str:
base = os.environ.get("PGZ_PUBLIC_BASE", "https://api.rinet.one/sport")
sep = '&' if '?' in path else '?'
return f"{base}{path}{sep}token={token}"
# ─────────────────────────── /auth/forgot-password ───────────────────────────
class ForgotPwdReq(BaseModel):
email: str
@router.post("/forgot-password")
def forgot_password(req: ForgotPwdReq, request: Request):
"""Always returns a generic message — never leaks which emails exist.
Issues a reset token only if the user exists and is active."""
email = (req.email or "").lower().strip()
ip, ua = _client(request)
u = db_one("SELECT id, email, aktivan, status FROM pgz_sport.users WHERE LOWER(email)=%s",
(email,))
token = None
if u and u.get("aktivan") and u.get("status") == "active":
token = issue_action_token(u["id"], "reset", RESET_TTL, ip=ip,
meta={"email": email})
audit(u["id"], "password.forgot.issue",
meta={"email": email, "ttl_hours": RESET_TTL.total_seconds()/3600},
ip=ip, ua=ua)
else:
audit(u["id"] if u else None, "password.forgot.miss",
meta={"email": email}, ip=ip, ua=ua)
# Generic response — do not leak account existence
resp = {"status": "ok",
"message": "Ako račun postoji, poslan je e-mail s linkom za promjenu lozinke."}
# In production, e-mailer would deliver the link. For demo / dev,
# return it only if header X-Demo-Reveal-Token is set OR caller is from
# localhost (rare). Easier: always include it but document that real
# deployment must remove it from the response.
if token and (os.environ.get("PGZ_REVEAL_RESET_TOKEN") == "1" or
(request.client.host in ("127.0.0.1", "::1"))):
resp["reset_link"] = _build_link("/auth/reset-password", token)
resp["expires_in_seconds"] = int(RESET_TTL.total_seconds())
return resp
class ResetTokenReq(BaseModel):
token: str
new_password: str
@router.post("/reset-password")
def reset_password_with_token(req: ResetTokenReq, request: Request):
"""Consume a reset token and set a new password."""
if len(req.new_password or "") < 8:
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
row = consume_action_token(req.token, "reset")
ip, ua = _client(request)
if not row:
audit(None, "password.reset.fail",
meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua)
raise HTTPException(400, "Token je nevažeći ili istekao")
if not row.get("aktivan") or row.get("status") != "active":
audit(row["user_id"], "password.reset.fail",
meta={"reason": "user_inactive"}, ip=ip, ua=ua)
raise HTTPException(403, "Račun nije aktivan")
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=false,
failed_login_count=0, locked_until=NULL, updated_at=now()
WHERE id=%s""", (hash_password(req.new_password), row["user_id"]))
# Revoke all active sessions for safety
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE user_id=%s",
(row["user_id"],))
audit(row["user_id"], "password.reset.ok", ip=ip, ua=ua)
return {"status": "ok", "email": row["email"]}
@router.get("/reset-password")
def reset_password_check(token: str, request: Request):
"""Pre-flight: validate that the token exists and isn't expired/used.
Does NOT consume the token."""
th = _hash_action_token(token)
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email
FROM pgz_sport.user_action_tokens t
JOIN pgz_sport.users u ON u.id = t.user_id
WHERE t.token_hash=%s AND t.kind='reset'""", (th,))
if not row:
raise HTTPException(404, "Token nije pronađen")
if row["used_at"] is not None:
raise HTTPException(410, "Token je već iskorišten")
exp = row["expires_at"]
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
if exp <= _now():
raise HTTPException(410, "Token je istekao")
return {"status": "ok", "email": row["email"], "expires_at": row["expires_at"].isoformat()}
# ─────────────────────────── /auth/setup-password (invite) ───────────────────────────
class SetupPwdReq(BaseModel):
token: str
new_password: str
@router.get("/setup-password")
def setup_password_check(token: str, request: Request):
"""Pre-flight: validate an invite token without consuming it."""
th = _hash_action_token(token)
row = db_one("""SELECT t.user_id, t.expires_at, t.used_at, u.email, u.full_name, u.user_type
FROM pgz_sport.user_action_tokens t
JOIN pgz_sport.users u ON u.id = t.user_id
WHERE t.token_hash=%s AND t.kind='invite'""", (th,))
if not row:
raise HTTPException(404, "Pozivnica nije pronađena")
if row["used_at"] is not None:
raise HTTPException(410, "Pozivnica je već iskorištena")
exp = row["expires_at"]
if exp.tzinfo is None: exp = exp.replace(tzinfo=timezone.utc)
if exp <= _now():
raise HTTPException(410, "Pozivnica je istekla")
return {"status": "ok",
"email": row["email"],
"full_name": row["full_name"],
"user_type": row["user_type"],
"expires_at": row["expires_at"].isoformat()}
@router.post("/setup-password")
def setup_password_consume(req: SetupPwdReq, request: Request):
"""Consume an invite token and set the user's first password."""
if len(req.new_password or "") < 8:
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
row = consume_action_token(req.token, "invite")
ip, ua = _client(request)
if not row:
audit(None, "invite.consume.fail",
meta={"reason": "invalid_or_expired_token"}, ip=ip, ua=ua)
raise HTTPException(400, "Pozivnica je nevažeća ili istekla")
if not row.get("aktivan") or row.get("status") != "active":
audit(row["user_id"], "invite.consume.fail",
meta={"reason": "user_inactive"}, ip=ip, ua=ua)
raise HTTPException(403, "Račun nije aktivan")
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=false,
email_verified=true,
failed_login_count=0, locked_until=NULL, updated_at=now()
WHERE id=%s""", (hash_password(req.new_password), row["user_id"]))
audit(row["user_id"], "invite.consume.ok",
meta={"email": row["email"]}, ip=ip, ua=ua)
return {"status": "ok", "email": row["email"]}
# ─────────────────────────── 2FA — real TOTP (RFC 6238) ───────────────────────────
try:
import pyotp as _pyotp
+221
View File
@@ -0,0 +1,221 @@
{
"_meta": {
"version": 1,
"author": "dradulic@outlook.com",
"date": "2026-05-04",
"purpose": "Sport-aware enrichment routing for /v2/enrich/sportas. Each entry maps a sport name (lower-case, multiple aliases supported) to its national federation, optional PGŽ regional federation, and a list of search/scrape URLs. Used by routers/enrich_router.py and workers/enrichment_worker.py."
},
"_aliases": {
"kosarkaski": "košarka",
"košarkaški": "košarka",
"nogometni": "nogomet",
"rukometni": "rukomet",
"stoni tenis": "stolni tenis",
"stolnotenis": "stolni tenis",
"bocanje": "boćanje",
"boćanje (boules)": "boćanje",
"kuglacki": "kuglanje",
"vaterpolski": "vaterpolo",
"konjicki sport": "konjički sport",
"auto-sport": "auto sport",
"skijaski sport": "skijanje"
},
"boćanje": {
"national": {
"name": "HBS",
"long_name": "Hrvatski boćarski savez",
"url": "https://hrvatski-bocarski-savez.hr",
"search_url": "https://hrvatski-bocarski-savez.hr/?s={q}",
"profile_url_pattern": "https://hrvatski-bocarski-savez.hr/igraci/{slug}/"
},
"pgz": {
"name": "BS PGŽ",
"url": "https://hrvatski-bocarski-savez.hr/savez/zupanijski-savezi/"
}
},
"nogomet": {
"national": {
"name": "HNS",
"long_name": "Hrvatski nogometni savez",
"url": "https://hns-cff.hr",
"search_url": "https://semafor.hns.family/?s={q}",
"profile_search": "https://semafor.hns.family/igraci/?ime={q}",
"profile_url_pattern": "https://semafor.hns.family/igraci/{hns_pid}/{slug}/"
},
"pgz": {"name": "NS PGŽ", "url": "https://nogomet-pgz.hr"}
},
"košarka": {
"national": {
"name": "HKS",
"long_name": "Hrvatski košarkaški savez",
"url": "https://hks-cbf.hr",
"search_url": "https://hks-cbf.hr/?s={q}"
},
"pgz": {"name": "KS PGŽ", "url": "https://kosarka-pgz.hr"}
},
"rukomet": {
"national": {
"name": "HRS",
"long_name": "Hrvatski rukometni savez",
"url": "https://hrs.hr",
"search_url": "https://hrs.hr/?s={q}"
},
"pgz": {"name": "RS PGŽ", "url": "https://rs-pgz.hr"}
},
"odbojka": {
"national": {
"name": "HOS",
"long_name": "Hrvatski odbojkaški savez",
"url": "https://hos-cvf.hr",
"search_url": "https://hos-cvf.hr/?s={q}"
},
"pgz": {"name": "OS PGŽ", "url": "https://odbojkaski-savez-pgz.hr"}
},
"vaterpolo": {
"national": {
"name": "HVS",
"long_name": "Hrvatski vaterpolski savez",
"url": "https://hvs.hr",
"search_url": "https://hvs.hr/?s={q}"
}
},
"plivanje": {
"national": {
"name": "HPS",
"long_name": "Hrvatski plivački savez",
"url": "https://hps.hr",
"search_url": "https://hps.hr/?s={q}"
}
},
"atletika": {
"national": {
"name": "HAS",
"long_name": "Hrvatski atletski savez",
"url": "https://atletika.hr",
"search_url": "https://atletika.hr/?s={q}"
}
},
"tenis": {
"national": {
"name": "HTS",
"long_name": "Hrvatski teniski savez",
"url": "https://htsavez.hr",
"search_url": "https://htsavez.hr/?s={q}"
}
},
"judo": {
"national": {
"name": "HJS",
"long_name": "Hrvatski judo savez",
"url": "https://judo-savez.hr",
"search_url": "https://judo-savez.hr/?s={q}"
}
},
"karate": {
"national": {
"name": "HKaS",
"long_name": "Hrvatski karate savez",
"url": "https://karate.hr",
"search_url": "https://karate.hr/?s={q}"
}
},
"veslanje": {
"national": {
"name": "HVeS",
"long_name": "Hrvatski veslački savez",
"url": "https://veslacki-savez.hr",
"search_url": "https://veslacki-savez.hr/?s={q}"
}
},
"jedrenje": {
"national": {
"name": "HJedS",
"long_name": "Hrvatski jedriličarski savez",
"url": "https://hjs.hr",
"search_url": "https://hjs.hr/?s={q}"
}
},
"gimnastika": {
"national": {
"name": "HGS",
"long_name": "Hrvatski gimnastički savez",
"url": "https://gimnastika.hr",
"search_url": "https://gimnastika.hr/?s={q}"
}
},
"streličarstvo": {
"national": {
"name": "HStS",
"long_name": "Hrvatski streličarski savez",
"url": "https://hss.hr",
"search_url": "https://hss.hr/?s={q}"
}
},
"biciklizam": {
"national": {
"name": "HBciS",
"long_name": "Hrvatski biciklistički savez",
"url": "https://hbs.hr",
"search_url": "https://hbs.hr/?s={q}"
}
},
"stolni tenis": {
"national": {
"name": "HSTS",
"long_name": "Hrvatski stolnoteniski savez",
"url": "https://stolni-tenis.hr",
"search_url": "https://stolni-tenis.hr/?s={q}"
}
},
"triatlon": {
"national": {
"name": "HTrS",
"long_name": "Hrvatski triatlon savez",
"url": "https://triatlon.hr",
"search_url": "https://triatlon.hr/?s={q}"
}
},
"skijanje": {
"national": {
"name": "HZS",
"long_name": "Hrvatski skijaški savez",
"url": "https://skijaski-savez.hr",
"search_url": "https://skijaski-savez.hr/?s={q}"
}
},
"kuglanje": {
"national": {
"name": "HKgS",
"long_name": "Hrvatski kuglački savez",
"url": "https://kuglanje.hr",
"search_url": "https://kuglanje.hr/?s={q}"
}
},
"šah": {
"national": {
"name": "HŠS",
"long_name": "Hrvatski šahovski savez",
"url": "https://hsk.hr",
"search_url": "https://hsk.hr/?s={q}"
}
},
"konjički sport": {
"national": {
"name": "HKonjS",
"long_name": "Hrvatski konjički sportski savez",
"url": "https://konjs.hr"
}
},
"auto sport": {
"national": {
"name": "HAKS",
"long_name": "Hrvatski auto klub savez",
"url": "https://hsa.hr"
}
},
"_local_media_pgz": [
{"name": "Novi list", "search_url": "https://www.novilist.hr/?s={q}"},
{"name": "Glas Istre", "search_url": "https://www.glasistre.hr/pretraga?q={q}"},
{"name": "Rijeka.danas","search_url": "https://www.rijeka-danas.com/?s={q}"}
]
}
+291
View File
@@ -816,6 +816,297 @@ def invoices_pay(invoice_id: int, body: dict = Body(default={}),
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
# ── R5.3 BULK OPERATIONS ──────────────────────────────────────────────
@router.post("/invoices/bulk-pay")
def invoices_bulk_pay(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Bulk označi listu računa kao plaćene.
Body: {ids: [int], paid_date?, payment_method?, iban_from?, iban_to?, reference?, tx_id?}"""
user = _resolve_user(authorization)
ids = body.get("ids") or []
if not ids or not isinstance(ids, list):
raise HTTPException(400, "ids je obavezna ne-prazna lista")
paid_date = body.get("paid_date") or date.today().isoformat()
payment_method = body.get("payment_method") or "transfer"
iban_from = body.get("iban_from")
iban_to = body.get("iban_to")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
results = {"paid": [], "skipped": [], "forbidden": [], "errors": []}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.id = ANY(%s)""", (ids,))
rows = cur.fetchall()
for inv in rows:
if (inv.get("payment_status") or "").lower() == "paid":
results["skipped"].append(inv["id"]); continue
if user and not can_pay_invoice(user, inv):
results["forbidden"].append(inv["id"]); continue
try:
with _db() as c:
cur = c.cursor()
cur.execute(
"""UPDATE pgz_sport.invoices
SET payment_status='paid', paid_date=%s,
payment_method=COALESCE(%s,payment_method),
iban_from=COALESCE(%s,iban_from),
iban_to=COALESCE(%s,iban_to),
updated_at=NOW()
WHERE id=%s""",
(paid_date, payment_method, iban_from, iban_to, inv["id"]),
)
cur.execute(
"""INSERT INTO pgz_sport.payments
(klub_id, invoice_id, payment_date, amount, currency, payment_method,
iban_from, iban_to, reference, bank_transaction_id, matched_status)
VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')""",
(inv.get("klub_id"), inv["id"], paid_date, inv.get("amount_gross"),
inv.get("currency"), payment_method, iban_from, iban_to, reference, tx_id),
)
audit_invoice(user, inv["id"], "bulk_pay",
field="payment_status", old=inv.get("payment_status"), new="paid")
results["paid"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
@router.post("/invoices/bulk-cancel")
def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Bulk otkaži (status='cancelled') — samo pgz_admin ili klub_admin svog kluba."""
user = _resolve_user(authorization)
ids = body.get("ids") or []
razlog = body.get("razlog") or body.get("reason") or "(bulk cancel)"
if not ids:
raise HTTPException(400, "ids je obavezna ne-prazna lista")
results = {"cancelled": [], "skipped": [], "forbidden": [], "errors": []}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT i.*, k.savez_id FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE i.id = ANY(%s)""", (ids,))
rows = cur.fetchall()
for inv in rows:
if (inv.get("payment_status") or "").lower() in ("paid", "cancelled"):
results["skipped"].append(inv["id"]); continue
if user and not can_edit_invoice(user, inv):
results["forbidden"].append(inv["id"]); continue
try:
with _db() as c:
c.cursor().execute(
"""UPDATE pgz_sport.invoices
SET payment_status='cancelled',
notes = COALESCE(notes,'') || E'\n[CANCEL] ' || %s,
updated_at=NOW() WHERE id=%s""",
(razlog, inv["id"]),
)
audit_invoice(user, inv["id"], "bulk_cancel",
field="payment_status", old=inv.get("payment_status"),
new=f"cancelled: {razlog}")
results["cancelled"].append(inv["id"])
except Exception as e:
results["errors"].append({"id": inv["id"], "err": str(e)[:200]})
return {"ok": True, "summary": {k: len(v) for k, v in results.items()}, "details": results}
# ── R5.4 XLSX EXPORT ───────────────────────────────────────────────────
@router.get("/invoices/export.xlsx")
def invoices_export_xlsx(
tenant_id: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
od: Optional[str] = Query(None, description="datum od YYYY-MM-DD"),
do: Optional[str] = Query(None, description="datum do YYYY-MM-DD"),
status: Optional[str] = None,
kind: Optional[str] = None,
authorization: Optional[str] = Header(None),
):
"""XLSX export računa za knjigovodstvo. Stupci: ID, datum, vrsta, broj,
izdavatelj, OIB, klub, neto, PDV, brutto, valuta, status, IBAN, opis."""
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment
from io import BytesIO
from fastapi.responses import StreamingResponse
user = _resolve_user(authorization)
sql = """SELECT i.id, i.invoice_date, i.invoice_kind, i.invoice_no,
i.vendor_name, i.vendor_oib, i.customer_oib,
i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate,
i.currency, i.payment_status, i.payment_method,
i.iban_to, i.description, i.category,
i.paid_date, i.tenant_id, i.klub_id,
k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE 1=1"""
args: list = []
if tenant_id is not None: sql += " AND i.tenant_id=%s"; args.append(tenant_id)
if klub_id is not None: sql += " AND i.klub_id=%s"; args.append(klub_id)
if od: sql += " AND i.invoice_date >= %s"; args.append(od)
if do: sql += " AND i.invoice_date <= %s"; args.append(do)
if status: sql += " AND i.payment_status=%s"; args.append(status)
if kind: sql += " AND i.invoice_kind=%s"; args.append(kind)
sql += " ORDER BY i.invoice_date DESC, i.id DESC"
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
# Filter po user permissions
if user and not is_pgz_admin(user):
rows = [r for r in rows if can_view_invoice(user, r)]
wb = Workbook()
ws = wb.active
ws.title = "Računi"
headers = ["ID", "Datum", "Vrsta", "Broj računa", "Izdavatelj", "OIB",
"Klub", "Iznos neto", "PDV", "Brutto", "Stopa PDV",
"Valuta", "Status", "Datum uplate", "IBAN primatelja",
"Opis", "Kategorija"]
bold = Font(bold=True, color="FFFFFF")
fill = PatternFill("solid", fgColor="003087")
for col_idx, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_idx, value=h)
cell.font = bold; cell.fill = fill
cell.alignment = Alignment(horizontal="center")
for r_idx, r in enumerate(rows, 2):
ws.cell(row=r_idx, column=1, value=r.get("id"))
ws.cell(row=r_idx, column=2, value=str(r.get("invoice_date") or ""))
ws.cell(row=r_idx, column=3, value=r.get("invoice_kind"))
ws.cell(row=r_idx, column=4, value=r.get("invoice_no"))
ws.cell(row=r_idx, column=5, value=r.get("vendor_name"))
ws.cell(row=r_idx, column=6, value=r.get("vendor_oib"))
ws.cell(row=r_idx, column=7, value=r.get("klub_naziv"))
ws.cell(row=r_idx, column=8, value=float(r["amount_net"]) if r.get("amount_net") is not None else None)
ws.cell(row=r_idx, column=9, value=float(r["amount_vat"]) if r.get("amount_vat") is not None else None)
ws.cell(row=r_idx, column=10, value=float(r["amount_gross"]) if r.get("amount_gross") is not None else None)
ws.cell(row=r_idx, column=11, value=float(r["vat_rate"]) if r.get("vat_rate") is not None else None)
ws.cell(row=r_idx, column=12, value=r.get("currency"))
ws.cell(row=r_idx, column=13, value=r.get("payment_status"))
ws.cell(row=r_idx, column=14, value=str(r.get("paid_date") or ""))
ws.cell(row=r_idx, column=15, value=r.get("iban_to"))
ws.cell(row=r_idx, column=16, value=r.get("description"))
ws.cell(row=r_idx, column=17, value=r.get("category"))
# Auto width
widths = [6, 12, 12, 18, 28, 14, 24, 12, 12, 12, 8, 6, 11, 12, 22, 30, 12]
for i, w in enumerate(widths, 1):
ws.column_dimensions[ws.cell(row=1, column=i).column_letter].width = w
ws.freeze_panes = "A2"
buf = BytesIO()
wb.save(buf); buf.seek(0)
fname = f"racuni_{date.today().isoformat()}.xlsx"
return StreamingResponse(
buf, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ── R5.6 STATS ─────────────────────────────────────────────────────────
@router.get("/stats")
def erp_stats(
klub_id: Optional[int] = Query(None),
tenant_id: Optional[int] = Query(None),
authorization: Optional[str] = Header(None),
):
"""Statistika ERP-a: ukupno troškova mjesec/kvartal/godina po klubu/savezu,
breakdown po vrstama (gorivo/cestarina/hotel/oprema/ostalo)."""
user = _resolve_user(authorization)
today = date.today()
month_start = today.replace(day=1).isoformat()
qmonth = ((today.month - 1) // 3) * 3 + 1
quarter_start = today.replace(month=qmonth, day=1).isoformat()
year_start = today.replace(month=1, day=1).isoformat()
where = ["1=1"]; args: list = []
if klub_id is not None:
where.append("klub_id=%s"); args.append(klub_id)
if tenant_id is not None:
where.append("tenant_id=%s"); args.append(tenant_id)
where_sql = " AND ".join(where)
def q_sum(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT COUNT(*) AS n,
COALESCE(SUM(amount_gross),0)::float AS total,
COALESCE(SUM(CASE WHEN payment_status='paid' THEN amount_gross END),0)::float AS paid,
COALESCE(SUM(CASE WHEN payment_status<>'paid' THEN amount_gross END),0)::float AS unpaid
FROM pgz_sport.invoices
WHERE {where_sql} AND invoice_date >= %s""",
args + [date_from],
)
return cur.fetchone()
def q_breakdown(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT invoice_kind, COUNT(*) AS n,
COALESCE(SUM(amount_gross),0)::float AS total
FROM pgz_sport.invoices
WHERE {where_sql} AND invoice_date >= %s
GROUP BY invoice_kind ORDER BY total DESC""",
args + [date_from],
)
return cur.fetchall()
def q_top(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
f"""SELECT i.klub_id, k.naziv AS klub_naziv,
COUNT(*) AS n, COALESCE(SUM(i.amount_gross),0)::float AS total
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id
WHERE {where_sql} AND i.invoice_date >= %s
GROUP BY i.klub_id, k.naziv ORDER BY total DESC LIMIT 10""",
args + [date_from],
)
return cur.fetchall()
# Putni nalozi totals
def q_pn(date_from):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
pn_where = ["report_type='putni_nalog'"]; pn_args: list = []
if klub_id is not None:
pn_where.append("klub_id=%s"); pn_args.append(klub_id)
if tenant_id is not None:
pn_where.append("tenant_id=%s"); pn_args.append(tenant_id)
cur.execute(
f"""SELECT COUNT(*) AS n,
COALESCE(SUM(cost_total),0)::float AS total,
COALESCE(SUM(dnevnice_amount),0)::float AS dnevnice,
COALESCE(SUM(cost_transport),0)::float AS transport
FROM pgz_sport.expense_reports
WHERE {' AND '.join(pn_where)} AND date_from >= %s""",
pn_args + [date_from],
)
return cur.fetchone()
return {
"ok": True,
"as_of": today.isoformat(),
"filters": {"klub_id": klub_id, "tenant_id": tenant_id},
"invoices": {
"month": {"since": month_start, **q_sum(month_start), "by_kind": q_breakdown(month_start)},
"quarter": {"since": quarter_start, **q_sum(quarter_start), "by_kind": q_breakdown(quarter_start)},
"year": {"since": year_start, **q_sum(year_start), "by_kind": q_breakdown(year_start)},
},
"top_klubovi_godina": q_top(year_start),
"putni_nalozi": {
"month": {"since": month_start, **q_pn(month_start)},
"quarter": {"since": quarter_start, **q_pn(quarter_start)},
"year": {"since": year_start, **q_pn(year_start)},
},
}
@router.get("/invoices/uploads/list")
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,
+135 -26
View File
@@ -246,32 +246,32 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
if user and not can_view_putni_nalog(user, row):
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
# Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments)
att = row.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
invoice_ids = att.get("invoice_ids") or []
invoices = []
if invoice_ids:
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices WHERE id = ANY(%s)
ORDER BY invoice_date DESC""", (invoice_ids,))
invoices = cur.fetchall()
else:
# Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak
cur.execute(
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
invoice_date, amount_gross, payment_status, currency, category
FROM pgz_sport.invoices
WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s
AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo')
ORDER BY invoice_date DESC LIMIT 50""",
(row.get("klub_id"), row.get("date_from"), row.get("date_to")),
)
invoices = cur.fetchall()
# Vezani računi iz m2m tablice
cur.execute(
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category,
pnr.kategorija AS attached_kategorija, pnr.attached_at
FROM pgz_sport.putni_nalog_racuni pnr
JOIN pgz_sport.invoices i ON i.id = pnr.invoice_id
WHERE pnr.putni_nalog_id=%s
ORDER BY i.invoice_date DESC""", (nalog_id,))
invoices = cur.fetchall()
# Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani
cur.execute(
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.putni_nalog_racuni pnr
ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s
WHERE i.klub_id=%s
AND i.invoice_date BETWEEN %s AND %s
AND i.invoice_kind IN ('gorivo','cestarina','hotel','restoran','oprema','ostalo')
AND pnr.id IS NULL
ORDER BY i.invoice_date DESC LIMIT 50""",
(nalog_id, row.get("klub_id"), row.get("date_from"), row.get("date_to")),
)
suggested = cur.fetchall()
# Payments za ovaj putni nalog
cur.execute(
@@ -284,9 +284,64 @@ def get_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
audit = fetch_audit("pgz_sport.expense_reports", nalog_id, 50)
actions = putni_nalog_actions(user, row) if user else {"view": True, "edit": False, "submit": False, "approve": False, "reject": False, "pay": False, "delete": False}
return {"ok": True, "putni_nalog": row, "invoices": invoices,
"suggested_invoices": suggested,
"payments": payments, "audit": audit, "actions": actions}
@router.post("/putni-nalog/{nalog_id}/attach-invoice")
def attach_invoice(nalog_id: int, body: dict = Body(...),
authorization: Optional[str] = Header(None)):
"""Veži postojeći račun na putni nalog (m2m)."""
user = _resolve_user(authorization)
inv_id = body.get("invoice_id")
kategorija = body.get("kategorija") or body.get("category")
if not inv_id:
raise HTTPException(400, "invoice_id je obavezan")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
raise HTTPException(403, "Nemate ovlasti za vezivanje računa")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.putni_nalog_racuni
(putni_nalog_id, invoice_id, kategorija, attached_by)
VALUES (%s,%s,%s,%s)
ON CONFLICT (putni_nalog_id, invoice_id) DO UPDATE SET kategorija=EXCLUDED.kategorija
RETURNING id, attached_at""",
(nalog_id, inv_id, kategorija, (user.get("id") if user else None)),
)
link = cur.fetchone()
audit_putni(user, nalog_id, "attach_invoice", field="invoice_id", new=inv_id)
return {"ok": True, "link_id": link["id"], "attached_at": link["attached_at"]}
@router.delete("/putni-nalog/{nalog_id}/invoice/{invoice_id}")
def detach_invoice(nalog_id: int, invoice_id: int,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT er.*, k.savez_id FROM pgz_sport.expense_reports er LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id WHERE er.id=%s AND er.report_type='putni_nalog'", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_edit_putni_nalog(user, pn) and not is_pgz_admin(user):
raise HTTPException(403, "Nemate ovlasti")
with _db() as c:
cur = c.cursor()
cur.execute(
"DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s AND invoice_id=%s",
(nalog_id, invoice_id),
)
audit_putni(user, nalog_id, "detach_invoice", field="invoice_id", old=invoice_id)
return {"ok": True}
@router.post("/putni-nalog/{nalog_id}/posalji")
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
"""Voditelj/klub_admin šalje draft → poslan."""
@@ -385,6 +440,60 @@ def isplati_putni_nalog(nalog_id: int, body: dict = Body(default={}),
return {"ok": True, "putni_nalog": row, "payment_id": pay["id"] if pay else None}
@router.get("/putni-nalog/{nalog_id}/hub3.pdf")
def putni_hub3(nalog_id: int, iban: Optional[str] = None,
authorization: Optional[str] = Header(None)):
"""HUB-3 uplatnica + EPC QR za isplatu putnog naloga voditelju."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT er.*, k.naziv AS klub_naziv, k.savez_id, k.adresa AS klub_adresa
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
pn = cur.fetchone()
if not pn:
raise HTTPException(404, "Putni nalog ne postoji")
if user and not can_view_putni_nalog(user, pn):
raise HTTPException(403, "Nemate ovlasti")
try:
from crm.payments import build_hub3_pdf
except Exception as e:
raise HTTPException(500, f"HUB-3 helper nije dostupan: {e}")
from fastapi.responses import Response
att = pn.get("attachments") or {}
if isinstance(att, str):
try: att = json.loads(att)
except Exception: att = {}
voditelj = att.get("voditelj") or "Voditelj putovanja"
iban_to = (iban or "").strip() or att.get("iban_voditelja") or "HR0000000000000000000"
iznos = float(pn.get("cost_total") or 0)
if iznos <= 0:
raise HTTPException(400, "Iznos isplate mora biti veći od 0")
poziv = f"{nalog_id:08d}"
opis = f"Putni nalog #{nalog_id}: {pn.get('destination') or ''} ({pn.get('date_from')}{pn.get('date_to')})"[:140]
pdf = build_hub3_pdf(
platitelj_naziv=pn.get("klub_naziv") or "PGŽ Sport klub",
platitelj_adresa=pn.get("klub_adresa") or "",
primatelj_naziv=voditelj,
primatelj_adresa="",
iban=iban_to,
amount_eur=iznos,
model="HR99",
poziv_na_broj=poziv,
opis=opis,
sifra_namjene="SALA",
datum=date.today(),
)
return Response(content=pdf, media_type="application/pdf",
headers={"Content-Disposition": f'inline; filename="putni-nalog-{nalog_id}-HUB3.pdf"'})
@router.get("/putni-nalog/{nalog_id}/audit")
def putni_audit(nalog_id: int, limit: int = 100,
authorization: Optional[str] = Header(None)):
+40
View File
@@ -76,6 +76,39 @@ def apply_privacy(rows, admin):
app = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0")
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
# ─── R5 #1: Defense-in-depth JWT enforcement on /api/admin/* ───
# Even if a route accidentally lacks `Depends(require_user)`, this middleware
# rejects requests with no/invalid Bearer token before they reach the handler.
@app.middleware("http")
async def require_jwt_on_admin(request, call_next):
p = request.url.path
# Only gate admin endpoints — leave /api/auth/*, public /api/v2/* etc. alone
if p.startswith("/api/admin/") or p == "/api/admin":
# OPTIONS preflight passes through
if request.method == "OPTIONS":
return await call_next(request)
try:
from auth.auth_v2 import decode_token, _is_revoked
auth = request.headers.get("authorization", "")
if not auth.lower().startswith("bearer "):
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Authentication required"}, status_code=401)
token = auth.split(" ", 1)[1].strip()
try:
payload = decode_token(token)
except Exception:
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Invalid or expired token"}, status_code=401)
if payload.get("typ") not in (None, "access"):
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Wrong token type"}, status_code=401)
if _is_revoked(payload.get("jti", "")):
from starlette.responses import JSONResponse as _JR
return _JR({"detail": "Token revoked"}, status_code=401)
except Exception as e:
print(f"[JWT-MW WARN] {e}")
return await call_next(request)
# === URL rewrite middleware - convert direct external image URLs to /img-proxy ===
import json as _json_mw
@@ -1361,6 +1394,13 @@ try:
except Exception as e:
print(f'[CRM/PANEL] clan_panel router fail: {e}')
try:
from crm_extras_router import router as crm_extras_router
app.include_router(crm_extras_router)
print('[CRM/R5] extras router loaded (bulk + xlsx + stats + notifications)')
except Exception as e:
print(f'[CRM/R5] extras router fail: {e}')
# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR ===
try:
from auth.auth_v2 import router as auth_v2_router
+588
View File
@@ -0,0 +1,588 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/crm_extras_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/crm_extras_router.py
# Svrha: R5 — bulk akcije za članarine, XLSX export članova, /crm/stats,
# notifikacije za isteke liječničkih (Email + InApp)
# ═══════════════════════════════════════════════════════════════════
"""R5 CRM extras.
Endpointi (montirani na /api/crm):
POST /clanarine/bulk/notify → opomena svim koji duguju (mock email + InApp)
POST /clanarine/bulk/uplatnice → batch HUB-3 PDF (zip ili JSON s URL-ovima)
GET /clanovi/export.xlsx → XLSX svih članova (filteri klub, aktivan)
GET /stats → aktivni vs neaktivni, trend uplata, ...
POST /lijecnicki/notify-scan → skenira pretvorbe < N dana, kreira notifikacije
GET /notifications → lista (filter user/status/channel)
POST /notifications/{id}/read → mark read
POST /notifications/mark-all-read → mark all read za usera
"""
from __future__ import annotations
import io
import json as _json
import sys
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query
from fastapi.responses import Response
from pydantic import BaseModel
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
sys.path.insert(0, "/opt/pgz-sport")
from crm.payments import (
build_hub3_pdf, make_poziv_na_broj, normalize_iban,
)
router = APIRouter(prefix="/api/crm", tags=["crm-extras"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
# Pragovi za scan liječničkih (dana do isteka)
LIJEC_THRESHOLDS = (30, 15, 7)
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 None if d is None else {k: _conv(v) for k, v in dict(d).items()}
# ════════════════════════════════════════════════════
# #3 — BULK AKCIJE ZA ČLANARINE
# ════════════════════════════════════════════════════
class BulkOpomenaIn(BaseModel):
klub_id: Optional[int] = None
godina: Optional[int] = None
ids: Optional[list[int]] = None # specifične clanarina ID
template: Optional[str] = "Poštovani, podsjećamo na nepodmirenu članarinu."
@router.post("/clanarine/bulk/notify")
def bulk_opomena(body: BulkOpomenaIn):
"""Pošalji opomenu (mock e-mail + InApp notification) svim dužnicima."""
where = ["c.status IN ('nepodmireno','djelomicno')"]
params: list = []
if body.ids:
where.append("c.id = ANY(%s)"); params.append(body.ids)
if body.klub_id:
where.append("c.klub_id = %s"); params.append(body.klub_id)
if body.godina:
where.append("c.godina = %s"); params.append(body.godina)
where_sql = "WHERE " + " AND ".join(where)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT c.id, c.godina, c.iznos_propisan,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.id AS clan_id, cl.ime || ' ' || cl.prezime AS clan,
cl.email AS clan_email, k.naziv AS klub
FROM pgz_sport.clanarine c
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY dug DESC
LIMIT 1000
""", params)
rows = [_row(r) for r in cur.fetchall()]
# Insert notifications za one s e-mailom
n_email, n_inapp = 0, 0
for r in rows:
subject = f"Opomena: nepodmirena članarina {r['godina']} ({r['dug']:.2f} €)"
body_txt = (f"{body.template}\n\n"
f"Klub: {r.get('klub')}\n"
f"Iznos duga: {r['dug']:.2f} EUR\n"
f"Godina: {r['godina']}\n\n"
f"PGŽ Sport ERP/CRM")
meta = _json.dumps({
"clanarina_id": r["id"], "clan_id": r["clan_id"],
"iznos_dug": float(r["dug"]),
"uplatnica_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf",
})
# InApp uvijek
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)""",
(subject, body_txt, meta))
n_inapp += 1
# Email mock — samo log
if r.get("clan_email"):
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)""",
(subject, body_txt, _json.dumps({**_json.loads(meta),
"to": r["clan_email"]})))
n_email += 1
conn.commit()
return {
"ok": True,
"matched": len(rows),
"queued_inapp": n_inapp,
"queued_email": n_email,
"note": "Mock — SMTP nije konfiguriran; e-mail je upisan u notifications tablicu sa status='pending'.",
"recipients_preview": rows[:20],
}
class BulkUplatniceIn(BaseModel):
ids: Optional[list[int]] = None
klub_id: Optional[int] = None
godina: Optional[int] = None
@router.post("/clanarine/bulk/uplatnice")
def bulk_uplatnice(body: BulkUplatniceIn):
"""
Vraća JSON s listom uplatnica + linkovima na pojedinačne PDF-ove.
(PDF-ovi se generiraju on-demand kroz /clanarine/{id}/uplatnica.pdf.)
"""
where = ["c.status IN ('nepodmireno','djelomicno')"]
params: list = []
if body.ids:
where = ["c.id = ANY(%s)"]; params = [body.ids]
else:
if body.klub_id:
where.append("c.klub_id = %s"); params.append(body.klub_id)
if body.godina:
where.append("c.godina = %s"); params.append(body.godina)
where_sql = "WHERE " + " AND ".join(where)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT c.id, c.godina, c.iznos_propisan, c.iznos_placen,
(c.iznos_propisan - COALESCE(c.iznos_placen,0))::numeric(10,2) AS dug,
cl.ime || ' ' || cl.prezime AS clan,
k.naziv AS klub, k.iban AS klub_iban
FROM pgz_sport.clanarine c
JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
{where_sql}
ORDER BY k.naziv, cl.prezime
LIMIT 500
""", params)
rows = [_row(r) for r in cur.fetchall()]
return {
"ok": True,
"count": len(rows),
"total_dug_eur": round(sum(float(r["dug"] or 0) for r in rows), 2),
"uplatnice": [{
"id": r["id"], "clan": r["clan"], "klub": r["klub"],
"godina": r["godina"], "iznos_eur": float(r["dug"] or 0),
"pdf_url": f"/sport/api/crm/clanarine/{r['id']}/uplatnica.pdf",
"qr_url": f"/sport/api/crm/clanarine/{r['id']}/qr.png",
} for r in rows],
}
# ════════════════════════════════════════════════════
# #4 — XLSX EXPORT ČLANOVA
# ════════════════════════════════════════════════════
@router.get("/clanovi/export.xlsx")
def export_clanovi_xlsx(
klub_id: Optional[int] = Query(None),
aktivan: Optional[bool] = Query(None),
sport: Optional[str] = Query(None),
q: Optional[str] = Query(None),
limit: int = Query(5000, le=20000),
):
where, params = ["1=1"], []
if klub_id: where.append("c.klub_id = %s"); params.append(klub_id)
if aktivan is not None: where.append("c.aktivan = %s"); params.append(aktivan)
if sport: where.append("(c.sport ILIKE %s OR k.sport ILIKE %s)"); params += [f"%{sport}%", f"%{sport}%"]
if q: where.append("(c.ime || ' ' || c.prezime) ILIKE %s"); params.append(f"%{q}%")
params.append(limit)
where_sql = "WHERE " + " AND ".join(where)
sql = f"""
SELECT c.id, c.ime, c.prezime, c.oib, c.datum_rodenja, c.spol,
c.email, c.telefon, c.adresa, c.grad, c.postanski_broj,
c.kategorija, c.podkategorija, c.pozicija, c.broj_dresa,
c.visina_cm, c.tezina_kg, c.dominantna_noga,
c.aktivan, c.datum_pristupa, c.reprezentativac,
c.kategoriziran, c.kategorija_hoo,
c.stipendiran, c.stipendija_iznos,
c.licenca_broj, c.licenca_vrijedi_do,
k.naziv AS klub, k.oib AS klub_oib,
s.naziv AS savez
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
{where_sql}
ORDER BY k.naziv NULLS LAST, c.prezime, c.ime
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Članovi PGŽ"
headers = [
"ID", "Ime", "Prezime", "OIB", "Datum rođ.", "Spol",
"E-mail", "Telefon", "Adresa", "Grad", "Pošt.",
"Kategorija", "Podkat.", "Pozicija", "Dres",
"Vis. (cm)", "Tež. (kg)", "Dom. noga",
"Aktivan", "Datum prist.", "Repr.",
"Kategoriziran", "HOO kat.",
"Stipendiran", "Stipendija (€)",
"Licenca", "Licenca do",
"Klub", "OIB kluba", "Savez",
]
for col, h in enumerate(headers, 1):
cell = ws.cell(row=1, column=col, value=h)
cell.font = Font(bold=True, color="FFFFFF", size=10)
cell.fill = PatternFill(start_color="1E3A8A", end_color="1E3A8A", fill_type="solid")
cell.alignment = Alignment(horizontal="center", vertical="center")
cell.border = Border(bottom=Side(border_style="thin", color="FFFFFF"))
keys = [
"id", "ime", "prezime", "oib", "datum_rodenja", "spol",
"email", "telefon", "adresa", "grad", "postanski_broj",
"kategorija", "podkategorija", "pozicija", "broj_dresa",
"visina_cm", "tezina_kg", "dominantna_noga",
"aktivan", "datum_pristupa", "reprezentativac",
"kategoriziran", "kategorija_hoo",
"stipendiran", "stipendija_iznos",
"licenca_broj", "licenca_vrijedi_do",
"klub", "klub_oib", "savez",
]
for ridx, r in enumerate(rows, start=2):
for cidx, k in enumerate(keys, 1):
v = r.get(k)
if isinstance(v, bool):
v = "DA" if v else "NE"
ws.cell(row=ridx, column=cidx, value=v)
# Auto column widths
for col_letter, h in zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "AA AB AC AD".split(), headers):
ws.column_dimensions[col_letter].width = max(10, min(28, len(h) + 4))
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
buf = io.BytesIO()
wb.save(buf)
fname = f"clanovi-pgz-{date.today().isoformat()}.xlsx"
return Response(
content=buf.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ════════════════════════════════════════════════════
# #5 — /crm/stats
# ════════════════════════════════════════════════════
@router.get("/stats")
def crm_stats(klub_id: Optional[int] = Query(None)):
"""Aktivni/neaktivni članovi, trend uplata, KPI summary."""
klub_filter = "AND klub_id = %s" if klub_id else ""
klub_params = [klub_id] if klub_id else []
with _conn() as conn, conn.cursor() as cur:
# aktivni vs neaktivni
cur.execute(f"""
SELECT
COUNT(*) FILTER (WHERE aktivan = TRUE) AS aktivni,
COUNT(*) FILTER (WHERE aktivan = FALSE) AS neaktivni,
COUNT(*) AS total,
COUNT(*) FILTER (WHERE reprezentativac = TRUE) AS reprezentativci,
COUNT(*) FILTER (WHERE kategoriziran = TRUE) AS kategorizirani,
COUNT(*) FILTER (WHERE stipendiran = TRUE) AS stipendirani
FROM pgz_sport.clanovi
WHERE 1=1 {klub_filter}
""", klub_params)
clanovi_summary = _row(cur.fetchone())
# po spolu
cur.execute(f"""
SELECT spol, COUNT(*) AS n
FROM pgz_sport.clanovi
WHERE aktivan = TRUE {klub_filter}
GROUP BY spol ORDER BY n DESC
""", klub_params)
po_spolu = [_row(r) for r in cur.fetchall()]
# po kategoriji
cur.execute(f"""
SELECT COALESCE(kategorija, '(nepoznato)') AS kategorija, COUNT(*) AS n
FROM pgz_sport.clanovi
WHERE aktivan = TRUE {klub_filter}
GROUP BY kategorija ORDER BY n DESC LIMIT 12
""", klub_params)
po_kategoriji = [_row(r) for r in cur.fetchall()]
# trend uplata po mjesecu — zadnjih 12
cur.execute(f"""
SELECT to_char(date_trunc('month', datum_uplate), 'YYYY-MM') AS mjesec,
COUNT(*) AS broj_uplata,
SUM(iznos_placen)::numeric(10,2) AS iznos_total
FROM pgz_sport.clanarine
WHERE datum_uplate IS NOT NULL
AND datum_uplate >= (CURRENT_DATE - INTERVAL '12 months')
{('AND klub_id = %s' if klub_id else '')}
GROUP BY date_trunc('month', datum_uplate)
ORDER BY mjesec
""", klub_params)
trend_uplata = [_row(r) for r in cur.fetchall()]
# članarine summary
cur.execute(f"""
SELECT COUNT(*) AS total,
SUM(iznos_propisan)::numeric(10,2) AS propisan,
SUM(iznos_placen)::numeric(10,2) AS placen,
SUM(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug,
COUNT(*) FILTER (WHERE status='nepodmireno') AS n_nepodmireno,
COUNT(*) FILTER (WHERE status='djelomicno') AS n_djelomicno,
COUNT(*) FILTER (WHERE status='podmireno') AS n_podmireno
FROM pgz_sport.clanarine
WHERE 1=1 {klub_filter}
""", klub_params)
clanarine_summary = _row(cur.fetchone())
# liječnički status
cur.execute(f"""
SELECT
COUNT(*) FILTER (WHERE vrijedi_do > CURRENT_DATE + INTERVAL '30 days') AS vazeci,
COUNT(*) FILTER (WHERE vrijedi_do BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '30 days') AS uskoro,
COUNT(*) FILTER (WHERE vrijedi_do < CURRENT_DATE) AS istekli,
COUNT(*) AS total
FROM pgz_sport.lijecnicki_pregledi
WHERE 1=1 {klub_filter}
""", klub_params)
lijecnicki_summary = _row(cur.fetchone())
# najnovije uplate (zadnjih 10)
cur.execute(f"""
SELECT c.id, c.iznos_placen, c.datum_uplate, c.godina,
cl.ime||' '||cl.prezime AS clan, k.naziv AS klub
FROM pgz_sport.clanarine c
LEFT JOIN pgz_sport.clanovi cl ON cl.id = c.clan_id
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.datum_uplate IS NOT NULL {klub_filter.replace('klub_id', 'c.klub_id')}
ORDER BY c.datum_uplate DESC
LIMIT 10
""", klub_params)
najnovije_uplate = [_row(r) for r in cur.fetchall()]
return {
"klub_id": klub_id,
"clanovi": clanovi_summary,
"po_spolu": po_spolu,
"po_kategoriji": po_kategoriji,
"trend_uplata_12m": trend_uplata,
"clanarine": clanarine_summary,
"lijecnicki": lijecnicki_summary,
"najnovije_uplate": najnovije_uplate,
}
# ════════════════════════════════════════════════════
# #6 — NOTIFIKACIJE LIJEČNIČKI ISTECI
# ════════════════════════════════════════════════════
class NotifScanIn(BaseModel):
klub_id: Optional[int] = None
thresholds: Optional[list[int]] = None # default = LIJEC_THRESHOLDS
@router.post("/lijecnicki/notify-scan")
def lijecnicki_notify_scan(body: NotifScanIn):
"""
Skenira nadolazeće isteke i kreira notifikacije (InApp + Email mock)
za pragove 30/15/7 dana. Ne duplicira: gleda meta.lijecnicki_id+threshold
u zadnjih 7 dana.
"""
thresholds = sorted(set(body.thresholds or LIJEC_THRESHOLDS), reverse=True)
klub_filter = "AND l.klub_id = %s" if body.klub_id else ""
klub_params = [body.klub_id] if body.klub_id else []
created = []
with _conn() as conn, conn.cursor() as cur:
for thr in thresholds:
cur.execute(f"""
SELECT l.id, l.vrijedi_do, l.clan_id,
(l.vrijedi_do - CURRENT_DATE)::int AS dana,
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.vrijedi_do IS NOT NULL
AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s
AND (l.vrijedi_do - CURRENT_DATE) > %s
{klub_filter}
""", [thr, thr - 1] + klub_params if False else
([thr - (thresholds[thresholds.index(thr)+1] if thresholds.index(thr)+1 < len(thresholds) else 0),
-1] + klub_params))
# Pojednostavljen scan: samo "≤ thr & > prev_thr" dovodi do duplika;
# umjesto toga samo gledamo "u prozoru ≤ thr".
cur.execute(f"""
SELECT l.id, l.vrijedi_do, l.clan_id,
(l.vrijedi_do - CURRENT_DATE)::int AS dana,
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.vrijedi_do IS NOT NULL
AND (l.vrijedi_do - CURRENT_DATE) BETWEEN 0 AND %s
{klub_filter}
""", [thr] + klub_params)
kandidati = [_row(r) for r in cur.fetchall()]
for r in kandidati:
# de-dup: već postoji notifikacija za ovaj lijec_id+threshold u <7 dana?
cur.execute("""
SELECT 1 FROM pgz_sport.notifications
WHERE meta->>'lijecnicki_id' = %s
AND meta->>'threshold' = %s
AND scheduled_at > now() - INTERVAL '7 days'
LIMIT 1
""", (str(r["id"]), str(thr)))
if cur.fetchone():
continue
subject = f"⚕ Liječnički pregled ističe za {r['dana']} dana: {r['clan']}"
body_txt = (
f"Liječnički pregled za sportaša {r['clan']} "
f"({r.get('klub') or '(bez kluba)'}) ističe {r['vrijedi_do']} "
f"{r['dana']} dana ostalo.\n\n"
f"Molimo zakažite novi termin u ZZJZ PGŽ "
f"(ili koristite /sport/api/crm/lijecnicki/{r['id']}/zakazi).\n\n"
f"PGŽ Sport ERP/CRM"
)
meta = _json.dumps({
"lijecnicki_id": r["id"],
"clan_id": r["clan_id"],
"threshold": thr,
"vrijedi_do": str(r["vrijedi_do"]),
"dana": r["dana"],
"zakazi_url": f"/sport/api/crm/lijecnicki/{r['id']}/zakazi",
"klub": r.get("klub"),
})
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('inapp', %s, %s, 'pending', now(), %s::jsonb)
RETURNING id""", (subject, body_txt, meta))
inapp_id = cur.fetchone()["id"]
created.append({"channel": "inapp", "id": inapp_id, "lijec_id": r["id"], "thr": thr})
if r.get("clan_email"):
cur.execute("""INSERT INTO pgz_sport.notifications
(channel, subject, body, status, scheduled_at, meta)
VALUES ('email', %s, %s, 'pending', now(), %s::jsonb)
RETURNING id""",
(subject, body_txt,
_json.dumps({**_json.loads(meta), "to": r["clan_email"]})))
em_id = cur.fetchone()["id"]
created.append({"channel": "email", "id": em_id, "lijec_id": r["id"], "thr": thr,
"to": r["clan_email"]})
conn.commit()
return {
"ok": True,
"thresholds_dana": thresholds,
"created": len(created),
"items": created[:50],
"note": "Mock — SMTP nije konfiguriran. Email notifikacije su upisane u DB sa status='pending'.",
}
@router.get("/notifications")
def list_notifications(
user_id: Optional[int] = Query(None),
status: Optional[str] = Query(None, description="pending|sent|read"),
channel: Optional[str] = Query(None, description="inapp|email"),
limit: int = Query(100, le=500),
):
where, params = [], []
if user_id is not None:
where.append("user_id = %s"); params.append(user_id)
if status:
where.append("status = %s"); params.append(status)
if channel:
where.append("channel = %s"); params.append(channel)
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
params.append(limit)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""
SELECT id, user_id, channel, subject, body, status,
scheduled_at, sent_at, read_at, meta
FROM pgz_sport.notifications
{where_sql}
ORDER BY scheduled_at DESC NULLS LAST
LIMIT %s
""", params)
rows = [_row(r) for r in cur.fetchall()]
cur.execute(f"""
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE status='pending') AS pending,
COUNT(*) FILTER (WHERE status='sent') AS sent,
COUNT(*) FILTER (WHERE read_at IS NULL AND channel='inapp') AS unread_inapp
FROM pgz_sport.notifications
{where_sql}
""", params[:-1])
summary = _row(cur.fetchone())
return {"count": len(rows), "summary": summary, "rows": rows}
@router.post("/notifications/{nid}/read")
def mark_read(nid: int):
with _conn() as conn, conn.cursor() as cur:
cur.execute("""UPDATE pgz_sport.notifications
SET read_at = now(), status = 'sent'
WHERE id = %s
RETURNING id""", (nid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Notifikacija ne postoji")
conn.commit()
return {"ok": True, "id": nid, "status": "read"}
class MarkAllReadIn(BaseModel):
user_id: Optional[int] = None
channel: Optional[str] = "inapp"
@router.post("/notifications/mark-all-read")
def mark_all_read(body: MarkAllReadIn):
where = ["read_at IS NULL"]
params = []
if body.user_id is not None:
where.append("user_id = %s"); params.append(body.user_id)
if body.channel:
where.append("channel = %s"); params.append(body.channel)
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"""UPDATE pgz_sport.notifications
SET read_at = now(), status = 'sent'
WHERE {' AND '.join(where)}
RETURNING id""", params)
ids = [r["id"] for r in cur.fetchall()]
conn.commit()
return {"ok": True, "marked_read": len(ids), "ids": ids[:200]}
+300 -36
View File
@@ -308,13 +308,19 @@ def _load_row(kind: str, eid: int) -> dict:
adresa, godina_osnutka, source_url, metadata
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
elif kind == 'sportas':
row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url,
slika_url, source_url, source, source_id,
hns_igrac_id, biografija,
datum_rodenja, mjesto_rodenja, broj_dresa,
visina_cm, tezina_kg, dominantna_noga, oib,
vanjski_id, metadata
FROM pgz_sport.clanovi WHERE id=%s""", (eid,))
row = _fetch_one("""SELECT c.id, c.ime, c.prezime, c.sport, c.klub_id, c.profile_url,
c.slika_url, c.source_url, c.source, c.source_id,
c.hns_igrac_id, c.biografija,
c.datum_rodenja, c.mjesto_rodenja, c.broj_dresa,
c.visina_cm, c.tezina_kg, c.dominantna_noga, c.oib,
c.vanjski_id, c.metadata,
k.sport AS klub_sport, k.naziv AS klub_naziv
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE c.id=%s""", (eid,))
# Fall back to klub.sport when c.sport is empty
if row and not row.get('sport') and row.get('klub_sport'):
row['sport'] = row['klub_sport']
else:
raise HTTPException(400, "kind must be klub|savez|sportas")
if not row:
@@ -328,7 +334,54 @@ def _display_name(kind: str, row: dict) -> str:
return row.get('naziv', '') or ''
def _research_links(naziv, kind, grad=None):
# ─── Sport federations map (loaded once, refresh on file mtime) ─────────
_SPORT_FED_PATH = '/opt/pgz-sport/data/sport_federations.json'
_SPORT_FED_CACHE: dict[str, Any] = {'mtime': 0, 'data': {}, 'aliases': {}, 'media': []}
def _load_sport_feds() -> tuple[dict, dict, list]:
"""Return (feds, aliases, local_media) — refreshed when JSON changes."""
try:
st = os.stat(_SPORT_FED_PATH)
except FileNotFoundError:
return ({}, {}, [])
if st.st_mtime != _SPORT_FED_CACHE['mtime']:
try:
with open(_SPORT_FED_PATH, 'r', encoding='utf-8') as f:
raw = json.load(f)
except Exception:
return (_SPORT_FED_CACHE['data'],
_SPORT_FED_CACHE['aliases'],
_SPORT_FED_CACHE['media'])
aliases = raw.pop('_aliases', {}) if isinstance(raw, dict) else {}
media = raw.pop('_local_media_pgz', []) if isinstance(raw, dict) else []
raw.pop('_meta', None)
_SPORT_FED_CACHE.update(mtime=st.st_mtime, data=raw, aliases=aliases, media=media)
return (_SPORT_FED_CACHE['data'],
_SPORT_FED_CACHE['aliases'],
_SPORT_FED_CACHE['media'])
def _normalize_sport(sport: Optional[str]) -> Optional[str]:
if not sport: return None
s = sport.strip().lower()
feds, aliases, _ = _load_sport_feds()
while s in aliases:
nxt = aliases[s]
if nxt == s: break
s = nxt
return s if s in feds else None
def _sport_fed(sport: Optional[str]) -> Optional[dict]:
"""Resolve sport → federations entry (or None)."""
norm = _normalize_sport(sport)
if not norm: return None
feds, _, _ = _load_sport_feds()
return feds.get(norm)
def _research_links(naziv, kind, grad=None, sport: Optional[str] = None):
base_q = (naziv or '').strip()
q = (base_q + ' ' + grad) if grad else base_q
qenc = urllib.parse.quote(q)
@@ -340,9 +393,33 @@ def _research_links(naziv, kind, grad=None):
if kind == 'klub':
out.append({'label': 'Sportilus', 'icon': '', 'url': 'https://www.sportilus.com/?s=' + qenc})
out.append({'label': 'Sudski registar', 'icon': '', 'url': 'https://sudreg.pravosudje.hr/registar/oc/index.html'})
# Sport-specific federation links (replace static HNS/transfermarkt for sportas)
fed = _sport_fed(sport) if sport else None
if kind == 'sportas':
out.append({'label': 'HNS Semafor', 'icon': '', 'url': 'https://semafor.hns.family/?s=' + qenc})
out.append({'label': 'transfermarkt','icon': '', 'url': 'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query=' + qenc})
if fed and isinstance(fed.get('national'), dict):
nat = fed['national']
search = (nat.get('search_url') or nat.get('url') or '').replace('{q}', qenc)
if search:
out.append({'label': nat.get('name', 'Nacionalni savez'),
'icon': '🏆', 'url': search})
if fed and isinstance(fed.get('pgz'), dict):
pgz = fed['pgz']
url = pgz.get('search_url') or pgz.get('url') or ''
if url:
out.append({'label': pgz.get('name', 'PGŽ savez'),
'icon': '🏟', 'url': url.replace('{q}', qenc)})
if not fed:
# No mapping for this sport → keep transfermarkt as legacy fallback
out.append({'label': 'HNS Semafor', 'icon': '', 'url': 'https://semafor.hns.family/?s=' + qenc})
out.append({'label': 'transfermarkt','icon': '', 'url': 'https://www.transfermarkt.com/schnellsuche/ergebnis/schnellsuche?query=' + qenc})
# Local PGŽ media for any sportas
_, _, media = _load_sport_feds()
for m in media:
url = (m.get('search_url') or '').replace('{q}', qenc)
if url:
out.append({'label': m.get('name', 'Lokalni medij'),
'icon': '📰', 'url': url})
if kind == 'savez':
out.append({'label': 'sport-pgz.hr savezi', 'icon': '🏅', 'url': 'https://sport-pgz.hr/savezi'})
return out
@@ -591,38 +668,219 @@ def _hns_fetch_player(url: str) -> Optional[dict]:
return _parse_hns_player(body, url) if body else None
# ─── Generic sport-federation scraper ───────────────────────────────────
def _fed_url_from_row(row: dict) -> Optional[str]:
"""If the row already points to a federation profile (source_url /
profile_url on a known fed host), return it."""
feds, _, _ = _load_sport_feds()
fed_hosts = set()
for entry in feds.values():
if not isinstance(entry, dict): continue
for which in ('national', 'pgz'):
sub = entry.get(which) or {}
for k in ('url', 'search_url', 'profile_url_pattern'):
v = sub.get(k)
if v:
try:
h = urllib.parse.urlparse(v.replace('{q}', 'x').replace('{slug}', 'x').replace('{hns_pid}', '1')).hostname
if h: fed_hosts.add(h)
except Exception:
pass
for k in ('source_url', 'profile_url'):
u = row.get(k)
if not u: continue
try:
h = urllib.parse.urlparse(u).hostname or ''
except Exception:
continue
if h in fed_hosts:
return u
return None
def _parse_federation_profile(html_doc: str, url: str, ime: str, prezime: str) -> Optional[dict]:
"""Best-effort parser for a generic sport-federation profile page.
Returns {source, url, slika_url, datum_rodenja, mjesto_rodenja, klub,
extract, raw_text}. Tolerant of varied page structures.
"""
if not html_doc: return None
host = urllib.parse.urlparse(url).hostname or ''
out: dict[str, Any] = {
'source': host,
'url': url,
}
# Title
m = re.search(r'<title[^>]*>([^<]+)</title>', html_doc, re.I)
if m: out['title'] = html.unescape(m.group(1).strip())[:300]
# Meta description
m = re.search(r'<meta\s+name=["\']description["\']\s+content=["\']([^"\']+)["\']', html_doc, re.I)
if m: out['description'] = html.unescape(m.group(1).strip())[:600]
name_tokens = []
for t in (ime, prezime):
if t and len(t) >= 3:
name_tokens.append(re.escape(t))
# Pick the first content image whose filename contains the player's name,
# or fall back to the first non-asset image.
img_candidates = re.findall(r'<img[^>]+src=["\']([^"\']+)["\']', html_doc, re.I)
chosen_img = None
for src in img_candidates:
low = src.lower()
if any(b in low for b in ('logo', 'icon', 'admin-ajax', 'spinner', 'loader',
'sprite', '/themes/', '/icons/', 'gdpr', 'banner',
'header', 'footer', 'placeholder', 'avatar-default')):
continue
if not low.endswith(('.jpg', '.jpeg', '.png', '.webp')):
continue
# Prefer matches on player name in URL
if name_tokens and any(re.search(t, src, re.I) for t in name_tokens):
chosen_img = src; break
if chosen_img is None:
chosen_img = src
if chosen_img:
if not chosen_img.startswith('http'):
chosen_img = urllib.parse.urljoin(url, chosen_img)
out['slika_url'] = chosen_img
# Plain text body for evidence + label scraping
text = re.sub(r'<script[^>]*>.*?</script>', ' ', html_doc, flags=re.S | re.I)
text = re.sub(r'<style[^>]*>.*?</style>', ' ', text, flags=re.S | re.I)
text = re.sub(r'<[^>]+>', ' ', text)
text = html.unescape(re.sub(r'\s+', ' ', text)).strip()
out['raw_text'] = text[:4000]
out['extract'] = (out.get('description')
or text[max(0, text.find(prezime)-30):max(0, text.find(prezime)-30)+500]
or text[:500])
# Common label-driven fields (HBS layout: "Godina rođenja: 1979.", "Matični klub: …")
m = re.search(r'Datum\s+ro[đdj]?enja[:\s]+(\d{1,2}[.\-/]\d{1,2}[.\-/]\d{4})', text, re.I)
if m:
try:
from datetime import date as _date
d = re.split(r'[.\-/]', m.group(1))
out['datum_rodenja'] = _date(int(d[2]), int(d[1]), int(d[0])).isoformat()
except Exception:
pass
if 'datum_rodenja' not in out:
m = re.search(r'Godina\s+ro[đdj]?enja[:\s]+(\d{4})', text, re.I)
if m:
try:
from datetime import date as _date
out['datum_rodenja'] = _date(int(m.group(1)), 1, 1).isoformat()
except Exception:
pass
m = re.search(r'Mjesto\s+ro[đdj]?enja[:\s]+([A-ZČĆŠĐŽ][^,\n.]{2,40})', text)
if m: out['mjesto_rodenja'] = m.group(1).strip()
m = re.search(r'Mati[čc]ni\s+klub[:\s]+([^\n]{3,60}?)(?:\s+(?:Sportski|Datum|Liječni|Reprezent|Sezona|Domaće|Nastupi))', text, re.I)
if m: out['klub_naziv'] = m.group(1).strip().rstrip('.')
return out
def _slugify_simple(s: str) -> str:
import unicodedata
s = unicodedata.normalize('NFKD', s or '').encode('ascii', 'ignore').decode('ascii').lower()
return re.sub(r'[^a-z0-9]+', '-', s).strip('-')
def scrape_sport_federation(sport: Optional[str], ime: str, prezime: str) -> Optional[dict]:
"""Try to find and parse the athlete's federation profile page."""
fed = _sport_fed(sport) if sport else None
if not fed: return None
nat = (fed or {}).get('national') or {}
full_name = (ime + ' ' + prezime).strip()
# 1) Direct profile URL via {slug} pattern (works for HBS at least)
pattern = nat.get('profile_url_pattern')
if pattern and '{slug}' in pattern:
slug = _slugify_simple(full_name)
url = pattern.replace('{slug}', slug)
body = _http_get(url, timeout=8)
if body and prezime.lower() in body.lower():
return _parse_federation_profile(body, url, ime, prezime)
# 2) Search URL → first /igraci|/profil|/clan link that mentions the surname
search = nat.get('search_url')
if search:
body = _http_get(search.replace('{q}', urllib.parse.quote(full_name)), timeout=10)
if body:
for href_re in (r'href="([^"]*?/igraci/[^"]+)"',
r'href="([^"]*?/igrac/[^"]+)"',
r'href="([^"]*?/sportasi/[^"]+)"',
r'href="([^"]*?/clanovi/[^"]+)"',
r'href="([^"]*?/profil/[^"]+)"'):
for m in re.finditer(href_re, body, re.I):
cand = m.group(1)
if not cand.startswith('http'):
cand = urllib.parse.urljoin(nat.get('url', search), cand)
if _slugify_simple(prezime) in _slugify_simple(cand):
b2 = _http_get(cand, timeout=8)
if b2:
return _parse_federation_profile(b2, cand, ime, prezime)
return None
def _propose_for_sportas(row: dict) -> dict:
naziv = ((row.get('ime') or '') + ' ' + (row.get('prezime') or '')).strip()
ime, prezime = (row.get('ime') or ''), (row.get('prezime') or '')
sport = row.get('sport')
sources, evidence = [], []
proposed: dict[str, Any] = {}
# 1) Resolve a HNS Semafor URL for this athlete (column / vanjski_id / source_id)
hns_url = _hns_url_from_row(row)
# 1) HNS Semafor — only meaningful when sport is football OR row already
# carries an HNS link.
hns_doc: Optional[dict] = None
if hns_url:
hns_doc = _hns_fetch_player(hns_url)
if hns_doc:
sources.append(hns_doc)
evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '')
if _normalize_sport(sport) == 'nogomet' or _hns_url_from_row(row):
hns_url = _hns_url_from_row(row)
if hns_url:
hns_doc = _hns_fetch_player(hns_url)
if hns_doc:
sources.append(hns_doc)
evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '')
# Field-level proposals from HNS Semafor (only when DB is empty)
if hns_doc:
if not row.get('profile_url') and hns_doc.get('url'):
proposed['profile_url'] = hns_doc['url']
if not row.get('source_url') and hns_doc.get('url'):
proposed['source_url'] = hns_doc['url']
if not row.get('slika_url') and hns_doc.get('slika_url'):
proposed['slika_url'] = hns_doc['slika_url']
if not row.get('hns_igrac_id') and hns_doc.get('hns_igrac_id'):
proposed['hns_igrac_id'] = hns_doc['hns_igrac_id']
if not row.get('datum_rodenja') and hns_doc.get('datum_rodenja'):
proposed['datum_rodenja'] = hns_doc['datum_rodenja']
if not row.get('mjesto_rodenja') and hns_doc.get('mjesto_rodenja'):
proposed['mjesto_rodenja'] = hns_doc['mjesto_rodenja']
if not row.get('broj_dresa') and hns_doc.get('broj_dresa'):
proposed['broj_dresa'] = hns_doc['broj_dresa']
# 2) Sport-aware federation scrape (HBS, HKS, etc.) — also use existing
# source_url/profile_url if it points at a known federation host.
fed_doc: Optional[dict] = None
direct_fed_url = _fed_url_from_row(row)
if direct_fed_url and (not hns_doc or hns_doc.get('url') != direct_fed_url):
body = _http_get(direct_fed_url, timeout=8)
if body:
fed_doc = _parse_federation_profile(body, direct_fed_url, ime, prezime)
if not fed_doc:
fed_doc = scrape_sport_federation(sport, ime, prezime)
if fed_doc:
sources.append(fed_doc)
evidence.append(fed_doc.get('raw_text') or fed_doc.get('extract') or '')
# 2) Wikipedia HR for biografija
# Helper: pick from hns_doc first then fed_doc
def _pick(field):
if hns_doc and hns_doc.get(field): return hns_doc[field]
if fed_doc and fed_doc.get(field): return fed_doc[field]
return None
if not row.get('profile_url'):
v = _pick('url') or (hns_doc and hns_doc.get('url')) or (fed_doc and fed_doc.get('url'))
if v: proposed['profile_url'] = v
if not row.get('source_url'):
v = (hns_doc and hns_doc.get('url')) or (fed_doc and fed_doc.get('url'))
if v: proposed['source_url'] = v
if not row.get('slika_url'):
v = _pick('slika_url')
if v: proposed['slika_url'] = v
if not row.get('hns_igrac_id') and hns_doc and hns_doc.get('hns_igrac_id'):
proposed['hns_igrac_id'] = hns_doc['hns_igrac_id']
if not row.get('datum_rodenja'):
v = _pick('datum_rodenja')
if v: proposed['datum_rodenja'] = v
if not row.get('mjesto_rodenja'):
v = _pick('mjesto_rodenja')
if v: proposed['mjesto_rodenja'] = v
if not row.get('broj_dresa') and hns_doc and hns_doc.get('broj_dresa'):
proposed['broj_dresa'] = hns_doc['broj_dresa']
# 3) Wikipedia HR for biografija
if not row.get('biografija'):
wiki = _wiki_summary(naziv)
if wiki:
@@ -631,7 +889,7 @@ def _propose_for_sportas(row: dict) -> dict:
# Description: prefer DeepSeek synthesis from all evidence; fallback to first long snippet
if not row.get('biografija'):
descr = _deepseek_describe(naziv, 'sportaš', evidence) if evidence else None
descr = _deepseek_describe(naziv, f'sportaš ({sport})' if sport else 'sportaš', evidence) if evidence else None
if not descr:
for s in sources:
ext = s.get('extract')
@@ -863,7 +1121,13 @@ def enrich_preview(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'), eid:
'coverage': coverage, 'filled_fields': filled, 'total_fields': len(keys),
'missing_fields': missing,
'live_snippet': _fetch_title(primary) if primary else None,
'research_links': _research_links(naziv, kind, grad),
'research_links': _research_links(naziv, kind, grad, sport=row.get('sport')),
'sport': row.get('sport'),
'sport_federation': (lambda f: {
'national': (f.get('national') or {}).get('name') if f else None,
'national_url': (f.get('national') or {}).get('url') if f else None,
'pgz': (f.get('pgz') or {}).get('name') if f else None,
})(_sport_fed(row.get('sport'))),
'sources': res['sources'],
'current': current,
'proposed': proposed,
+211
View File
@@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
cleanup_garbage_clubs.py — fix klubovi where naziv is an address
Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one)
Date: 2026-05-05
Symptoms (R3B/R4 cleanup pass):
- 14 odbojkaški klubovi imaju adresu u polju `naziv`
- others: null/empty naziv, naziv equal to grad, naziv only digits
- sportaši with email/phone in ime/prezime
Strategy:
1) For each problem klub, look up civic.entities by address fragment.
2) If exactly one candidate → swap (naziv ← candidate.name, adresa ← old naziv,
oib ← candidate.oib if missing) with confidence 0.95.
3) If multiple candidates → mark metadata.manual_review=true with candidates list.
4) If zero candidates → broader fallback (city + sport=odbojka) and same logic.
Backup: pgz_sport.klubovi_backup_20260505 must already exist (run from SQL).
Reports written to /opt/pgz-sport/data_cleanup_report.md (separate driver).
"""
from __future__ import annotations
import os, json, sys
from datetime import datetime, timezone
import psycopg2, psycopg2.extras
PG = dict(host=os.environ.get('PG_HOST','10.10.0.2'),
port=int(os.environ.get('PG_PORT','6432')),
dbname=os.environ.get('PG_DB','rinet_v3'),
user=os.environ.get('PG_USER','rinet'),
password=os.environ.get('PG_PASS',''))
PROBLEM_IDS = [2613, 2616, 2618, 2619, 2622, 2624, 2626, 2630, 2632, 2634, 2636, 2638, 2641, 2643]
# Hand-curated picks where DB has multiple candidates at same address.
# Source: cross-reference with HOS (Hrvatski odbojkaški savez) member roster.
MANUAL_PICKS = {
# 2613 = Trg Viktora Bubnja 1 — savez address; primary host is HAOK Rijeka.
2613: 100700, # Hrvatski Akademski Odbojkaški Klub "Rijeka"
# 2618 = Zdravka Kučića 1 — both ŽOK and MOK Gornja Vežica share. The municipal
# club entry is MOK Gornja Vežica which is the registered active senior team.
2618: 82677, # Muški Odbojkaški Klub "Gornja Vežica"
}
# Hand-curated for zero-match cases (verified via HOS public list)
ZERO_MATCH_HINTS = {
2619: {'name': 'Odbojkaški Klub Čavle', 'note':'Vrh Čavje 31, Čavle'},
2630: {'name': 'Odbojkaški Klub Opatija', 'note':'1. Istarske čete 3, Opatija'},
2636: {'name': 'Odbojkaški Klub Rijeka', 'note':'Sv. Križ 24, Rijeka — possibly OK Rijeka senior'},
2641: {'name': 'Odbojkaški Klub Crikvenica','note':'Kotorska 15a, Crikvenica'},
}
def db():
c = psycopg2.connect(**PG); c.autocommit = False; return c
def fetch_candidates(cur, addr_fragment, sport='odbojka'):
cur.execute("""
SELECT id, name, oib, address, city, entity_type
FROM civic.entities
WHERE address ILIKE %s
AND (name ILIKE '%%odbojk%%' OR name ILIKE 'OK %%' OR name ILIKE 'ŽOK%%'
OR name ILIKE 'MOK %%' OR name ILIKE '%%volley%%')
ORDER BY length(name)
LIMIT 5
""", ('%'+addr_fragment+'%',))
return [dict(r) for r in cur.fetchall()]
def update_klub(cur, kid, new_naziv, old_naziv_as_address, oib, manual_review=False, candidates=None, source=None):
"""Move old naziv → adresa, set new naziv, optionally set oib + metadata."""
md = {
'cleanup_at': datetime.now(timezone.utc).isoformat(),
'cleanup_reason': 'naziv_is_address',
'cleanup_source': source or 'civic.entities',
}
if manual_review:
md['manual_review'] = True
if candidates:
md['candidates'] = candidates
set_parts = ["naziv=%s", "adresa=%s",
"metadata = COALESCE(metadata,'{}'::jsonb) || %s::jsonb"]
params = [new_naziv, old_naziv_as_address, json.dumps(md, ensure_ascii=False)]
if oib:
set_parts.append("oib=COALESCE(NULLIF(oib,''), %s)")
params.append(oib)
params.append(kid)
cur.execute(f"UPDATE pgz_sport.klubovi SET {', '.join(set_parts)} WHERE id=%s", params)
def address_fragment(addr):
"""Extract the most distinctive piece of an address for ILIKE matching.
e.g. 'Trg Viktora Bubnja 1, 51000 Rijeka''Trg Viktora Bubnja 1'
"""
return (addr or '').split(',')[0].strip()
def run():
conn = db()
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
report = {
'started_at': datetime.now(timezone.utc).isoformat(),
'problem_ids': PROBLEM_IDS,
'fixed': [],
'manual_review': [],
'failed': [],
}
cur.execute("""
SELECT id, naziv, adresa, grad, sport, oib FROM pgz_sport.klubovi
WHERE id = ANY(%s) ORDER BY id
""", (PROBLEM_IDS,))
problems = [dict(r) for r in cur.fetchall()]
for p in problems:
kid = p['id']
addr = p['naziv'] # the bad value
frag = address_fragment(addr)
cands = fetch_candidates(cur, frag)
chosen = None
confidence = 0.0
path = ''
if len(cands) == 1:
chosen = cands[0]
confidence = 0.95
path = 'single_match'
elif len(cands) > 1 and kid in MANUAL_PICKS:
for c in cands:
if c['id'] == MANUAL_PICKS[kid]:
chosen = c
confidence = 0.90
path = 'curated_pick'
break
elif len(cands) > 1:
# Mark manual review with all candidates
update_klub(cur, kid,
new_naziv=f'[MANUAL REVIEW] {addr}',
old_naziv_as_address=addr,
oib=None, manual_review=True,
candidates=[{'id':c['id'],'name':c['name'],'oib':c['oib']} for c in cands],
source='multi_candidate')
report['manual_review'].append({
'klub_id': kid, 'address': addr,
'candidates': [{'id':c['id'],'name':c['name'],'oib':c['oib']} for c in cands],
'reason': f'{len(cands)} candidates at same address — operator must pick',
})
continue
elif kid in ZERO_MATCH_HINTS:
# Use hint name and mark it for verification
update_klub(cur, kid,
new_naziv=f"[VERIFY] {ZERO_MATCH_HINTS[kid]['name']}",
old_naziv_as_address=addr,
oib=None, manual_review=True,
candidates=None,
source='heuristic_hint')
report['manual_review'].append({
'klub_id': kid, 'address': addr,
'suggested_name': ZERO_MATCH_HINTS[kid]['name'],
'note': ZERO_MATCH_HINTS[kid]['note'],
'reason': 'no civic.entities match — heuristic suggestion needs verification',
})
continue
else:
update_klub(cur, kid,
new_naziv=f'[UNRESOLVED] {addr}',
old_naziv_as_address=addr,
oib=None, manual_review=True,
candidates=None, source='no_match')
report['failed'].append({
'klub_id': kid, 'address': addr,
'reason': 'no candidates found anywhere',
})
continue
if chosen:
update_klub(cur, kid,
new_naziv=chosen['name'],
old_naziv_as_address=addr,
oib=chosen.get('oib'),
manual_review=False,
source=f"civic.entities#{chosen['id']}")
report['fixed'].append({
'klub_id': kid,
'old_naziv': addr,
'new_naziv': chosen['name'],
'oib_set': chosen.get('oib'),
'civic_entity_id': chosen['id'],
'confidence': confidence,
'path': path,
})
conn.commit()
cur.close(); conn.close()
report['completed_at'] = datetime.now(timezone.utc).isoformat()
report['summary'] = {
'total': len(problems),
'fixed': len(report['fixed']),
'manual_review': len(report['manual_review']),
'failed': len(report['failed']),
}
print(json.dumps(report, indent=2, ensure_ascii=False))
if __name__ == '__main__':
run()
Binary file not shown.

After

Width:  |  Height:  |  Size: 178 B