RUSH 4-sub: filteri Klubovi/Sportaši + manifestacije card view + CRM v2 redesign

RUSH-1 Klubovi: list_klubovi() LEFT JOIN v_klubovi_financiranje (prima_pgz/rss/grad_rijeka, u_godisnjaku, ukupno_potpora). financiran=true sad OR od 3 davatelja (drop legacy klubovi.pgz_sufinanciran s 1312 false-positive). Sort sort=potpora&order=desc. UI: gold ukupno_potpora + tooltip + sortable kolona. Defaults priority view (financirani+godišnjak ON, hns_roster OFF). Test: priority=604, +hns=36, all=1641, financiran=15 sorted ZAMET 80208€.

RUSH-2 Sportaši: SELECT widened (slika_url, reprezentativac, kategoriziran, broj_dresa). avatarUrl() helper s 3 forme (apsolutni / lokalni /sport/uploads/avatars / initials fallback) + 32px circular avatar lijevo od imena. Test: priority=3712, no-priority=6086, +hns=1439, 1990-2000=645.

RUSH-3 Manifestacije: bugfix razina filter HTTP 500 (ambiguous column nakon LEFT JOIN savezi → m.razina/mjesto/organizator). 3 dropdowna iz meta (26 mjesta / 8 razina / 50 organizatora), view toggle 🃏 Kartice / 📋 Tablica (localStorage), 🔗 link ikona u card+table, source_url → Google fallback. Test: default=3, mjesto=Lošinj=2, razina=Tradicionalna=3, organizator=AK Kvarner=1.

