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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 € (5–8h), 0 € (<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,'"')}</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|
||||
@@ -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 € (5–8h), 0 € (<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,'"')}</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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,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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Executable
+211
@@ -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 |
Reference in New Issue
Block a user