Files

2189 lines
110 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<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; }
/* === CRM v2 redesign — sticky tabs, ERP-style (RUSH-4 / 2026-05-05) === */
.tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--rim);
padding:0 18px; gap:2px; overflow-x:auto; overflow-y:hidden;
position:sticky; top:0; z-index:6; white-space:nowrap;
scrollbar-width:thin; scrollbar-color:var(--rim) transparent; }
.tabs::-webkit-scrollbar { height:4px; }
.tabs::-webkit-scrollbar-thumb { background:var(--rim); }
.tab { padding:11px 16px; cursor:pointer; color:var(--t2); border-bottom:2px solid transparent;
font-weight:600; user-select:none; font-size:12px; flex:0 0 auto; transition:all .15s; }
.tab:hover { color:var(--t1); }
.tab.active { color:var(--pgz-gold); border-bottom-color:var(--pgz-gold); 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-gold); color:#000; }
/* === Card grid for Accounts/Contacts/Leads/Opps === */
.cgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; margin-top:6px; }
.ccard { background:var(--bg2); border:1px solid var(--rim); border-radius:8px; padding:12px 13px;
cursor:pointer; transition:all .15s; position:relative; }
.ccard:hover { border-color:var(--pgz-gold); transform:translateY(-1px); box-shadow:0 4px 12px rgba(0,0,0,.3); }
.ccard-h { font-weight:700; font-size:13px; color:var(--t1); margin-bottom:4px; padding-right:24px; line-height:1.25; }
.ccard-sub { font-size:11px; color:var(--t2); margin-bottom:8px; }
.ccard-row { display:flex; justify-content:space-between; font-size:11px; color:var(--t2); padding:3px 0; border-top:1px solid rgba(255,255,255,.04); }
.ccard-row:first-of-type { border-top:0; }
.ccard-row strong { color:var(--t1); font-weight:600; }
.ccard-actions { position:absolute; top:8px; right:8px; display:flex; gap:4px; }
.ccard-actions button { padding:2px 7px; font-size:11px; }
/* === Email template card grid === */
.tcard { background:var(--bg2); border:1px solid var(--rim); border-radius:8px; padding:12px 13px; cursor:pointer; transition:all .15s; }
.tcard:hover { border-color:var(--pgz-gold); }
.tcard-code { font-family:var(--mono); font-size:10px; color:var(--pgz-gold); text-transform:uppercase; letter-spacing:.5px; }
.tcard-naziv { font-weight:700; font-size:13px; color:var(--t1); margin:4px 0; }
.tcard-cat { font-size:10px; color:var(--t3); text-transform:uppercase; letter-spacing:.4px; margin-bottom:6px; }
.tcard-snip { font-size:11px; color:var(--t2); line-height:1.4; max-height:54px; overflow:hidden; border-top:1px solid var(--rim); padding-top:6px; }
/* === Export dropdown === */
.exp { position:relative; display:inline-block; }
.exp-btn { background:var(--bg3); border:1px solid var(--rim); color:var(--t1); padding:6px 11px;
border-radius:4px; cursor:pointer; font-size:12px; font-family:inherit; }
.exp-btn:hover { border-color:var(--pgz-gold); color:var(--pgz-gold); }
.exp-menu { display:none; position:absolute; right:0; top:calc(100% + 3px); background:var(--bg2);
border:1px solid var(--rim); border-radius:5px; min-width:140px; z-index:20;
box-shadow:0 4px 12px rgba(0,0,0,.5); overflow:hidden; }
.exp-menu.on { display:block; }
.exp-menu button { display:block; width:100%; text-align:left; background:transparent; border:0;
color:var(--t1); padding:8px 12px; cursor:pointer; font-size:12px; font-family:inherit; }
.exp-menu button:hover { background:var(--bg3); color:var(--pgz-gold); }
@media print {
.topbar, .tabs, .toolbar, footer, #toast, .modal, .ccard-actions, .exp { display:none !important; }
body, .main { background:#fff !important; color:#000 !important; overflow:visible !important; height:auto !important; }
.ccard, .tcard, .card { background:#fff !important; color:#000 !important; border:1px solid #999 !important; break-inside:avoid; }
table th, table td { color:#000 !important; border-color:#999 !important; }
}
.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>
<script src="/static/shared/sortable.js" defer></script>
</head>
<body>
<div class="topbar">
<span class="logo">PGŽ SPORT</span>
<span class="sep"></span>
<span class="title">CRM v2 — Salesforce-Lite</span>
<div class="right">
<span id="me"></span>
<a href="/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="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 class="tab" data-tab="emailtpl">E-mail templates <span class="count" id="cnt-emailtpl">·</span></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>
<div class="main">
<!-- ────── PIPELINE (legacy tab removed — KPIs + kanban now live in Opportunities tab) ────── -->
<!-- ────── 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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-accounts')">📥 Export ▾</button>
<div class="exp-menu" id="exp-accounts">
<button onclick="exportTab('accounts','csv')">CSV</button>
<button onclick="exportTab('accounts','xlsx')">XLSX</button>
<button onclick="exportTab('accounts','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openAccountModal()">+ Novi account</button>
</div>
<div id="acc-cards" class="cgrid"></div>
<div class="card" style="display:none"><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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-contacts')">📥 Export ▾</button>
<div class="exp-menu" id="exp-contacts">
<button onclick="exportTab('contacts','csv')">CSV</button>
<button onclick="exportTab('contacts','xlsx')">XLSX</button>
<button onclick="exportTab('contacts','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openContactModal()">+ Novi kontakt</button>
</div>
<div id="con-cards" class="cgrid"></div>
<div class="card" style="display:none"><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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-leads')">📥 Export ▾</button>
<div class="exp-menu" id="exp-leads">
<button onclick="exportTab('leads','csv')">CSV</button>
<button onclick="exportTab('leads','xlsx')">XLSX</button>
<button onclick="exportTab('leads','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openLeadModal()">+ Novi lead</button>
</div>
<div id="lead-cards" class="cgrid"></div>
<div class="card" style="display:none"><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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-opps')">📥 Export ▾</button>
<div class="exp-menu" id="exp-opps">
<button onclick="exportTab('opps','csv')">CSV</button>
<button onclick="exportTab('opps','xlsx')">XLSX</button>
<button onclick="exportTab('opps','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openOppModal()">+ Nova prilika</button>
</div>
<!-- KPIs + Pipeline kanban (migrirano iz Pipeline taba) -->
<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="card">
<div class="card-h"><div class="card-t">Pipeline kanban</div>
<button class="btn sm" onclick="loadPipeline()">↻ Refresh</button>
</div>
<div class="card-b"><div class="kanban" id="kanban"></div></div>
</div>
<div id="opp-cards" class="cgrid"></div>
<div class="card" style="display:none"><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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-activities')">📥 Export ▾</button>
<div class="exp-menu" id="exp-activities">
<button onclick="exportTab('activities','csv')">CSV</button>
<button onclick="exportTab('activities','xlsx')">XLSX</button>
<button onclick="exportTab('activities','pdf')">PDF</button>
</div>
</div>
<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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-cases')">📥 Export ▾</button>
<div class="exp-menu" id="exp-cases">
<button onclick="exportTab('cases','csv')">CSV</button>
<button onclick="exportTab('cases','xlsx')">XLSX</button>
<button onclick="exportTab('cases','pdf')">PDF</button>
</div>
</div>
<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 on" 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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-clanarine')">📥 Export ▾</button>
<div class="exp-menu" id="exp-clanarine">
<button onclick="exportTab('clanarine','csv')">CSV</button>
<button onclick="exportTab('clanarine','xlsx')">XLSX</button>
<button onclick="exportTab('clanarine','pdf')">PDF</button>
</div>
</div>
<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>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-lijecnicki')">📥 Export ▾</button>
<div class="exp-menu" id="exp-lijecnicki">
<button onclick="exportTab('lijecnicki','csv')">CSV</button>
<button onclick="exportTab('lijecnicki','xlsx')">XLSX</button>
<button onclick="exportTab('lijecnicki','pdf')">PDF</button>
</div>
</div>
<button id="lij-srv-export-btn" class="export-btn" type="button" title="Server-side export svih redaka iz baze">Export ▾ (svi)</button>
<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 class="exp"><button class="exp-btn" onclick="toggleExp('exp-obrasci')">📥 Export ▾</button>
<div class="exp-menu" id="exp-obrasci">
<button onclick="exportTab('obrasci','csv')">CSV</button>
<button onclick="exportTab('obrasci','xlsx')">XLSX</button>
<button onclick="exportTab('obrasci','pdf')">PDF</button>
</div>
</div>
</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>
<!-- ────── E-MAIL TEMPLATES (RUSH-4 / 2026-05-05) ────── -->
<div class="tab-c" id="tc-emailtpl">
<div class="toolbar">
<input id="etpl-q" placeholder="Pretraži (kod / naziv)…">
<select id="etpl-cat">
<option value="">— Sve kategorije —</option>
<option value="clanarine">clanarine</option>
<option value="lijecnicki">lijecnicki</option>
<option value="obrasci">obrasci</option>
<option value="opci">opci</option>
</select>
<button class="btn" onclick="loadEmailTpls()">Pretraži</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-emailtpl')">📥 Export ▾</button>
<div class="exp-menu" id="exp-emailtpl">
<button onclick="exportTab('emailtpl','csv')">CSV</button>
<button onclick="exportTab('emailtpl','xlsx')">XLSX</button>
<button onclick="exportTab('emailtpl','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openEmailTplModal()">+ Novi predložak</button>
</div>
<div id="etpl-grid" class="cgrid"></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') || (name==='emailtpl' && c.id==='tc-emailtpl')));
// Always refresh KPIs/pipeline counts in background
if (name==='accounts') loadAccounts();
if (name==='contacts') loadContacts();
if (name==='leads') loadLeads();
if (name==='opportunities') { loadOpps(); loadPipeline(); }
if (name==='activities') loadActivities();
if (name==='cases') loadCases();
if (name==='clanarine') loadClanarine();
if (name==='lijecnicki') loadLijecnicki();
if (name==='obrasci') { loadObrasciTemplates(); loadObrasciSubmissions(); }
if (name==='emailtpl') loadEmailTpls();
}
// ────── Export dropdown helpers ──────
function toggleExp(id) {
const m = document.getElementById(id); if (!m) return;
document.querySelectorAll('.exp-menu').forEach(x => { if (x.id !== id) x.classList.remove('on'); });
m.classList.toggle('on');
}
document.addEventListener('click', e => {
if (!e.target.closest('.exp')) document.querySelectorAll('.exp-menu').forEach(x => x.classList.remove('on'));
});
// Tabular export: pull rows from current cards/tables for a given tab
const EXPORT_HEADERS = {
accounts: ['Naziv','Tip','Grad','OIB','Email','Telefon','Kontakti','Opps','Owner'],
contacts: ['Ime','Prezime','Account','Funkcija','Email','Telefon'],
leads: ['Ime','Prezime','Organizacija','Email','Telefon','Izvor','Status'],
opps: ['Naziv','Account','Tip','Faza','EUR','Vjerojatnost %','Close'],
activities: ['Tip','Subject','Account','Kontakt','Due','Status'],
cases: ['Subject','Account','Status','Priority','Stvoren'],
clanarine: ['Član','Klub','Godina','Razdoblje','Propisan','Plaćen','Datum uplate','Status'],
lijecnicki: ['Član','Klub','Datum','Vrsta','Vrijedi do','Liječnik','Spreman','Plaćeno'],
obrasci: ['ID','Predložak','Klub','Član','Status','Submitted','Approved'],
emailtpl: ['Code','Naziv','Kategorija','Subject','Active'],
};
const EXPORT_CACHE = {}; // tab → array of row arrays
function setExportRows(tab, rows) { EXPORT_CACHE[tab] = rows; }
function exportTab(tab, fmt) {
document.querySelectorAll('.exp-menu').forEach(x => x.classList.remove('on'));
const headers = EXPORT_HEADERS[tab] || [];
const rows = EXPORT_CACHE[tab] || [];
if (!rows.length) { toast('Nema redaka za export', 'err'); return; }
const fname = 'crm_' + tab + '_' + new Date().toISOString().slice(0,10);
if (fmt === 'csv') {
const csv = [headers, ...rows].map(r => r.map(c => {
const s = (c==null?'':String(c)).replace(/"/g,'""');
return /[",\n;]/.test(s) ? '"'+s+'"' : s;
}).join(';')).join('\r\n');
const blob = new Blob([''+csv], {type:'text/csv;charset=utf-8'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = fname + '.csv'; a.click();
setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
toast('CSV: '+rows.length+' redaka');
} else if (fmt === 'xlsx') {
if (typeof XLSX === 'undefined') { toast('SheetJS nije učitan', 'err'); return; }
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, tab);
XLSX.writeFile(wb, fname + '.xlsx');
toast('XLSX: '+rows.length+' redaka');
} else if (fmt === 'pdf') {
// Print-friendly window: use a fresh document with table only
const w = window.open('', '_blank', 'width=900,height=700');
const html = `<!doctype html><html><head><title>${fname}</title>
<style>
body{font-family:Arial,sans-serif;font-size:11px;margin:18px;color:#000}
h2{font-size:15px;margin:0 0 8px}
table{width:100%;border-collapse:collapse;font-size:10px}
th{background:#eee;text-align:left;padding:5px 7px;border:1px solid #999;text-transform:uppercase;font-size:9px;letter-spacing:.4px}
td{padding:4px 7px;border:1px solid #ccc}
tr:nth-child(even) td{background:#fafafa}
</style><script src="/static/shared/sortable.js" defer></script>
</head><body>
<h2>PGŽ Sport CRM — ${tab.toUpperCase()} <small style="font-weight:400;color:#666">(${new Date().toLocaleString('hr-HR')})</small></h2>
<table><thead><tr>${headers.map(h=>'<th>'+h+'</th>').join('')}</tr></thead>
<tbody>${rows.map(r=>'<tr>'+r.map(c=>'<td>'+(c==null?'':String(c).replace(/&/g,'&amp;').replace(/</g,'&lt;'))+'</td>').join('')+'</tr>').join('')}</tbody></table>
</body></html>`;
w.document.write(html); w.document.close();
setTimeout(() => { try { w.focus(); w.print(); } catch(e){} }, 350);
toast('PDF print dialog otvoren');
}
}
// ────── /me ──────
async function loadMe() {
try {
const tok = getToken();
const me = await fetch('/sport/api/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 items = data.items||[];
// Card grid (primary)
const grid = document.getElementById('acc-cards');
if (grid) {
grid.innerHTML = items.map(a => `
<div class="ccard" onclick="editAccount(${a.id})">
<div class="ccard-actions">
<button class="btn sm danger" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button>
</div>
<div class="ccard-h">${esc(a.naziv)}</div>
<div class="ccard-sub"><span class="chip ${a.type}">${esc(a.type)}</span> ${esc(a.grad||'')}</div>
<div class="ccard-row"><span>OIB</span><strong>${esc(a.oib||'—')}</strong></div>
<div class="ccard-row"><span>Email</span><strong>${esc(a.email||'—')}</strong></div>
<div class="ccard-row"><span>Kontakti / Opps</span><strong>${a.contacts_n||0} / ${a.opps_n||0}</strong></div>
<div class="ccard-row"><span>Owner</span><strong>${esc(a.owner_email||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema accounta — dodajte prvi.</div>';
}
// Hidden table (compat for legacy code/exports)
const tb = document.querySelector('#t-accounts tbody');
if (tb) {
tb.innerHTML = 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.</td></tr>';
}
setExportRows('accounts', items.map(a => [a.naziv, a.type, a.grad||'', a.oib||'', a.email||'', a.telefon||'', a.contacts_n||0, a.opps_n||0, a.owner_email||'']));
document.getElementById('cnt-accounts').textContent = 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 items = data.items||[];
const grid = document.getElementById('con-cards');
if (grid) {
grid.innerHTML = items.map(c => `
<div class="ccard" onclick="editContact(${c.id})">
<div class="ccard-actions">
<button class="btn sm danger" onclick="event.stopPropagation();delContact(${c.id})">×</button>
</div>
<div class="ccard-h">${esc(c.ime)} ${esc(c.prezime)}</div>
<div class="ccard-sub">${esc(c.funkcija||'—')} · ${esc(c.account_naziv||'—')}</div>
<div class="ccard-row"><span>Email</span><strong>${esc(c.email||'—')}</strong></div>
<div class="ccard-row"><span>Telefon</span><strong>${esc(c.telefon||c.mobitel||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema kontakata.</div>';
}
const tb = document.querySelector('#t-contacts tbody');
if (tb) {
tb.innerHTML = 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('');
}
setExportRows('contacts', items.map(c => [c.ime||'', c.prezime||'', c.account_naziv||'', c.funkcija||'', c.email||'', c.telefon||c.mobitel||'']));
document.getElementById('cnt-contacts').textContent = 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 items = data.items||[];
const grid = document.getElementById('lead-cards');
if (grid) {
grid.innerHTML = items.map(l => `
<div class="ccard" onclick="editLead(${l.id})">
<div class="ccard-actions">
${l.status!=='converted' ? `<button class="btn sm gold" onclick="event.stopPropagation();convertLead(${l.id})"></button>` : ''}
<button class="btn sm danger" onclick="event.stopPropagation();delLead(${l.id})">×</button>
</div>
<div class="ccard-h">${esc(l.ime||'')} ${esc(l.prezime||'')}</div>
<div class="ccard-sub"><span class="chip ${l.status}">${l.status}</span> ${esc(l.organizacija||'—')}</div>
<div class="ccard-row"><span>Email</span><strong>${esc(l.email||'—')}</strong></div>
<div class="ccard-row"><span>Telefon</span><strong>${esc(l.telefon||'—')}</strong></div>
<div class="ccard-row"><span>Izvor</span><strong>${esc(l.izvor||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema leadova.</div>';
}
const tb = document.querySelector('#t-leads tbody');
if (tb) {
tb.innerHTML = 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><button class="btn sm" onclick="event.stopPropagation();delLead(${l.id})">×</button></td>
</tr>`).join('');
}
setExportRows('leads', items.map(l => [l.ime||'', l.prezime||'', l.organizacija||'', l.email||'', l.telefon||'', l.izvor||'', l.status||'']));
document.getElementById('cnt-leads').textContent = 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 items = data.items||[];
const grid = document.getElementById('opp-cards');
if (grid) {
grid.innerHTML = items.map(o => `
<div class="ccard" onclick="editOpp(${o.id})">
<div class="ccard-actions">
<button class="btn sm danger" onclick="event.stopPropagation();delOpp(${o.id})">×</button>
</div>
<div class="ccard-h">${esc(o.naziv)}</div>
<div class="ccard-sub">${esc(o.account_naziv||'—')} · <span class="chip">${esc(o.stage)}</span></div>
<div class="ccard-row"><span>Iznos</span><strong style="color:var(--pgz-gold)">${fmtEur(o.amount_eur)}</strong></div>
<div class="ccard-row"><span>Vjerojatnost</span><strong>${o.probability||0}%</strong></div>
<div class="ccard-row"><span>Close</span><strong>${fmtDate(o.close_date)}</strong></div>
<div class="ccard-row"><span>Tip</span><strong>${esc(o.type||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema prilika.</div>';
}
const tb = document.querySelector('#t-opps tbody');
if (tb) {
tb.innerHTML = 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('');
}
setExportRows('opps', items.map(o => [o.naziv||'', o.account_naziv||'', o.type||'', o.stage||'', o.amount_eur||0, o.probability||0, fmtDate(o.close_date)]));
document.getElementById('cnt-opps').textContent = 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>';
setExportRows('activities', (data.items||[]).map(a => [a.type||'', a.subject||'', a.account_naziv||'', a.contact_naziv||'', fmtDT(a.due_at), a.completed_at?'done':'open']));
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>';
setExportRows('cases', (data.items||[]).map(c => [c.subject||'', c.account_naziv||'', c.status||'', c.priority||'', fmtDT(c.created_at)]));
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/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>';
setExportRows('clanarine', (data.items||[]).map(c => [c.clan_naziv||'', c.klub_naziv||'', c.godina||'', c.razdoblje||'', c.iznos_propisan||0, c.iznos_placen||0, fmtDate(c.datum_uplate), c.status||'']));
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>';
setExportRows('lijecnicki', (data.items||[]).map(l => [l.clan_naziv||'', l.klub_naziv||'', fmtDate(l.datum_pregleda), l.vrsta_pregleda||'', fmtDate(l.vrijedi_do), l.lijecnik||'', l.spreman_za_natjecanje?'DA':'NE', l.placeno?'DA':'NE']));
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>';
setExportRows('obrasci', (data.items||[]).map(s => [s.id, s.template_naziv||s.template_code||'', s.klub_naziv||'', s.clan_naziv||'', s.status||'', fmtDT(s.submitted_at), fmtDT(s.approved_at)]));
} 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'); }
}
// ══════════════════════════════════════════════════════════════════
// RUSH-4 — E-mail templates (CRM v2 GUI redesign, 2026-05-05)
// dradulic@outlook.com / damir@rinet.one
// Endpoint: /api/v2/crm/email-templates (CRUD)
// ══════════════════════════════════════════════════════════════════
let EMAIL_TPLS = [];
async function loadEmailTpls() {
const q = (document.getElementById('etpl-q')?.value || '').trim().toLowerCase();
const cat = document.getElementById('etpl-cat')?.value || '';
const qs = new URLSearchParams();
qs.set('active_only', 'false');
if (cat) qs.set('kategorija', cat);
try {
const data = await api('/email-templates?'+qs.toString());
EMAIL_TPLS = (data.items||[]).filter(t => {
if (!q) return true;
return (t.code||'').toLowerCase().includes(q)
|| (t.naziv||'').toLowerCase().includes(q);
});
const grid = document.getElementById('etpl-grid');
grid.innerHTML = EMAIL_TPLS.map(t => `
<div class="tcard" onclick="editEmailTpl(${t.id})">
<div class="tcard-code">${esc(t.code)}</div>
<div class="tcard-naziv">${esc(t.naziv)} ${t.active===false?'<span class="chip closed" style="margin-left:6px">neaktivan</span>':''}</div>
<div class="tcard-cat">${esc(t.kategorija||'—')}</div>
<div class="tcard-snip"><strong>Subject:</strong> ${esc((t.subject_tpl||'').slice(0,90))}${(t.subject_tpl||'').length>90?'…':''}<br>${esc((t.body_tpl||'').replace(/\s+/g,' ').slice(0,140))}${(t.body_tpl||'').length>140?'…':''}</div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema predložaka.</div>';
setExportRows('emailtpl', EMAIL_TPLS.map(t => [t.code||'', t.naziv||'', t.kategorija||'', t.subject_tpl||'', t.active?'true':'false']));
document.getElementById('cnt-emailtpl').textContent = EMAIL_TPLS.length;
} catch (e) { toast('Email tpl err: '+e.message, 'err'); }
}
function emailTplFormHTML(t={}) {
return `
<div class="fld-row">
<div class="fld"><label>Code*</label><input id="ef-code" value="${esc(t.code||'')}" placeholder="npr. clanarina_opomena"></div>
<div class="fld"><label>Kategorija</label>
<select id="ef-cat">
<option value=""></option>
${['clanarine','lijecnicki','obrasci','opci'].map(c=>`<option value="${c}" ${t.kategorija===c?'selected':''}>${c}</option>`).join('')}
</select>
</div>
</div>
<div class="fld"><label>Naziv*</label><input id="ef-naziv" value="${esc(t.naziv||'')}"></div>
<div class="fld"><label>Subject (predmet)*</label><input id="ef-subj" value="${esc(t.subject_tpl||'')}" placeholder="npr. Opomena za {{godina}}"></div>
<div class="fld"><label>Body (HTML / tekst)* — placeholderi {{...}}</label>
<textarea id="ef-body" style="min-height:160px;font-family:var(--mono)">${esc(t.body_tpl||'')}</textarea>
</div>
<div class="fld"><label>Variables (JSON, npr. {"naziv_kluba":"string"})</label>
<textarea id="ef-vars" style="min-height:60px;font-family:var(--mono)">${esc(t.variables ? JSON.stringify(t.variables, null, 2) : '')}</textarea>
</div>
<div class="fld"><label><input id="ef-act" type="checkbox" ${t.active===false?'':'checked'}> Aktivan</label></div>
`;
}
function readEmailTplForm() {
let vars = null;
const raw = document.getElementById('ef-vars').value.trim();
if (raw) {
try { vars = JSON.parse(raw); }
catch(e) { toast('Variables JSON nije validan', 'err'); throw e; }
}
return {
code: document.getElementById('ef-code').value.trim(),
naziv: document.getElementById('ef-naziv').value.trim(),
kategorija: document.getElementById('ef-cat').value || null,
subject_tpl: document.getElementById('ef-subj').value,
body_tpl: document.getElementById('ef-body').value,
variables: vars,
active: document.getElementById('ef-act').checked,
};
}
function openEmailTplModal(t) {
const isEdit = !!(t && t.id);
showModal(isEdit?'Uredi predložak':'Novi e-mail predložak',
emailTplFormHTML(t||{active:true}),
async () => {
let body; try { body = readEmailTplForm(); } catch(e) { return; }
if (!body.code || !body.naziv || !body.subject_tpl || !body.body_tpl) {
toast('Code, naziv, subject i body su obavezni', 'err'); return;
}
try {
if (isEdit) await api('/email-templates/'+t.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/email-templates', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadEmailTpls();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editEmailTpl(id) {
try { const t = await api('/email-templates/'+id);
// Add delete button to footer
openEmailTplModal(t);
setTimeout(() => {
const foot = document.getElementById('m-foot');
if (foot && !foot.querySelector('.btn.danger')) {
const del = document.createElement('button');
del.className = 'btn danger'; del.textContent = 'Obriši';
del.onclick = () => delEmailTpl(id, t.naziv);
foot.insertBefore(del, foot.firstChild);
}
}, 0);
} catch (e) { toast(e.message, 'err'); }
}
async function delEmailTpl(id, naziv) {
if (!confirm('Obrisati predložak "'+naziv+'"?')) return;
try { await api('/email-templates/'+id, {method:'DELETE'}); toast('Obrisano'); closeModal(); loadEmailTpls(); }
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(); // populates KPI counters + kanban (kanban only visible in Opportunities tab now)
loadClanarine(); // default active tab
// ── Universal Export ▾ — server-side fallback for tabs that load full
// record sets from REST (lijecnicki/obrasci). The existing exportTab()
// flow above keeps working for client-side cached tabs (accounts,
// contacts, leads, opps). attachExportDropdown is a no-op when
// export_dropdown.js fails to load.
document.addEventListener('DOMContentLoaded', function(){
if (!window.attachExportDropdown) return;
const lij = document.getElementById('lij-srv-export-btn');
if (lij) window.attachExportDropdown(lij, function(){
const klub = document.getElementById('lij-klub'); const clan = document.getElementById('lij-clan');
const qp = new URLSearchParams(); qp.set('limit','2000');
if (klub && klub.value) qp.set('klub_id', klub.value);
if (clan && clan.value) qp.set('clan_id', clan.value);
return '/sport/api/v2/lijecnicki?'+qp.toString();
}, 'lijecnicki');
});
</script>
<script src="/static/js/export_dropdown.js"></script>
<script src="/static/_ai_widget.js" defer></script>
</body>
</html>