RUSH-4 CRM v2: tab strip rewrite (10 taba u spec redu Članarine|Liječnički|Obrasci|E-mail|Accounts|Contacts|Leads|Opps|Activities|Cases, sticky+scrollable+gold underline). Pipeline → Opps tab. Novi e-mail templates tab (5 endpointa, 3 seed templates, +Novi modal). Card layout (.cgrid/.ccard) za Accounts/Contacts/Leads/Opps. Export dropdown 📥 ▾ CSV/XLSX(SheetJS CDN)/PDF na svaki tab. Test: /crm_v2 200, 10/10 tab labela, 10 Export dropdowna + 31 exportTab() handlera.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 18:33:20 +02:00
parent b72d037141
commit 9b0ed43b92
8 changed files with 1203 additions and 135 deletions
+7 -3
View File
@@ -1261,7 +1261,8 @@ SECTIONS['pgz:savezi'] = async () => {
<td><button class="btn sm" onclick="event.stopPropagation();showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">Detalji</button></td>
</tr>`).join('');
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})${onPGZ?' · ⭐ samo PGŽ-relevantni':''}</div></div>
setTimeout(()=>{ const b=document.getElementById('app-exp-savezi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'savezi'); },0);
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})${onPGZ?' · ⭐ samo PGŽ-relevantni':''}</div><button id="app-exp-savezi" class="export-btn" type="button">Export ▾</button></div>
${tb}
<table><thead><tr><th>Naziv</th><th class="num">Klubovi</th><th class="num">Sportaši</th><th>Predsjednik</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">Učitavam...</td></tr>'}</tbody></table>
</div>`;
@@ -1281,7 +1282,8 @@ SECTIONS['pgz:klubovi'] = async () => {
<td>${esc(k.predsjednik||'—')}</td>
</tr>`).join('');
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
return `<div class="card"><div class="card-h"><div class="card-t">⬢ Klubovi (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div></div>
setTimeout(()=>{ const b=document.getElementById('app-exp-klubovi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'klubovi'); },0);
return `<div class="card"><div class="card-h"><div class="card-t">⬢ Klubovi (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div><button id="app-exp-klubovi" class="export-btn" type="button">Export ▾</button></div>
${tb}
<table><thead><tr><th>Naziv</th><th>Savez</th><th>Grad</th><th class="num">Članova</th><th>Predsjednik</th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">—</td></tr>'}</tbody></table>
</div>`;
@@ -1301,7 +1303,8 @@ SECTIONS['pgz:sportasi'] = async () => {
<td>${esc(c.datum_rodjenja||c.datum_rodenja||'—')}</td>
</tr>`).join('');
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
return `<div class="card"><div class="card-h"><div class="card-t">👤 Sportaši (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div></div>
setTimeout(()=>{ const b=document.getElementById('app-exp-sportasi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'sportasi'); },0);
return `<div class="card"><div class="card-h"><div class="card-t">👤 Sportaši (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div><button id="app-exp-sportasi" class="export-btn" type="button">Export ▾</button></div>
${tb}
<table><thead><tr><th>Ime i prezime</th><th>Klub</th><th>Kategorija</th><th>Spol</th><th>Rođen</th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">—</td></tr>'}</tbody></table>
</div>`;
@@ -2493,5 +2496,6 @@ window.renderPGZToggleBtn = function(){
+ (on ? '⭐ PGŽ filter ON' : '☆ PGŽ filter OFF') + '</button>';
};
</script>
<script src="/static/js/export_dropdown.js"></script>
</body>
</html>
+456 -88
View File
@@ -10,6 +10,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport — CRM v2 (Salesforce-Lite)</title>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
<style>
:root {
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a; --pgz-gold:#fbbf24;
@@ -234,7 +235,7 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
<div class="topbar">
<span class="logo">PGŽ SPORT</span>
<span class="sep"></span>
<span class="title">CRM v2 — Salesforce-Lite (Pipeline)</span>
<span class="title">CRM v2 — Salesforce-Lite</span>
<div class="right">
<span id="me"></span>
<a href="/sport/platform">Platform</a>
@@ -245,40 +246,21 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</div>
<div class="tabs">
<div class="tab active" data-tab="pipeline">Pipeline</div>
<div class="tab active" data-tab="clanarine">Članarine <span class="count" id="cnt-clanarine">·</span></div>
<div class="tab" data-tab="lijecnicki">Liječnički <span class="count" id="cnt-lijecnicki">·</span></div>
<div class="tab" data-tab="obrasci">Obrasci <span class="count" id="cnt-obrasci">·</span></div>
<div class="tab" data-tab="emailtpl">E-mail templates <span class="count" id="cnt-emailtpl">·</span></div>
<div class="tab" data-tab="accounts">Accounts <span class="count" id="cnt-accounts">·</span></div>
<div class="tab" data-tab="contacts">Contacts <span class="count" id="cnt-contacts">·</span></div>
<div class="tab" data-tab="leads">Leads <span class="count" id="cnt-leads">·</span></div>
<div class="tab" data-tab="opportunities">Opportunities <span class="count" id="cnt-opps">·</span></div>
<div class="tab" data-tab="activities">Activities <span class="count" id="cnt-activities">·</span></div>
<div class="tab" data-tab="cases">Cases <span class="count" id="cnt-cases">·</span></div>
<div 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>
<!-- ────── PIPELINE (legacy tab removed — KPIs + kanban now live in Opportunities tab) ────── -->
<!-- ────── ACCOUNTS ────── -->
<div class="tab-c" id="tc-accounts">
@@ -294,9 +276,17 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<button class="btn" onclick="loadAccounts()">Pretraži</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-accounts')">📥 Export ▾</button>
<div class="exp-menu" id="exp-accounts">
<button onclick="exportTab('accounts','csv')">CSV</button>
<button onclick="exportTab('accounts','xlsx')">XLSX</button>
<button onclick="exportTab('accounts','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openAccountModal()">+ Novi account</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<div id="acc-cards" class="cgrid"></div>
<div class="card" style="display:none"><div class="card-b" style="padding:0">
<table id="t-accounts"><thead><tr>
<th>Naziv</th><th>Tip</th><th>Grad</th><th>OIB</th><th>Email</th>
<th>Kontakti</th><th>Opps</th><th>Owner</th><th></th>
@@ -311,9 +301,17 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
<input id="con-acc" type="number" placeholder="Account ID (filter)" style="max-width:170px">
<button class="btn" onclick="loadContacts()">Pretraži</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-contacts')">📥 Export ▾</button>
<div class="exp-menu" id="exp-contacts">
<button onclick="exportTab('contacts','csv')">CSV</button>
<button onclick="exportTab('contacts','xlsx')">XLSX</button>
<button onclick="exportTab('contacts','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openContactModal()">+ Novi kontakt</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<div id="con-cards" class="cgrid"></div>
<div class="card" style="display:none"><div class="card-b" style="padding:0">
<table id="t-contacts"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Account</th><th>Funkcija</th>
<th>Email</th><th>Telefon</th><th></th>
@@ -335,9 +333,17 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<button class="btn" onclick="loadLeads()">Pretraži</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-leads')">📥 Export ▾</button>
<div class="exp-menu" id="exp-leads">
<button onclick="exportTab('leads','csv')">CSV</button>
<button onclick="exportTab('leads','xlsx')">XLSX</button>
<button onclick="exportTab('leads','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openLeadModal()">+ Novi lead</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<div id="lead-cards" class="cgrid"></div>
<div class="card" style="display:none"><div class="card-b" style="padding:0">
<table id="t-leads"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Organizacija</th><th>Email</th>
<th>Izvor</th><th>Status</th><th></th>
@@ -360,9 +366,34 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<button class="btn" onclick="loadOpps()">Pretraži</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-opps')">📥 Export ▾</button>
<div class="exp-menu" id="exp-opps">
<button onclick="exportTab('opps','csv')">CSV</button>
<button onclick="exportTab('opps','xlsx')">XLSX</button>
<button onclick="exportTab('opps','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openOppModal()">+ Nova prilika</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
<!-- KPIs + Pipeline kanban (migrirano iz Pipeline taba) -->
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Open opps</div><div class="kpi-v" id="k-opps">·</div><div class="kpi-s" id="k-opps-eur">·</div></div>
<div class="kpi gold"><div class="kpi-l">Weighted total</div><div class="kpi-v" id="k-weighted">·</div><div class="kpi-s">prosjek vjerojatnosti</div></div>
<div class="kpi g"><div class="kpi-l">Won this quarter</div><div class="kpi-v" id="k-won">·</div><div class="kpi-s" id="k-won-eur">·</div></div>
<div class="kpi a"><div class="kpi-l">Leads (new+contacted)</div><div class="kpi-v" id="k-leads">·</div><div class="kpi-s">qualified: <span id="k-leads-q">·</span></div></div>
<div class="kpi r"><div class="kpi-l">Overdue activities</div><div class="kpi-v" id="k-overdue">·</div><div class="kpi-s">upcoming: <span id="k-upcoming">·</span></div></div>
<div class="kpi"><div class="kpi-l">Open cases</div><div class="kpi-v" id="k-cases">·</div><div class="kpi-s" id="k-cases-urgent">·</div></div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">Pipeline kanban</div>
<button class="btn sm" onclick="loadPipeline()">↻ Refresh</button>
</div>
<div class="card-b"><div class="kanban" id="kanban"></div></div>
</div>
<div id="opp-cards" class="cgrid"></div>
<div class="card" style="display:none"><div class="card-b" style="padding:0">
<table id="t-opps"><thead><tr>
<th>Naziv</th><th>Account</th><th>Tip</th><th>Faza</th>
<th>EUR</th><th>%</th><th>Close</th><th></th>
@@ -388,6 +419,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<button class="btn" onclick="loadActivities()">Filtriraj</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-activities')">📥 Export ▾</button>
<div class="exp-menu" id="exp-activities">
<button onclick="exportTab('activities','csv')">CSV</button>
<button onclick="exportTab('activities','xlsx')">XLSX</button>
<button onclick="exportTab('activities','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openActivityModal()">+ Nova aktivnost</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
@@ -419,6 +457,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<button class="btn" onclick="loadCases()">Pretraži</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-cases')">📥 Export ▾</button>
<div class="exp-menu" id="exp-cases">
<button onclick="exportTab('cases','csv')">CSV</button>
<button onclick="exportTab('cases','xlsx')">XLSX</button>
<button onclick="exportTab('cases','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openCaseModal()">+ Novi case</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
@@ -430,7 +475,7 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</div>
<!-- ────── ČLANARINE ────── -->
<div class="tab-c" id="tc-clanarine">
<div class="tab-c on" id="tc-clanarine">
<div class="toolbar">
<input id="cln-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="cln-clan" type="number" placeholder="Član ID" style="max-width:120px">
@@ -444,6 +489,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<button class="btn" onclick="loadClanarine()">Filtriraj</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-clanarine')">📥 Export ▾</button>
<div class="exp-menu" id="exp-clanarine">
<button onclick="exportTab('clanarine','csv')">CSV</button>
<button onclick="exportTab('clanarine','xlsx')">XLSX</button>
<button onclick="exportTab('clanarine','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openClanarinaModal()">+ Nova članarina</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
@@ -465,6 +517,14 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<button class="btn" onclick="loadLijecnicki()">Filtriraj</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-lijecnicki')">📥 Export ▾</button>
<div class="exp-menu" id="exp-lijecnicki">
<button onclick="exportTab('lijecnicki','csv')">CSV</button>
<button onclick="exportTab('lijecnicki','xlsx')">XLSX</button>
<button onclick="exportTab('lijecnicki','pdf')">PDF</button>
</div>
</div>
<button id="lij-srv-export-btn" class="export-btn" type="button" title="Server-side export svih redaka iz baze">Export ▾ (svi)</button>
<button class="btn primary" onclick="openLijecnickiModal()">+ Novi pregled</button>
</div>
<div class="card"><div class="card-b" style="padding:0">
@@ -501,6 +561,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</select>
<input id="obr-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<button class="btn" onclick="loadObrasciSubmissions()">Filtriraj</button>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-obrasci')">📥 Export ▾</button>
<div class="exp-menu" id="exp-obrasci">
<button onclick="exportTab('obrasci','csv')">CSV</button>
<button onclick="exportTab('obrasci','xlsx')">XLSX</button>
<button onclick="exportTab('obrasci','pdf')">PDF</button>
</div>
</div>
</div>
<div class="card"><div class="card-b" style="padding:0" id="obr-right-body">
<table id="t-obr-sub"><thead><tr>
@@ -512,6 +579,31 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</div>
</div>
<!-- ────── E-MAIL TEMPLATES (RUSH-4 / 2026-05-05) ────── -->
<div class="tab-c" id="tc-emailtpl">
<div class="toolbar">
<input id="etpl-q" placeholder="Pretraži (kod / naziv)…">
<select id="etpl-cat">
<option value="">— Sve kategorije —</option>
<option value="clanarine">clanarine</option>
<option value="lijecnicki">lijecnicki</option>
<option value="obrasci">obrasci</option>
<option value="opci">opci</option>
</select>
<button class="btn" onclick="loadEmailTpls()">Pretraži</button>
<span class="grow"></span>
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-emailtpl')">📥 Export ▾</button>
<div class="exp-menu" id="exp-emailtpl">
<button onclick="exportTab('emailtpl','csv')">CSV</button>
<button onclick="exportTab('emailtpl','xlsx')">XLSX</button>
<button onclick="exportTab('emailtpl','pdf')">PDF</button>
</div>
</div>
<button class="btn primary" onclick="openEmailTplModal()">+ Novi predložak</button>
</div>
<div id="etpl-grid" class="cgrid"></div>
</div>
</div><!-- /main -->
<footer>
@@ -610,17 +702,90 @@ const esc = s => String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&amp;','<':'
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();
document.querySelectorAll('.tab-c').forEach(c => c.classList.toggle('on', c.id==='tc-'+name || (name==='opportunities' && c.id==='tc-opps') || (name==='emailtpl' && c.id==='tc-emailtpl')));
// Always refresh KPIs/pipeline counts in background
if (name==='accounts') loadAccounts();
if (name==='contacts') loadContacts();
if (name==='leads') loadLeads();
if (name==='opportunities') loadOpps();
if (name==='opportunities') { loadOpps(); loadPipeline(); }
if (name==='activities') loadActivities();
if (name==='cases') loadCases();
if (name==='clanarine') loadClanarine();
if (name==='lijecnicki') loadLijecnicki();
if (name==='obrasci') { loadObrasciTemplates(); loadObrasciSubmissions(); }
if (name==='emailtpl') loadEmailTpls();
}
// ────── Export dropdown helpers ──────
function toggleExp(id) {
const m = document.getElementById(id); if (!m) return;
document.querySelectorAll('.exp-menu').forEach(x => { if (x.id !== id) x.classList.remove('on'); });
m.classList.toggle('on');
}
document.addEventListener('click', e => {
if (!e.target.closest('.exp')) document.querySelectorAll('.exp-menu').forEach(x => x.classList.remove('on'));
});
// Tabular export: pull rows from current cards/tables for a given tab
const EXPORT_HEADERS = {
accounts: ['Naziv','Tip','Grad','OIB','Email','Telefon','Kontakti','Opps','Owner'],
contacts: ['Ime','Prezime','Account','Funkcija','Email','Telefon'],
leads: ['Ime','Prezime','Organizacija','Email','Telefon','Izvor','Status'],
opps: ['Naziv','Account','Tip','Faza','EUR','Vjerojatnost %','Close'],
activities: ['Tip','Subject','Account','Kontakt','Due','Status'],
cases: ['Subject','Account','Status','Priority','Stvoren'],
clanarine: ['Član','Klub','Godina','Razdoblje','Propisan','Plaćen','Datum uplate','Status'],
lijecnicki: ['Član','Klub','Datum','Vrsta','Vrijedi do','Liječnik','Spreman','Plaćeno'],
obrasci: ['ID','Predložak','Klub','Član','Status','Submitted','Approved'],
emailtpl: ['Code','Naziv','Kategorija','Subject','Active'],
};
const EXPORT_CACHE = {}; // tab → array of row arrays
function setExportRows(tab, rows) { EXPORT_CACHE[tab] = rows; }
function exportTab(tab, fmt) {
document.querySelectorAll('.exp-menu').forEach(x => x.classList.remove('on'));
const headers = EXPORT_HEADERS[tab] || [];
const rows = EXPORT_CACHE[tab] || [];
if (!rows.length) { toast('Nema redaka za export', 'err'); return; }
const fname = 'crm_' + tab + '_' + new Date().toISOString().slice(0,10);
if (fmt === 'csv') {
const csv = [headers, ...rows].map(r => r.map(c => {
const s = (c==null?'':String(c)).replace(/"/g,'""');
return /[",\n;]/.test(s) ? '"'+s+'"' : s;
}).join(';')).join('\r\n');
const blob = new Blob([''+csv], {type:'text/csv;charset=utf-8'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = fname + '.csv'; a.click();
setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
toast('CSV: '+rows.length+' redaka');
} else if (fmt === 'xlsx') {
if (typeof XLSX === 'undefined') { toast('SheetJS nije učitan', 'err'); return; }
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, tab);
XLSX.writeFile(wb, fname + '.xlsx');
toast('XLSX: '+rows.length+' redaka');
} else if (fmt === 'pdf') {
// Print-friendly window: use a fresh document with table only
const w = window.open('', '_blank', 'width=900,height=700');
const html = `<!doctype html><html><head><title>${fname}</title>
<style>
body{font-family:Arial,sans-serif;font-size:11px;margin:18px;color:#000}
h2{font-size:15px;margin:0 0 8px}
table{width:100%;border-collapse:collapse;font-size:10px}
th{background:#eee;text-align:left;padding:5px 7px;border:1px solid #999;text-transform:uppercase;font-size:9px;letter-spacing:.4px}
td{padding:4px 7px;border:1px solid #ccc}
tr:nth-child(even) td{background:#fafafa}
</style></head><body>
<h2>PGŽ Sport CRM — ${tab.toUpperCase()} <small style="font-weight:400;color:#666">(${new Date().toLocaleString('hr-HR')})</small></h2>
<table><thead><tr>${headers.map(h=>'<th>'+h+'</th>').join('')}</tr></thead>
<tbody>${rows.map(r=>'<tr>'+r.map(c=>'<td>'+(c==null?'':String(c).replace(/&/g,'&amp;').replace(/</g,'&lt;'))+'</td>').join('')+'</tr>').join('')}</tbody></table>
</body></html>`;
w.document.write(html); w.document.close();
setTimeout(() => { try { w.focus(); w.print(); } catch(e){} }, 350);
toast('PDF print dialog otvoren');
}
}
// ────── /me ──────
@@ -734,21 +899,43 @@ async function loadAccounts() {
if (t) qs.set('type', t);
try {
const data = await api('/accounts?'+qs.toString());
const items = data.items||[];
// Card grid (primary)
const grid = document.getElementById('acc-cards');
if (grid) {
grid.innerHTML = items.map(a => `
<div class="ccard" onclick="editAccount(${a.id})">
<div class="ccard-actions">
<button class="btn sm danger" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button>
</div>
<div class="ccard-h">${esc(a.naziv)}</div>
<div class="ccard-sub"><span class="chip ${a.type}">${esc(a.type)}</span> ${esc(a.grad||'')}</div>
<div class="ccard-row"><span>OIB</span><strong>${esc(a.oib||'—')}</strong></div>
<div class="ccard-row"><span>Email</span><strong>${esc(a.email||'—')}</strong></div>
<div class="ccard-row"><span>Kontakti / Opps</span><strong>${a.contacts_n||0} / ${a.opps_n||0}</strong></div>
<div class="ccard-row"><span>Owner</span><strong>${esc(a.owner_email||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema accounta — dodajte prvi.</div>';
}
// Hidden table (compat for legacy code/exports)
const tb = document.querySelector('#t-accounts tbody');
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;
if (tb) {
tb.innerHTML = items.map(a => `
<tr onclick="editAccount(${a.id})">
<td><strong>${esc(a.naziv)}</strong></td>
<td>${esc(a.type)}</td>
<td>${esc(a.grad||'—')}</td>
<td>${esc(a.oib||'—')}</td>
<td>${esc(a.email||'—')}</td>
<td>${a.contacts_n||0}</td>
<td>${a.opps_n||0}</td>
<td>${esc(a.owner_email||'—')}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button></td>
</tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema accounta.</td></tr>';
}
setExportRows('accounts', items.map(a => [a.naziv, a.type, a.grad||'', a.oib||'', a.email||'', a.telefon||'', a.contacts_n||0, a.opps_n||0, a.owner_email||'']));
document.getElementById('cnt-accounts').textContent = items.length;
} catch (e) { toast('Accounts err: '+e.message, 'err'); }
}
@@ -823,19 +1010,33 @@ async function loadContacts() {
if (aid) qs.set('account_id', aid);
try {
const data = await api('/contacts?'+qs.toString());
const items = data.items||[];
const grid = document.getElementById('con-cards');
if (grid) {
grid.innerHTML = items.map(c => `
<div class="ccard" onclick="editContact(${c.id})">
<div class="ccard-actions">
<button class="btn sm danger" onclick="event.stopPropagation();delContact(${c.id})">×</button>
</div>
<div class="ccard-h">${esc(c.ime)} ${esc(c.prezime)}</div>
<div class="ccard-sub">${esc(c.funkcija||'—')} · ${esc(c.account_naziv||'—')}</div>
<div class="ccard-row"><span>Email</span><strong>${esc(c.email||'—')}</strong></div>
<div class="ccard-row"><span>Telefon</span><strong>${esc(c.telefon||c.mobitel||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema kontakata.</div>';
}
const tb = document.querySelector('#t-contacts tbody');
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;
if (tb) {
tb.innerHTML = items.map(c => `
<tr onclick="editContact(${c.id})">
<td><strong>${esc(c.ime)}</strong></td><td>${esc(c.prezime)}</td>
<td>${esc(c.account_naziv||'—')}</td><td>${esc(c.funkcija||'—')}</td>
<td>${esc(c.email||'—')}</td><td>${esc(c.telefon||c.mobitel||'—')}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delContact(${c.id})">×</button></td>
</tr>`).join('');
}
setExportRows('contacts', items.map(c => [c.ime||'', c.prezime||'', c.account_naziv||'', c.funkcija||'', c.email||'', c.telefon||c.mobitel||'']));
document.getElementById('cnt-contacts').textContent = items.length;
} catch (e) { toast(e.message, 'err'); }
}
function contactFormHTML(c={}) {
@@ -903,22 +1104,35 @@ async function loadLeads() {
if (s) qs.set('status', s);
try {
const data = await api('/leads?'+qs.toString());
const items = data.items||[];
const grid = document.getElementById('lead-cards');
if (grid) {
grid.innerHTML = items.map(l => `
<div class="ccard" onclick="editLead(${l.id})">
<div class="ccard-actions">
${l.status!=='converted' ? `<button class="btn sm gold" onclick="event.stopPropagation();convertLead(${l.id})">→</button>` : ''}
<button class="btn sm danger" onclick="event.stopPropagation();delLead(${l.id})">×</button>
</div>
<div class="ccard-h">${esc(l.ime||'')} ${esc(l.prezime||'')}</div>
<div class="ccard-sub"><span class="chip ${l.status}">${l.status}</span> ${esc(l.organizacija||'—')}</div>
<div class="ccard-row"><span>Email</span><strong>${esc(l.email||'—')}</strong></div>
<div class="ccard-row"><span>Telefon</span><strong>${esc(l.telefon||'—')}</strong></div>
<div class="ccard-row"><span>Izvor</span><strong>${esc(l.izvor||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema leadova.</div>';
}
const tb = document.querySelector('#t-leads tbody');
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;
if (tb) {
tb.innerHTML = items.map(l => `
<tr onclick="editLead(${l.id})">
<td>${esc(l.ime||'—')}</td><td>${esc(l.prezime||'—')}</td>
<td>${esc(l.organizacija||'—')}</td><td>${esc(l.email||'—')}</td>
<td>${esc(l.izvor||'—')}</td><td><span class="chip ${l.status}">${l.status}</span></td>
<td><button class="btn sm" onclick="event.stopPropagation();delLead(${l.id})">×</button></td>
</tr>`).join('');
}
setExportRows('leads', items.map(l => [l.ime||'', l.prezime||'', l.organizacija||'', l.email||'', l.telefon||'', l.izvor||'', l.status||'']));
document.getElementById('cnt-leads').textContent = items.length;
} catch (e) { toast(e.message, 'err'); }
}
function leadFormHTML(l={}) {
@@ -1029,20 +1243,36 @@ async function loadOpps() {
if (s) qs.set('stage', s);
try {
const data = await api('/opportunities?'+qs.toString());
const items = data.items||[];
const grid = document.getElementById('opp-cards');
if (grid) {
grid.innerHTML = items.map(o => `
<div class="ccard" onclick="editOpp(${o.id})">
<div class="ccard-actions">
<button class="btn sm danger" onclick="event.stopPropagation();delOpp(${o.id})">×</button>
</div>
<div class="ccard-h">${esc(o.naziv)}</div>
<div class="ccard-sub">${esc(o.account_naziv||'—')} · <span class="chip">${esc(o.stage)}</span></div>
<div class="ccard-row"><span>Iznos</span><strong style="color:var(--pgz-gold)">${fmtEur(o.amount_eur)}</strong></div>
<div class="ccard-row"><span>Vjerojatnost</span><strong>${o.probability||0}%</strong></div>
<div class="ccard-row"><span>Close</span><strong>${fmtDate(o.close_date)}</strong></div>
<div class="ccard-row"><span>Tip</span><strong>${esc(o.type||'—')}</strong></div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema prilika.</div>';
}
const tb = document.querySelector('#t-opps tbody');
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;
if (tb) {
tb.innerHTML = items.map(o => `
<tr onclick="editOpp(${o.id})">
<td><strong>${esc(o.naziv)}</strong></td><td>${esc(o.account_naziv||'—')}</td>
<td>${esc(o.type||'—')}</td><td><span class="chip">${esc(o.stage)}</span></td>
<td>${fmtEur(o.amount_eur)}</td><td>${o.probability||0}%</td>
<td>${fmtDate(o.close_date)}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delOpp(${o.id})">×</button></td>
</tr>`).join('');
}
setExportRows('opps', items.map(o => [o.naziv||'', o.account_naziv||'', o.type||'', o.stage||'', o.amount_eur||0, o.probability||0, fmtDate(o.close_date)]));
document.getElementById('cnt-opps').textContent = items.length;
} catch (e) { toast(e.message, 'err'); }
}
function oppFormHTML(o={}) {
@@ -1131,6 +1361,7 @@ async function loadActivities() {
</td>
</tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema aktivnosti.</td></tr>';
setExportRows('activities', (data.items||[]).map(a => [a.type||'', a.subject||'', a.account_naziv||'', a.contact_naziv||'', fmtDT(a.due_at), a.completed_at?'done':'open']));
document.getElementById('cnt-activities').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
@@ -1217,6 +1448,7 @@ async function loadCases() {
<td><button class="btn sm" onclick="event.stopPropagation();delCase(${c.id})">×</button></td>
</tr>
`).join('') || '<tr><td colspan="6" class="empty">Nema caseova.</td></tr>';
setExportRows('cases', (data.items||[]).map(c => [c.subject||'', c.account_naziv||'', c.status||'', c.priority||'', fmtDT(c.created_at)]));
document.getElementById('cnt-cases').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); }
}
@@ -1323,6 +1555,7 @@ async function loadClanarine() {
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delClanarina(${c.id})">×</button>` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema članarina za odabrane filtere.</td></tr>';
setExportRows('clanarine', (data.items||[]).map(c => [c.clan_naziv||'', c.klub_naziv||'', c.godina||'', c.razdoblje||'', c.iznos_propisan||0, c.iznos_placen||0, fmtDate(c.datum_uplate), c.status||'']));
document.getElementById('cnt-clanarine').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Članarine err: '+e.message, 'err'); }
}
@@ -1424,6 +1657,7 @@ async function loadLijecnicki() {
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delLijecnicki(${l.id})">×</button>` : ''}</td>
</tr>`;
}).join('') || '<tr><td colspan="9" class="empty">Nema liječničkih pregleda.</td></tr>';
setExportRows('lijecnicki', (data.items||[]).map(l => [l.clan_naziv||'', l.klub_naziv||'', fmtDate(l.datum_pregleda), l.vrsta_pregleda||'', fmtDate(l.vrijedi_do), l.lijecnik||'', l.spreman_za_natjecanje?'DA':'NE', l.placeno?'DA':'NE']));
document.getElementById('cnt-lijecnicki').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Liječnički err: '+e.message, 'err'); }
}
@@ -1642,6 +1876,7 @@ async function loadObrasciSubmissions() {
` : ''}</td>
</tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema podnesenih obrazaca.</td></tr>';
setExportRows('obrasci', (data.items||[]).map(s => [s.id, s.template_naziv||s.template_code||'', s.klub_naziv||'', s.clan_naziv||'', s.status||'', fmtDT(s.submitted_at), fmtDT(s.approved_at)]));
} catch (e) { toast('Submissions err: '+e.message, 'err'); }
}
@@ -1712,6 +1947,120 @@ async function subStatus(id, status) {
} catch (e) { toast('Greška: '+e.message, 'err'); }
}
// ══════════════════════════════════════════════════════════════════
// RUSH-4 — E-mail templates (CRM v2 GUI redesign, 2026-05-05)
// dradulic@outlook.com / damir@rinet.one
// Endpoint: /api/v2/crm/email-templates (CRUD)
// ══════════════════════════════════════════════════════════════════
let EMAIL_TPLS = [];
async function loadEmailTpls() {
const q = (document.getElementById('etpl-q')?.value || '').trim().toLowerCase();
const cat = document.getElementById('etpl-cat')?.value || '';
const qs = new URLSearchParams();
qs.set('active_only', 'false');
if (cat) qs.set('kategorija', cat);
try {
const data = await api('/email-templates?'+qs.toString());
EMAIL_TPLS = (data.items||[]).filter(t => {
if (!q) return true;
return (t.code||'').toLowerCase().includes(q)
|| (t.naziv||'').toLowerCase().includes(q);
});
const grid = document.getElementById('etpl-grid');
grid.innerHTML = EMAIL_TPLS.map(t => `
<div class="tcard" onclick="editEmailTpl(${t.id})">
<div class="tcard-code">${esc(t.code)}</div>
<div class="tcard-naziv">${esc(t.naziv)} ${t.active===false?'<span class="chip closed" style="margin-left:6px">neaktivan</span>':''}</div>
<div class="tcard-cat">${esc(t.kategorija||'—')}</div>
<div class="tcard-snip"><strong>Subject:</strong> ${esc((t.subject_tpl||'').slice(0,90))}${(t.subject_tpl||'').length>90?'…':''}<br>${esc((t.body_tpl||'').replace(/\s+/g,' ').slice(0,140))}${(t.body_tpl||'').length>140?'…':''}</div>
</div>
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema predložaka.</div>';
setExportRows('emailtpl', EMAIL_TPLS.map(t => [t.code||'', t.naziv||'', t.kategorija||'', t.subject_tpl||'', t.active?'true':'false']));
document.getElementById('cnt-emailtpl').textContent = EMAIL_TPLS.length;
} catch (e) { toast('Email tpl err: '+e.message, 'err'); }
}
function emailTplFormHTML(t={}) {
return `
<div class="fld-row">
<div class="fld"><label>Code*</label><input id="ef-code" value="${esc(t.code||'')}" placeholder="npr. clanarina_opomena"></div>
<div class="fld"><label>Kategorija</label>
<select id="ef-cat">
<option value="">—</option>
${['clanarine','lijecnicki','obrasci','opci'].map(c=>`<option value="${c}" ${t.kategorija===c?'selected':''}>${c}</option>`).join('')}
</select>
</div>
</div>
<div class="fld"><label>Naziv*</label><input id="ef-naziv" value="${esc(t.naziv||'')}"></div>
<div class="fld"><label>Subject (predmet)*</label><input id="ef-subj" value="${esc(t.subject_tpl||'')}" placeholder="npr. Opomena za {{godina}}"></div>
<div class="fld"><label>Body (HTML / tekst)* — placeholderi {{...}}</label>
<textarea id="ef-body" style="min-height:160px;font-family:var(--mono)">${esc(t.body_tpl||'')}</textarea>
</div>
<div class="fld"><label>Variables (JSON, npr. {"naziv_kluba":"string"})</label>
<textarea id="ef-vars" style="min-height:60px;font-family:var(--mono)">${esc(t.variables ? JSON.stringify(t.variables, null, 2) : '')}</textarea>
</div>
<div class="fld"><label><input id="ef-act" type="checkbox" ${t.active===false?'':'checked'}> Aktivan</label></div>
`;
}
function readEmailTplForm() {
let vars = null;
const raw = document.getElementById('ef-vars').value.trim();
if (raw) {
try { vars = JSON.parse(raw); }
catch(e) { toast('Variables JSON nije validan', 'err'); throw e; }
}
return {
code: document.getElementById('ef-code').value.trim(),
naziv: document.getElementById('ef-naziv').value.trim(),
kategorija: document.getElementById('ef-cat').value || null,
subject_tpl: document.getElementById('ef-subj').value,
body_tpl: document.getElementById('ef-body').value,
variables: vars,
active: document.getElementById('ef-act').checked,
};
}
function openEmailTplModal(t) {
const isEdit = !!(t && t.id);
showModal(isEdit?'Uredi predložak':'Novi e-mail predložak',
emailTplFormHTML(t||{active:true}),
async () => {
let body; try { body = readEmailTplForm(); } catch(e) { return; }
if (!body.code || !body.naziv || !body.subject_tpl || !body.body_tpl) {
toast('Code, naziv, subject i body su obavezni', 'err'); return;
}
try {
if (isEdit) await api('/email-templates/'+t.id, {method:'PUT', body:JSON.stringify(body)});
else await api('/email-templates', {method:'POST', body:JSON.stringify(body)});
toast('Spremljeno'); closeModal(); loadEmailTpls();
} catch (e) { toast('Greška: '+e.message, 'err'); }
});
}
async function editEmailTpl(id) {
try { const t = await api('/email-templates/'+id);
// Add delete button to footer
openEmailTplModal(t);
setTimeout(() => {
const foot = document.getElementById('m-foot');
if (foot && !foot.querySelector('.btn.danger')) {
const del = document.createElement('button');
del.className = 'btn danger'; del.textContent = 'Obriši';
del.onclick = () => delEmailTpl(id, t.naziv);
foot.insertBefore(del, foot.firstChild);
}
}, 0);
} catch (e) { toast(e.message, 'err'); }
}
async function delEmailTpl(id, naziv) {
if (!confirm('Obrisati predložak "'+naziv+'"?')) return;
try { await api('/email-templates/'+id, {method:'DELETE'}); toast('Obrisano'); closeModal(); loadEmailTpls(); }
catch (e) { toast('Greška: '+e.message, 'err'); }
}
// ────── Modal helpers ──────
function showModal(title, bodyHTML, onSave) {
document.getElementById('m-title').textContent = title;
@@ -1731,7 +2080,26 @@ document.getElementById('modal').addEventListener('click', e => {
// ────── Init ──────
loadMe();
ensureMe();
loadPipeline();
loadPipeline(); // populates KPI counters + kanban (kanban only visible in Opportunities tab now)
loadClanarine(); // default active tab
// ── Universal Export ▾ — server-side fallback for tabs that load full
// record sets from REST (lijecnicki/obrasci). The existing exportTab()
// flow above keeps working for client-side cached tabs (accounts,
// contacts, leads, opps). attachExportDropdown is a no-op when
// export_dropdown.js fails to load.
document.addEventListener('DOMContentLoaded', function(){
if (!window.attachExportDropdown) return;
const lij = document.getElementById('lij-srv-export-btn');
if (lij) window.attachExportDropdown(lij, function(){
const klub = document.getElementById('lij-klub'); const clan = document.getElementById('lij-clan');
const qp = new URLSearchParams(); qp.set('limit','2000');
if (klub && klub.value) qp.set('klub_id', klub.value);
if (clan && clan.value) qp.set('clan_id', clan.value);
return '/sport/api/v2/lijecnicki?'+qp.toString();
}, 'lijecnicki');
});
</script>
<script src="/static/js/export_dropdown.js"></script>
</body>
</html>
+46
View File
@@ -199,6 +199,7 @@ table tbody tr:hover{background:var(--bg3)}
<label>Status <select id="rac-status"><option value="">— svi —</option><option value="nacrt">Nacrt</option><option value="knjizen">Knjižen</option><option value="placen">Plaćen</option><option value="otkazan">Otkazan</option></select></label>
<label>Godina <input type="number" id="rac-godina" value="2026" style="width:90px"></label>
<button class="btn" onclick="loadRacuni()">Osvježi</button>
<button id="rac-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div class="tbl-wrap">
<table id="rac-tbl"><thead><tr><th>#</th><th>Broj</th><th>Datum</th><th>Partner</th><th>OIB</th><th class="num">Neto</th><th class="num">PDV</th><th class="num">Brutto</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi" za učitavanje…</td></tr></tbody></table>
@@ -253,6 +254,7 @@ table tbody tr:hover{background:var(--bg3)}
<label>Status <select id="pn-status"><option value="">— svi —</option><option value="draft">draft</option><option value="podnesen">podnesen</option><option value="odobren">odobren</option><option value="isplacen">isplacen</option><option value="rejected">rejected</option></select></label>
<label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label>
<button class="btn" onclick="loadExpenseReports()">Osvježi</button>
<button id="pn-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div class="tbl-wrap">
<table id="pn-tbl"><thead><tr><th>#</th><th>Tip</th><th>Klub</th><th>Odredište</th><th>Svrha</th><th>Od</th><th>Do</th><th class="num">Km</th><th class="num">Trošak</th><th class="num">Dnevnice</th><th>Status</th></tr></thead><tbody><tr><td colspan="11" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
@@ -278,6 +280,7 @@ table tbody tr:hover{background:var(--bg3)}
<label>Način <select id="py-method"><option value="">— svi —</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
<label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
<button class="btn" onclick="loadPayments()">Osvježi</button>
<button id="py-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div class="tbl-wrap">
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Način</th><th>IBAN OD</th><th>IBAN ZA</th><th>Referenca</th><th>Račun</th><th>Putni nalog</th><th>Match</th></tr></thead><tbody><tr><td colspan="12" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
@@ -1228,6 +1231,49 @@ document.addEventListener('DOMContentLoaded', () => {
}
loadDnevnik();
});
// ── Universal Export ▾ — wired to representative ERP tabs (racuni,
// putni nalozi, payments). Uses live filter values so the exported
// rows match what's on screen.
document.addEventListener('DOMContentLoaded', function(){
if (!window.attachExportDropdown) return;
const racBtn = document.getElementById('rac-export-btn');
if (racBtn) window.attachExportDropdown(racBtn, function(){
const tip = (document.getElementById('rac-tip')||{}).value || 'ulazni';
const status = (document.getElementById('rac-status')||{}).value || '';
const god = (document.getElementById('rac-godina')||{}).value || '';
const qp = new URLSearchParams(); qp.set('limit','2000');
if (status) qp.set('status', status);
if (god) qp.set('godina', god);
return '/api/v2/erp/racuni/'+tip+'?'+qp.toString();
}, 'racuni');
const pnBtn = document.getElementById('pn-export-btn');
if (pnBtn) window.attachExportDropdown(pnBtn, function(){
const t = (document.getElementById('pn-type')||{}).value || '';
const s = (document.getElementById('pn-status')||{}).value || '';
const g = (document.getElementById('pn-godina')||{}).value || '';
const qp = new URLSearchParams(); qp.set('limit','2000');
if (t) qp.set('tip', t);
if (s) qp.set('status', s);
if (g) qp.set('godina', g);
return '/api/v2/erp/expense-reports?'+qp.toString();
}, 'expense_reports');
const pyBtn = document.getElementById('py-export-btn');
if (pyBtn) window.attachExportDropdown(pyBtn, function(){
const s = (document.getElementById('py-status')||{}).value || '';
const m = (document.getElementById('py-method')||{}).value || '';
const g = (document.getElementById('py-godina')||{}).value || '';
const qp = new URLSearchParams(); qp.set('limit','2000');
if (s) qp.set('status', s);
if (m) qp.set('metoda', m);
if (g) qp.set('godina', g);
return '/api/v2/erp/payments?'+qp.toString();
}, 'payments');
});
</script>
<script src="/static/js/export_dropdown.js"></script>
</body>
</html>
+181
View File
@@ -0,0 +1,181 @@
/* ═══════════════════════════════════════════════════════════════════════
* Fajl: static/js/export_dropdown.js | v1.0.0 | 05.05.2026
* Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
* Lokacija: /opt/pgz-sport/static/js/export_dropdown.js
* Svrha: Shared "Export ▾" dropdown — CSV / XLSX / PDF — za sve tablice u
* sport2.html, app.html, crm_v2.html, erp_full.html. Iza scene
* koristi /api/v2/export?format=...&endpoint=... s autoriziranim
* Bearer tokenom iz localStorage / sessionStorage.
* Public API:
* window.attachExportDropdown(buttonEl, endpointFn, filenameBase)
* - buttonEl: <button> element trigger
* - endpointFn: function returning the current endpoint+querystring
* each time it's called (so filters stay live)
* - filenameBase: short name for downloaded file (e.g. 'klubovi')
* ═══════════════════════════════════════════════════════════════════════ */
(function () {
'use strict';
if (window.attachExportDropdown) return; // idempotent
// ── inject minimal Palantir-ish CSS once ──────────────────────────────
var STYLE_ID = 'pgz-export-dropdown-style';
if (!document.getElementById(STYLE_ID)) {
var st = document.createElement('style');
st.id = STYLE_ID;
st.textContent = [
'.export-btn{background:var(--bg3,#1a1d24);border:1px solid var(--rim,#2a2e38);',
' color:var(--t1,#e0e3e9);padding:6px 11px;border-radius:5px;font-size:11px;',
' cursor:pointer;font-family:inherit;letter-spacing:.3px}',
'.export-btn:hover{border-color:var(--pgz-gold,#d4a849);color:var(--pgz-gold,#d4a849)}',
'.pgz-exp-wrap{position:relative;display:inline-block}',
'.pgz-exp-menu{position:absolute;right:0;top:calc(100% + 4px);min-width:130px;',
' background:var(--bg2,#15181f);border:1px solid var(--rim,#2a2e38);border-radius:6px;',
' box-shadow:0 6px 20px rgba(0,0,0,.45);padding:4px;z-index:9999;display:none;',
' font-family:ui-monospace,Menlo,Consolas,monospace}',
'.pgz-exp-menu.on{display:block}',
'.pgz-exp-menu button{display:block;width:100%;text-align:left;background:transparent;',
' border:0;color:var(--t1,#e0e3e9);padding:7px 11px;font-size:11px;cursor:pointer;',
' border-radius:4px;font-family:inherit;letter-spacing:.4px}',
'.pgz-exp-menu button:hover{background:var(--bg3,#1a1d24);color:var(--pgz-gold,#d4a849)}',
'.pgz-exp-menu .sep{border-top:1px solid var(--rim,#2a2e38);margin:3px 0}'
].join('\n');
document.head.appendChild(st);
}
// Close menus on outside click.
document.addEventListener('click', function (ev) {
var menus = document.querySelectorAll('.pgz-exp-menu.on');
menus.forEach(function (m) {
if (!m.contains(ev.target) && !(m.__trigger && m.__trigger.contains(ev.target))) {
m.classList.remove('on');
}
});
});
function _token() {
return (
localStorage.getItem('pgz_access') ||
sessionStorage.getItem('pgz_access') ||
localStorage.getItem('access_token') ||
sessionStorage.getItem('access_token') ||
''
);
}
function _resolveEndpoint(endpointFn) {
try {
var ep = (typeof endpointFn === 'function') ? endpointFn() : String(endpointFn || '');
if (!ep) return null;
// Normalize: must start with / so the server proxies to localhost.
if (!/^https?:\/\//i.test(ep) && !ep.startsWith('/')) ep = '/' + ep;
return ep;
} catch (e) {
console.error('[export] endpointFn threw', e);
return null;
}
}
function _trigger(format, endpointFn, filenameBase) {
var ep = _resolveEndpoint(endpointFn);
if (!ep) {
alert('Export: endpoint nije dostupan.');
return;
}
var url = '/api/v2/export?format=' + encodeURIComponent(format) +
'&endpoint=' + encodeURIComponent(ep) +
'&filename=' + encodeURIComponent(filenameBase || 'export');
var tok = _token();
if (format === 'pdf') {
// PDF mock = HTML page. Open in a new tab; the page has a Print button.
// If we have a token, push it as a hash so the user stays logged in
// when they re-fetch (server still validates from the original GET).
var w = window.open('', '_blank');
if (!w) { alert('Pop-up blocked — dopusti pop-up za export.'); return; }
// Use fetch with auth, then write to the new window.
fetch(url, { headers: tok ? { 'Authorization': 'Bearer ' + tok } : {} })
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
.then(function (html) { w.document.open(); w.document.write(html); w.document.close(); })
.catch(function (e) {
w.document.body.innerText = 'Export greška: ' + e.message;
});
return;
}
// csv / xlsx — fetch as blob, force download via hidden <a>.
fetch(url, { headers: tok ? { 'Authorization': 'Bearer ' + tok } : {} })
.then(function (r) {
if (!r.ok) throw new Error('HTTP ' + r.status);
var dispo = r.headers.get('Content-Disposition') || '';
var m = dispo.match(/filename="?([^"]+)"?/);
var fname = m ? m[1] : (filenameBase || 'export') + '.' + format;
return r.blob().then(function (b) { return { blob: b, fname: fname }; });
})
.then(function (o) {
var burl = URL.createObjectURL(o.blob);
var a = document.createElement('a');
a.href = burl;
a.download = o.fname;
document.body.appendChild(a);
a.click();
setTimeout(function () {
document.body.removeChild(a);
URL.revokeObjectURL(burl);
}, 200);
})
.catch(function (e) {
alert('Export greška: ' + e.message);
console.error('[export]', e);
});
}
function attachExportDropdown(btn, endpointFn, filenameBase) {
if (!btn) return;
if (btn.__pgzExpAttached) return;
btn.__pgzExpAttached = true;
// Wrap in a positioned container so the menu floats correctly.
var wrap;
if (btn.parentElement && btn.parentElement.classList.contains('pgz-exp-wrap')) {
wrap = btn.parentElement;
} else {
wrap = document.createElement('span');
wrap.className = 'pgz-exp-wrap';
btn.parentNode.insertBefore(wrap, btn);
wrap.appendChild(btn);
}
if (!btn.classList.contains('export-btn')) btn.classList.add('export-btn');
if (!/▾|▼/.test(btn.textContent)) btn.textContent = (btn.textContent || 'Export') + ' ▾';
var menu = document.createElement('div');
menu.className = 'pgz-exp-menu';
menu.innerHTML = [
'<button data-fmt="csv">CSV (HR Excel)</button>',
'<button data-fmt="xlsx">XLSX</button>',
'<div class="sep"></div>',
'<button data-fmt="pdf">PDF (print)</button>'
].join('');
wrap.appendChild(menu);
menu.__trigger = btn;
btn.addEventListener('click', function (ev) {
ev.stopPropagation();
// Close other open menus first.
document.querySelectorAll('.pgz-exp-menu.on').forEach(function (m) {
if (m !== menu) m.classList.remove('on');
});
menu.classList.toggle('on');
});
menu.addEventListener('click', function (ev) {
var t = ev.target.closest('button[data-fmt]');
if (!t) return;
ev.stopPropagation();
menu.classList.remove('on');
_trigger(t.getAttribute('data-fmt'), endpointFn, filenameBase);
});
}
window.attachExportDropdown = attachExportDropdown;
})();
+144 -44
View File
@@ -1409,6 +1409,7 @@ function renderSaveziShell(){
</div>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''}
<span class="tb-s" id="sav-cnt"></span>
<button id="sav-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div id="sav-out"></div>
`;
@@ -1416,6 +1417,20 @@ function renderSaveziShell(){
$('#sav-sport').addEventListener('change', applySaveziFilter);
$('#sav-kat').addEventListener('change', applySaveziFilter);
$('#sav-pgz').addEventListener('change', applySaveziFilter);
// Export ▾ — uses same /v2/savezi/priority-sort URL as the table loader.
if (window.attachExportDropdown) {
window.attachExportDropdown(
document.getElementById('sav-export-btn'),
function(){
const f = _filters.savezi || {};
const useOnly = f.financirani || window._pgz_filter_priority;
return '/sport/api' + (useOnly
? '/v2/savezi/priority-sort?only=true&limit=500'
: '/v2/savezi/priority-sort?only=false&limit=500');
},
'savezi'
);
}
}
function setSaveziView(v){
_state.viewSavezi = v;
@@ -1535,13 +1550,18 @@ async function loadKlubovi(){
const root = $('#pg-klubovi');
if(!_cache.klubovi){
root.innerHTML = '<div class="loading">Učitavanje klubova…</div>';
// BUG-E (2026-05-05): build /api/klubovi URL from explicit _filters.klubovi state.
// Defaults: financirani=true + godisnjak=true. When BOTH off → load all.
// RUSH-1 (2026-05-05): /api/klubovi URL built from _filters.klubovi state.
// Spec (CC_FINAL_RUSH slika 4) — 3 checkboxes:
// ☑ Samo financirani (PGŽ + RSS + Grad Rijeka) — single combined
// ☑ U godišnjaku
// ☐ Ima HNS roster
// Backend `financiran=true` is OR of all 3 davateljs (single source of truth
// = v_klubovi_financiranje view). Default = priority (fin OR godišnjak).
// Sort: ukupno_potpora DESC.
const f = _filters.klubovi;
const qs = new URLSearchParams();
qs.set('limit','2500');
qs.set('sort','financiran'); qs.set('order','desc'); // sort by potpore DESC (financiran flag)
// financirani + godisnjak combined with kategorija=priority logic:
qs.set('sort','potpora'); qs.set('order','desc'); // ukupno_potpora DESC NULLS LAST
if(f.financirani && f.godisnjak){
qs.set('kategorija','priority'); // OR semantics → priority = financiran OR godišnjak
} else if(f.financirani){
@@ -1581,11 +1601,10 @@ function renderKluboviShell(){
<button id="kl-card" class="${_state.viewKlubovi==='card'?'active':''}" onclick="setKluboviView('card')">Kartice</button>
<button id="kl-table" class="${_state.viewKlubovi==='table'?'active':''}" onclick="setKluboviView('table')">Tablica</button>
</div>
<button class="btn" onclick="exportKlubovi('xlsx')">⬇ XLSX</button>
<button class="btn" onclick="exportKlubovi('csv')">⬇ CSV</button>
<button class="btn" onclick="enrichBulk('klub', 50, 70)">✨ Obogati (50)</button>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''}
<span class="tb-s" id="kl-cnt"></span>
<button id="kl-export-btn" class="export-btn" type="button">Export ▾</button>
</div>
<div id="kl-out"></div>
`;
@@ -1594,6 +1613,25 @@ function renderKluboviShell(){
$('#kl-grad').addEventListener('change', applyKluboviFilter);
$('#kl-kat').addEventListener('change', applyKluboviFilter);
$('#kl-nk').addEventListener('change', applyKluboviFilter);
// Export ▾ — rebuilds the same querystring that loadKlubovi uses.
if (window.attachExportDropdown) {
window.attachExportDropdown(
document.getElementById('kl-export-btn'),
function(){
const f = _filters.klubovi || {};
const qs = new URLSearchParams();
qs.set('limit','2500');
qs.set('sort','potpora'); qs.set('order','desc');
if(f.financirani && f.godisnjak) qs.set('kategorija','priority');
else if(f.financirani) qs.set('financiran','true');
else if(f.godisnjak) qs.set('godisnjak','true');
if(f.hns_roster) qs.set('samo_hns_roster','true');
if(window._pgz_filter_priority && !qs.has('kategorija')) qs.set('kategorija','priority');
return '/sport/api/klubovi?'+qs.toString();
},
'klubovi'
);
}
}
function setKluboviView(v){
_state.viewKlubovi = v;
@@ -1634,25 +1672,31 @@ function applyKluboviFilter(){
}
function renderKluboviGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid-club">'+rows.map(k => `
return '<div class="grid-club">'+rows.map(k => {
const finTitle = [k.prima_pgz?'PGŽ':null, k.prima_rss?'RSS':null, k.prima_grad_rijeka?'Grad Rijeka':null].filter(Boolean).join(' + ') || 'financiran';
const potpora = (k.ukupno_potpora!=null) ? ' <b style="color:var(--pgz-gold)" title="ukupno potpora">'+fmtEur(k.ukupno_potpora)+'</b>' : '';
return `
<div class="entity" onclick="openKlub(${k.id})">
${k.priority?'<div class="et-tag" style="background:#ffd700;color:#1a1a1a">★ PRIO</div>':(k.nositelj_kvalitete?'<div class="et-tag">N.K.</div>':'')}
<div class="et">${(window.pgzBadgePrefix?window.pgzBadgePrefix(k,'klub'):'')}${esc(k.klub||k.sport||'(bez naziva)')}</div>
<div class="es">${txt(k.razina,'')} · ${txt(k.grad,'—')}</div>
<div class="em">
${k.financiran?'<span class="tag gd" title="PGŽ sufinanciran">€</span>':''}
${k.financiran?'<span class="tag gd" title="'+esc(finTitle)+'">€</span>':''}
${k.godisnjak?'<span class="tag b" title="U godišnjaku">G</span>':''}
${potpora}
<span><b>${fmtNum(k.registriranih)}</b> reg.</span>
<span><b>${fmtNum(k.trenera)}</b> trenera</span>
<span><b>${fmtNum(k.reprezentativaca)}</b> repr.</span>
</div>
</div>`).join('')+'</div>';
</div>`;
}).join('')+'</div>';
}
function renderKluboviTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr><th style="width:34px"><input type="checkbox" id="kl-all" title="Označi sve"></th><th title="PGŽ priority">★</th>${sortHeader('klubovi','klub','Klub','')}${sortHeader('klubovi','sport','Sport','')}${sortHeader('klubovi','razina','Razina','')}${sortHeader('klubovi','grad','Grad','')}${sortHeader('klubovi','registriranih','Reg.','num')}${sortHeader('klubovi','trenera','Trenera','num')}${sortHeader('klubovi','nositelj_kvalitete','Status','')}</tr></thead>
<tbody>${rows.map(k => `
<thead><tr><th style="width:34px"><input type="checkbox" id="kl-all" title="Označi sve"></th><th title="PGŽ priority">★</th>${sortHeader('klubovi','klub','Klub','')}${sortHeader('klubovi','sport','Sport','')}${sortHeader('klubovi','razina','Razina','')}${sortHeader('klubovi','grad','Grad','')}${sortHeader('klubovi','ukupno_potpora','Potpora','num')}${sortHeader('klubovi','registriranih','Reg.','num')}${sortHeader('klubovi','nositelj_kvalitete','Status','')}</tr></thead>
<tbody>${rows.map(k => {
const finTitle = [k.prima_pgz?'PGŽ':null, k.prima_rss?'RSS':null, k.prima_grad_rijeka?'Grad Rijeka':null].filter(Boolean).join(' + ') || 'financiran';
return `
<tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="kl-pick" data-id="${k.id}"></td>
<td onclick="openKlub(${k.id})">${k.priority?'<span class="tag gd" title="financiran ili u godišnjaku">★</span>':''}</td>
@@ -1660,10 +1704,11 @@ function renderKluboviTable(rows){
<td onclick="openKlub(${k.id})">${txt(k.sport)}</td>
<td onclick="openKlub(${k.id})">${txt(k.razina)}</td>
<td onclick="openKlub(${k.id})">${txt(k.grad)}</td>
<td onclick="openKlub(${k.id})" class="num"><b style="color:var(--pgz-gold)">${k.ukupno_potpora!=null?fmtEur(k.ukupno_potpora):'—'}</b></td>
<td onclick="openKlub(${k.id})" class="num">${fmtNum(k.registriranih)}</td>
<td onclick="openKlub(${k.id})" class="num">${fmtNum(k.trenera)}</td>
<td onclick="openKlub(${k.id})">${k.financiran?'<span class="tag gd" title="financiran">€</span>':''}${k.godisnjak?'<span class="tag b" title="godišnjak">G</span>':''}${k.nositelj_kvalitete?'<span class="tag gd">N.K.</span>':''}${k.aktivan?'<span class="tag gr">AKT</span>':'<span class="tag rd">NK</span>'}</td>
</tr>`).join('')}</tbody>
<td onclick="openKlub(${k.id})">${k.financiran?'<span class="tag gd" title="'+esc(finTitle)+'">€</span>':''}${k.godisnjak?'<span class="tag b" title="godišnjak">G</span>':''}${k.nositelj_kvalitete?'<span class="tag gd">N.K.</span>':''}${k.aktivan?'<span class="tag gr">AKT</span>':'<span class="tag rd">NK</span>'}</td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
@@ -2698,77 +2743,131 @@ function openObjekt(id){
}
//=========== MANIFESTACIJE ===========
// View mode persisted in localStorage as `_manifViewMode` ('card'|'table')
const _manifFilter = {mjesto:'', razina:'', organizator:'', q:''};
let _manifMeta = null;
let _manifLoadSeq = 0;
async function loadManifestacije(){
const root = $('#pg-manifestacije');
if(!_cache.manifestacije){
// Restore view mode from localStorage
const saved = localStorage.getItem('_manifViewMode');
if(saved==='card' || saved==='table') _state.viewManif = saved;
if(!_manifMeta){
root.innerHTML = '<div class="loading">Učitavanje manifestacija…</div>';
const d = await api('/manifestacije-full');
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.manifestacije = d.rows || (Array.isArray(d) ? d : []);
_manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[]};
}
renderManifShell();
applyManifFilter();
await reloadManifestacije();
}
async function reloadManifestacije(){
const seq = ++_manifLoadSeq;
const out = $('#mn-out');
if(out) out.innerHTML = '<div class="loading">Učitavanje…</div>';
const cnt = $('#mn-cnt');
if(cnt) cnt.textContent = '…';
const params = new URLSearchParams();
if(_manifFilter.mjesto) params.set('mjesto', _manifFilter.mjesto);
if(_manifFilter.razina) params.set('razina', _manifFilter.razina);
if(_manifFilter.organizator) params.set('organizator', _manifFilter.organizator);
if(_manifFilter.q) params.set('q', _manifFilter.q);
params.set('limit', '500');
const qs = params.toString();
const d = await api('/v2/manifestacije'+(qs?'?'+qs:''));
if(seq !== _manifLoadSeq) return; // newer request superseded this one
if(!d){
if(out) out.innerHTML = '<div class="empty">Greška pri dohvatu</div>';
return;
}
_cache.manifestacije = d.rows || [];
renderManifBody();
}
function renderManifShell(){
const root = $('#pg-manifestacije');
const razine = Array.from(new Set((_cache.manifestacije||[]).map(m=>m.razina).filter(Boolean))).sort();
const meta = _manifMeta || {mjesta:[], razine:[], organizatori:[]};
const optList = (arr) => (arr||[]).filter(x=>x!==null && x!==undefined && x!=='').map(v=>'<option value="'+esc(v)+'">'+esc(v)+'</option>').join('');
root.innerHTML = `
<div class="toolbar">
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…">
<select id="mn-raz"><option value="">Sve razine</option>${razine.map(r=>'<option value="'+esc(r)+'">'+esc(r)+'</option>').join('')}</select>
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…" value="${esc(_manifFilter.q)}">
<select id="mn-mjesto" title="Mjesto"><option value="">Sva mjesta</option>${optList(meta.mjesta)}</select>
<select id="mn-raz" title="Razina"><option value="">Sve razine</option>${optList(meta.razine)}</select>
<select id="mn-org" title="Organizator"><option value="">Svi organizatori</option>${optList(meta.organizatori)}</select>
<button id="mn-reset" class="btn" type="button" title="Poništi filtere">↺ Reset</button>
<div class="toggle">
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')">Kartice</button>
<button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')">Tablica</button>
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')" title="Kartice">🃏 Kartice</button>
<button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')" title="Tablica">📋 Tablica</button>
</div>
<span class="tb-s" id="mn-cnt"></span>
</div>
<div id="mn-out"></div>
`;
$('#mn-q').addEventListener('input', debounce(applyManifFilter, 200));
$('#mn-raz').addEventListener('change', applyManifFilter);
// Restore selections after re-render
if($('#mn-mjesto')) $('#mn-mjesto').value = _manifFilter.mjesto;
if($('#mn-raz')) $('#mn-raz').value = _manifFilter.razina;
if($('#mn-org')) $('#mn-org').value = _manifFilter.organizator;
$('#mn-q').addEventListener('input', debounce(()=>{ _manifFilter.q = $('#mn-q').value.trim(); reloadManifestacije(); }, 250));
$('#mn-mjesto').addEventListener('change', ()=>{ _manifFilter.mjesto = $('#mn-mjesto').value; reloadManifestacije(); });
$('#mn-raz').addEventListener('change', ()=>{ _manifFilter.razina = $('#mn-raz').value; reloadManifestacije(); });
$('#mn-org').addEventListener('change', ()=>{ _manifFilter.organizator = $('#mn-org').value; reloadManifestacije(); });
$('#mn-reset').addEventListener('click', ()=>{
_manifFilter.mjesto=''; _manifFilter.razina=''; _manifFilter.organizator=''; _manifFilter.q='';
$('#mn-q').value=''; $('#mn-mjesto').value=''; $('#mn-raz').value=''; $('#mn-org').value='';
reloadManifestacije();
});
}
function setManifView(v){
_state.viewManif = v;
$('#mn-card').classList.toggle('active', v==='card');
$('#mn-table').classList.toggle('active', v==='table');
applyManifFilter();
try{ localStorage.setItem('_manifViewMode', v); }catch(_){}
if($('#mn-card')) $('#mn-card').classList.toggle('active', v==='card');
if($('#mn-table')) $('#mn-table').classList.toggle('active', v==='table');
renderManifBody();
}
function applyManifFilter(){
const q = (($('#mn-q')?$('#mn-q').value:'') || '').toLowerCase().trim();
const raz = $('#mn-raz') ? $('#mn-raz').value : '';
function renderManifBody(){
let rows = _cache.manifestacije || [];
if(q) rows = rows.filter(m => (m.naziv||'').toLowerCase().includes(q) || (m.organizator||'').toLowerCase().includes(q) || (m.mjesto||'').toLowerCase().includes(q));
if(raz) rows = rows.filter(m => m.razina===raz);
if(_sort.manifestacije) rows = sortRows(rows, _sort.manifestacije.key, _sort.manifestacije.dir);
$('#mn-cnt').textContent = rows.length+' manifestacija';
$('#mn-out').innerHTML = _state.viewManif==='card' ? renderManifGrid(rows) : renderManifTable(rows);
}
// Backwards-compat: existing handlers (e.g. sortHeader) call applyManifFilter()
function applyManifFilter(){ renderManifBody(); }
function manifLinkFor(m){
if(m && m.source_url) return m.source_url;
const gq = encodeURIComponent(((m&&m.naziv)||'')+' '+((m&&m.mjesto)||'')+' sport');
return 'https://www.google.com/search?q='+gq;
}
function renderManifGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return '<div class="grid">'+rows.map(m => `
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
return '<div class="grid">'+rows.map(m => {
const url = manifLinkFor(m);
const linkIcon = '<a class="et-link" href="'+esc(url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="'+(m.source_url?'Otvori izvor':'Pretraži online')+'">🔗</a>';
return `
<div class="entity" onclick="openManif(${m.id})">
${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''}
<div class="et">${esc(m.naziv)}</div>
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}</div>
<div class="et">${esc(m.naziv)} ${linkIcon}</div>
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}${m.godina_od?' · od '+esc(m.godina_od):''}</div>
<div class="em">
${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''}
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''}
</div>
</div>`).join('')+'</div>';
</div>`;
}).join('')+'</div>';
}
function renderManifTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table>
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','razina','Razina','')}${sortHeader('manifestacije','organizator','Organizator','')}${sortHeader('manifestacije','broj_ucesnika','Sudionici','')}<th>Link</th></tr></thead>
<tbody>${rows.map(m => `
<tbody>${rows.map(m => {
const url = manifLinkFor(m);
return `
<tr onclick="openManif(${m.id})">
<td><b>${esc(m.naziv)}</b></td>
<td>${txt(m.mjesto)}</td>
<td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td>
<td>${txt(m.organizator)}</td>
<td>${txt(m.broj_ucesnika)}</td>
<td>${m.source_url?'<a href="'+esc(m.source_url)+'" target="_blank">↗</a>':'—'}</td>
</tr>`).join('')}</tbody>
<td><a href="${esc(url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="${m.source_url?'Otvori izvor':'Pretraži online'}">🔗</a></td>
</tr>`;
}).join('')}</tbody>
</table></div>`;
}
function openManif(id){
@@ -3815,5 +3914,6 @@ window.closePanel = function(){
if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); }
};
</script>
<script src="/static/js/export_dropdown.js"></script>
</body>
</html>