1d02c0897d
- pgz nav now includes /erp/full, /crm/v2, /admin/users, /dokumenti
- 4 dokumenti endpoints: list, godišnjaci/list, godišnjak/{godina} PDF, detail
- 18 godišnjaka u pgz_sport.dokumenti (2006-2024) with savez_id=333
- PGŽ filter helpers (window._pgz_filter_priority, togglePGZFilter)
- navItemClick handler for nav items with href
1109 lines
52 KiB
HTML
1109 lines
52 KiB
HTML
<!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; }
|
||
|
||
/* 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>
|
||
|
||
<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>
|
||
|
||
</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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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();
|
||
}
|
||
|
||
// ────── /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>
|