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,
|
require_user, audit, _client,
|
||||||
_resolve_tenant, _tier_for,
|
_resolve_tenant, _tier_for,
|
||||||
PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES,
|
PGZ_USER_TYPES, SAVEZ_USER_TYPES, KLUB_USER_TYPES,
|
||||||
|
issue_action_token, INVITE_TTL, _build_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
router = APIRouter(prefix="/api/admin", tags=["admin"])
|
||||||
@@ -246,25 +247,34 @@ class InviteReq(BaseModel):
|
|||||||
@router.post("/users/{uid}/invite")
|
@router.post("/users/{uid}/invite")
|
||||||
def invite_user(uid: int, req: InviteReq, request: Request,
|
def invite_user(uid: int, req: InviteReq, request: Request,
|
||||||
actor = Depends(require_user)):
|
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",
|
target = db_one("SELECT email, user_type, klub_id, savez_id FROM pgz_sport.users WHERE id=%s",
|
||||||
(uid,))
|
(uid,))
|
||||||
if not target: raise HTTPException(404, "User not found")
|
if not target: raise HTTPException(404, "User not found")
|
||||||
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
if not _can_manage(actor, target["user_type"], target["klub_id"], target["savez_id"]):
|
||||||
raise HTTPException(403, "Forbidden")
|
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)
|
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,
|
audit(actor["id"], "user.invite", "user", uid,
|
||||||
{"email": target["email"], "send_email": req.send_email}, ip, ua)
|
{"email": target["email"], "send_email": req.send_email,
|
||||||
invite_link = f"https://api.rinet.one/sport/login?email={target['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,
|
return {"status": "ok", "id": uid,
|
||||||
"temporary_password": new_temp,
|
"email": target["email"],
|
||||||
"invite_link": invite_link,
|
"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 ───────────────────────────
|
# ─────────────────────────── Role change ───────────────────────────
|
||||||
class RoleReq(BaseModel):
|
class RoleReq(BaseModel):
|
||||||
|
|||||||
+200
@@ -547,6 +547,206 @@ def password_reset(req: ResetPwdReq, request: Request):
|
|||||||
return {"status": "ok",
|
return {"status": "ok",
|
||||||
"message": "Ako račun postoji, administrator će vam poslati instrukcije."}
|
"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) ───────────────────────────
|
# ─────────────────────────── 2FA — real TOTP (RFC 6238) ───────────────────────────
|
||||||
try:
|
try:
|
||||||
import pyotp as _pyotp
|
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}
|
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")
|
@router.get("/invoices/uploads/list")
|
||||||
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
|
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,
|
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,
|
||||||
|
|||||||
+131
-22
@@ -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):
|
if user and not can_view_putni_nalog(user, row):
|
||||||
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
|
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj putni nalog")
|
||||||
|
|
||||||
# Lista vezanih računa (po klubu, datumu, ili ID-evima u attachments)
|
# Vezani računi iz m2m tablice
|
||||||
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(
|
cur.execute(
|
||||||
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
|
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
|
||||||
invoice_date, amount_gross, payment_status, currency, category
|
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category,
|
||||||
FROM pgz_sport.invoices WHERE id = ANY(%s)
|
pnr.kategorija AS attached_kategorija, pnr.attached_at
|
||||||
ORDER BY invoice_date DESC""", (invoice_ids,))
|
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()
|
invoices = cur.fetchall()
|
||||||
else:
|
|
||||||
# Auto-suggest: računi kluba u rasponu putovanja s kategorijom putni-trošak
|
# Auto-suggest: računi kluba u rasponu putovanja koji NISU jos vezani
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT id, invoice_no, invoice_kind, vendor_name, vendor_oib,
|
"""SELECT i.id, i.invoice_no, i.invoice_kind, i.vendor_name, i.vendor_oib,
|
||||||
invoice_date, amount_gross, payment_status, currency, category
|
i.invoice_date, i.amount_gross, i.payment_status, i.currency, i.category
|
||||||
FROM pgz_sport.invoices
|
FROM pgz_sport.invoices i
|
||||||
WHERE klub_id=%s AND invoice_date BETWEEN %s AND %s
|
LEFT JOIN pgz_sport.putni_nalog_racuni pnr
|
||||||
AND invoice_kind IN ('gorivo','cestarina','hotel','restoran','ostalo')
|
ON pnr.invoice_id=i.id AND pnr.putni_nalog_id=%s
|
||||||
ORDER BY invoice_date DESC LIMIT 50""",
|
WHERE i.klub_id=%s
|
||||||
(row.get("klub_id"), row.get("date_from"), row.get("date_to")),
|
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")),
|
||||||
)
|
)
|
||||||
invoices = cur.fetchall()
|
suggested = cur.fetchall()
|
||||||
|
|
||||||
# Payments za ovaj putni nalog
|
# Payments za ovaj putni nalog
|
||||||
cur.execute(
|
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)
|
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}
|
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,
|
return {"ok": True, "putni_nalog": row, "invoices": invoices,
|
||||||
|
"suggested_invoices": suggested,
|
||||||
"payments": payments, "audit": audit, "actions": actions}
|
"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")
|
@router.post("/putni-nalog/{nalog_id}/posalji")
|
||||||
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
|
def posalji_putni_nalog(nalog_id: int, authorization: Optional[str] = Header(None)):
|
||||||
"""Voditelj/klub_admin šalje draft → poslan."""
|
"""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}
|
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")
|
@router.get("/putni-nalog/{nalog_id}/audit")
|
||||||
def putni_audit(nalog_id: int, limit: int = 100,
|
def putni_audit(nalog_id: int, limit: int = 100,
|
||||||
authorization: Optional[str] = Header(None)):
|
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 = FastAPI(title="PGŽ Sportski savez ERP/CRM", version="1.0.0")
|
||||||
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])
|
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 ===
|
# === URL rewrite middleware - convert direct external image URLs to /img-proxy ===
|
||||||
import json as _json_mw
|
import json as _json_mw
|
||||||
@@ -1361,6 +1394,13 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[CRM/PANEL] clan_panel router fail: {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 ===
|
# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR ===
|
||||||
try:
|
try:
|
||||||
from auth.auth_v2 import router as auth_v2_router
|
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]}
|
||||||
+291
-27
@@ -308,13 +308,19 @@ def _load_row(kind: str, eid: int) -> dict:
|
|||||||
adresa, godina_osnutka, source_url, metadata
|
adresa, godina_osnutka, source_url, metadata
|
||||||
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
|
FROM pgz_sport.savezi WHERE id=%s""", (eid,))
|
||||||
elif kind == 'sportas':
|
elif kind == 'sportas':
|
||||||
row = _fetch_one("""SELECT id, ime, prezime, sport, klub_id, profile_url,
|
row = _fetch_one("""SELECT c.id, c.ime, c.prezime, c.sport, c.klub_id, c.profile_url,
|
||||||
slika_url, source_url, source, source_id,
|
c.slika_url, c.source_url, c.source, c.source_id,
|
||||||
hns_igrac_id, biografija,
|
c.hns_igrac_id, c.biografija,
|
||||||
datum_rodenja, mjesto_rodenja, broj_dresa,
|
c.datum_rodenja, c.mjesto_rodenja, c.broj_dresa,
|
||||||
visina_cm, tezina_kg, dominantna_noga, oib,
|
c.visina_cm, c.tezina_kg, c.dominantna_noga, c.oib,
|
||||||
vanjski_id, metadata
|
c.vanjski_id, c.metadata,
|
||||||
FROM pgz_sport.clanovi WHERE id=%s""", (eid,))
|
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:
|
else:
|
||||||
raise HTTPException(400, "kind must be klub|savez|sportas")
|
raise HTTPException(400, "kind must be klub|savez|sportas")
|
||||||
if not row:
|
if not row:
|
||||||
@@ -328,7 +334,54 @@ def _display_name(kind: str, row: dict) -> str:
|
|||||||
return row.get('naziv', '') or ''
|
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()
|
base_q = (naziv or '').strip()
|
||||||
q = (base_q + ' ' + grad) if grad else base_q
|
q = (base_q + ' ' + grad) if grad else base_q
|
||||||
qenc = urllib.parse.quote(q)
|
qenc = urllib.parse.quote(q)
|
||||||
@@ -340,9 +393,33 @@ def _research_links(naziv, kind, grad=None):
|
|||||||
if kind == 'klub':
|
if kind == 'klub':
|
||||||
out.append({'label': 'Sportilus', 'icon': '⬡', 'url': 'https://www.sportilus.com/?s=' + qenc})
|
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'})
|
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':
|
if kind == 'sportas':
|
||||||
|
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': '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})
|
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':
|
if kind == 'savez':
|
||||||
out.append({'label': 'sport-pgz.hr savezi', 'icon': '🏅', 'url': 'https://sport-pgz.hr/savezi'})
|
out.append({'label': 'sport-pgz.hr savezi', 'icon': '🏅', 'url': 'https://sport-pgz.hr/savezi'})
|
||||||
return out
|
return out
|
||||||
@@ -591,38 +668,219 @@ def _hns_fetch_player(url: str) -> Optional[dict]:
|
|||||||
return _parse_hns_player(body, url) if body else None
|
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:
|
def _propose_for_sportas(row: dict) -> dict:
|
||||||
naziv = ((row.get('ime') or '') + ' ' + (row.get('prezime') or '')).strip()
|
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 = [], []
|
sources, evidence = [], []
|
||||||
proposed: dict[str, Any] = {}
|
proposed: dict[str, Any] = {}
|
||||||
|
|
||||||
# 1) Resolve a HNS Semafor URL for this athlete (column / vanjski_id / source_id)
|
# 1) HNS Semafor — only meaningful when sport is football OR row already
|
||||||
hns_url = _hns_url_from_row(row)
|
# carries an HNS link.
|
||||||
hns_doc: Optional[dict] = None
|
hns_doc: Optional[dict] = None
|
||||||
|
if _normalize_sport(sport) == 'nogomet' or _hns_url_from_row(row):
|
||||||
|
hns_url = _hns_url_from_row(row)
|
||||||
if hns_url:
|
if hns_url:
|
||||||
hns_doc = _hns_fetch_player(hns_url)
|
hns_doc = _hns_fetch_player(hns_url)
|
||||||
if hns_doc:
|
if hns_doc:
|
||||||
sources.append(hns_doc)
|
sources.append(hns_doc)
|
||||||
evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '')
|
evidence.append(hns_doc.get('raw_text') or hns_doc.get('extract') or '')
|
||||||
|
|
||||||
# Field-level proposals from HNS Semafor (only when DB is empty)
|
# 2) Sport-aware federation scrape (HBS, HKS, etc.) — also use existing
|
||||||
if hns_doc:
|
# source_url/profile_url if it points at a known federation host.
|
||||||
if not row.get('profile_url') and hns_doc.get('url'):
|
fed_doc: Optional[dict] = None
|
||||||
proposed['profile_url'] = hns_doc['url']
|
direct_fed_url = _fed_url_from_row(row)
|
||||||
if not row.get('source_url') and hns_doc.get('url'):
|
if direct_fed_url and (not hns_doc or hns_doc.get('url') != direct_fed_url):
|
||||||
proposed['source_url'] = hns_doc['url']
|
body = _http_get(direct_fed_url, timeout=8)
|
||||||
if not row.get('slika_url') and hns_doc.get('slika_url'):
|
if body:
|
||||||
proposed['slika_url'] = hns_doc['slika_url']
|
fed_doc = _parse_federation_profile(body, direct_fed_url, ime, prezime)
|
||||||
if not row.get('hns_igrac_id') and hns_doc.get('hns_igrac_id'):
|
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 '')
|
||||||
|
|
||||||
|
# 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']
|
proposed['hns_igrac_id'] = hns_doc['hns_igrac_id']
|
||||||
if not row.get('datum_rodenja') and hns_doc.get('datum_rodenja'):
|
if not row.get('datum_rodenja'):
|
||||||
proposed['datum_rodenja'] = hns_doc['datum_rodenja']
|
v = _pick('datum_rodenja')
|
||||||
if not row.get('mjesto_rodenja') and hns_doc.get('mjesto_rodenja'):
|
if v: proposed['datum_rodenja'] = v
|
||||||
proposed['mjesto_rodenja'] = hns_doc['mjesto_rodenja']
|
if not row.get('mjesto_rodenja'):
|
||||||
if not row.get('broj_dresa') and hns_doc.get('broj_dresa'):
|
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']
|
proposed['broj_dresa'] = hns_doc['broj_dresa']
|
||||||
|
|
||||||
# 2) Wikipedia HR for biografija
|
# 3) Wikipedia HR for biografija
|
||||||
if not row.get('biografija'):
|
if not row.get('biografija'):
|
||||||
wiki = _wiki_summary(naziv)
|
wiki = _wiki_summary(naziv)
|
||||||
if wiki:
|
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
|
# Description: prefer DeepSeek synthesis from all evidence; fallback to first long snippet
|
||||||
if not row.get('biografija'):
|
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:
|
if not descr:
|
||||||
for s in sources:
|
for s in sources:
|
||||||
ext = s.get('extract')
|
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),
|
'coverage': coverage, 'filled_fields': filled, 'total_fields': len(keys),
|
||||||
'missing_fields': missing,
|
'missing_fields': missing,
|
||||||
'live_snippet': _fetch_title(primary) if primary else None,
|
'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'],
|
'sources': res['sources'],
|
||||||
'current': current,
|
'current': current,
|
||||||
'proposed': proposed,
|
'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