Files
pgz-sport/static/crm_v2.html
T
Damir Radulić f488623920 Task 1: OCR u ERP/CRM — /api/ocr/upload + tab Računi (OCR)
- routers/ocr_router.py: POST /api/ocr/upload (Tesseract+pdf2image, regex field extraction)
- pgz_sport_api.py: mount ocr_router with try/except guard
- static/erp_full.html: nova tab "📷 OCR" + panel
- static/crm_v2.html: OCR upload modal/tab

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:28:22 +02:00

1771 lines
85 KiB
HTML
Raw 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>
<!--
PGŽ Sport — CRM v2 (Salesforce-Lite) | v1.0.0 | 05.05.2026
Author: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
Lokacija: /opt/pgz-sport/static/crm_v2.html
Svrha: Pipeline kanban + Accounts/Contacts/Leads/Opportunities/Activities/Cases
-->
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport — CRM v2 (Salesforce-Lite)</title>
<style>
:root {
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a; --pgz-gold:#fbbf24;
--bg:#0f1115; --bg2:#171a21; --bg3:#1f242d;
--rim:#293040; --t1:#e6e8ef; --t2:#9aa3b6; --t3:#6b748b; --t4:#4b5269;
--ok:#22c55e; --warn:#f59e0b; --err:#ef4444; --info:#3b82f6;
--mono:'JetBrains Mono',Menlo,monospace;
}
* { box-sizing: border-box; }
html,body { margin:0; padding:0; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:var(--bg); color:var(--t1); font-size:13px; height:100vh; overflow:hidden; }
.topbar { height:50px; background:linear-gradient(90deg,var(--pgz-blue2),var(--pgz-blue));
display:flex; align-items:center; padding:0 18px; gap:14px;
box-shadow:0 2px 8px rgba(0,0,0,.4); }
.topbar .logo { font-weight:700; font-size:15px; }
.topbar .sep { color:rgba(255,255,255,.5); }
.topbar .title { font-size:13px; opacity:.95; }
.topbar .right { margin-left:auto; display:flex; gap:10px; align-items:center; font-size:12px; }
.topbar a { color:#fff; text-decoration:none; opacity:.85; padding:5px 9px; border-radius:4px; }
.topbar a:hover { opacity:1; background:rgba(255,255,255,.1); }
.topbar #me { padding:4px 10px; background:rgba(0,0,0,.2); border-radius:14px; font-size:11px; }
.tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--rim); padding:0 18px; flex-wrap:wrap; }
.tab { padding:11px 16px; cursor:pointer; color:var(--t2); border-bottom:2px solid transparent;
font-weight:500; user-select:none; font-size:12px; }
.tab:hover { color:var(--t1); }
.tab.active { color:var(--pgz-blue); border-bottom-color:var(--pgz-blue); background:var(--bg3); }
.tab .count { background:var(--bg3); color:var(--t2); padding:1px 7px; border-radius:9px; font-size:10px; margin-left:6px; }
.tab.active .count { background:var(--pgz-blue); color:#fff; }
.main { padding:14px 18px; height:calc(100vh - 50px - 36px); overflow:auto; }
.tab-c { display:none; }
.tab-c.on { display:block; }
.kpi-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:14px; }
.kpi { background:var(--bg2); border:1px solid var(--rim); padding:11px 13px; border-radius:6px; }
.kpi.g { border-left:3px solid var(--ok); }
.kpi.r { border-left:3px solid var(--err); }
.kpi.a { border-left:3px solid var(--warn); }
.kpi.b { border-left:3px solid var(--pgz-blue); }
.kpi.gold { border-left:3px solid var(--pgz-gold); }
.kpi-l { font-size:10px; color:var(--t3); text-transform:uppercase; letter-spacing:.5px; }
.kpi-v { font-size:20px; font-weight:700; margin-top:3px; }
.kpi-s { font-size:10px; color:var(--t3); margin-top:2px; }
.toolbar { display:flex; gap:9px; flex-wrap:wrap; margin-bottom:12px; align-items:center; }
.toolbar input, .toolbar select {
background:var(--bg2); border:1px solid var(--rim); color:var(--t1);
padding:6px 10px; border-radius:4px; font-size:12px; min-width:130px; font-family:inherit;
}
.toolbar input:focus, .toolbar select:focus { outline:none; border-color:var(--pgz-blue); }
.toolbar .grow { flex:1; }
.btn { background:var(--bg3); color:var(--t1); border:1px solid var(--rim);
padding:6px 11px; border-radius:4px; cursor:pointer; font-size:12px; font-family:inherit; }
.btn:hover { background:var(--bg2); border-color:var(--pgz-blue); }
.btn.primary { background:linear-gradient(135deg,var(--pgz-blue),var(--pgz-blue2));
border-color:var(--pgz-blue); color:#fff; }
.btn.primary:hover { filter:brightness(1.1); }
.btn.danger { color:var(--err); border-color:var(--err); }
.btn.sm { padding:3px 7px; font-size:11px; }
.btn.gold { background:var(--pgz-gold); color:#000; border-color:var(--pgz-gold); }
.card { background:var(--bg2); border:1px solid var(--rim); border-radius:6px; margin-bottom:12px; overflow:hidden; }
.card-h { padding:10px 14px; border-bottom:1px solid var(--rim); display:flex;
align-items:center; justify-content:space-between; background:var(--bg3); }
.card-t { font-weight:600; font-size:12px; }
.card-b { padding:11px 14px; }
table { width:100%; border-collapse:collapse; font-size:12px; }
table th, table td { padding:8px 11px; text-align:left; border-bottom:1px solid var(--rim); }
table th { background:var(--bg3); color:var(--t2); font-weight:600; font-size:10px;
text-transform:uppercase; letter-spacing:.4px; cursor:default; }
table tr:hover td { background:rgba(26,115,232,.06); cursor:pointer; }
table tr.sel td { background:rgba(26,115,232,.15); }
.chip { display:inline-block; padding:2px 8px; border-radius:10px; font-size:10px;
font-weight:600; text-transform:uppercase; letter-spacing:.3px; }
.chip.new { background:#1e293b; color:#94a3b8; }
.chip.contacted { background:#1e3a5f; color:#60a5fa; }
.chip.qualified { background:#3a2e1a; color:#fbbf24; }
.chip.lost { background:#3a1e1e; color:#f87171; }
.chip.converted { background:#1a3a2a; color:#4ade80; }
.chip.open { background:#1e293b; color:#94a3b8; }
.chip.in_progress { background:#1e3a5f; color:#60a5fa; }
.chip.waiting { background:#3a2e1a; color:#fbbf24; }
.chip.resolved { background:#1a3a2a; color:#4ade80; }
.chip.closed { background:#1f1f1f; color:#71717a; }
.chip.urgent { background:#3a1e1e; color:#f87171; }
.chip.high { background:#3a2e1a; color:#fbbf24; }
.chip.normal { background:#1e293b; color:#94a3b8; }
.chip.low { background:#1f1f1f; color:#71717a; }
.chip.nepodmireno { background:#3a1e1e; color:#f87171; }
.chip.djelomicno { background:#3a2e1a; color:#fbbf24; }
.chip.podmireno { background:#1a3a2a; color:#4ade80; }
.chip.storno { background:#1f1f1f; color:#71717a; }
.chip.draft { background:#1e293b; color:#94a3b8; }
.chip.submitted { background:#1e3a5f; color:#60a5fa; }
.chip.approved { background:#1a3a2a; color:#4ade80; }
.chip.rejected { background:#3a1e1e; color:#f87171; }
.chip.spreman { background:#1a3a2a; color:#4ade80; }
.chip.nije-spreman{ background:#3a1e1e; color:#f87171; }
.tpl-row { padding:9px 12px; border-bottom:1px solid var(--rim); cursor:pointer; }
.tpl-row:hover { background:var(--bg3); }
.tpl-row.sel { background:rgba(26,115,232,.18); border-left:3px solid var(--pgz-blue); padding-left:9px; }
.tpl-row .tpl-n { font-weight:600; font-size:12px; color:var(--t1); }
.tpl-row .tpl-c { font-size:10px; color:var(--t3); margin-top:2px; text-transform:uppercase; letter-spacing:.4px; }
/* Kanban */
.kanban { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; min-height:60vh; }
.kcol { background:var(--bg2); border:1px solid var(--rim); border-radius:6px;
padding:8px; display:flex; flex-direction:column; min-height:200px; }
.kcol.drag-over { border-color:var(--pgz-blue); background:rgba(26,115,232,.08); }
.kcol-h { display:flex; justify-content:space-between; align-items:center;
padding:5px 4px 9px; border-bottom:1px solid var(--rim); margin-bottom:8px; }
.kcol-t { font-size:10px; text-transform:uppercase; letter-spacing:.5px; color:var(--t2); font-weight:700; }
.kcol-s { font-size:10px; color:var(--t3); font-family:var(--mono); }
.kcol.prospecting .kcol-t { color:#94a3b8; }
.kcol.qualification .kcol-t { color:#60a5fa; }
.kcol.proposal .kcol-t { color:#fbbf24; }
.kcol.negotiation .kcol-t { color:#f97316; }
.kcol.closed_won .kcol-t { color:#4ade80; }
.kcol.closed_lost .kcol-t { color:#f87171; }
.kcards { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:6px; }
.kcard { background:var(--bg3); border:1px solid var(--rim); border-radius:5px;
padding:8px 9px; cursor:grab; font-size:11px; }
.kcard:hover { border-color:var(--pgz-blue); }
.kcard.dragging { opacity:.45; }
.kcard-t { font-weight:600; color:var(--t1); margin-bottom:3px; }
.kcard-a { color:var(--t2); font-size:10px; margin-bottom:5px; }
.kcard-r { display:flex; justify-content:space-between; font-size:10px; color:var(--t3); font-family:var(--mono); }
.kcard-eur { color:var(--pgz-gold); font-weight:700; }
/* Modal */
.modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:50;
align-items:flex-start; justify-content:center; padding-top:5vh; overflow:auto; }
.modal.on { display:flex; }
.mbox { background:var(--bg2); border:1px solid var(--rim); border-radius:8px;
width:560px; max-width:92vw; max-height:90vh; overflow:auto; }
.mbox.wide { width:780px; }
.mh { padding:12px 16px; border-bottom:1px solid var(--rim); display:flex;
justify-content:space-between; align-items:center; background:var(--bg3); }
.mh h3 { margin:0; font-size:14px; }
.mh .x { background:none; border:none; color:var(--t2); font-size:18px; cursor:pointer; }
.mh .x:hover { color:var(--t1); }
.mb { padding:14px 16px; }
.mf { padding:11px 16px; border-top:1px solid var(--rim); display:flex; gap:8px; justify-content:flex-end; background:var(--bg3); }
.fld { margin-bottom:10px; }
.fld label { display:block; font-size:11px; color:var(--t3); text-transform:uppercase;
letter-spacing:.4px; margin-bottom:3px; font-weight:600; }
.fld input, .fld select, .fld textarea {
width:100%; background:var(--bg); border:1px solid var(--rim); color:var(--t1);
padding:6px 9px; border-radius:4px; font-size:12px; font-family:inherit;
}
.fld textarea { min-height:60px; resize:vertical; }
.fld input:focus, .fld select:focus, .fld textarea:focus { outline:none; border-color:var(--pgz-blue); }
.fld-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.empty { padding:30px; text-align:center; color:var(--t3); font-size:12px; }
#toast { position:fixed; bottom:20px; right:20px; background:var(--bg3); border:1px solid var(--rim);
padding:10px 14px; border-radius:6px; z-index:99; display:none; font-size:12px; }
#toast.ok { border-color:var(--ok); }
#toast.err { border-color:var(--err); }
footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
display:flex; align-items:center; padding:0 18px; font-size:10px;
color:var(--t3); font-family:var(--mono); justify-content:space-between; }
</style>
</head>
<body>
<div class="topbar">
<span class="logo">PGŽ SPORT</span>
<span class="sep"></span>
<span class="title">CRM v2 — Salesforce-Lite (Pipeline)</span>
<div class="right">
<span id="me"></span>
<a href="/sport/platform">Platform</a>
<a href="/sport/erp">ERP</a>
<a href="/sport/crm">CRM</a>
<a href="#" id="logout">Odjava</a>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="pipeline">Pipeline</div>
<div class="tab" data-tab="accounts">Accounts <span class="count" id="cnt-accounts">·</span></div>
<div class="tab" data-tab="contacts">Contacts <span class="count" id="cnt-contacts">·</span></div>
<div class="tab" data-tab="leads">Leads <span class="count" id="cnt-leads">·</span></div>
<div class="tab" data-tab="opportunities">Opportunities <span class="count" id="cnt-opps">·</span></div>
<div class="tab" data-tab="activities">Activities <span class="count" id="cnt-activities">·</span></div>
<div class="tab" data-tab="cases">Cases <span class="count" id="cnt-cases">·</span></div>
<div class="tab" data-tab="clanarine">Članarine <span class="count" id="cnt-clanarine">·</span></div>
<div class="tab" data-tab="lijecnicki">Liječnički <span class="count" id="cnt-lijecnicki">·</span></div>
<div class="tab" data-tab="obrasci">Obrasci <span class="count" id="cnt-obrasci">·</span></div>
</div>
<div class="main">
<!-- ────── PIPELINE ────── -->
<div class="tab-c on" id="tc-pipeline">
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Open opps</div><div class="kpi-v" id="k-opps">·</div><div class="kpi-s" id="k-opps-eur">·</div></div>
<div class="kpi gold"><div class="kpi-l">Weighted total</div><div class="kpi-v" id="k-weighted">·</div><div class="kpi-s">prosjek vjerojatnosti</div></div>
<div class="kpi g"><div class="kpi-l">Won this quarter</div><div class="kpi-v" id="k-won">·</div><div class="kpi-s" id="k-won-eur">·</div></div>
<div class="kpi a"><div class="kpi-l">Leads (new+contacted)</div><div class="kpi-v" id="k-leads">·</div><div class="kpi-s">qualified: <span id="k-leads-q">·</span></div></div>
<div class="kpi r"><div class="kpi-l">Overdue activities</div><div class="kpi-v" id="k-overdue">·</div><div class="kpi-s">upcoming: <span id="k-upcoming">·</span></div></div>
<div class="kpi"><div class="kpi-l">Open cases</div><div class="kpi-v" id="k-cases">·</div><div class="kpi-s" id="k-cases-urgent">·</div></div>
</div>
<div class="toolbar">
<strong>Pipeline kanban</strong>
<span class="grow"></span>
<button class="btn primary sm" onclick="openOppModal()">+ Nova prilika</button>
<button class="btn sm" onclick="loadPipeline()">↻ Refresh</button>
</div>
<div class="kanban" id="kanban"></div>
</div>
<!-- ────── ACCOUNTS ────── -->
<div class="tab-c" id="tc-accounts">
<div class="toolbar">
<input id="acc-q" placeholder="Pretraži (naziv/OIB/email/grad)…">
<select id="acc-type">
<option value="">— Svi tipovi —</option>
<option value="klub">klub</option>
<option value="savez">savez</option>
<option value="sponzor">sponzor</option>
<option value="drzava">drzava</option>
<option value="drugo">drugo</option>
</select>
<button class="btn" onclick="loadAccounts()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openAccountModal()">+ Novi account</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-accounts"><thead><tr>
<th>Naziv</th><th>Tip</th><th>Grad</th><th>OIB</th><th>Email</th>
<th>Kontakti</th><th>Opps</th><th>Owner</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── CONTACTS ────── -->
<div class="tab-c" id="tc-contacts">
<div class="toolbar">
<input id="con-q" placeholder="Pretraži (ime/email/funkcija)…">
<input id="con-acc" type="number" placeholder="Account ID (filter)" style="max-width:170px">
<button class="btn" onclick="loadContacts()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openContactModal()">+ Novi kontakt</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-contacts"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Account</th><th>Funkcija</th>
<th>Email</th><th>Telefon</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── LEADS ────── -->
<div class="tab-c" id="tc-leads">
<div class="toolbar">
<input id="lead-q" placeholder="Pretraži (ime/organizacija/email)…">
<select id="lead-status">
<option value="">— Svi statusi —</option>
<option value="new">new</option>
<option value="contacted">contacted</option>
<option value="qualified">qualified</option>
<option value="lost">lost</option>
<option value="converted">converted</option>
</select>
<button class="btn" onclick="loadLeads()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openLeadModal()">+ Novi lead</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-leads"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Organizacija</th><th>Email</th>
<th>Izvor</th><th>Status</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── OPPORTUNITIES ────── -->
<div class="tab-c" id="tc-opps">
<div class="toolbar">
<input id="opp-q" placeholder="Pretraži (naziv/account)…">
<select id="opp-stage">
<option value="">— Sve faze —</option>
<option value="prospecting">prospecting</option>
<option value="qualification">qualification</option>
<option value="proposal">proposal</option>
<option value="negotiation">negotiation</option>
<option value="closed_won">closed_won</option>
<option value="closed_lost">closed_lost</option>
</select>
<button class="btn" onclick="loadOpps()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openOppModal()">+ Nova prilika</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-opps"><thead><tr>
<th>Naziv</th><th>Account</th><th>Tip</th><th>Faza</th>
<th>EUR</th><th>%</th><th>Close</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── ACTIVITIES ────── -->
<div class="tab-c" id="tc-activities">
<div class="toolbar">
<select id="act-type">
<option value="">— Svi tipovi —</option>
<option value="call">call</option>
<option value="meeting">meeting</option>
<option value="email">email</option>
<option value="task">task</option>
<option value="note">note</option>
</select>
<select id="act-open">
<option value="">— Sve —</option>
<option value="true">Otvorene</option>
<option value="false">Zatvorene</option>
</select>
<button class="btn" onclick="loadActivities()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openActivityModal()">+ Nova aktivnost</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-activities"><thead><tr>
<th>Tip</th><th>Subject</th><th>Account</th><th>Kontakt</th>
<th>Due</th><th>Status</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── CASES ────── -->
<div class="tab-c" id="tc-cases">
<div class="toolbar">
<input id="case-q" placeholder="Pretraži…">
<select id="case-status">
<option value="">— Svi statusi —</option>
<option value="open">open</option>
<option value="in_progress">in_progress</option>
<option value="waiting">waiting</option>
<option value="resolved">resolved</option>
<option value="closed">closed</option>
</select>
<select id="case-priority">
<option value="">— Sve prioriteti —</option>
<option value="urgent">urgent</option>
<option value="high">high</option>
<option value="normal">normal</option>
<option value="low">low</option>
</select>
<button class="btn" onclick="loadCases()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openCaseModal()">+ Novi case</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-cases"><thead><tr>
<th>Subject</th><th>Account</th><th>Status</th>
<th>Priority</th><th>Stvoren</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── ČLANARINE ────── -->
<div class="tab-c" id="tc-clanarine">
<div class="toolbar">
<input id="cln-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="cln-clan" type="number" placeholder="Član ID" style="max-width:120px">
<input id="cln-godina" type="number" placeholder="Godina" style="max-width:120px">
<select id="cln-status">
<option value="">— Svi statusi —</option>
<option value="nepodmireno">nepodmireno</option>
<option value="djelomicno">djelomično</option>
<option value="podmireno">podmireno</option>
<option value="storno">storno</option>
</select>
<button class="btn" onclick="loadClanarine()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openClanarinaModal()">+ Nova članarina</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-clanarine"><thead><tr>
<th>Član</th><th>Klub</th><th>Godina</th><th>Razdoblje</th>
<th>Propisan</th><th>Plaćen</th><th>Datum uplate</th><th>Status</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── LIJEČNIČKI ────── -->
<div class="tab-c" id="tc-lijecnicki">
<div class="toolbar">
<input id="lij-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="lij-clan" type="number" placeholder="Član ID" style="max-width:120px">
<select id="lij-expiring">
<option value="">— Svi —</option>
<option value="true">Ističu (≤30d) ili istekli</option>
</select>
<button class="btn" onclick="loadLijecnicki()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openLijecnickiModal()">+ Novi pregled</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-lijecnicki"><thead><tr>
<th>Član</th><th>Klub</th><th>Datum</th><th>Vrsta</th>
<th>Vrijedi do</th><th>Liječnik</th><th>Spreman</th><th>Plaćeno</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── OBRASCI ────── -->
<div class="tab-c" id="tc-obrasci">
<div style="display:grid; grid-template-columns:300px 1fr; gap:12px; align-items:start;">
<!-- Templates list (left) -->
<div class="card">
<div class="card-h"><div class="card-t">Predlošci</div>
<button class="btn sm" onclick="loadObrasciTemplates()"></button>
</div>
<div class="card-b" style="padding:0; max-height:70vh; overflow:auto;" id="obr-tpl-list">
<div class="empty">Učitavanje…</div>
</div>
</div>
<!-- Submissions / detail (right) -->
<div>
<div class="toolbar">
<strong id="obr-right-title">Podnešeni obrasci</strong>
<span class="grow"></span>
<select id="obr-status">
<option value="">— Svi statusi —</option>
<option value="draft">draft</option>
<option value="submitted">submitted</option>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
<input id="obr-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<button class="btn" onclick="loadObrasciSubmissions()">Filtriraj</button>
</div>
<div class="card"><div class="card-b" style="padding:0" id="obr-right-body">
<table id="t-obr-sub"><thead><tr>
<th>ID</th><th>Predložak</th><th>Klub</th><th>Član</th>
<th>Status</th><th>Submitted</th><th>Approved</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
</div>
</div>
</div><!-- /main -->
<footer>
<span>PGŽ Sport CRM v2 · Salesforce-Lite</span>
<span id="api-info">api.rinet.one/sport/api/v2/crm/*</span>
</footer>
<!-- Modal shell -->
<div class="modal" id="modal">
<div class="mbox" id="mbox">
<div class="mh"><h3 id="m-title">Modal</h3><button class="x" onclick="closeModal()">×</button></div>
<div class="mb" id="m-body"></div>
<div class="mf" id="m-foot">
<button class="btn" onclick="closeModal()">Odustani</button>
<button class="btn primary" id="m-save">Spremi</button>
</div>
</div>
</div>
<!-- ━━━ OCR floating button + modal ━━━ -->
<button id="ocr-fab" onclick="ocrOpen()"
style="position:fixed;right:18px;bottom:18px;z-index:60;
background:#1f6feb;color:#fff;border:none;border-radius:24px;
padding:10px 16px;font-size:13px;cursor:pointer;
box-shadow:0 6px 18px rgba(0,0,0,0.4)">
📷 OCR Upload
</button>
<div id="ocr-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:80;align-items:center;justify-content:center">
<div style="background:#0f1620;color:#dbe2ee;border:1px solid #25334a;border-radius:10px;width:min(720px,94vw);max-height:90vh;overflow:auto;padding:14px">
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #25334a;padding-bottom:8px;margin-bottom:10px">
<h3 style="margin:0;font-size:14px">📷 OCR Upload (PDF / JPG / PNG)</h3>
<button onclick="ocrClose()" style="background:none;border:none;color:#dbe2ee;font-size:18px;cursor:pointer">×</button>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" id="ocr-crm-file" accept="application/pdf,image/jpeg,image/jpg,image/png">
<button class="btn primary" onclick="ocrCrmUpload()">Upload</button>
<button class="btn" onclick="ocrCrmHealth()">Health</button>
<span id="ocr-crm-status" style="font-size:11px;color:#8aa0bd"></span>
</div>
<div id="ocr-crm-health" style="font-size:11px;color:#8aa0bd;margin-top:6px"></div>
<div id="ocr-crm-fields" style="margin-top:10px;font-size:12px"></div>
<pre id="ocr-crm-text" style="margin-top:10px;max-height:300px;overflow:auto;background:#0a1018;padding:10px;border-radius:6px;font-size:11px;white-space:pre-wrap">— prazno —</pre>
</div>
</div>
<div id="toast"></div>
<script>
// ━━━ AUTH: model after app.html (pgz_access primary, fallbacks for legacy keys) ━━━
function getToken(){
try {
return localStorage.getItem('pgz_access')
|| sessionStorage.getItem('pgz_access')
|| localStorage.getItem('jwt')
|| localStorage.getItem('access_token')
|| localStorage.getItem('token')
|| '';
} catch(e){ return ''; }
}
let TOKEN = getToken();
const API = '/sport/api/v2/crm';
const STAGE_LABEL = {
prospecting:'Prospecting', qualification:'Qualification', proposal:'Proposal',
negotiation:'Negotiation', closed_won:'Closed Won', closed_lost:'Closed Lost',
};
const STAGES = ['prospecting','qualification','proposal','negotiation','closed_won','closed_lost'];
// JWT expiry pre-check + redirect only when truly missing/expired
(function checkAuth(){
if(!TOKEN){
if(!window.__pgz_redirecting && window.__pgz_made_api_call){ window.__pgz_redirecting = true; location.href = '/login?next=' + encodeURIComponent(location.pathname); } else { console.warn('[CRM] no token — login optional'); }
return;
}
try {
const payload = JSON.parse(atob(TOKEN.split('.')[1]));
if(payload.exp && payload.exp * 1000 < Date.now()){
['pgz_access','pgz_refresh','pgz_user','jwt','access_token','token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
if(!window.__pgz_redirecting){ window.__pgz_redirecting = true; location.href = '/login?reason=expired'; }
}
} catch(e){ /* not parseable, let server respond */ }
})();
async function api(path, opts={}) {
TOKEN = getToken(); // refresh in case of token rotation
const headers = {'Authorization':'Bearer '+TOKEN, 'Content-Type':'application/json', ...(opts.headers||{})};
const res = await fetch(API+path, {...opts, headers});
if (res.status === 401) {
['pgz_access','pgz_refresh','pgz_user','jwt','access_token','token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
if(!window.__pgz_redirecting){ window.__pgz_redirecting = true; location.href='/login?reason=unauthorized'; }
throw new Error('401');
}
const txt = await res.text();
let data; try { data = JSON.parse(txt); } catch { data = txt; }
if (!res.ok) {
const msg = (data && data.detail) ? data.detail : ('HTTP '+res.status);
throw new Error(typeof msg==='string'?msg:JSON.stringify(msg));
}
return data;
}
function toast(msg, type='ok') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = type;
t.style.display='block';
setTimeout(()=>t.style.display='none', 2800);
}
const fmtEur = n => (n==null||n==='') ? '—' : Number(n).toLocaleString('hr-HR',{maximumFractionDigits:0})+' €';
const fmtDate = s => s ? String(s).slice(0,10) : '—';
const fmtDT = s => {
if (!s) return '—';
const d = new Date(s);
return isNaN(d) ? String(s).slice(0,16) : d.toLocaleString('hr-HR',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
};
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ────── Tabs ──────
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab===name));
document.querySelectorAll('.tab-c').forEach(c => c.classList.toggle('on', c.id==='tc-'+name || (name==='opportunities' && c.id==='tc-opps')));
if (name==='pipeline') loadPipeline();
if (name==='accounts') loadAccounts();
if (name==='contacts') loadContacts();
if (name==='leads') loadLeads();
if (name==='opportunities') loadOpps();
if (name==='activities') loadActivities();
if (name==='cases') loadCases();
if (name==='clanarine') loadClanarine();
if (name==='lijecnicki') loadLijecnicki();
if (name==='obrasci') { loadObrasciTemplates(); loadObrasciSubmissions(); }
}
// ────── /me ──────
async function loadMe() {
try {
const tok = getToken();
const me = await fetch('/sport/api/v2/auth/me', {headers:{'Authorization':'Bearer '+tok}}).then(r=>r.json());
document.getElementById('me').textContent = (me.email || me.full_name || 'user');
} catch { document.getElementById('me').textContent='?'; }
}
document.getElementById('logout').addEventListener('click', async (e) => {
e.preventDefault();
const tok = getToken();
try { await fetch('/sport/api/v2/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+tok}}); } catch {}
['pgz_access','pgz_refresh','pgz_user','app-role','jwt','access_token','refresh_token','pgz_session_id','token'].forEach(k => {
try{localStorage.removeItem(k); sessionStorage.removeItem(k);}catch(e){}
});
location.href='/login';
});
// ────── Pipeline & Dashboard ──────
async function loadPipeline() {
try {
const [pipe, dash] = await Promise.all([api('/pipeline'), api('/dashboard')]);
// KPIs
document.getElementById('k-opps').textContent = dash.opportunities?.open_opps ?? 0;
document.getElementById('k-opps-eur').textContent = fmtEur(dash.opportunities?.open_amount);
document.getElementById('k-weighted').textContent = fmtEur(dash.opportunities?.weighted_amount);
document.getElementById('k-won').textContent = dash.opportunities?.won_q ?? 0;
document.getElementById('k-won-eur').textContent = fmtEur(dash.opportunities?.won_q_amount);
const lbs = (dash.leads_by_status||[]).reduce((m,r)=>{m[r.status]=Number(r.n);return m;},{});
document.getElementById('k-leads').textContent = (lbs.new||0)+(lbs.contacted||0);
document.getElementById('k-leads-q').textContent = lbs.qualified||0;
document.getElementById('k-overdue').textContent = dash.activities?.overdue ?? 0;
document.getElementById('k-upcoming').textContent = dash.activities?.upcoming ?? 0;
const cbs = (dash.cases_by_status||[]).reduce((m,r)=>{m[r.status]=Number(r.n);return m;},{});
document.getElementById('k-cases').textContent = (cbs.open||0)+(cbs.in_progress||0)+(cbs.waiting||0);
document.getElementById('k-cases-urgent').textContent = 'closed: '+(cbs.closed||0)+' / resolved: '+(cbs.resolved||0);
// Counts in tabs
document.getElementById('cnt-accounts').textContent = dash.accounts_total ?? 0;
document.getElementById('cnt-contacts').textContent = dash.contacts_total ?? 0;
document.getElementById('cnt-leads').textContent = (dash.leads_by_status||[]).reduce((s,r)=>s+Number(r.n),0);
document.getElementById('cnt-opps').textContent = pipe.stages.reduce((s,b)=>s+b.count,0);
document.getElementById('cnt-activities').textContent = (dash.activities?.overdue||0)+(dash.activities?.upcoming||0)+(dash.activities?.done||0);
document.getElementById('cnt-cases').textContent = (dash.cases_by_status||[]).reduce((s,r)=>s+Number(r.n),0);
// Kanban
const kb = document.getElementById('kanban');
kb.innerHTML = pipe.stages.map(b => `
<div class="kcol ${b.stage}" data-stage="${b.stage}">
<div class="kcol-h">
<div class="kcol-t">${STAGE_LABEL[b.stage]}</div>
<div class="kcol-s">${b.count} · ${fmtEur(b.amount_total)}</div>
</div>
<div class="kcards">
${(b.items||[]).map(o => `
<div class="kcard" draggable="true" data-id="${o.id}" data-stage="${o.stage}" onclick="editOpp(${o.id})">
<div class="kcard-t">${esc(o.naziv)}</div>
<div class="kcard-a">${esc(o.account_naziv||'—')}</div>
<div class="kcard-r">
<span class="kcard-eur">${fmtEur(o.amount_eur)}</span>
<span>${o.probability||0}% · ${fmtDate(o.close_date)}</span>
</div>
</div>
`).join('')}
</div>
</div>
`).join('');
bindKanbanDnD();
} catch (e) { toast('Pipeline err: '+e.message, 'err'); }
}
function bindKanbanDnD() {
const cards = document.querySelectorAll('.kcard');
const cols = document.querySelectorAll('.kcol');
let dragId = null;
cards.forEach(c => {
c.addEventListener('dragstart', e => {
dragId = c.dataset.id;
c.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
c.addEventListener('dragend', () => c.classList.remove('dragging'));
});
cols.forEach(col => {
col.addEventListener('dragover', e => { e.preventDefault(); col.classList.add('drag-over'); });
col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
col.addEventListener('drop', async e => {
e.preventDefault(); col.classList.remove('drag-over');
const newStage = col.dataset.stage;
if (!dragId) return;
try {
await api('/opportunities/'+dragId+'/stage', {method:'PATCH', body:JSON.stringify({stage:newStage})});
toast('Faza promijenjena: '+STAGE_LABEL[newStage]);
loadPipeline();
} catch (er) { toast('Stage update err: '+er.message, 'err'); }
});
});
}
// ────── Accounts ──────
async function loadAccounts() {
const q = document.getElementById('acc-q').value.trim();
const t = document.getElementById('acc-type').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (t) qs.set('type', t);
try {
const data = await api('/accounts?'+qs.toString());
const tb = document.querySelector('#t-accounts tbody');
tb.innerHTML = (data.items||[]).map(a => `
<tr onclick="editAccount(${a.id})">
<td><strong>${esc(a.naziv)}</strong></td>
<td>${esc(a.type)}</td>
<td>${esc(a.grad||'—')}</td>
<td>${esc(a.oib||'—')}</td>
<td>${esc(a.email||'—')}</td>
<td>${a.contacts_n||0}</td>
<td>${a.opps_n||0}</td>
<td>${esc(a.owner_email||'—')}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button></td>
</tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema accounta — dodajte prvi.</td></tr>';
document.getElementById('cnt-accounts').textContent = (data.items||[]).length;
} catch (e) { toast('Accounts err: '+e.message, 'err'); }
}
function accountFormHTML(a={}) {
return `
<div class="fld"><label>Naziv*</label><input id="f-naziv" value="${esc(a.naziv||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Tip</label>
<select id="f-type">
${['klub','savez','sponzor','drzava','drugo'].map(x=>`<option value="${x}" ${a.type===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>OIB</label><input id="f-oib" value="${esc(a.oib||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Email</label><input id="f-email" value="${esc(a.email||'')}"></div>
<div class="fld"><label>Telefon</label><input id="f-telefon" value="${esc(a.telefon||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Web</label><input id="f-web" value="${esc(a.web||'')}"></div>
<div class="fld"><label>Industry</label><input id="f-industry" value="${esc(a.industry||'')}" placeholder="sport / grana / ..."></div>
</div>
<div class="fld-row">
<div class="fld"><label>Adresa</label><input id="f-adresa" value="${esc(a.adresa||'')}"></div>
<div class="fld"><label>Grad</label><input id="f-grad" value="${esc(a.grad||'')}"></div>
</div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(a.napomene||'')}</textarea></div>
`;
}
function readAccountForm() {
return {
naziv: document.getElementById('f-naziv').value.trim(),
type: document.getElementById('f-type').value,
oib: document.getElementById('f-oib').value.trim() || null,
email: document.getElementById('f-email').value.trim() || null,
telefon: document.getElementById('f-telefon').value.trim() || null,
web: document.getElementById('f-web').value.trim() || null,
industry: document.getElementById('f-industry').value.trim() || null,
adresa: document.getElementById('f-adresa').value.trim() || null,
grad: document.getElementById('f-grad').value.trim() || null,
napomene: document.getElementById('f-napomene').value || null,
};
}
function openAccountModal(a) {
const isEdit = !!(a && a.id);
showModal(isEdit ? 'Uredi account' : 'Novi account', accountFormHTML(a||{}), async () => {
const body = readAccountForm();
if (!body.naziv) { toast('Naziv je obavezan', 'err'); return; }
try {
if (isEdit) await api('/accounts/'+a.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/accounts', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadAccounts(); loadPipeline();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editAccount(id) {
try { const a = await api('/accounts/'+id); openAccountModal(a); }
catch (e) { toast(e.message, 'err'); }
}
async function delAccount(id, naziv) {
if (!confirm('Obrisati account "'+naziv+'"? (kaskadno briše opps/cases/activities)')) return;
try { await api('/accounts/'+id, {method:'DELETE'}); toast('Obrisano'); loadAccounts(); loadPipeline(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Contacts ──────
async function loadContacts() {
const q = document.getElementById('con-q').value.trim();
const aid = document.getElementById('con-acc').value.trim();
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (aid) qs.set('account_id', aid);
try {
const data = await api('/contacts?'+qs.toString());
const tb = document.querySelector('#t-contacts tbody');
tb.innerHTML = (data.items||[]).map(c => `
<tr onclick="editContact(${c.id})">
<td><strong>${esc(c.ime)}</strong></td>
<td>${esc(c.prezime)}</td>
<td>${esc(c.account_naziv||'—')}</td>
<td>${esc(c.funkcija||'—')}</td>
<td>${esc(c.email||'—')}</td>
<td>${esc(c.telefon||c.mobitel||'—')}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delContact(${c.id})">×</button></td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema kontakata.</td></tr>';
document.getElementById('cnt-contacts').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function contactFormHTML(c={}) {
return `
<div class="fld-row">
<div class="fld"><label>Ime*</label><input id="f-ime" value="${esc(c.ime||'')}"></div>
<div class="fld"><label>Prezime*</label><input id="f-prezime" value="${esc(c.prezime||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Account ID</label><input id="f-account_id" type="number" value="${c.account_id||''}"></div>
<div class="fld"><label>Funkcija</label><input id="f-funkcija" value="${esc(c.funkcija||'')}" placeholder="predsjednik / tajnik / trener / ..."></div>
</div>
<div class="fld-row">
<div class="fld"><label>Email</label><input id="f-email" value="${esc(c.email||'')}"></div>
<div class="fld"><label>Telefon</label><input id="f-telefon" value="${esc(c.telefon||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Mobitel</label><input id="f-mobitel" value="${esc(c.mobitel||'')}"></div>
<div class="fld"><label>Clan ID (opc.)</label><input id="f-clan_id" type="number" value="${c.clan_id||''}"></div>
</div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(c.napomene||'')}</textarea></div>
`;
}
function readContactForm() {
return {
ime: document.getElementById('f-ime').value.trim(),
prezime: document.getElementById('f-prezime').value.trim(),
account_id: parseInt(document.getElementById('f-account_id').value)||null,
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
funkcija: document.getElementById('f-funkcija').value.trim()||null,
email: document.getElementById('f-email').value.trim()||null,
telefon: document.getElementById('f-telefon').value.trim()||null,
mobitel: document.getElementById('f-mobitel').value.trim()||null,
napomene: document.getElementById('f-napomene').value||null,
};
}
function openContactModal(c) {
const isEdit = !!(c && c.id);
showModal(isEdit?'Uredi kontakt':'Novi kontakt', contactFormHTML(c||{}), async () => {
const body = readContactForm();
if (!body.ime || !body.prezime) { toast('Ime i prezime su obavezni', 'err'); return; }
try {
if (isEdit) await api('/contacts/'+c.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/contacts', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadContacts();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editContact(id) {
try { const c = await api('/contacts/'+id); openContactModal(c); }
catch (e) { toast(e.message, 'err'); }
}
async function delContact(id) {
if (!confirm('Obrisati kontakt?')) return;
try { await api('/contacts/'+id, {method:'DELETE'}); toast('Obrisano'); loadContacts(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Leads ──────
async function loadLeads() {
const q = document.getElementById('lead-q').value.trim();
const s = document.getElementById('lead-status').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (s) qs.set('status', s);
try {
const data = await api('/leads?'+qs.toString());
const tb = document.querySelector('#t-leads tbody');
tb.innerHTML = (data.items||[]).map(l => `
<tr onclick="editLead(${l.id})">
<td>${esc(l.ime||'—')}</td>
<td>${esc(l.prezime||'—')}</td>
<td>${esc(l.organizacija||'—')}</td>
<td>${esc(l.email||'—')}</td>
<td>${esc(l.izvor||'—')}</td>
<td><span class="chip ${l.status}">${l.status}</span></td>
<td>
${l.status!=='converted' ? `<button class="btn sm gold" onclick="event.stopPropagation();convertLead(${l.id})">→ Konvertiraj</button>` : ''}
<button class="btn sm" onclick="event.stopPropagation();delLead(${l.id})">×</button>
</td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema leadova.</td></tr>';
document.getElementById('cnt-leads').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function leadFormHTML(l={}) {
return `
<div class="fld-row">
<div class="fld"><label>Ime</label><input id="f-ime" value="${esc(l.ime||'')}"></div>
<div class="fld"><label>Prezime</label><input id="f-prezime" value="${esc(l.prezime||'')}"></div>
</div>
<div class="fld"><label>Organizacija</label><input id="f-org" value="${esc(l.organizacija||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Email</label><input id="f-email" value="${esc(l.email||'')}"></div>
<div class="fld"><label>Telefon</label><input id="f-telefon" value="${esc(l.telefon||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Izvor</label><input id="f-izvor" value="${esc(l.izvor||'')}" placeholder="web / sajam / preporuka / cold-call"></div>
<div class="fld"><label>Status</label>
<select id="f-status">
${['new','contacted','qualified','lost','converted'].map(x=>`<option value="${x}" ${l.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
</div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(l.napomene||'')}</textarea></div>
`;
}
function readLeadForm() {
return {
ime: document.getElementById('f-ime').value.trim()||null,
prezime: document.getElementById('f-prezime').value.trim()||null,
organizacija: document.getElementById('f-org').value.trim()||null,
email: document.getElementById('f-email').value.trim()||null,
telefon: document.getElementById('f-telefon').value.trim()||null,
izvor: document.getElementById('f-izvor').value.trim()||null,
status: document.getElementById('f-status').value,
napomene: document.getElementById('f-napomene').value||null,
};
}
function openLeadModal(l) {
const isEdit = !!(l && l.id);
showModal(isEdit?'Uredi lead':'Novi lead', leadFormHTML(l||{status:'new'}), async () => {
const body = readLeadForm();
try {
if (isEdit) await api('/leads/'+l.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/leads', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadLeads();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editLead(id) {
try { const l = await api('/leads/'+id); openLeadModal(l); }
catch (e) { toast(e.message, 'err'); }
}
async function delLead(id) {
if (!confirm('Obrisati lead?')) return;
try { await api('/leads/'+id, {method:'DELETE'}); toast('Obrisano'); loadLeads(); }
catch (e) { toast(e.message, 'err'); }
}
async function convertLead(id) {
try {
const l = await api('/leads/'+id);
const body = `
<div class="fld"><label>Account naziv</label><input id="cv-acc" value="${esc(l.organizacija || ((l.ime||'')+' '+(l.prezime||'')).trim())}"></div>
<div class="fld"><label>Account tip</label>
<select id="cv-type">
${['klub','savez','sponzor','drzava','drugo'].map(x=>`<option value="${x}">${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label><input type="checkbox" id="cv-opp"> Stvori i Opportunity</label></div>
<div id="cv-opp-fields" style="display:none">
<div class="fld"><label>Naziv prilike</label><input id="cv-opp-naziv" value="Lead ${l.id} → Opportunity"></div>
<div class="fld-row">
<div class="fld"><label>Iznos (EUR)</label><input id="cv-opp-amount" type="number" step="0.01"></div>
<div class="fld"><label>Vjerojatnost %</label><input id="cv-opp-prob" type="number" value="20"></div>
</div>
<div class="fld"><label>Close datum</label><input id="cv-opp-close" type="date"></div>
</div>
`;
showModal('Konvertiraj lead', body, async () => {
const payload = {
account: { naziv: document.getElementById('cv-acc').value, type: document.getElementById('cv-type').value },
};
if (document.getElementById('cv-opp').checked) {
payload.opportunity = {
naziv: document.getElementById('cv-opp-naziv').value,
amount_eur: parseFloat(document.getElementById('cv-opp-amount').value)||null,
probability: parseInt(document.getElementById('cv-opp-prob').value)||20,
close_date: document.getElementById('cv-opp-close').value || null,
};
}
try {
await api('/leads/'+id+'/convert', {method:'POST', body:JSON.stringify(payload)});
toast('Lead konvertiran'); closeModal(); loadLeads(); loadPipeline();
} catch (e) { toast(e.message, 'err'); }
});
setTimeout(() => {
document.getElementById('cv-opp').addEventListener('change', e => {
document.getElementById('cv-opp-fields').style.display = e.target.checked ? 'block' : 'none';
});
}, 0);
} catch (e) { toast(e.message, 'err'); }
}
// ────── Opportunities ──────
async function loadOpps() {
const q = document.getElementById('opp-q').value.trim();
const s = document.getElementById('opp-stage').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (s) qs.set('stage', s);
try {
const data = await api('/opportunities?'+qs.toString());
const tb = document.querySelector('#t-opps tbody');
tb.innerHTML = (data.items||[]).map(o => `
<tr onclick="editOpp(${o.id})">
<td><strong>${esc(o.naziv)}</strong></td>
<td>${esc(o.account_naziv||'—')}</td>
<td>${esc(o.type||'—')}</td>
<td><span class="chip">${esc(o.stage)}</span></td>
<td>${fmtEur(o.amount_eur)}</td>
<td>${o.probability||0}%</td>
<td>${fmtDate(o.close_date)}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delOpp(${o.id})">×</button></td>
</tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema prilika.</td></tr>';
document.getElementById('cnt-opps').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function oppFormHTML(o={}) {
return `
<div class="fld"><label>Naziv*</label><input id="f-naziv" value="${esc(o.naziv||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Account ID*</label><input id="f-account_id" type="number" value="${o.account_id||''}"></div>
<div class="fld"><label>Contact ID</label><input id="f-contact_id" type="number" value="${o.contact_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Tip</label>
<select id="f-type">
${['financiranje','sponzorstvo','grant','natjecanje','drugo'].map(x=>`<option value="${x}" ${o.type===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Faza</label>
<select id="f-stage">
${STAGES.map(x=>`<option value="${x}" ${o.stage===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos (EUR)</label><input id="f-amount" type="number" step="0.01" value="${o.amount_eur||''}"></div>
<div class="fld"><label>Vjerojatnost %</label><input id="f-prob" type="number" value="${o.probability??20}"></div>
</div>
<div class="fld"><label>Close datum</label><input id="f-close" type="date" value="${(o.close_date||'').slice(0,10)}"></div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(o.napomene||'')}</textarea></div>
`;
}
function readOppForm() {
return {
naziv: document.getElementById('f-naziv').value.trim(),
account_id: parseInt(document.getElementById('f-account_id').value),
contact_id: parseInt(document.getElementById('f-contact_id').value)||null,
type: document.getElementById('f-type').value,
stage: document.getElementById('f-stage').value,
amount_eur: parseFloat(document.getElementById('f-amount').value)||null,
probability: parseInt(document.getElementById('f-prob').value)||0,
close_date: document.getElementById('f-close').value||null,
napomene: document.getElementById('f-napomene').value||null,
};
}
function openOppModal(o) {
const isEdit = !!(o && o.id);
showModal(isEdit?'Uredi priliku':'Nova prilika', oppFormHTML(o||{stage:'prospecting',probability:20}), async () => {
const body = readOppForm();
if (!body.naziv || !body.account_id) { toast('Naziv i Account ID su obavezni', 'err'); return; }
try {
if (isEdit) await api('/opportunities/'+o.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/opportunities', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadOpps(); loadPipeline();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editOpp(id) {
try { const o = await api('/opportunities/'+id); openOppModal(o); }
catch (e) { toast(e.message, 'err'); }
}
async function delOpp(id) {
if (!confirm('Obrisati priliku?')) return;
try { await api('/opportunities/'+id, {method:'DELETE'}); toast('Obrisano'); loadOpps(); loadPipeline(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Activities ──────
async function loadActivities() {
const t = document.getElementById('act-type').value;
const o = document.getElementById('act-open').value;
const qs = new URLSearchParams();
if (t) qs.set('type', t);
if (o) qs.set('open_only', o);
try {
const data = await api('/activities?'+qs.toString());
const tb = document.querySelector('#t-activities tbody');
tb.innerHTML = (data.items||[]).map(a => `
<tr onclick="editActivity(${a.id})">
<td>${esc(a.type)}</td>
<td><strong>${esc(a.subject)}</strong></td>
<td>${esc(a.account_naziv||'—')}</td>
<td>${esc(a.contact_naziv||'—')}</td>
<td>${fmtDT(a.due_at)}</td>
<td>${a.completed_at ? '<span class="chip resolved">done</span>' : '<span class="chip new">open</span>'}</td>
<td>
${!a.completed_at ? `<button class="btn sm gold" onclick="event.stopPropagation();completeActivity(${a.id})">✓</button>` : ''}
<button class="btn sm" onclick="event.stopPropagation();delActivity(${a.id})">×</button>
</td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema aktivnosti.</td></tr>';
document.getElementById('cnt-activities').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function activityFormHTML(a={}) {
return `
<div class="fld-row">
<div class="fld"><label>Tip*</label>
<select id="f-type">
${['call','meeting','email','task','note'].map(x=>`<option value="${x}" ${a.type===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Due (datum/vrijeme)</label><input id="f-due" type="datetime-local" value="${(a.due_at||'').slice(0,16)}"></div>
</div>
<div class="fld"><label>Subject*</label><input id="f-subject" value="${esc(a.subject||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Account ID</label><input id="f-account_id" type="number" value="${a.account_id||''}"></div>
<div class="fld"><label>Contact ID</label><input id="f-contact_id" type="number" value="${a.contact_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Opportunity ID</label><input id="f-opp_id" type="number" value="${a.opportunity_id||''}"></div>
<div class="fld"><label>Lead ID</label><input id="f-lead_id" type="number" value="${a.lead_id||''}"></div>
</div>
<div class="fld"><label>Body / bilješke</label><textarea id="f-body">${esc(a.body||'')}</textarea></div>
`;
}
function readActivityForm() {
const due = document.getElementById('f-due').value;
return {
type: document.getElementById('f-type').value,
subject: document.getElementById('f-subject').value.trim(),
body: document.getElementById('f-body').value||null,
account_id: parseInt(document.getElementById('f-account_id').value)||null,
contact_id: parseInt(document.getElementById('f-contact_id').value)||null,
opportunity_id: parseInt(document.getElementById('f-opp_id').value)||null,
lead_id: parseInt(document.getElementById('f-lead_id').value)||null,
due_at: due ? new Date(due).toISOString() : null,
};
}
function openActivityModal(a) {
const isEdit = !!(a && a.id);
showModal(isEdit?'Uredi aktivnost':'Nova aktivnost', activityFormHTML(a||{type:'task'}), async () => {
const body = readActivityForm();
if (!body.subject) { toast('Subject je obavezan', 'err'); return; }
try {
if (isEdit) await api('/activities/'+a.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/activities', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadActivities();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editActivity(id) {
try { const a = await api('/activities/'+id); openActivityModal(a); }
catch (e) { toast(e.message, 'err'); }
}
async function completeActivity(id) {
try { await api('/activities/'+id+'/complete', {method:'PATCH'}); toast('Označeno gotovo'); loadActivities(); }
catch (e) { toast(e.message, 'err'); }
}
async function delActivity(id) {
if (!confirm('Obrisati aktivnost?')) return;
try { await api('/activities/'+id, {method:'DELETE'}); toast('Obrisano'); loadActivities(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Cases ──────
async function loadCases() {
const q = document.getElementById('case-q').value.trim();
const s = document.getElementById('case-status').value;
const p = document.getElementById('case-priority').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (s) qs.set('status', s);
if (p) qs.set('priority', p);
try {
const data = await api('/cases?'+qs.toString());
const tb = document.querySelector('#t-cases tbody');
tb.innerHTML = (data.items||[]).map(c => `
<tr onclick="editCase(${c.id})">
<td><strong>${esc(c.subject)}</strong></td>
<td>${esc(c.account_naziv||'—')}</td>
<td><span class="chip ${c.status}">${c.status}</span></td>
<td><span class="chip ${c.priority}">${c.priority}</span></td>
<td>${fmtDT(c.created_at)}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delCase(${c.id})">×</button></td>
</tr>
`).join('') || '<tr><td colspan="6" class="empty">Nema caseova.</td></tr>';
document.getElementById('cnt-cases').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function caseFormHTML(k={}) {
return `
<div class="fld"><label>Subject*</label><input id="f-subject" value="${esc(k.subject||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Account ID</label><input id="f-account_id" type="number" value="${k.account_id||''}"></div>
<div class="fld"><label>Contact ID</label><input id="f-contact_id" type="number" value="${k.contact_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Status</label>
<select id="f-status">
${['open','in_progress','waiting','resolved','closed'].map(x=>`<option value="${x}" ${k.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Prioritet</label>
<select id="f-priority">
${['low','normal','high','urgent'].map(x=>`<option value="${x}" ${k.priority===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
</div>
<div class="fld"><label>Opis</label><textarea id="f-desc">${esc(k.description||'')}</textarea></div>
`;
}
function readCaseForm() {
return {
subject: document.getElementById('f-subject').value.trim(),
account_id: parseInt(document.getElementById('f-account_id').value)||null,
contact_id: parseInt(document.getElementById('f-contact_id').value)||null,
status: document.getElementById('f-status').value,
priority: document.getElementById('f-priority').value,
description: document.getElementById('f-desc').value||null,
};
}
function openCaseModal(k) {
const isEdit = !!(k && k.id);
showModal(isEdit?'Uredi case':'Novi case', caseFormHTML(k||{status:'open',priority:'normal'}), async () => {
const body = readCaseForm();
if (!body.subject) { toast('Subject je obavezan', 'err'); return; }
try {
if (isEdit) await api('/cases/'+k.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/cases', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadCases();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editCase(id) {
try { const k = await api('/cases/'+id); openCaseModal(k); }
catch (e) { toast(e.message, 'err'); }
}
async function delCase(id) {
if (!confirm('Obrisati case?')) return;
try { await api('/cases/'+id, {method:'DELETE'}); toast('Obrisano'); loadCases(); }
catch (e) { toast(e.message, 'err'); }
}
// ══════════════════════════════════════════════════════════════════
// AGENT F — Članarine / Liječnički / Obrasci
// ══════════════════════════════════════════════════════════════════
let CURRENT_USER = null;
async function ensureMe() {
if (CURRENT_USER) return CURRENT_USER;
const candidates = ['/sport/api/auth/me', '/sport/api/v2/auth/me', '/sport/api/v2/me'];
for (const url of candidates) {
try {
const r = await fetch(url, {headers:{'Authorization':'Bearer '+TOKEN}});
if (r.ok) { CURRENT_USER = await r.json(); break; }
} catch {}
}
return CURRENT_USER;
}
function isAdminUser() {
if (!CURRENT_USER) return false;
const t = CURRENT_USER.user_type || CURRENT_USER.role || (CURRENT_USER.user && CURRENT_USER.user.user_type) || '';
return t === 'super_admin' || t === 'pgz_admin';
}
// ────── Članarine ──────
async function loadClanarine() {
const klub = document.getElementById('cln-klub').value.trim();
const clan = document.getElementById('cln-clan').value.trim();
const god = document.getElementById('cln-godina').value.trim();
const st = document.getElementById('cln-status').value;
const qs = new URLSearchParams();
if (klub) qs.set('klub_id', klub);
if (clan) qs.set('clan_id', clan);
if (god) qs.set('godina', god);
if (st) qs.set('status', st);
try {
const data = await api('/clanarine?'+qs.toString());
const tb = document.querySelector('#t-clanarine tbody');
tb.innerHTML = (data.items||[]).map(c => `
<tr onclick="editClanarina(${c.id})">
<td><strong>${esc(c.clan_naziv||'—')}</strong> <span style="color:var(--t3)">#${c.clan_id||''}</span></td>
<td>${esc(c.klub_naziv||'—')}</td>
<td>${c.godina}</td>
<td>${esc(c.razdoblje||'—')}</td>
<td>${fmtEur(c.iznos_propisan)}</td>
<td>${fmtEur(c.iznos_placen)}</td>
<td>${fmtDate(c.datum_uplate)}</td>
<td><span class="chip ${c.status}">${c.status}</span></td>
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delClanarina(${c.id})">×</button>` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema članarina za odabrane filtere.</td></tr>';
document.getElementById('cnt-clanarine').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Članarine err: '+e.message, 'err'); }
}
function clanarinaFormHTML(c={}) {
return `
<div class="fld-row">
<div class="fld"><label>Klub ID</label><input id="f-klub_id" type="number" value="${c.klub_id||''}"></div>
<div class="fld"><label>Član ID</label><input id="f-clan_id" type="number" value="${c.clan_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Godina*</label><input id="f-godina" type="number" value="${c.godina||(new Date()).getFullYear()}"></div>
<div class="fld"><label>Razdoblje</label><input id="f-razdoblje" value="${esc(c.razdoblje||'')}" placeholder="npr. cijela godina"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos propisan (EUR)*</label><input id="f-iznos_propisan" type="number" step="0.01" value="${c.iznos_propisan||''}"></div>
<div class="fld"><label>Iznos plaćen (EUR)</label><input id="f-iznos_placen" type="number" step="0.01" value="${c.iznos_placen||0}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Datum uplate</label><input id="f-datum_uplate" type="date" value="${(c.datum_uplate||'').slice(0,10)}"></div>
<div class="fld"><label>Način uplate</label><input id="f-nacin_uplate" value="${esc(c.nacin_uplate||'')}" placeholder="virman / kartica / gotovina"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Referenca</label><input id="f-referenca" value="${esc(c.referenca||'')}"></div>
<div class="fld"><label>Račun broj</label><input id="f-racun_broj" value="${esc(c.racun_broj||'')}"></div>
</div>
<div class="fld"><label>Status</label>
<select id="f-status">
${['nepodmireno','djelomicno','podmireno','storno'].map(x=>`<option value="${x}" ${c.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Napomena</label><textarea id="f-napomena">${esc(c.napomena||'')}</textarea></div>
`;
}
function readClanarinaForm() {
return {
klub_id: parseInt(document.getElementById('f-klub_id').value)||null,
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
godina: parseInt(document.getElementById('f-godina').value)||null,
razdoblje: document.getElementById('f-razdoblje').value.trim()||null,
iznos_propisan: parseFloat(document.getElementById('f-iznos_propisan').value)||0,
iznos_placen: parseFloat(document.getElementById('f-iznos_placen').value)||0,
datum_uplate: document.getElementById('f-datum_uplate').value||null,
nacin_uplate: document.getElementById('f-nacin_uplate').value.trim()||null,
referenca: document.getElementById('f-referenca').value.trim()||null,
racun_broj: document.getElementById('f-racun_broj').value.trim()||null,
status: document.getElementById('f-status').value,
napomena: document.getElementById('f-napomena').value||null,
};
}
function openClanarinaModal(c) {
const isEdit = !!(c && c.id);
showModal(isEdit?'Uredi članarinu':'Nova članarina',
clanarinaFormHTML(c||{godina:(new Date()).getFullYear(), status:'nepodmireno'}),
async () => {
const body = readClanarinaForm();
if (!body.godina || !body.iznos_propisan) { toast('Godina i iznos propisan su obavezni', 'err'); return; }
try {
if (isEdit) await api('/clanarine/'+c.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/clanarine', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadClanarine();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editClanarina(id) {
try { const c = await api('/clanarine/'+id); openClanarinaModal(c); }
catch (e) { toast(e.message, 'err'); }
}
async function delClanarina(id) {
if (!confirm('Obrisati članarinu #'+id+'?')) return;
try { await api('/clanarine/'+id, {method:'DELETE'}); toast('Obrisano'); loadClanarine(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Liječnički ──────
async function loadLijecnicki() {
const klub = document.getElementById('lij-klub').value.trim();
const clan = document.getElementById('lij-clan').value.trim();
const exp = document.getElementById('lij-expiring').value;
const qs = new URLSearchParams();
if (klub) qs.set('klub_id', klub);
if (clan) qs.set('clan_id', clan);
if (exp) qs.set('expiring', exp);
try {
const data = await api('/lijecnicki?'+qs.toString());
const tb = document.querySelector('#t-lijecnicki tbody');
const today = new Date().toISOString().slice(0,10);
tb.innerHTML = (data.items||[]).map(l => {
const expired = l.vrijedi_do && l.vrijedi_do < today;
return `
<tr onclick="editLijecnicki(${l.id})">
<td><strong>${esc(l.clan_naziv||'—')}</strong> <span style="color:var(--t3)">#${l.clan_id||''}</span></td>
<td>${esc(l.klub_naziv||'—')}</td>
<td>${fmtDate(l.datum_pregleda)}</td>
<td>${esc(l.vrsta_pregleda||'—')}</td>
<td>${fmtDate(l.vrijedi_do)} ${expired?'<span class="chip nepodmireno" style="margin-left:6px;">istekao</span>':''}</td>
<td>${esc(l.lijecnik||'—')}</td>
<td>${l.spreman_za_natjecanje ? '<span class="chip spreman">DA</span>' : '<span class="chip nije-spreman">NE</span>'}</td>
<td>${l.placeno ? '<span class="chip podmireno">DA</span>' : '<span class="chip nepodmireno">NE</span>'}</td>
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delLijecnicki(${l.id})">×</button>` : ''}</td>
</tr>`;
}).join('') || '<tr><td colspan="9" class="empty">Nema liječničkih pregleda.</td></tr>';
document.getElementById('cnt-lijecnicki').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Liječnički err: '+e.message, 'err'); }
}
function lijecnickiFormHTML(l={}) {
return `
<div class="fld-row">
<div class="fld"><label>Član ID*</label><input id="f-clan_id" type="number" value="${l.clan_id||''}"></div>
<div class="fld"><label>Klub ID</label><input id="f-klub_id" type="number" value="${l.klub_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Datum pregleda*</label><input id="f-datum_pregleda" type="date" value="${(l.datum_pregleda||'').slice(0,10)}"></div>
<div class="fld"><label>Vrijedi do</label><input id="f-vrijedi_do" type="date" value="${(l.vrijedi_do||'').slice(0,10)}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Vrsta pregleda</label>
<select id="f-vrsta_pregleda">
<option value="">—</option>
${['osnovni','prosireni','specijalisticki','kontrolni','povratak_nakon_ozljede'].map(x=>`<option value="${x}" ${l.vrsta_pregleda===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Ustanova</label><input id="f-ustanova" value="${esc(l.ustanova||'')}"></div>
</div>
<div class="fld"><label>Liječnik</label><input id="f-lijecnik" value="${esc(l.lijecnik||'')}"></div>
<div class="fld-row">
<div class="fld"><label><input id="f-spreman" type="checkbox" ${l.spreman_za_natjecanje!==false?'checked':''}> Spreman za natjecanje</label></div>
<div class="fld"><label><input id="f-placeno" type="checkbox" ${l.placeno?'checked':''}> Plaćeno</label></div>
</div>
<div class="fld-row">
<div class="fld"><label><input id="f-ekg" type="checkbox" ${l.ekg?'checked':''}> EKG</label></div>
<div class="fld"><label><input id="f-krv" type="checkbox" ${l.krv?'checked':''}> Krvna slika</label></div>
<div class="fld"><label><input id="f-spirometrija" type="checkbox" ${l.spirometrija?'checked':''}> Spirometrija</label></div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos ukupno (EUR)</label><input id="f-iznos" type="number" step="0.01" value="${l.iznos||''}"></div>
<div class="fld"><label>Datum plaćanja</label><input id="f-datum_placanja" type="date" value="${(l.datum_placanja||'').slice(0,10)}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>ZZJZ EUR</label><input id="f-iznos_zzjz" type="number" step="0.01" value="${l.iznos_zzjz||0}"></div>
<div class="fld"><label>Klub EUR</label><input id="f-iznos_klub" type="number" step="0.01" value="${l.iznos_klub||0}"></div>
<div class="fld"><label>Član EUR</label><input id="f-iznos_clan" type="number" step="0.01" value="${l.iznos_clan||0}"></div>
</div>
<div class="fld"><label>Nalaz</label><textarea id="f-nalaz">${esc(l.nalaz||'')}</textarea></div>
<div class="fld"><label>Komentar liječnika</label><textarea id="f-komentar_lijecnika">${esc(l.komentar_lijecnika||'')}</textarea></div>
<div class="fld"><label>Napomena</label><textarea id="f-napomena">${esc(l.napomena||'')}</textarea></div>
`;
}
function readLijecnickiForm() {
return {
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
klub_id: parseInt(document.getElementById('f-klub_id').value)||null,
datum_pregleda: document.getElementById('f-datum_pregleda').value||null,
vrijedi_do: document.getElementById('f-vrijedi_do').value||null,
vrsta_pregleda: document.getElementById('f-vrsta_pregleda').value||null,
ustanova: document.getElementById('f-ustanova').value.trim()||null,
lijecnik: document.getElementById('f-lijecnik').value.trim()||null,
spreman_za_natjecanje: document.getElementById('f-spreman').checked,
ekg: document.getElementById('f-ekg').checked,
krv: document.getElementById('f-krv').checked,
spirometrija: document.getElementById('f-spirometrija').checked,
nalaz: document.getElementById('f-nalaz').value||null,
komentar_lijecnika: document.getElementById('f-komentar_lijecnika').value||null,
iznos: parseFloat(document.getElementById('f-iznos').value)||null,
iznos_zzjz: parseFloat(document.getElementById('f-iznos_zzjz').value)||0,
iznos_klub: parseFloat(document.getElementById('f-iznos_klub').value)||0,
iznos_clan: parseFloat(document.getElementById('f-iznos_clan').value)||0,
datum_placanja: document.getElementById('f-datum_placanja').value||null,
placeno: document.getElementById('f-placeno').checked,
napomena: document.getElementById('f-napomena').value||null,
};
}
function openLijecnickiModal(l) {
const isEdit = !!(l && l.id);
showModal(isEdit?'Uredi liječnički pregled':'Novi liječnički pregled',
lijecnickiFormHTML(l||{spreman_za_natjecanje:true}),
async () => {
const body = readLijecnickiForm();
if (!body.clan_id || !body.datum_pregleda) { toast('Član i datum su obavezni', 'err'); return; }
try {
if (isEdit) await api('/lijecnicki/'+l.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/lijecnicki', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadLijecnicki();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editLijecnicki(id) {
try { const l = await api('/lijecnicki/'+id); openLijecnickiModal(l); }
catch (e) { toast(e.message, 'err'); }
}
async function delLijecnicki(id) {
if (!confirm('Obrisati pregled #'+id+'?')) return;
try { await api('/lijecnicki/'+id, {method:'DELETE'}); toast('Obrisano'); loadLijecnicki(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Obrasci ──────
let OBR_TEMPLATES = [];
let OBR_SELECTED_TPL = null;
async function loadObrasciTemplates() {
try {
const data = await api('/obrasci');
OBR_TEMPLATES = data.items||[];
const list = document.getElementById('obr-tpl-list');
if (!OBR_TEMPLATES.length) {
list.innerHTML = '<div class="empty">Nema predložaka.</div>';
return;
}
list.innerHTML = OBR_TEMPLATES.map(t => `
<div class="tpl-row" data-id="${t.id}" onclick="selectObrasciTpl(${t.id})">
<div class="tpl-n">${esc(t.naziv)}</div>
<div class="tpl-c">${esc(t.kategorija||'—')} · ${esc(t.code)}</div>
</div>
`).join('');
document.getElementById('cnt-obrasci').textContent = OBR_TEMPLATES.length;
} catch (e) { toast('Obrasci err: '+e.message, 'err'); }
}
function selectObrasciTpl(id) {
OBR_SELECTED_TPL = OBR_TEMPLATES.find(t => t.id === id);
document.querySelectorAll('#obr-tpl-list .tpl-row').forEach(r =>
r.classList.toggle('sel', parseInt(r.dataset.id) === id));
document.getElementById('obr-right-title').textContent =
'Predložak: ' + (OBR_SELECTED_TPL?.naziv || '');
openObrasciSubmitModal();
}
function obrasciSubmitFormHTML(t) {
const sch = (t.schema_json && t.schema_json.fields) || [];
let inner = '';
if (sch.length) {
inner = sch.map((f, i) => {
const lbl = esc(f.label || f.name || ('Polje '+(i+1)));
const req = f.required ? '*' : '';
const name = esc(f.name || ('f'+i));
const type = f.type || 'text';
if (type === 'textarea') {
return `<div class="fld"><label>${lbl}${req}</label><textarea data-fname="${name}"></textarea></div>`;
}
if (type === 'select' && Array.isArray(f.options)) {
return `<div class="fld"><label>${lbl}${req}</label><select data-fname="${name}">
${f.options.map(o => `<option value="${esc(o)}">${esc(o)}</option>`).join('')}
</select></div>`;
}
const it = (type==='number'?'number': type==='date'?'date': type==='email'?'email':'text');
return `<div class="fld"><label>${lbl}${req}</label><input data-fname="${name}" type="${it}"></div>`;
}).join('');
} else {
inner = `<div class="fld"><label>Podaci (JSON)</label><textarea data-fname="__json" placeholder='{"polje":"vrijednost"}'></textarea></div>`;
}
return `
<div style="background:var(--bg3); padding:8px 10px; border-radius:4px; margin-bottom:10px; font-size:11px; color:var(--t2);">
<strong>${esc(t.naziv)}</strong> · ${esc(t.kategorija||'—')}<br>
${esc(t.opis||'')}
</div>
<div class="fld-row">
<div class="fld"><label>Klub ID</label><input id="f-sub-klub" type="number"></div>
<div class="fld"><label>Član ID</label><input id="f-sub-clan" type="number"></div>
</div>
${inner}
<div class="fld"><label>Status</label>
<select id="f-sub-status">
<option value="draft">draft</option>
<option value="submitted" selected>submitted</option>
</select>
</div>
`;
}
function openObrasciSubmitModal() {
const t = OBR_SELECTED_TPL; if (!t) return;
showModal('Podnesi: '+t.naziv, obrasciSubmitFormHTML(t), async () => {
const klub_id = parseInt(document.getElementById('f-sub-klub').value)||null;
const clan_id = parseInt(document.getElementById('f-sub-clan').value)||null;
const status = document.getElementById('f-sub-status').value;
const data = {};
document.querySelectorAll('#m-body [data-fname]').forEach(el => {
const k = el.dataset.fname;
if (k === '__json') {
try { Object.assign(data, JSON.parse(el.value||'{}')); }
catch(e) { toast('JSON nije validan', 'err'); throw e; }
} else {
data[k] = el.value;
}
});
try {
await api('/obrasci/submission', {method:'POST', body:JSON.stringify({
template_id: t.id, template_code: t.code, klub_id, clan_id, data, status
})});
toast('Obrazac podnesen'); closeModal(); loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function loadObrasciSubmissions() {
const st = document.getElementById('obr-status').value;
const klb = document.getElementById('obr-klub').value.trim();
const qs = new URLSearchParams();
if (st) qs.set('status', st);
if (klb) qs.set('klub_id', klb);
try {
const data = await api('/obrasci/submission?'+qs.toString());
const tb = document.querySelector('#t-obr-sub tbody');
const admin = isAdminUser();
tb.innerHTML = (data.items||[]).map(s => `
<tr onclick="openObrasciSubDetail(${s.id})">
<td><strong>#${s.id}</strong></td>
<td>${esc(s.template_naziv||s.template_code||'—')}</td>
<td>${esc(s.klub_naziv||'—')}</td>
<td>${esc(s.clan_naziv||'—')}</td>
<td><span class="chip ${s.status}">${s.status}</span></td>
<td>${fmtDT(s.submitted_at)}</td>
<td>${fmtDT(s.approved_at)}</td>
<td>${admin && (s.status==='submitted'||s.status==='draft') ? `
<button class="btn sm gold" onclick="event.stopPropagation();subStatus(${s.id},'approved')">✓</button>
<button class="btn sm danger" onclick="event.stopPropagation();subStatus(${s.id},'rejected')">×</button>
` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema podnesenih obrazaca.</td></tr>';
} catch (e) { toast('Submissions err: '+e.message, 'err'); }
}
async function openObrasciSubDetail(id) {
try {
const s = await api('/obrasci/submission/'+id);
const admin = isAdminUser();
const dataPretty = JSON.stringify(s.data||{}, null, 2);
const body = `
<div style="background:var(--bg3); padding:9px 11px; border-radius:4px; margin-bottom:10px; font-size:11px;">
<div><strong>${esc(s.template_naziv||'—')}</strong> <span style="color:var(--t3)">(${esc(s.template_code||'')})</span></div>
<div style="margin-top:3px; color:var(--t2);">
Klub: ${esc(s.klub_naziv||'—')} · Član: ${esc(s.clan_naziv||'—')}<br>
Submitter: ${esc(s.submitter_email||'—')} · Status: <span class="chip ${s.status}">${s.status}</span>
</div>
<div style="margin-top:4px; color:var(--t3); font-family:var(--mono); font-size:10px;">
submitted: ${fmtDT(s.submitted_at)} · reviewed: ${fmtDT(s.reviewed_at)} · approved: ${fmtDT(s.approved_at)}
${s.rejected_reason ? '<br>rejected_reason: '+esc(s.rejected_reason) : ''}
</div>
</div>
<div class="fld"><label>Podaci (JSON)</label>
<textarea readonly style="font-family:var(--mono); min-height:200px;">${esc(dataPretty)}</textarea>
</div>
`;
document.getElementById('m-title').textContent = 'Obrazac #'+s.id;
document.getElementById('m-body').innerHTML = body;
const foot = document.getElementById('m-foot');
foot.innerHTML = `<button class="btn" onclick="closeModal()">Zatvori</button>` +
(admin && (s.status==='submitted'||s.status==='draft') ? `
<button class="btn danger" onclick="subStatusFromModal(${s.id},'rejected')">Odbij</button>
<button class="btn primary" onclick="subStatusFromModal(${s.id},'approved')">Odobri</button>
` : '') +
((s.status==='draft' && (CURRENT_USER && (CURRENT_USER.user_id===s.user_id || admin))) ?
`<button class="btn gold" onclick="subStatusFromModal(${s.id},'submitted')">Pošalji</button>` : '');
document.getElementById('modal').classList.add('on');
} catch (e) { toast(e.message, 'err'); }
}
async function subStatusFromModal(id, status) {
let reason = null;
if (status === 'rejected') {
reason = prompt('Razlog odbijanja:'); if (reason === null) return;
}
try {
await api('/obrasci/submission/'+id+'/status', {
method:'PUT', body:JSON.stringify({status, rejected_reason: reason})
});
toast('Status: '+status);
closeModal();
// Restore footer
document.getElementById('m-foot').innerHTML = `
<button class="btn" onclick="closeModal()">Odustani</button>
<button class="btn primary" id="m-save">Spremi</button>`;
loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
}
async function subStatus(id, status) {
let reason = null;
if (status === 'rejected') {
reason = prompt('Razlog odbijanja:'); if (reason === null) return;
}
try {
await api('/obrasci/submission/'+id+'/status', {
method:'PUT', body:JSON.stringify({status, rejected_reason: reason})
});
toast('Status: '+status); loadObrasciSubmissions();
} catch (e) { toast('Greška: '+e.message, 'err'); }
}
// ────── Modal helpers ──────
function showModal(title, bodyHTML, onSave) {
document.getElementById('m-title').textContent = title;
document.getElementById('m-body').innerHTML = bodyHTML;
// Restore the standard footer (it may have been replaced by detail views)
document.getElementById('m-foot').innerHTML =
'<button class="btn" onclick="closeModal()">Odustani</button>' +
'<button class="btn primary" id="m-save">Spremi</button>';
document.getElementById('m-save').onclick = onSave;
document.getElementById('modal').classList.add('on');
}
function closeModal() { document.getElementById('modal').classList.remove('on'); }
document.getElementById('modal').addEventListener('click', e => {
if (e.target.id === 'modal') closeModal();
});
// ────── OCR (lightweight /api/ocr) ──────
const OCR_API = '/sport/api/ocr';
function ocrOpen(){ document.getElementById('ocr-modal').style.display = 'flex'; }
function ocrClose(){ document.getElementById('ocr-modal').style.display = 'none'; }
async function ocrCrmHealth(){
const out = document.getElementById('ocr-crm-health');
if(out) out.textContent = '...checking';
try {
const r = await fetch(OCR_API + '/health');
const j = await r.json();
if(out){
out.textContent = 'tesseract: ' + (j.tesseract_available ? 'OK' : 'NO') +
' · pdf2image: ' + (j.pdf2image_available ? 'OK' : 'NO');
}
} catch(e){
if(out) out.textContent = 'health err: ' + (e && e.message || e);
}
}
async function ocrCrmUpload(){
const f = document.getElementById('ocr-crm-file').files[0];
const stat = document.getElementById('ocr-crm-status');
const fields = document.getElementById('ocr-crm-fields');
const txt = document.getElementById('ocr-crm-text');
if(!f){ if(stat) stat.textContent = 'odaberi datoteku'; return; }
if(stat) stat.textContent = 'uploading…';
const fd = new FormData();
fd.append('file', f);
try {
const r = await fetch(OCR_API + '/upload', { method: 'POST', body: fd });
const j = await r.json();
if(!r.ok){ if(stat) stat.textContent = 'err ' + r.status; return; }
const ex = j.extracted || {};
fields.innerHTML =
'<table style="width:100%;font-size:12px">'
+ '<tr><th style="text-align:left;width:140px">vendor</th><td>'+(ex.vendor||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">OIB</th><td>'+(ex.oib||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">invoice_no</th><td>'+(ex.invoice_no||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">date</th><td>'+(ex.date||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">amount</th><td>'+(ex.amount==null?'—':ex.amount)+'</td></tr>'
+ '<tr><th style="text-align:left">ocr_status</th><td>'+(j.ocr_status||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">confidence</th><td>'+(j.ocr_confidence==null?'—':j.ocr_confidence)+'</td></tr>'
+ '<tr><th style="text-align:left">file</th><td>'+((j.file_name||'?')+' · '+(j.file_size||0)+' B')+'</td></tr>'
+ '</table>';
txt.textContent = j.ocr_text || '— (prazno / OCR nije izvršen) —';
if(stat) stat.textContent = 'done · id=' + (j.id == null ? 'n/a' : j.id);
} catch(e){
if(stat) stat.textContent = 'err: ' + (e && e.message || e);
}
}
// ────── Init ──────
loadMe();
ensureMe();
loadPipeline();
</script>
</body>
</html>