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