Files
pgz-sport/static/crm_v2.html
T
damir f7b5114f58 PDF link target=_blank + nginx timeouts + priority filteri (samo s podacima)
nginx (sport.rinet.one):
- proxy_read_timeout 60s → 300s
- proxy_send_timeout 300s
- proxy_buffering off (PDF stream)
- client_max_body_size 50M → 100M

Endpoints:
- /api/v2/klubovi/financirani: +with_data filter (samo s potporama/godišnjakom/HNS)
- /api/v2/sportasi/filtered: +samo_priority +samo_s_hns

Frontend:
- PDF link target=_blank rel=noopener
- window._klub_only_priority = true (default)
- window._sportas_only_priority = true (default)

DB View:
- pgz_sport.v_nogomet_priority (prima_potpore, u_godisnjaku, ima_hns_roster)
2026-05-05 13:51:07 +02:00

1213 lines
57 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<!--
PGŽ Sport — CRM v2 (Salesforce-Lite) | v1.0.0 | 05.05.2026
Author: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
Lokacija: /opt/pgz-sport/static/crm_v2.html
Svrha: Pipeline kanban + Accounts/Contacts/Leads/Opportunities/Activities/Cases
-->
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport — CRM v2 (Salesforce-Lite)</title>
<style>
:root {
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a; --pgz-gold:#fbbf24;
--bg:#0f1115; --bg2:#171a21; --bg3:#1f242d;
--rim:#293040; --t1:#e6e8ef; --t2:#9aa3b6; --t3:#6b748b; --t4:#4b5269;
--ok:#22c55e; --warn:#f59e0b; --err:#ef4444; --info:#3b82f6;
--mono:'JetBrains Mono',Menlo,monospace;
}
* { box-sizing: border-box; }
html,body { margin:0; padding:0; }
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background:var(--bg); color:var(--t1); font-size:13px; height:100vh; overflow:hidden; }
.topbar { height:50px; background:linear-gradient(90deg,var(--pgz-blue2),var(--pgz-blue));
display:flex; align-items:center; padding:0 18px; gap:14px;
box-shadow:0 2px 8px rgba(0,0,0,.4); }
.topbar .logo { font-weight:700; font-size:15px; }
.topbar .sep { color:rgba(255,255,255,.5); }
.topbar .title { font-size:13px; opacity:.95; }
.topbar .right { margin-left:auto; display:flex; gap:10px; align-items:center; font-size:12px; }
.topbar a { color:#fff; text-decoration:none; opacity:.85; padding:5px 9px; border-radius:4px; }
.topbar a:hover { opacity:1; background:rgba(255,255,255,.1); }
.topbar #me { padding:4px 10px; background:rgba(0,0,0,.2); border-radius:14px; font-size:11px; }
.tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--rim); padding:0 18px; flex-wrap:wrap; }
.tab { padding:11px 16px; cursor:pointer; color:var(--t2); border-bottom:2px solid transparent;
font-weight:500; user-select:none; font-size:12px; }
.tab:hover { color:var(--t1); }
.tab.active { color:var(--pgz-blue); border-bottom-color:var(--pgz-blue); background:var(--bg3); }
.tab .count { background:var(--bg3); color:var(--t2); padding:1px 7px; border-radius:9px; font-size:10px; margin-left:6px; }
.tab.active .count { background:var(--pgz-blue); color:#fff; }
.main { padding:14px 18px; height:calc(100vh - 50px - 36px); overflow:auto; }
.tab-c { display:none; }
.tab-c.on { display:block; }
.kpi-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:14px; }
.kpi { background:var(--bg2); border:1px solid var(--rim); padding:11px 13px; border-radius:6px; }
.kpi.g { border-left:3px solid var(--ok); }
.kpi.r { border-left:3px solid var(--err); }
.kpi.a { border-left:3px solid var(--warn); }
.kpi.b { border-left:3px solid var(--pgz-blue); }
.kpi.gold { border-left:3px solid var(--pgz-gold); }
.kpi-l { font-size:10px; color:var(--t3); text-transform:uppercase; letter-spacing:.5px; }
.kpi-v { font-size:20px; font-weight:700; margin-top:3px; }
.kpi-s { font-size:10px; color:var(--t3); margin-top:2px; }
.toolbar { display:flex; gap:9px; flex-wrap:wrap; margin-bottom:12px; align-items:center; }
.toolbar input, .toolbar select {
background:var(--bg2); border:1px solid var(--rim); color:var(--t1);
padding:6px 10px; border-radius:4px; font-size:12px; min-width:130px; font-family:inherit;
}
.toolbar input:focus, .toolbar select:focus { outline:none; border-color:var(--pgz-blue); }
.toolbar .grow { flex:1; }
.btn { background:var(--bg3); color:var(--t1); border:1px solid var(--rim);
padding:6px 11px; border-radius:4px; cursor:pointer; font-size:12px; font-family:inherit; }
.btn:hover { background:var(--bg2); border-color:var(--pgz-blue); }
.btn.primary { background:linear-gradient(135deg,var(--pgz-blue),var(--pgz-blue2));
border-color:var(--pgz-blue); color:#fff; }
.btn.primary:hover { filter:brightness(1.1); }
.btn.danger { color:var(--err); border-color:var(--err); }
.btn.sm { padding:3px 7px; font-size:11px; }
.btn.gold { background:var(--pgz-gold); color:#000; border-color:var(--pgz-gold); }
.card { background:var(--bg2); border:1px solid var(--rim); border-radius:6px; margin-bottom:12px; overflow:hidden; }
.card-h { padding:10px 14px; border-bottom:1px solid var(--rim); display:flex;
align-items:center; justify-content:space-between; background:var(--bg3); }
.card-t { font-weight:600; font-size:12px; }
.card-b { padding:11px 14px; }
table { width:100%; border-collapse:collapse; font-size:12px; }
table th, table td { padding:8px 11px; text-align:left; border-bottom:1px solid var(--rim); }
table th { background:var(--bg3); color:var(--t2); font-weight:600; font-size:10px;
text-transform:uppercase; letter-spacing:.4px; cursor:default; }
table tr:hover td { background:rgba(26,115,232,.06); cursor:pointer; }
table tr.sel td { background:rgba(26,115,232,.15); }
.chip { display:inline-block; padding:2px 8px; border-radius:10px; font-size:10px;
font-weight:600; text-transform:uppercase; letter-spacing:.3px; }
.chip.new { background:#1e293b; color:#94a3b8; }
.chip.contacted { background:#1e3a5f; color:#60a5fa; }
.chip.qualified { background:#3a2e1a; color:#fbbf24; }
.chip.lost { background:#3a1e1e; color:#f87171; }
.chip.converted { background:#1a3a2a; color:#4ade80; }
.chip.open { background:#1e293b; color:#94a3b8; }
.chip.in_progress { background:#1e3a5f; color:#60a5fa; }
.chip.waiting { background:#3a2e1a; color:#fbbf24; }
.chip.resolved { background:#1a3a2a; color:#4ade80; }
.chip.closed { background:#1f1f1f; color:#71717a; }
.chip.urgent { background:#3a1e1e; color:#f87171; }
.chip.high { background:#3a2e1a; color:#fbbf24; }
.chip.normal { background:#1e293b; color:#94a3b8; }
.chip.low { background:#1f1f1f; color:#71717a; }
.chip.nepodmireno { background:#3a1e1e; color:#f87171; }
.chip.djelomicno { background:#3a2e1a; color:#fbbf24; }
.chip.podmireno { background:#1a3a2a; color:#4ade80; }
.chip.storno { background:#1f1f1f; color:#71717a; }
.chip.draft { background:#1e293b; color:#94a3b8; }
.chip.submitted { background:#1e3a5f; color:#60a5fa; }
.chip.approved { background:#1a3a2a; color:#4ade80; }
.chip.rejected { background:#3a1e1e; color:#f87171; }
.chip.spreman { background:#1a3a2a; color:#4ade80; }
.chip.nije-spreman{ background:#3a1e1e; color:#f87171; }
.tpl-row { padding:9px 12px; border-bottom:1px solid var(--rim); cursor:pointer; }
.tpl-row:hover { background:var(--bg3); }
.tpl-row.sel { background:rgba(26,115,232,.18); border-left:3px solid var(--pgz-blue); padding-left:9px; }
.tpl-row .tpl-n { font-weight:600; font-size:12px; color:var(--t1); }
.tpl-row .tpl-c { font-size:10px; color:var(--t3); margin-top:2px; text-transform:uppercase; letter-spacing:.4px; }
/* Kanban */
.kanban { display:grid; grid-template-columns:repeat(6,1fr); gap:10px; min-height:60vh; }
.kcol { background:var(--bg2); border:1px solid var(--rim); border-radius:6px;
padding:8px; display:flex; flex-direction:column; min-height:200px; }
.kcol.drag-over { border-color:var(--pgz-blue); background:rgba(26,115,232,.08); }
.kcol-h { display:flex; justify-content:space-between; align-items:center;
padding:5px 4px 9px; border-bottom:1px solid var(--rim); margin-bottom:8px; }
.kcol-t { font-size:10px; text-transform:uppercase; letter-spacing:.5px; color:var(--t2); font-weight:700; }
.kcol-s { font-size:10px; color:var(--t3); font-family:var(--mono); }
.kcol.prospecting .kcol-t { color:#94a3b8; }
.kcol.qualification .kcol-t { color:#60a5fa; }
.kcol.proposal .kcol-t { color:#fbbf24; }
.kcol.negotiation .kcol-t { color:#f97316; }
.kcol.closed_won .kcol-t { color:#4ade80; }
.kcol.closed_lost .kcol-t { color:#f87171; }
.kcards { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:6px; }
.kcard { background:var(--bg3); border:1px solid var(--rim); border-radius:5px;
padding:8px 9px; cursor:grab; font-size:11px; }
.kcard:hover { border-color:var(--pgz-blue); }
.kcard.dragging { opacity:.45; }
.kcard-t { font-weight:600; color:var(--t1); margin-bottom:3px; }
.kcard-a { color:var(--t2); font-size:10px; margin-bottom:5px; }
.kcard-r { display:flex; justify-content:space-between; font-size:10px; color:var(--t3); font-family:var(--mono); }
.kcard-eur { color:var(--pgz-gold); font-weight:700; }
/* Modal */
.modal { display:none; position:fixed; inset:0; background:rgba(0,0,0,.65); z-index:50;
align-items:flex-start; justify-content:center; padding-top:5vh; overflow:auto; }
.modal.on { display:flex; }
.mbox { background:var(--bg2); border:1px solid var(--rim); border-radius:8px;
width:560px; max-width:92vw; max-height:90vh; overflow:auto; }
.mbox.wide { width:780px; }
.mh { padding:12px 16px; border-bottom:1px solid var(--rim); display:flex;
justify-content:space-between; align-items:center; background:var(--bg3); }
.mh h3 { margin:0; font-size:14px; }
.mh .x { background:none; border:none; color:var(--t2); font-size:18px; cursor:pointer; }
.mh .x:hover { color:var(--t1); }
.mb { padding:14px 16px; }
.mf { padding:11px 16px; border-top:1px solid var(--rim); display:flex; gap:8px; justify-content:flex-end; background:var(--bg3); }
.fld { margin-bottom:10px; }
.fld label { display:block; font-size:11px; color:var(--t3); text-transform:uppercase;
letter-spacing:.4px; margin-bottom:3px; font-weight:600; }
.fld input, .fld select, .fld textarea {
width:100%; background:var(--bg); border:1px solid var(--rim); color:var(--t1);
padding:6px 9px; border-radius:4px; font-size:12px; font-family:inherit;
}
.fld textarea { min-height:60px; resize:vertical; }
.fld input:focus, .fld select:focus, .fld textarea:focus { outline:none; border-color:var(--pgz-blue); }
.fld-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.empty { padding:30px; text-align:center; color:var(--t3); font-size:12px; }
#toast { position:fixed; bottom:20px; right:20px; background:var(--bg3); border:1px solid var(--rim);
padding:10px 14px; border-radius:6px; z-index:99; display:none; font-size:12px; }
#toast.ok { border-color:var(--ok); }
#toast.err { border-color:var(--err); }
footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
display:flex; align-items:center; padding:0 18px; font-size:10px;
color:var(--t3); font-family:var(--mono); justify-content:space-between; }
</style>
</head>
<body>
<div class="topbar">
<span class="logo">PGŽ SPORT</span>
<span class="sep"></span>
<span class="title">CRM v2 — Salesforce-Lite (Pipeline)</span>
<div class="right">
<span id="me"></span>
<a href="/sport/platform">Platform</a>
<a href="/sport/erp">ERP</a>
<a href="/sport/crm">CRM</a>
<a href="#" id="logout">Odjava</a>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="pipeline">Pipeline</div>
<div class="tab" data-tab="accounts">Accounts <span class="count" id="cnt-accounts">·</span></div>
<div class="tab" data-tab="contacts">Contacts <span class="count" id="cnt-contacts">·</span></div>
<div class="tab" data-tab="leads">Leads <span class="count" id="cnt-leads">·</span></div>
<div class="tab" data-tab="opportunities">Opportunities <span class="count" id="cnt-opps">·</span></div>
<div class="tab" data-tab="activities">Activities <span class="count" id="cnt-activities">·</span></div>
<div class="tab" data-tab="cases">Cases <span class="count" id="cnt-cases">·</span></div>
<div class="tab" data-tab="clanarine">Članarine <span class="count" id="cnt-clanarine">·</span></div>
<div class="tab" data-tab="lijecnicki">Liječnički <span class="count" id="cnt-lijecnicki">·</span></div>
<div class="tab" data-tab="obrasci">Obrasci <span class="count" id="cnt-obrasci">·</span></div>
</div>
<div class="main">
<!-- ────── PIPELINE ────── -->
<div class="tab-c on" id="tc-pipeline">
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Open opps</div><div class="kpi-v" id="k-opps">·</div><div class="kpi-s" id="k-opps-eur">·</div></div>
<div class="kpi gold"><div class="kpi-l">Weighted total</div><div class="kpi-v" id="k-weighted">·</div><div class="kpi-s">prosjek vjerojatnosti</div></div>
<div class="kpi g"><div class="kpi-l">Won this quarter</div><div class="kpi-v" id="k-won">·</div><div class="kpi-s" id="k-won-eur">·</div></div>
<div class="kpi a"><div class="kpi-l">Leads (new+contacted)</div><div class="kpi-v" id="k-leads">·</div><div class="kpi-s">qualified: <span id="k-leads-q">·</span></div></div>
<div class="kpi r"><div class="kpi-l">Overdue activities</div><div class="kpi-v" id="k-overdue">·</div><div class="kpi-s">upcoming: <span id="k-upcoming">·</span></div></div>
<div class="kpi"><div class="kpi-l">Open cases</div><div class="kpi-v" id="k-cases">·</div><div class="kpi-s" id="k-cases-urgent">·</div></div>
</div>
<div class="toolbar">
<strong>Pipeline kanban</strong>
<span class="grow"></span>
<button class="btn primary sm" onclick="openOppModal()">+ Nova prilika</button>
<button class="btn sm" onclick="loadPipeline()">↻ Refresh</button>
</div>
<div class="kanban" id="kanban"></div>
</div>
<!-- ────── ACCOUNTS ────── -->
<div class="tab-c" id="tc-accounts">
<div class="toolbar">
<input id="acc-q" placeholder="Pretraži (naziv/OIB/email/grad)…">
<select id="acc-type">
<option value="">— Svi tipovi —</option>
<option value="klub">klub</option>
<option value="savez">savez</option>
<option value="sponzor">sponzor</option>
<option value="drzava">drzava</option>
<option value="drugo">drugo</option>
</select>
<button class="btn" onclick="loadAccounts()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openAccountModal()">+ Novi account</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-accounts"><thead><tr>
<th>Naziv</th><th>Tip</th><th>Grad</th><th>OIB</th><th>Email</th>
<th>Kontakti</th><th>Opps</th><th>Owner</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── CONTACTS ────── -->
<div class="tab-c" id="tc-contacts">
<div class="toolbar">
<input id="con-q" placeholder="Pretraži (ime/email/funkcija)…">
<input id="con-acc" type="number" placeholder="Account ID (filter)" style="max-width:170px">
<button class="btn" onclick="loadContacts()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openContactModal()">+ Novi kontakt</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-contacts"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Account</th><th>Funkcija</th>
<th>Email</th><th>Telefon</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── LEADS ────── -->
<div class="tab-c" id="tc-leads">
<div class="toolbar">
<input id="lead-q" placeholder="Pretraži (ime/organizacija/email)…">
<select id="lead-status">
<option value="">— Svi statusi —</option>
<option value="new">new</option>
<option value="contacted">contacted</option>
<option value="qualified">qualified</option>
<option value="lost">lost</option>
<option value="converted">converted</option>
</select>
<button class="btn" onclick="loadLeads()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openLeadModal()">+ Novi lead</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-leads"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Organizacija</th><th>Email</th>
<th>Izvor</th><th>Status</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── OPPORTUNITIES ────── -->
<div class="tab-c" id="tc-opps">
<div class="toolbar">
<input id="opp-q" placeholder="Pretraži (naziv/account)…">
<select id="opp-stage">
<option value="">— Sve faze —</option>
<option value="prospecting">prospecting</option>
<option value="qualification">qualification</option>
<option value="proposal">proposal</option>
<option value="negotiation">negotiation</option>
<option value="closed_won">closed_won</option>
<option value="closed_lost">closed_lost</option>
</select>
<button class="btn" onclick="loadOpps()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openOppModal()">+ Nova prilika</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-opps"><thead><tr>
<th>Naziv</th><th>Account</th><th>Tip</th><th>Faza</th>
<th>EUR</th><th>%</th><th>Close</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── ACTIVITIES ────── -->
<div class="tab-c" id="tc-activities">
<div class="toolbar">
<select id="act-type">
<option value="">— Svi tipovi —</option>
<option value="call">call</option>
<option value="meeting">meeting</option>
<option value="email">email</option>
<option value="task">task</option>
<option value="note">note</option>
</select>
<select id="act-open">
<option value="">— Sve —</option>
<option value="true">Otvorene</option>
<option value="false">Zatvorene</option>
</select>
<button class="btn" onclick="loadActivities()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openActivityModal()">+ Nova aktivnost</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-activities"><thead><tr>
<th>Tip</th><th>Subject</th><th>Account</th><th>Kontakt</th>
<th>Due</th><th>Status</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── CASES ────── -->
<div class="tab-c" id="tc-cases">
<div class="toolbar">
<input id="case-q" placeholder="Pretraži…">
<select id="case-status">
<option value="">— Svi statusi —</option>
<option value="open">open</option>
<option value="in_progress">in_progress</option>
<option value="waiting">waiting</option>
<option value="resolved">resolved</option>
<option value="closed">closed</option>
</select>
<select id="case-priority">
<option value="">— Sve prioriteti —</option>
<option value="urgent">urgent</option>
<option value="high">high</option>
<option value="normal">normal</option>
<option value="low">low</option>
</select>
<button class="btn" onclick="loadCases()">Pretraži</button>
<span class="grow"></span>
<button class="btn primary" onclick="openCaseModal()">+ Novi case</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-cases"><thead><tr>
<th>Subject</th><th>Account</th><th>Status</th>
<th>Priority</th><th>Stvoren</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── ČLANARINE ────── -->
<div class="tab-c" id="tc-clanarine">
<div class="toolbar">
<input id="cln-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="cln-clan" type="number" placeholder="Član ID" style="max-width:120px">
<input id="cln-godina" type="number" placeholder="Godina" style="max-width:120px">
<select id="cln-status">
<option value="">— Svi statusi —</option>
<option value="nepodmireno">nepodmireno</option>
<option value="djelomicno">djelomično</option>
<option value="podmireno">podmireno</option>
<option value="storno">storno</option>
</select>
<button class="btn" onclick="loadClanarine()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openClanarinaModal()">+ Nova članarina</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-clanarine"><thead><tr>
<th>Član</th><th>Klub</th><th>Godina</th><th>Razdoblje</th>
<th>Propisan</th><th>Plaćen</th><th>Datum uplate</th><th>Status</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── LIJEČNIČKI ────── -->
<div class="tab-c" id="tc-lijecnicki">
<div class="toolbar">
<input id="lij-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="lij-clan" type="number" placeholder="Član ID" style="max-width:120px">
<select id="lij-expiring">
<option value="">— Svi —</option>
<option value="true">Ističu (≤30d) ili istekli</option>
</select>
<button class="btn" onclick="loadLijecnicki()">Filtriraj</button>
<span class="grow"></span>
<button class="btn primary" onclick="openLijecnickiModal()">+ Novi pregled</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<table id="t-lijecnicki"><thead><tr>
<th>Član</th><th>Klub</th><th>Datum</th><th>Vrsta</th>
<th>Vrijedi do</th><th>Liječnik</th><th>Spreman</th><th>Plaćeno</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
<!-- ────── OBRASCI ────── -->
<div class="tab-c" id="tc-obrasci">
<div style="display:grid; grid-template-columns:300px 1fr; gap:12px; align-items:start;">
<!-- Templates list (left) -->
<div class="card">
<div class="card-h"><div class="card-t">Predlošci</div>
<button class="btn sm" onclick="loadObrasciTemplates()"></button>
</div>
<div class="card-b" style="padding:0; max-height:70vh; overflow:auto;" id="obr-tpl-list">
<div class="empty">Učitavanje…</div>
</div>
</div>
<!-- Submissions / detail (right) -->
<div>
<div class="toolbar">
<strong id="obr-right-title">Podnešeni obrasci</strong>
<span class="grow"></span>
<select id="obr-status">
<option value="">— Svi statusi —</option>
<option value="draft">draft</option>
<option value="submitted">submitted</option>
<option value="approved">approved</option>
<option value="rejected">rejected</option>
</select>
<input id="obr-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<button class="btn" onclick="loadObrasciSubmissions()">Filtriraj</button>
</div>
<div class="card"><div class="card-b" style="padding:0" id="obr-right-body">
<table id="t-obr-sub"><thead><tr>
<th>ID</th><th>Predložak</th><th>Klub</th><th>Član</th>
<th>Status</th><th>Submitted</th><th>Approved</th><th></th>
</tr></thead><tbody></tbody></table>
</div></div>
</div>
</div>
</div>
</div><!-- /main -->
<footer>
<span>PGŽ Sport CRM v2 · Salesforce-Lite</span>
<span id="api-info">api.rinet.one/sport/api/v2/crm/*</span>
</footer>
<!-- Modal shell -->
<div class="modal" id="modal">
<div class="mbox" id="mbox">
<div class="mh"><h3 id="m-title">Modal</h3><button class="x" onclick="closeModal()">×</button></div>
<div class="mb" id="m-body"></div>
<div class="mf" id="m-foot">
<button class="btn" onclick="closeModal()">Odustani</button>
<button class="btn primary" id="m-save">Spremi</button>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const TOKEN = localStorage.getItem('token') || localStorage.getItem('access_token') || '';
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'];
if (!TOKEN) {
location.href = '/sport/login?next=' + encodeURIComponent(location.pathname);
}
async function api(path, opts={}) {
const headers = {'Authorization':'Bearer '+TOKEN, 'Content-Type':'application/json', ...(opts.headers||{})};
const res = await fetch(API+path, {...opts, headers});
if (res.status === 401) { location.href='/sport/login'; throw new Error('401'); }
const txt = await res.text();
let data; try { data = JSON.parse(txt); } catch { data = txt; }
if (!res.ok) {
const msg = (data && data.detail) ? data.detail : ('HTTP '+res.status);
throw new Error(typeof msg==='string'?msg:JSON.stringify(msg));
}
return data;
}
function toast(msg, type='ok') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = type;
t.style.display='block';
setTimeout(()=>t.style.display='none', 2800);
}
const fmtEur = n => (n==null||n==='') ? '—' : Number(n).toLocaleString('hr-HR',{maximumFractionDigits:0})+' €';
const fmtDate = s => s ? String(s).slice(0,10) : '—';
const fmtDT = s => {
if (!s) return '—';
const d = new Date(s);
return isNaN(d) ? String(s).slice(0,16) : d.toLocaleString('hr-HR',{year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'});
};
const esc = s => String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
// ────── Tabs ──────
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab===name));
document.querySelectorAll('.tab-c').forEach(c => c.classList.toggle('on', c.id==='tc-'+name || (name==='opportunities' && c.id==='tc-opps')));
if (name==='pipeline') loadPipeline();
if (name==='accounts') loadAccounts();
if (name==='contacts') loadContacts();
if (name==='leads') loadLeads();
if (name==='opportunities') loadOpps();
if (name==='activities') loadActivities();
if (name==='cases') loadCases();
if (name==='clanarine') loadClanarine();
if (name==='lijecnicki') loadLijecnicki();
if (name==='obrasci') { loadObrasciTemplates(); loadObrasciSubmissions(); }
}
// ────── /me ──────
async function loadMe() {
try {
const me = await fetch('/sport/api/v2/me', {headers:{'Authorization':'Bearer '+TOKEN}}).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();
try { await fetch('/sport/api/v2/auth/logout', {method:'POST', headers:{'Authorization':'Bearer '+TOKEN}}); } catch {}
localStorage.removeItem('token'); localStorage.removeItem('access_token');
location.href='/sport/login';
});
// ────── Pipeline & Dashboard ──────
async function loadPipeline() {
try {
const [pipe, dash] = await Promise.all([api('/pipeline'), api('/dashboard')]);
// KPIs
document.getElementById('k-opps').textContent = dash.opportunities?.open_opps ?? 0;
document.getElementById('k-opps-eur').textContent = fmtEur(dash.opportunities?.open_amount);
document.getElementById('k-weighted').textContent = fmtEur(dash.opportunities?.weighted_amount);
document.getElementById('k-won').textContent = dash.opportunities?.won_q ?? 0;
document.getElementById('k-won-eur').textContent = fmtEur(dash.opportunities?.won_q_amount);
const lbs = (dash.leads_by_status||[]).reduce((m,r)=>{m[r.status]=Number(r.n);return m;},{});
document.getElementById('k-leads').textContent = (lbs.new||0)+(lbs.contacted||0);
document.getElementById('k-leads-q').textContent = lbs.qualified||0;
document.getElementById('k-overdue').textContent = dash.activities?.overdue ?? 0;
document.getElementById('k-upcoming').textContent = dash.activities?.upcoming ?? 0;
const cbs = (dash.cases_by_status||[]).reduce((m,r)=>{m[r.status]=Number(r.n);return m;},{});
document.getElementById('k-cases').textContent = (cbs.open||0)+(cbs.in_progress||0)+(cbs.waiting||0);
document.getElementById('k-cases-urgent').textContent = 'closed: '+(cbs.closed||0)+' / resolved: '+(cbs.resolved||0);
// Counts in tabs
document.getElementById('cnt-accounts').textContent = dash.accounts_total ?? 0;
document.getElementById('cnt-contacts').textContent = dash.contacts_total ?? 0;
document.getElementById('cnt-leads').textContent = (dash.leads_by_status||[]).reduce((s,r)=>s+Number(r.n),0);
document.getElementById('cnt-opps').textContent = pipe.stages.reduce((s,b)=>s+b.count,0);
document.getElementById('cnt-activities').textContent = (dash.activities?.overdue||0)+(dash.activities?.upcoming||0)+(dash.activities?.done||0);
document.getElementById('cnt-cases').textContent = (dash.cases_by_status||[]).reduce((s,r)=>s+Number(r.n),0);
// Kanban
const kb = document.getElementById('kanban');
kb.innerHTML = pipe.stages.map(b => `
<div class="kcol ${b.stage}" data-stage="${b.stage}">
<div class="kcol-h">
<div class="kcol-t">${STAGE_LABEL[b.stage]}</div>
<div class="kcol-s">${b.count} · ${fmtEur(b.amount_total)}</div>
</div>
<div class="kcards">
${(b.items||[]).map(o => `
<div class="kcard" draggable="true" data-id="${o.id}" data-stage="${o.stage}" onclick="editOpp(${o.id})">
<div class="kcard-t">${esc(o.naziv)}</div>
<div class="kcard-a">${esc(o.account_naziv||'—')}</div>
<div class="kcard-r">
<span class="kcard-eur">${fmtEur(o.amount_eur)}</span>
<span>${o.probability||0}% · ${fmtDate(o.close_date)}</span>
</div>
</div>
`).join('')}
</div>
</div>
`).join('');
bindKanbanDnD();
} catch (e) { toast('Pipeline err: '+e.message, 'err'); }
}
function bindKanbanDnD() {
const cards = document.querySelectorAll('.kcard');
const cols = document.querySelectorAll('.kcol');
let dragId = null;
cards.forEach(c => {
c.addEventListener('dragstart', e => {
dragId = c.dataset.id;
c.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
c.addEventListener('dragend', () => c.classList.remove('dragging'));
});
cols.forEach(col => {
col.addEventListener('dragover', e => { e.preventDefault(); col.classList.add('drag-over'); });
col.addEventListener('dragleave', () => col.classList.remove('drag-over'));
col.addEventListener('drop', async e => {
e.preventDefault(); col.classList.remove('drag-over');
const newStage = col.dataset.stage;
if (!dragId) return;
try {
await api('/opportunities/'+dragId+'/stage', {method:'PATCH', body:JSON.stringify({stage:newStage})});
toast('Faza promijenjena: '+STAGE_LABEL[newStage]);
loadPipeline();
} catch (er) { toast('Stage update err: '+er.message, 'err'); }
});
});
}
// ────── Accounts ──────
async function loadAccounts() {
const q = document.getElementById('acc-q').value.trim();
const t = document.getElementById('acc-type').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (t) qs.set('type', t);
try {
const data = await api('/accounts?'+qs.toString());
const tb = document.querySelector('#t-accounts tbody');
tb.innerHTML = (data.items||[]).map(a => `
<tr onclick="editAccount(${a.id})">
<td><strong>${esc(a.naziv)}</strong></td>
<td>${esc(a.type)}</td>
<td>${esc(a.grad||'—')}</td>
<td>${esc(a.oib||'—')}</td>
<td>${esc(a.email||'—')}</td>
<td>${a.contacts_n||0}</td>
<td>${a.opps_n||0}</td>
<td>${esc(a.owner_email||'—')}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button></td>
</tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema accounta — dodajte prvi.</td></tr>';
document.getElementById('cnt-accounts').textContent = (data.items||[]).length;
} catch (e) { toast('Accounts err: '+e.message, 'err'); }
}
function accountFormHTML(a={}) {
return `
<div class="fld"><label>Naziv*</label><input id="f-naziv" value="${esc(a.naziv||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Tip</label>
<select id="f-type">
${['klub','savez','sponzor','drzava','drugo'].map(x=>`<option value="${x}" ${a.type===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>OIB</label><input id="f-oib" value="${esc(a.oib||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Email</label><input id="f-email" value="${esc(a.email||'')}"></div>
<div class="fld"><label>Telefon</label><input id="f-telefon" value="${esc(a.telefon||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Web</label><input id="f-web" value="${esc(a.web||'')}"></div>
<div class="fld"><label>Industry</label><input id="f-industry" value="${esc(a.industry||'')}" placeholder="sport / grana / ..."></div>
</div>
<div class="fld-row">
<div class="fld"><label>Adresa</label><input id="f-adresa" value="${esc(a.adresa||'')}"></div>
<div class="fld"><label>Grad</label><input id="f-grad" value="${esc(a.grad||'')}"></div>
</div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(a.napomene||'')}</textarea></div>
`;
}
function readAccountForm() {
return {
naziv: document.getElementById('f-naziv').value.trim(),
type: document.getElementById('f-type').value,
oib: document.getElementById('f-oib').value.trim() || null,
email: document.getElementById('f-email').value.trim() || null,
telefon: document.getElementById('f-telefon').value.trim() || null,
web: document.getElementById('f-web').value.trim() || null,
industry: document.getElementById('f-industry').value.trim() || null,
adresa: document.getElementById('f-adresa').value.trim() || null,
grad: document.getElementById('f-grad').value.trim() || null,
napomene: document.getElementById('f-napomene').value || null,
};
}
function openAccountModal(a) {
const isEdit = !!(a && a.id);
showModal(isEdit ? 'Uredi account' : 'Novi account', accountFormHTML(a||{}), async () => {
const body = readAccountForm();
if (!body.naziv) { toast('Naziv je obavezan', 'err'); return; }
try {
if (isEdit) await api('/accounts/'+a.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/accounts', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadAccounts(); loadPipeline();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editAccount(id) {
try { const a = await api('/accounts/'+id); openAccountModal(a); }
catch (e) { toast(e.message, 'err'); }
}
async function delAccount(id, naziv) {
if (!confirm('Obrisati account "'+naziv+'"? (kaskadno briše opps/cases/activities)')) return;
try { await api('/accounts/'+id, {method:'DELETE'}); toast('Obrisano'); loadAccounts(); loadPipeline(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Contacts ──────
async function loadContacts() {
const q = document.getElementById('con-q').value.trim();
const aid = document.getElementById('con-acc').value.trim();
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (aid) qs.set('account_id', aid);
try {
const data = await api('/contacts?'+qs.toString());
const tb = document.querySelector('#t-contacts tbody');
tb.innerHTML = (data.items||[]).map(c => `
<tr onclick="editContact(${c.id})">
<td><strong>${esc(c.ime)}</strong></td>
<td>${esc(c.prezime)}</td>
<td>${esc(c.account_naziv||'—')}</td>
<td>${esc(c.funkcija||'—')}</td>
<td>${esc(c.email||'—')}</td>
<td>${esc(c.telefon||c.mobitel||'—')}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delContact(${c.id})">×</button></td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema kontakata.</td></tr>';
document.getElementById('cnt-contacts').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function contactFormHTML(c={}) {
return `
<div class="fld-row">
<div class="fld"><label>Ime*</label><input id="f-ime" value="${esc(c.ime||'')}"></div>
<div class="fld"><label>Prezime*</label><input id="f-prezime" value="${esc(c.prezime||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Account ID</label><input id="f-account_id" type="number" value="${c.account_id||''}"></div>
<div class="fld"><label>Funkcija</label><input id="f-funkcija" value="${esc(c.funkcija||'')}" placeholder="predsjednik / tajnik / trener / ..."></div>
</div>
<div class="fld-row">
<div class="fld"><label>Email</label><input id="f-email" value="${esc(c.email||'')}"></div>
<div class="fld"><label>Telefon</label><input id="f-telefon" value="${esc(c.telefon||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Mobitel</label><input id="f-mobitel" value="${esc(c.mobitel||'')}"></div>
<div class="fld"><label>Clan ID (opc.)</label><input id="f-clan_id" type="number" value="${c.clan_id||''}"></div>
</div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(c.napomene||'')}</textarea></div>
`;
}
function readContactForm() {
return {
ime: document.getElementById('f-ime').value.trim(),
prezime: document.getElementById('f-prezime').value.trim(),
account_id: parseInt(document.getElementById('f-account_id').value)||null,
clan_id: parseInt(document.getElementById('f-clan_id').value)||null,
funkcija: document.getElementById('f-funkcija').value.trim()||null,
email: document.getElementById('f-email').value.trim()||null,
telefon: document.getElementById('f-telefon').value.trim()||null,
mobitel: document.getElementById('f-mobitel').value.trim()||null,
napomene: document.getElementById('f-napomene').value||null,
};
}
function openContactModal(c) {
const isEdit = !!(c && c.id);
showModal(isEdit?'Uredi kontakt':'Novi kontakt', contactFormHTML(c||{}), async () => {
const body = readContactForm();
if (!body.ime || !body.prezime) { toast('Ime i prezime su obavezni', 'err'); return; }
try {
if (isEdit) await api('/contacts/'+c.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/contacts', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadContacts();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editContact(id) {
try { const c = await api('/contacts/'+id); openContactModal(c); }
catch (e) { toast(e.message, 'err'); }
}
async function delContact(id) {
if (!confirm('Obrisati kontakt?')) return;
try { await api('/contacts/'+id, {method:'DELETE'}); toast('Obrisano'); loadContacts(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Leads ──────
async function loadLeads() {
const q = document.getElementById('lead-q').value.trim();
const s = document.getElementById('lead-status').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (s) qs.set('status', s);
try {
const data = await api('/leads?'+qs.toString());
const tb = document.querySelector('#t-leads tbody');
tb.innerHTML = (data.items||[]).map(l => `
<tr onclick="editLead(${l.id})">
<td>${esc(l.ime||'—')}</td>
<td>${esc(l.prezime||'—')}</td>
<td>${esc(l.organizacija||'—')}</td>
<td>${esc(l.email||'—')}</td>
<td>${esc(l.izvor||'—')}</td>
<td><span class="chip ${l.status}">${l.status}</span></td>
<td>
${l.status!=='converted' ? `<button class="btn sm gold" onclick="event.stopPropagation();convertLead(${l.id})">→ Konvertiraj</button>` : ''}
<button class="btn sm" onclick="event.stopPropagation();delLead(${l.id})">×</button>
</td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema leadova.</td></tr>';
document.getElementById('cnt-leads').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function leadFormHTML(l={}) {
return `
<div class="fld-row">
<div class="fld"><label>Ime</label><input id="f-ime" value="${esc(l.ime||'')}"></div>
<div class="fld"><label>Prezime</label><input id="f-prezime" value="${esc(l.prezime||'')}"></div>
</div>
<div class="fld"><label>Organizacija</label><input id="f-org" value="${esc(l.organizacija||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Email</label><input id="f-email" value="${esc(l.email||'')}"></div>
<div class="fld"><label>Telefon</label><input id="f-telefon" value="${esc(l.telefon||'')}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Izvor</label><input id="f-izvor" value="${esc(l.izvor||'')}" placeholder="web / sajam / preporuka / cold-call"></div>
<div class="fld"><label>Status</label>
<select id="f-status">
${['new','contacted','qualified','lost','converted'].map(x=>`<option value="${x}" ${l.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
</div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(l.napomene||'')}</textarea></div>
`;
}
function readLeadForm() {
return {
ime: document.getElementById('f-ime').value.trim()||null,
prezime: document.getElementById('f-prezime').value.trim()||null,
organizacija: document.getElementById('f-org').value.trim()||null,
email: document.getElementById('f-email').value.trim()||null,
telefon: document.getElementById('f-telefon').value.trim()||null,
izvor: document.getElementById('f-izvor').value.trim()||null,
status: document.getElementById('f-status').value,
napomene: document.getElementById('f-napomene').value||null,
};
}
function openLeadModal(l) {
const isEdit = !!(l && l.id);
showModal(isEdit?'Uredi lead':'Novi lead', leadFormHTML(l||{status:'new'}), async () => {
const body = readLeadForm();
try {
if (isEdit) await api('/leads/'+l.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/leads', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadLeads();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editLead(id) {
try { const l = await api('/leads/'+id); openLeadModal(l); }
catch (e) { toast(e.message, 'err'); }
}
async function delLead(id) {
if (!confirm('Obrisati lead?')) return;
try { await api('/leads/'+id, {method:'DELETE'}); toast('Obrisano'); loadLeads(); }
catch (e) { toast(e.message, 'err'); }
}
async function convertLead(id) {
try {
const l = await api('/leads/'+id);
const body = `
<div class="fld"><label>Account naziv</label><input id="cv-acc" value="${esc(l.organizacija || ((l.ime||'')+' '+(l.prezime||'')).trim())}"></div>
<div class="fld"><label>Account tip</label>
<select id="cv-type">
${['klub','savez','sponzor','drzava','drugo'].map(x=>`<option value="${x}">${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label><input type="checkbox" id="cv-opp"> Stvori i Opportunity</label></div>
<div id="cv-opp-fields" style="display:none">
<div class="fld"><label>Naziv prilike</label><input id="cv-opp-naziv" value="Lead ${l.id} → Opportunity"></div>
<div class="fld-row">
<div class="fld"><label>Iznos (EUR)</label><input id="cv-opp-amount" type="number" step="0.01"></div>
<div class="fld"><label>Vjerojatnost %</label><input id="cv-opp-prob" type="number" value="20"></div>
</div>
<div class="fld"><label>Close datum</label><input id="cv-opp-close" type="date"></div>
</div>
`;
showModal('Konvertiraj lead', body, async () => {
const payload = {
account: { naziv: document.getElementById('cv-acc').value, type: document.getElementById('cv-type').value },
};
if (document.getElementById('cv-opp').checked) {
payload.opportunity = {
naziv: document.getElementById('cv-opp-naziv').value,
amount_eur: parseFloat(document.getElementById('cv-opp-amount').value)||null,
probability: parseInt(document.getElementById('cv-opp-prob').value)||20,
close_date: document.getElementById('cv-opp-close').value || null,
};
}
try {
await api('/leads/'+id+'/convert', {method:'POST', body:JSON.stringify(payload)});
toast('Lead konvertiran'); closeModal(); loadLeads(); loadPipeline();
} catch (e) { toast(e.message, 'err'); }
});
setTimeout(() => {
document.getElementById('cv-opp').addEventListener('change', e => {
document.getElementById('cv-opp-fields').style.display = e.target.checked ? 'block' : 'none';
});
}, 0);
} catch (e) { toast(e.message, 'err'); }
}
// ────── Opportunities ──────
async function loadOpps() {
const q = document.getElementById('opp-q').value.trim();
const s = document.getElementById('opp-stage').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (s) qs.set('stage', s);
try {
const data = await api('/opportunities?'+qs.toString());
const tb = document.querySelector('#t-opps tbody');
tb.innerHTML = (data.items||[]).map(o => `
<tr onclick="editOpp(${o.id})">
<td><strong>${esc(o.naziv)}</strong></td>
<td>${esc(o.account_naziv||'—')}</td>
<td>${esc(o.type||'—')}</td>
<td><span class="chip">${esc(o.stage)}</span></td>
<td>${fmtEur(o.amount_eur)}</td>
<td>${o.probability||0}%</td>
<td>${fmtDate(o.close_date)}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delOpp(${o.id})">×</button></td>
</tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema prilika.</td></tr>';
document.getElementById('cnt-opps').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function oppFormHTML(o={}) {
return `
<div class="fld"><label>Naziv*</label><input id="f-naziv" value="${esc(o.naziv||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Account ID*</label><input id="f-account_id" type="number" value="${o.account_id||''}"></div>
<div class="fld"><label>Contact ID</label><input id="f-contact_id" type="number" value="${o.contact_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Tip</label>
<select id="f-type">
${['financiranje','sponzorstvo','grant','natjecanje','drugo'].map(x=>`<option value="${x}" ${o.type===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Faza</label>
<select id="f-stage">
${STAGES.map(x=>`<option value="${x}" ${o.stage===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
</div>
<div class="fld-row">
<div class="fld"><label>Iznos (EUR)</label><input id="f-amount" type="number" step="0.01" value="${o.amount_eur||''}"></div>
<div class="fld"><label>Vjerojatnost %</label><input id="f-prob" type="number" value="${o.probability??20}"></div>
</div>
<div class="fld"><label>Close datum</label><input id="f-close" type="date" value="${(o.close_date||'').slice(0,10)}"></div>
<div class="fld"><label>Napomene</label><textarea id="f-napomene">${esc(o.napomene||'')}</textarea></div>
`;
}
function readOppForm() {
return {
naziv: document.getElementById('f-naziv').value.trim(),
account_id: parseInt(document.getElementById('f-account_id').value),
contact_id: parseInt(document.getElementById('f-contact_id').value)||null,
type: document.getElementById('f-type').value,
stage: document.getElementById('f-stage').value,
amount_eur: parseFloat(document.getElementById('f-amount').value)||null,
probability: parseInt(document.getElementById('f-prob').value)||0,
close_date: document.getElementById('f-close').value||null,
napomene: document.getElementById('f-napomene').value||null,
};
}
function openOppModal(o) {
const isEdit = !!(o && o.id);
showModal(isEdit?'Uredi priliku':'Nova prilika', oppFormHTML(o||{stage:'prospecting',probability:20}), async () => {
const body = readOppForm();
if (!body.naziv || !body.account_id) { toast('Naziv i Account ID su obavezni', 'err'); return; }
try {
if (isEdit) await api('/opportunities/'+o.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/opportunities', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadOpps(); loadPipeline();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editOpp(id) {
try { const o = await api('/opportunities/'+id); openOppModal(o); }
catch (e) { toast(e.message, 'err'); }
}
async function delOpp(id) {
if (!confirm('Obrisati priliku?')) return;
try { await api('/opportunities/'+id, {method:'DELETE'}); toast('Obrisano'); loadOpps(); loadPipeline(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Activities ──────
async function loadActivities() {
const t = document.getElementById('act-type').value;
const o = document.getElementById('act-open').value;
const qs = new URLSearchParams();
if (t) qs.set('type', t);
if (o) qs.set('open_only', o);
try {
const data = await api('/activities?'+qs.toString());
const tb = document.querySelector('#t-activities tbody');
tb.innerHTML = (data.items||[]).map(a => `
<tr onclick="editActivity(${a.id})">
<td>${esc(a.type)}</td>
<td><strong>${esc(a.subject)}</strong></td>
<td>${esc(a.account_naziv||'—')}</td>
<td>${esc(a.contact_naziv||'—')}</td>
<td>${fmtDT(a.due_at)}</td>
<td>${a.completed_at ? '<span class="chip resolved">done</span>' : '<span class="chip new">open</span>'}</td>
<td>
${!a.completed_at ? `<button class="btn sm gold" onclick="event.stopPropagation();completeActivity(${a.id})">✓</button>` : ''}
<button class="btn sm" onclick="event.stopPropagation();delActivity(${a.id})">×</button>
</td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema aktivnosti.</td></tr>';
document.getElementById('cnt-activities').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function activityFormHTML(a={}) {
return `
<div class="fld-row">
<div class="fld"><label>Tip*</label>
<select id="f-type">
${['call','meeting','email','task','note'].map(x=>`<option value="${x}" ${a.type===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Due (datum/vrijeme)</label><input id="f-due" type="datetime-local" value="${(a.due_at||'').slice(0,16)}"></div>
</div>
<div class="fld"><label>Subject*</label><input id="f-subject" value="${esc(a.subject||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Account ID</label><input id="f-account_id" type="number" value="${a.account_id||''}"></div>
<div class="fld"><label>Contact ID</label><input id="f-contact_id" type="number" value="${a.contact_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Opportunity ID</label><input id="f-opp_id" type="number" value="${a.opportunity_id||''}"></div>
<div class="fld"><label>Lead ID</label><input id="f-lead_id" type="number" value="${a.lead_id||''}"></div>
</div>
<div class="fld"><label>Body / bilješke</label><textarea id="f-body">${esc(a.body||'')}</textarea></div>
`;
}
function readActivityForm() {
const due = document.getElementById('f-due').value;
return {
type: document.getElementById('f-type').value,
subject: document.getElementById('f-subject').value.trim(),
body: document.getElementById('f-body').value||null,
account_id: parseInt(document.getElementById('f-account_id').value)||null,
contact_id: parseInt(document.getElementById('f-contact_id').value)||null,
opportunity_id: parseInt(document.getElementById('f-opp_id').value)||null,
lead_id: parseInt(document.getElementById('f-lead_id').value)||null,
due_at: due ? new Date(due).toISOString() : null,
};
}
function openActivityModal(a) {
const isEdit = !!(a && a.id);
showModal(isEdit?'Uredi aktivnost':'Nova aktivnost', activityFormHTML(a||{type:'task'}), async () => {
const body = readActivityForm();
if (!body.subject) { toast('Subject je obavezan', 'err'); return; }
try {
if (isEdit) await api('/activities/'+a.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/activities', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadActivities();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editActivity(id) {
try { const a = await api('/activities/'+id); openActivityModal(a); }
catch (e) { toast(e.message, 'err'); }
}
async function completeActivity(id) {
try { await api('/activities/'+id+'/complete', {method:'PATCH'}); toast('Označeno gotovo'); loadActivities(); }
catch (e) { toast(e.message, 'err'); }
}
async function delActivity(id) {
if (!confirm('Obrisati aktivnost?')) return;
try { await api('/activities/'+id, {method:'DELETE'}); toast('Obrisano'); loadActivities(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Cases ──────
async function loadCases() {
const q = document.getElementById('case-q').value.trim();
const s = document.getElementById('case-status').value;
const p = document.getElementById('case-priority').value;
const qs = new URLSearchParams();
if (q) qs.set('q', q);
if (s) qs.set('status', s);
if (p) qs.set('priority', p);
try {
const data = await api('/cases?'+qs.toString());
const tb = document.querySelector('#t-cases tbody');
tb.innerHTML = (data.items||[]).map(c => `
<tr onclick="editCase(${c.id})">
<td><strong>${esc(c.subject)}</strong></td>
<td>${esc(c.account_naziv||'—')}</td>
<td><span class="chip ${c.status}">${c.status}</span></td>
<td><span class="chip ${c.priority}">${c.priority}</span></td>
<td>${fmtDT(c.created_at)}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delCase(${c.id})">×</button></td>
</tr>
`).join('') || '<tr><td colspan="6" class="empty">Nema caseova.</td></tr>';
document.getElementById('cnt-cases').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
function caseFormHTML(k={}) {
return `
<div class="fld"><label>Subject*</label><input id="f-subject" value="${esc(k.subject||'')}"></div>
<div class="fld-row">
<div class="fld"><label>Account ID</label><input id="f-account_id" type="number" value="${k.account_id||''}"></div>
<div class="fld"><label>Contact ID</label><input id="f-contact_id" type="number" value="${k.contact_id||''}"></div>
</div>
<div class="fld-row">
<div class="fld"><label>Status</label>
<select id="f-status">
${['open','in_progress','waiting','resolved','closed'].map(x=>`<option value="${x}" ${k.status===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
<div class="fld"><label>Prioritet</label>
<select id="f-priority">
${['low','normal','high','urgent'].map(x=>`<option value="${x}" ${k.priority===x?'selected':''}>${x}</option>`).join('')}
</select>
</div>
</div>
<div class="fld"><label>Opis</label><textarea id="f-desc">${esc(k.description||'')}</textarea></div>
`;
}
function readCaseForm() {
return {
subject: document.getElementById('f-subject').value.trim(),
account_id: parseInt(document.getElementById('f-account_id').value)||null,
contact_id: parseInt(document.getElementById('f-contact_id').value)||null,
status: document.getElementById('f-status').value,
priority: document.getElementById('f-priority').value,
description: document.getElementById('f-desc').value||null,
};
}
function openCaseModal(k) {
const isEdit = !!(k && k.id);
showModal(isEdit?'Uredi case':'Novi case', caseFormHTML(k||{status:'open',priority:'normal'}), async () => {
const body = readCaseForm();
if (!body.subject) { toast('Subject je obavezan', 'err'); return; }
try {
if (isEdit) await api('/cases/'+k.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/cases', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadCases();
} catch (e) { toast(e.message, 'err'); }
});
}
async function editCase(id) {
try { const k = await api('/cases/'+id); openCaseModal(k); }
catch (e) { toast(e.message, 'err'); }
}
async function delCase(id) {
if (!confirm('Obrisati case?')) return;
try { await api('/cases/'+id, {method:'DELETE'}); toast('Obrisano'); loadCases(); }
catch (e) { toast(e.message, 'err'); }
}
// ────── Modal helpers ──────
function showModal(title, bodyHTML, onSave) {
document.getElementById('m-title').textContent = title;
document.getElementById('m-body').innerHTML = bodyHTML;
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();
});
// ────── Init ──────
loadMe();
loadPipeline();
</script>
</body>
</html>