Files
pgz-sport/_backups/erp.html.cc3_pre_redesign.1777937786
Damir Radulić f9ebcddf28 CC2 R6: middleware-wide JWT, avatar demo mode, mock mailer, login rate limit
#1 JWT middleware extended:
- Was: /api/admin/* only
- Now: any POST/PUT/PATCH/DELETE under /api/* requires Bearer JWT
- Whitelist (still anonymous): /api/auth/login, /refresh, /forgot-password,
  /password/reset, /reset-password, /setup-password, /google;
  /api/gdpr/consent; any path ending /avatar
- 14 mutating endpoints verified to return 401 without token

#2 Avatar upload demo mode (routers/clan_panel_router.py):
- Anonymous → returns {demo_mode:true, slika_url:null,
  message:'Demo mode — slika nije spremljena. Prijavite se za pravu pohranu.'},
  no FS write, no DB write
- Authenticated (valid JWT, allowed role) → real save as before
- Auth check now uses auth.auth_v2.decode_token (proper secret + revocation)
  instead of the broken local _resolve_role

#3 Mock mailer (auth/mailer.py):
- send_email writes RFC 822 .eml to /tmp/pgz_mailbox + appends to INDEX.jsonl
- send_password_reset, send_invite helpers with HR text + HTML alt
- Real SMTP active when PGZ_SMTP_HOST is set (env-driven, off by default)
- forgot-password and admin invite both call mailer; audit logs mail status

#5 Rate limiting on /api/auth/login:
- Per-user: 5 wrong attempts → 5-minute DB-backed lockout
  (was 5 → 15 min). Configurable via PGZ_LOGIN_LOCK_THRESHOLD/MINUTES.
- Per-IP: 10 fails / 5-min sliding window in-memory → HTTP 429
  Configurable via PGZ_LOGIN_IP_THRESHOLD/WINDOW_SEC. Successful
  login clears the IP counter.
- Failed attempts respond '(N/5) — račun je zaključan na 5 minuta'
- New audit actions: login.ratelimit.ip; login.fail meta now
  includes fails count, locked, lock_minutes

#4 Live test report: 46/46 across 6 demo users — login, JWT gate on 14
   mutating endpoints, public path whitelist, demo-mode avatar +
   real save, forgot-password e-mail to mailbox, no-leak unknown email,
   5-fail lockout, 423 during lockout, audit coverage.
2026-05-05 01:42:53 +02:00

1007 lines
60 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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="stats"><span>📊</span><span>Statistika</span></div>
<div class="nav-item" 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 -->
<!-- STATS TAB (R5.6) -->
<div class="tab active" id="tab-stats">
<div class="section">
<h3>📊 ERP statistika — mjesec / kvartal / godina</h3>
<div style="display:flex;gap:10px;align-items:end;margin-bottom:14px;flex-wrap:wrap">
<div><label class="lbl">Klub (opcionalno)</label><select id="st_klub" class="fld" style="max-width:280px"></select></div>
<button class="btn" onclick="loadStats()">↻ Osvježi</button>
<a id="st_export" class="btn sec" style="text-decoration:none" target="_blank">📥 Export XLSX</a>
</div>
<div id="stats_grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Top klubovi (godina)</h4>
<table id="st_top_table"><thead><tr><th>Klub</th><th class="num">Br. računa</th><th class="num">Total</th></tr></thead><tbody></tbody></table>
</div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Putni nalozi</h4>
<div id="st_pn" style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px"></div>
</div>
</div>
</div>
<div class="tab" 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>
<div id="bulk_toolbar" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;padding:10px;background:var(--bg-3);border:1px solid var(--border);border-radius:6px;flex-wrap:wrap">
<span style="font-size:12px;color:var(--text-3)">Označeno: <strong id="bulk_count" style="color:var(--accent)">0</strong></span>
<button class="btn green" id="bulk_pay_btn" onclick="openBulkPay()" disabled>💰 Plati sve označene</button>
<button class="btn red" id="bulk_cancel_btn" onclick="bulkCancel()" disabled>✗ Otkaži označene</button>
<button class="btn sec" onclick="bulkClear()">Očisti odabir</button>
<a id="inv_export_btn" class="btn sec" style="text-decoration:none;margin-left:auto" target="_blank">📥 Export XLSX (svi)</a>
</div>
<table id="invTable"><thead><tr><th style="width:24px"><input type="checkbox" id="bulk_all" onchange="bulkSelectAll(this.checked)"></th><th>#</th><th>Vrsta</th><th>Broj</th><th>Dobavljač</th><th>OIB</th><th>Klub</th><th class="num">Brutto</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Putni nalog form -->
<div class="tab" id="tab-putni">
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div class="grid3" style="font-size:13px">
<div><label class="lbl">Klub</label><select id="pn_klub" class="fld"></select></div>
<div><label class="lbl">Voditelj</label><input id="pn_voditelj" class="fld" placeholder="Ime Prezime"></div>
<div><label class="lbl">Putnici (zarez)</label><input id="pn_putnici" class="fld"></div>
<div style="grid-column:span 3"><label class="lbl">Svrha putovanja</label><input id="pn_svrha" class="fld" placeholder="Natjecanje, treninzi, edukacija…"></div>
<div><label class="lbl">Od grada</label><input id="pn_od" class="fld" value="Rijeka"></div>
<div><label class="lbl">Do grada</label><input id="pn_do" class="fld"></div>
<div><label class="lbl">Zemlja</label><input id="pn_country" class="fld" value="Hrvatska"></div>
<div><label class="lbl">Polazak</label><input id="pn_from" type="datetime-local" class="fld"></div>
<div><label class="lbl">Povratak</label><input id="pn_to" type="datetime-local" class="fld"></div>
<div><label class="lbl">Tip vozila</label>
<select id="pn_vehicle" class="fld">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label class="lbl">Registracija</label><input id="pn_plate" class="fld"></div>
<div><label class="lbl">Kilometara</label><input id="pn_km" type="number" step="1" class="fld" value="0"></div>
<div><label class="lbl">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="fld" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button id="pnSave" class="btn">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
<p style="margin-top:14px;font-size:11px;color:var(--text-3);line-height:1.6">
<b>HR pravilnik 2025:</b> domaće 26.54 € (>8h), 13.27 € (58h), 0 € (&lt;5h). Inozemne dnevnice po zemlji
(Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km.
</p>
</div>
</div>
<!-- Putni nalozi list -->
<div class="tab" id="tab-putni-list">
<div class="section">
<h3>Lista putnih naloga</h3>
<table id="pnTable"><thead><tr><th>#</th><th>Klub</th><th>Destinacija</th><th>Polazak</th><th>Povratak</th><th class="num">Dnevnice</th><th class="num">Transport</th><th class="num">Total</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<!-- ============ INVOICE DETAIL MODAL (M5.5) ============ -->
<div id="invModal" class="modal-bg" onclick="if(event.target===this)closeModal('invModal')">
<div class="modal">
<div class="modal-h">
<h3 id="invModalTitle">Račun</h3>
<button class="x" onclick="closeModal('invModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Skenirana datoteka</h4>
<div id="inv_preview" style="text-align:center"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Podaci računa</h4>
<div class="kv" id="inv_kv"></div>
<div id="inv_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div class="actions-row" id="inv_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="inv_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY INVOICE MODAL (M5.5) ============ -->
<div id="payModal" class="modal-bg" onclick="if(event.target===this)closeModal('payModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Označi kao plaćen</h3>
<button class="x" onclick="closeModal('payModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="pay_iban_to" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">IBAN platitelja</label><input id="pay_iban_from" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">Datum uplate</label><input id="pay_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="pay_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Poziv na broj / referenca</label><input id="pay_ref" class="fld" placeholder="HR00 12345-67890"></div>
<div><label class="lbl">Tx ID (banka)</label><input id="pay_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="payConfirm">✓ Potvrdi plaćanje</button>
<button class="btn sec" onclick="closeModal('payModal')">Odustani</button>
<span id="payStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ COMMENT MODAL (M5.5) ============ -->
<div id="commentModal" class="modal-bg" onclick="if(event.target===this)closeModal('commentModal')">
<div class="modal" style="max-width:520px">
<div class="modal-h">
<h3>💬 Komentar (savez/admin)</h3>
<button class="x" onclick="closeModal('commentModal')">×</button>
</div>
<div class="modal-body">
<textarea id="commentText" class="fld" rows="5" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn" id="commentSave">Spremi komentar</button>
<button class="btn sec" onclick="closeModal('commentModal')">Odustani</button>
<span id="commentStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ PUTNI NALOG DETAIL MODAL (M6.3) ============ -->
<div id="pnModal" class="modal-bg" onclick="if(event.target===this)closeModal('pnModal')">
<div class="modal">
<div class="modal-h">
<h3 id="pnModalTitle">Putni nalog</h3>
<button class="x" onclick="closeModal('pnModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Voditelj + putnici, ruta, vozilo</h4>
<div class="kv" id="pn_kv"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Obračun (HR pravilnik 2025)</h4>
<div class="kv" id="pn_obracun"></div>
<div id="pn_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">📎 Vezani računi (gorivo, cestarina, hotel...)</h4>
<table id="pn_invoices_table"><thead><tr><th>#</th><th>Vrsta</th><th>Dobavljač</th><th>OIB</th><th>Datum</th><th class="num">Brutto</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
<div class="actions-row" id="pn_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="pn_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY PUTNI NALOG MODAL ============ -->
<div id="payPnModal" class="modal-bg" onclick="if(event.target===this)closeModal('payPnModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Isplata putnog naloga</h3>
<button class="x" onclick="closeModal('payPnModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="ppn_iban_to" class="fld"></div>
<div><label class="lbl">IBAN platitelja</label><input id="ppn_iban_from" class="fld"></div>
<div><label class="lbl">Datum uplate</label><input id="ppn_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="ppn_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Referenca</label><input id="ppn_ref" class="fld"></div>
<div><label class="lbl">Tx ID</label><input id="ppn_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="ppnConfirm">✓ Potvrdi isplatu</button>
<button class="btn sec" onclick="closeModal('payPnModal')">Odustani</button>
<span id="ppnStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ BULK PAY MODAL (R5.3) ============ -->
<div id="bulkPayModal" class="modal-bg" onclick="if(event.target===this)closeModal('bulkPayModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Bulk plaćanje računa</h3>
<button class="x" onclick="closeModal('bulkPayModal')">×</button>
</div>
<div class="modal-body">
<div id="bulkPayList" style="margin-bottom:12px;font-size:12px;color:var(--text-2)"></div>
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN platitelja</label><input id="bp_iban_from" class="fld"></div>
<div><label class="lbl">Datum uplate</label><input id="bp_date" type="date" class="fld"></div>
<div><label class="lbl">Način plaćanja</label><select id="bp_method" class="fld"><option>transfer</option><option>cash</option><option>card</option></select></div>
<div><label class="lbl">Referenca</label><input id="bp_ref" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="bulkPayConfirm">✓ Potvrdi plaćanje za sve</button>
<button class="btn sec" onclick="closeModal('bulkPayModal')">Odustani</button>
<span id="bulkPayStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ REJECT PUTNI NALOG MODAL ============ -->
<div id="rejectModal" class="modal-bg" onclick="if(event.target===this)closeModal('rejectModal')">
<div class="modal" style="max-width:480px">
<div class="modal-h">
<h3>❌ Odbij putni nalog</h3>
<button class="x" onclick="closeModal('rejectModal')">×</button>
</div>
<div class="modal-body">
<label class="lbl">Razlog odbijanja</label>
<textarea id="rejectText" class="fld" rows="4" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn red" id="rejectConfirm">Odbij</button>
<button class="btn sec" onclick="closeModal('rejectModal')">Odustani</button>
<span id="rejectStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<script>
const ERP_API = '/api/erp';
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
const fmt = n => n == null ? '—' : new Intl.NumberFormat('hr-HR').format(n);
const fmtEur = n => n != null ? '€' + fmt(Math.round(n*100)/100) : '—';
const fmtDate = d => d ? d.substring(0,10) : '—';
function badge(t,c) { return `<span class="badge ${c}">${t||'—'}</span>`; }
function sBadge(s) {
if (!s) return badge('—','gray');
const x = s.toLowerCase();
if (['paid','approved','active','odobren','zatvoren'].includes(x)) return badge(s,'green');
if (['pending','draft','submitted','open','unpaid'].includes(x)) return badge(s,'yellow');
if (['overdue','rejected','cancelled','failed'].includes(x)) return badge(s,'red');
return badge(s,'gray');
}
async function loadKlubovi() {
const r = await fetch('/api/klubovi?limit=400').then(r=>r.json()).catch(()=>null);
if (!r) return;
const arr = Array.isArray(r) ? r : (r.rows || r.items || []);
const opts = '<option value="">— odaberi klub —</option>' + arr
.map(k => ({id: k.id, naziv: (k.naziv || k.klub || k.sport || '#'+k.id).toString().trim()}))
.filter(k => k.naziv)
.sort((a,b) => a.naziv.localeCompare(b.naziv,'hr'))
.map(k => `<option value="${k.id}">${k.naziv.replace(/"/g,'&quot;')}</option>`).join('');
['oc_klub','pn_klub','st_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)';
}
});
}
const _bulkSel = new Set();
function bulkUpdateUI() {
const n = _bulkSel.size;
const c = document.getElementById('bulk_count'); if (c) c.textContent = n;
['bulk_pay_btn','bulk_cancel_btn'].forEach(id => { const b=document.getElementById(id); if (b) b.disabled = n===0; });
document.querySelectorAll('.inv_chk').forEach(cb => cb.checked = _bulkSel.has(parseInt(cb.dataset.id)));
const all = document.getElementById('bulk_all'); if (all) all.checked = n>0 && document.querySelectorAll('.inv_chk').length === n;
}
function bulkToggle(id, on) { if (on) _bulkSel.add(id); else _bulkSel.delete(id); bulkUpdateUI(); }
function bulkSelectAll(on) {
_bulkSel.clear();
if (on) document.querySelectorAll('.inv_chk').forEach(cb => _bulkSel.add(parseInt(cb.dataset.id)));
bulkUpdateUI();
}
function bulkClear() { _bulkSel.clear(); bulkUpdateUI(); }
async function loadInvoices() {
const r = await fetch(`${ERP_API}/invoices?limit=200`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
_bulkSel.clear();
$('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>`
<tr class="clickable">
<td onclick="event.stopPropagation()"><input type="checkbox" class="inv_chk" data-id="${i.id}" onchange="bulkToggle(${i.id}, this.checked)"></td>
<td onclick="openInvoice(${i.id})">${i.id}</td>
<td onclick="openInvoice(${i.id})">${i.invoice_kind||'—'}</td>
<td onclick="openInvoice(${i.id})">${i.invoice_no||'—'}</td>
<td onclick="openInvoice(${i.id})">${i.vendor_name||'—'}</td>
<td onclick="openInvoice(${i.id})" style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
<td onclick="openInvoice(${i.id})">${i.klub_naziv||'—'}</td>
<td class="num" onclick="openInvoice(${i.id})">${fmtEur(i.amount_gross)}</td>
<td onclick="openInvoice(${i.id})">${sBadge(i.payment_status)}</td>
<td onclick="openInvoice(${i.id})">${fmtDate(i.invoice_date)}</td>
</tr>`).join('')
: '<tr><td colspan="10" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
bulkUpdateUI();
const exp = document.getElementById('inv_export_btn');
if (exp) exp.href = `${ERP_API}/export/invoices.xlsx`;
}
function openBulkPay() {
if (!_bulkSel.size) return;
$('#bulkPayList').textContent = `Računi: #${[..._bulkSel].sort((a,b)=>a-b).join(', #')}`;
$('#bp_date').value = new Date().toISOString().substring(0,10);
$('#bp_method').value = 'transfer';
$('#bulkPayStatus').textContent = '';
openModal('bulkPayModal');
$('#bulkPayConfirm').onclick = async () => {
const body = {
ids: [..._bulkSel],
paid_date: $('#bp_date').value,
payment_method: $('#bp_method').value,
iban_from: $('#bp_iban_from').value.trim(),
reference: $('#bp_ref').value.trim(),
};
$('#bulkPayStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/invoices/bulk-pay`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) {
$('#bulkPayStatus').innerHTML = `✓ paid:${r.summary.paid} skipped:${r.summary.skipped} forbidden:${r.summary.forbidden}`;
$('#bulkPayStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('bulkPayModal'); loadInvoices(); }, 1200);
} else $('#bulkPayStatus').textContent = '❌ Greška';
};
}
async function bulkCancel() {
if (!_bulkSel.size) return;
const reason = prompt('Razlog otkazivanja za ' + _bulkSel.size + ' računa:', 'duplikat / pogrešan upis');
if (reason === null) return;
const r = await fetch(`${ERP_API}/invoices/bulk-cancel`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({ids:[..._bulkSel], razlog:reason})}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { alert(`Otkazano: ${r.summary.cancelled}, preskočeno: ${r.summary.skipped}, zabrana: ${r.summary.forbidden}`); loadInvoices(); }
else alert('Greška pri otkazivanju.');
}
// === STATS (R5.6) ===
async function loadStats() {
const klub = $('#st_klub')?.value || '';
const url = `${ERP_API}/stats${klub ? '?klub_id='+klub : ''}`;
const r = await fetch(url, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) return;
const inv = r.invoices;
const card = (label, period, accent) => `
<div style="background:var(--bg-3);border:1px solid var(--border);border-radius:8px;padding:14px;border-left:3px solid ${accent}">
<div style="font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px">${label}</div>
<div style="font-size:24px;font-weight:700;font-family:'JetBrains Mono';color:${accent};margin:6px 0">€${period.total.toLocaleString('hr-HR')}</div>
<div style="font-size:11px;color:var(--text-2)">${period.n} računa · plaćeno €${period.paid.toLocaleString('hr-HR')} · neplaćeno €${period.unpaid.toLocaleString('hr-HR')}</div>
<div style="margin-top:8px;font-size:10px;color:var(--text-3)">od ${period.since}</div>
${period.by_kind && period.by_kind.length ? '<div style="margin-top:6px;font-size:10px">' + period.by_kind.slice(0,4).map(k => `${k.invoice_kind||'?'}: €${Math.round(k.total)}`).join(' · ') + '</div>' : ''}
</div>`;
$('#stats_grid').innerHTML = card('Mjesec', inv.month, 'var(--accent)') + card('Kvartal', inv.quarter, 'var(--yellow)') + card('Godina', inv.year, 'var(--green)');
$('#st_top_table tbody').innerHTML = (r.top_klubovi_godina||[]).map(t => `
<tr><td>${escHtml(t.klub_naziv||'—')}</td><td class="num">${t.n}</td><td class="num">${fmtEur(t.total)}</td></tr>`).join('') || '<tr><td colspan="3" style="text-align:center;color:var(--text-3);padding:14px">Nema podataka</td></tr>';
const pn = r.putni_nalozi;
const pnCard = (label, period, accent) => `
<div style="background:var(--bg-3);border:1px solid var(--border);border-radius:8px;padding:14px;border-left:3px solid ${accent}">
<div style="font-size:11px;color:var(--text-3);text-transform:uppercase">${label}</div>
<div style="font-size:22px;font-weight:700;font-family:'JetBrains Mono';color:${accent};margin:6px 0">€${period.total.toLocaleString('hr-HR')}</div>
<div style="font-size:11px;color:var(--text-2)">${period.n} naloga · dnevnice €${Math.round(period.dnevnice||0)} · transport €${Math.round(period.transport||0)}</div>
</div>`;
$('#st_pn').innerHTML = pnCard('Mjesec', pn.month, 'var(--accent)') + pnCard('Kvartal', pn.quarter, 'var(--yellow)') + pnCard('Godina', pn.year, 'var(--green)');
const exp = $('#st_export'); if (exp) exp.href = `${ERP_API}/export/invoices.xlsx${klub?'?klub_id='+klub:''}`;
}
async function loadPutni() {
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>`
<tr class="clickable" onclick="openPutni(${p.id})"><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td>
<td>${fmtDate(p.date_from)}</td><td>${fmtDate(p.date_to)}</td>
<td class="num">${fmtEur(p.dnevnice_amount)}</td>
<td class="num">${fmtEur(p.cost_transport)}</td>
<td class="num"><strong>${fmtEur(p.cost_total)}</strong></td>
<td>${sBadge(p.status)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
// ===== AUTH (JWT iz localStorage ili admin token fallback) =====
function AUTH_HDR(extra) {
const h = Object.assign({}, extra || {});
let t = null;
try { t = localStorage.getItem('jwt') || sessionStorage.getItem('jwt'); } catch(e){}
if (!t) t = 'admin-pgz-2026';
h['Authorization'] = 'Bearer ' + t;
return h;
}
function AUTH_HDR_JSON() { return AUTH_HDR({'Content-Type': 'application/json'}); }
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderAudit(audit) {
if (!audit || !audit.length) return '<div style="color:var(--text-3);font-size:12px">Nema audit zapisa.</div>';
return audit.map(a => `
<div class="audit-row">
<div class="ts">${(a.timestamp||'').replace('T',' ').substring(0,19)}</div>
<div class="op">${escHtml(a.operacija)}</div>
<div class="who">${escHtml(a.korisnik||'—')}</div>
<div>${escHtml(a.promijenjeno_polje||'')}: <span style="color:var(--text-3)">${escHtml(a.stara_vrijednost||'∅')}</span> → <span style="color:var(--green)">${escHtml(a.nova_vrijednost||'∅')}</span></div>
</div>`).join('');
}
// ===== INVOICE DETAIL =====
let _currentInvoice = null;
async function openInvoice(id) {
const r = await fetch(`${ERP_API}/invoices/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju računa #' + id); return; }
_currentInvoice = r;
const i = r.invoice;
$('#invModalTitle').textContent = `Račun #${i.id} · ${i.invoice_no || '—'}`;
// Preview slike
const pv = $('#inv_preview');
if (r.uploads && r.uploads.length) {
const up = r.uploads[0];
const fileUrl = `${ERP_API}/invoices/${id}/file`;
const isPdf = (up.mime || '').includes('pdf') || (up.file_name || '').toLowerCase().endsWith('.pdf');
if (isPdf) {
pv.innerHTML = `<embed src="${fileUrl}" type="application/pdf" style="width:100%;height:480px;border:1px solid var(--border);border-radius:6px"><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · ${escHtml(up.mime||'')}</div>`;
} else {
pv.innerHTML = `<a href="${fileUrl}" target="_blank"><img class="preview-img" src="${fileUrl}" alt="skena"></a><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · OCR ${escHtml(up.ocr_engine||up.ocr_status||'')}</div>`;
}
} else {
pv.innerHTML = '<div style="padding:60px;background:var(--bg-3);border-radius:6px;color:var(--text-3);font-size:12px">Bez priložene datoteke</div>';
}
// KV polja
$('#inv_kv').innerHTML = `
<div>Izdavatelj</div><div>${escHtml(i.vendor_name||'—')}</div>
<div>OIB izdavatelja</div><div>${escHtml(i.vendor_oib||'—')}</div>
<div>Broj računa</div><div>${escHtml(i.invoice_no||'—')}</div>
<div>Datum</div><div>${fmtDate(i.invoice_date)}</div>
<div>Klub</div><div>${escHtml(i.klub_naziv||'—')}</div>
<div>Vrsta</div><div>${escHtml(i.invoice_kind||'—')}</div>
<div>Iznos neto</div><div>${fmtEur(i.amount_net)}</div>
<div>PDV (${i.vat_rate||'—'}%)</div><div>${fmtEur(i.amount_vat)}</div>
<div>Brutto</div><div style="color:var(--accent);font-weight:700">${fmtEur(i.amount_gross)}</div>
<div>Valuta</div><div>${escHtml(i.currency||'EUR')}</div>
<div>Opis</div><div>${escHtml(i.description||'—')}</div>
`;
// Status block
const status = (i.payment_status||'unpaid').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(i.payment_status)}</div>`;
if (status === 'paid') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to || i.iban_to || '—')}</span></div>
<div><span style="color:var(--text-3)">IBAN platitelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_from || i.iban_from || '—')}</span></div>
<div><span style="color:var(--text-3)">Datum uplate:</span> ${fmtDate(i.paid_date) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos uplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount || i.amount_gross)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'cancelled' || status === 'otkazan') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">Račun je otkazan.</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">Neplaćen — čeka uplatu.</div>`;
}
$('#inv_status_block').innerHTML = sb;
// Actions po permission-ima
const a = r.actions || {};
const acts = [];
if (a.pay && status !== 'paid') acts.push(`<button class="btn green" onclick="openPayModal(${id})">💰 Označi kao plaćen</button>`);
if (a.edit && status !== 'paid') acts.push(`<button class="btn yellow" onclick="alert('Edit u UI: koristi M5 OCR formu — ovaj panel je read-only za prikaz')">✏ Korekcija polja</button>`);
if (a.comment) acts.push(`<button class="btn sec" onclick="openCommentModal(${id})">💬 Komentar</button>`);
if (r.uploads && r.uploads.length) acts.push(`<a href="${ERP_API}/invoices/${id}/file" target="_blank" class="btn sec" style="text-decoration:none">📥 Preuzmi sken</a>`);
if (a.delete) acts.push(`<button class="btn red" onclick="if(confirm('Obrisati račun #${id}?')){alert('Brisanje: TODO endpoint')}">🗑 Obriši</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#inv_actions').innerHTML = acts.join('');
$('#inv_audit').innerHTML = renderAudit(r.audit);
openModal('invModal');
}
function openPayModal(id) {
const inv = _currentInvoice && _currentInvoice.invoice;
if (inv) {
$('#pay_iban_to').value = inv.iban_to || '';
$('#pay_amount').value = inv.amount_gross || '';
}
$('#pay_date').value = new Date().toISOString().substring(0,10);
$('#payStatus').textContent = '';
openModal('payModal');
$('#payConfirm').onclick = async () => {
const body = {
iban_to: $('#pay_iban_to').value.trim(),
iban_from: $('#pay_iban_from').value.trim(),
paid_date: $('#pay_date').value,
amount: parseFloat($('#pay_amount').value) || undefined,
reference: $('#pay_ref').value.trim(),
bank_transaction_id: $('#pay_tx').value.trim(),
payment_method: 'transfer',
};
$('#payStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices/${id}/pay`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#payStatus').textContent = '✓ Plaćeno';
$('#payStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('payModal'); openInvoice(id); loadInvoices(); }, 700);
} else {
$('#payStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#payStatus').style.color = 'var(--red)';
}
};
}
function openCommentModal(id) {
$('#commentText').value = '';
$('#commentStatus').textContent = '';
openModal('commentModal');
$('#commentSave').onclick = async () => {
const txt = $('#commentText').value.trim();
if (!txt) { $('#commentStatus').textContent = 'Komentar je prazan'; return; }
$('#commentStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/invoices/${id}/comment`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({comment: txt})}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#commentStatus').textContent = '✓ Spremljeno';
$('#commentStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('commentModal'); openInvoice(id); }, 600);
} else {
$('#commentStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#commentStatus').style.color = 'var(--red)';
}
};
}
// ===== PUTNI NALOG DETAIL =====
let _currentPn = null;
async function openPutni(id) {
const r = await fetch(`${ERP_API}/putni-nalog/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju putnog naloga #' + id); return; }
_currentPn = r;
const p = r.putni_nalog;
$('#pnModalTitle').textContent = `Putni nalog #${p.id} · ${p.klub_naziv||'—'}`;
const att = p.attachments || {};
const dnv = att.dnevnice_calc || {};
const putnici = (att.putnici || []).join(', ');
const voditelj = att.voditelj || '—';
const country = att.country || '—';
const fromCity = att.from_city || '—', toCity = att.to_city || '—';
$('#pn_kv').innerHTML = `
<div>Voditelj</div><div>${escHtml(voditelj)}</div>
<div>Putnici</div><div>${escHtml(putnici||'—')}</div>
<div>Svrha</div><div>${escHtml(p.purpose||'—')}</div>
<div>Ruta</div><div>${escHtml(fromCity)} → ${escHtml(toCity)}</div>
<div>Zemlja</div><div>${escHtml(country)}</div>
<div>Polazak</div><div>${fmtDate(p.date_from)}</div>
<div>Povratak</div><div>${fmtDate(p.date_to)}</div>
<div>Vozilo</div><div>${escHtml(p.vehicle_type||'—')} ${escHtml(p.vehicle_plate||'')}</div>
<div>Kilometara</div><div>${p.km_driven||0} km × €${p.km_rate||0.5}</div>
`;
$('#pn_obracun').innerHTML = `
<div>Pune dnevnice</div><div style="color:var(--accent)">${dnv.days_full||0} × €${dnv.rate_full||0}</div>
<div>Pola dnevnica</div><div style="color:var(--yellow)">${dnv.days_half||0} × €${dnv.rate_half||0}</div>
<div>Dnevnice ukupno</div><div style="color:var(--green)">${fmtEur(p.dnevnice_amount)}</div>
<div>Kilometrina</div><div>${fmtEur(p.cost_transport)}</div>
<div>Smještaj</div><div>${fmtEur(p.cost_lodging)}</div>
<div>Hrana / ostalo</div><div>${fmtEur((p.cost_meals||0)+(p.cost_other||0))}</div>
<div style="font-weight:700">UKUPNO</div><div style="color:var(--accent);font-weight:700;font-size:18px">${fmtEur(p.cost_total)}</div>
`;
// Status block
const status = (p.status||'draft').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(p.status)}</div>`;
if (status === 'isplacen') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to||'—')}</span></div>
<div><span style="color:var(--text-3)">Datum isplate:</span> ${fmtDate(p.paid_at) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos isplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount||p.cost_total)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'odbijen') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">${escHtml(p.notes||'Odbijen').slice(-200)}</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">${status === 'odobren' || status === 'zatvoren' ? 'Čeka isplatu.' : status === 'poslan' ? 'Čeka odobrenje.' : 'Draft — još nije poslan na odobrenje.'}</div>`;
}
$('#pn_status_block').innerHTML = sb;
// Vezani računi
const invs = r.invoices || [];
$('#pn_invoices_table tbody').innerHTML = invs.length ? invs.map(i => `
<tr class="clickable" onclick="closeModal('pnModal'); setTimeout(()=>openInvoice(${i.id}), 100)">
<td>${i.id}</td><td>${escHtml(i.invoice_kind||'—')}</td><td>${escHtml(i.vendor_name||'—')}</td>
<td style="font-family:'JetBrains Mono'">${escHtml(i.vendor_oib||'—')}</td>
<td>${fmtDate(i.invoice_date)}</td>
<td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td>
</tr>`).join('') : '<tr><td colspan="7" style="color:var(--text-3);text-align:center;padding:14px">Nema vezanih računa</td></tr>';
// Actions
const a = r.actions || {};
const acts = [];
if (a.submit) acts.push(`<button class="btn yellow" onclick="submitPn(${id})">📤 Pošalji na odobrenje</button>`);
if (a.approve) acts.push(`<button class="btn green" onclick="approvePn(${id})">✓ Odobri</button>`);
if (a.reject) acts.push(`<button class="btn red" onclick="openRejectModal(${id})">✗ Odbij</button>`);
if (a.pay) acts.push(`<button class="btn green" onclick="openPayPnModal(${id})">💰 Isplati</button>`);
acts.push(`<a href="${ERP_API}/putni-nalog/${id}/hub3.pdf" target="_blank" class="btn sec" style="text-decoration:none">📄 HUB-3 uplatnica (PDF)</a>`);
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) {
if (!name) return;
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset && n.dataset.tab === name));
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
const titles = {stats:'Statistika',ocr:'Skeniraj račun (OCR)',invoices:'Računi',putni:'Novi putni nalog','putni-list':'Lista putnih naloga'};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'stats') loadStats();
if (name === 'invoices') loadInvoices();
if (name === 'putni-list') loadPutni();
}
$$('.nav-item').forEach(n => { if (n.dataset && n.dataset.tab) n.addEventListener('click', () => activate(n.dataset.tab)); });
(async () => {
await loadKlubovi();
ocrInit();
pnInit();
loadStats();
})();
</script>
</body>
</html>