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
+8
View File
@@ -1864,6 +1864,14 @@ try:
except Exception as e: except Exception as e:
print(f'[KALENDAR] router fail: {e}') print(f'[KALENDAR] router fail: {e}')
# ═══ EXPORT (univerzalni CSV / XLSX / PDF) router — /api/v2/export/*
try:
from routers.export_router import router as export_router
app.include_router(export_router)
print('[EXPORT] router loaded (/api/v2/export/*)')
except Exception as e:
print(f'[EXPORT] router fail: {e}')
@app.get("/crm") @app.get("/crm")
@app.get("/crm/") @app.get("/crm/")
def serve_crm(): def serve_crm():
+297
View File
@@ -0,0 +1,297 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════════════
# Fajl: routers/export_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/export_router.py
# Svrha: Univerzalni Export modul (CSV / XLSX / PDF) za sve tablice u PGŽ Sport
# ERP/CRM. Endpoint /api/v2/export?format=...&endpoint=...&filters=...
# proxy-aporta unutarnji JSON API i konvertira odgovor u traženi
# format. CSV (HR Excel friendly: ; delimiter, UTF-8 BOM), XLSX preko
# openpyxla (s graceful fallback na CSV ako openpyxl nije dostupan),
# PDF preko HTML print-to-PDF (mock — TODO: weasyprint/reportlab).
# Mount: /api/v2/export/*
# ═══════════════════════════════════════════════════════════════════════════
from __future__ import annotations
import csv
import io
import json
from datetime import datetime
from typing import Any, Dict, List, Optional
from urllib.parse import unquote
import requests
from fastapi import APIRouter, Header, HTTPException, Query
from fastapi.responses import HTMLResponse, Response, StreamingResponse
# openpyxl is optional — fall back to CSV if missing.
try:
from openpyxl import Workbook # type: ignore
from openpyxl.styles import Alignment, Font, PatternFill # type: ignore
OPENPYXL_AVAILABLE = True
except Exception: # pragma: no cover
OPENPYXL_AVAILABLE = False
router = APIRouter(prefix="/api/v2/export", tags=["export"])
INTERNAL_BASE = "http://127.0.0.1:8095"
# ───────────────────────────────────────────────────────────────────────────
# Utilities
# ───────────────────────────────────────────────────────────────────────────
def _ts() -> str:
return datetime.now().strftime("%Y%m%d_%H%M%S")
def _flatten_value(v: Any) -> str:
"""Normalize a single cell value to a flat string."""
if v is None:
return ""
if isinstance(v, (dict, list, tuple)):
try:
return json.dumps(v, ensure_ascii=False, default=str)
except Exception:
return str(v)
if isinstance(v, bool):
return "true" if v else "false"
return str(v)
def _coerce_rows(payload: Any) -> List[Dict[str, Any]]:
"""Accept many common JSON shapes and return a list of dicts."""
if payload is None:
return []
# Top-level list
if isinstance(payload, list):
return [r for r in payload if isinstance(r, dict)]
if isinstance(payload, dict):
# {rows:[...]} or {data:[...]} or {items:[...]} or {results:[...]}
for key in ("rows", "data", "items", "results", "list"):
v = payload.get(key)
if isinstance(v, list):
return [r for r in v if isinstance(r, dict)]
# {count, rows}
if "rows" in payload and isinstance(payload["rows"], list):
return [r for r in payload["rows"] if isinstance(r, dict)]
# Fallback: a single record
return [payload]
return []
def _ordered_columns(rows: List[Dict[str, Any]]) -> List[str]:
"""Use first row's keys as primary column order, then append any
extra keys discovered later (preserves order, no duplicates)."""
seen: List[str] = []
seen_set = set()
if rows:
for k in rows[0].keys():
if k not in seen_set:
seen.append(k)
seen_set.add(k)
for r in rows[1:]:
for k in r.keys():
if k not in seen_set:
seen.append(k)
seen_set.add(k)
return seen
def _fetch_internal(endpoint: str, authorization: Optional[str]) -> Any:
"""Call the local FastAPI server with the user's Authorization header
forwarded so existing auth/permissions are respected."""
if not endpoint:
raise HTTPException(status_code=400, detail="endpoint required")
# Normalize: must start with / — accept full URL only if it points at us.
ep = unquote(endpoint).strip()
if ep.startswith(("http://", "https://")):
# Only allow our own host to avoid SSRF.
if not ep.startswith(INTERNAL_BASE):
raise HTTPException(status_code=400, detail="external endpoint not allowed")
url = ep
else:
if not ep.startswith("/"):
ep = "/" + ep
url = INTERNAL_BASE + ep
headers: Dict[str, str] = {"Accept": "application/json"}
if authorization:
headers["Authorization"] = authorization
try:
r = requests.get(url, headers=headers, timeout=60)
except Exception as e:
raise HTTPException(status_code=502, detail=f"upstream fetch failed: {e}")
if r.status_code >= 400:
raise HTTPException(
status_code=r.status_code,
detail=f"upstream {r.status_code}: {r.text[:200]}",
)
try:
return r.json()
except Exception:
raise HTTPException(status_code=502, detail="upstream did not return JSON")
# ───────────────────────────────────────────────────────────────────────────
# Format builders
# ───────────────────────────────────────────────────────────────────────────
def _build_csv(rows: List[Dict[str, Any]], filename: str) -> Response:
cols = _ordered_columns(rows)
buf = io.StringIO()
# HR Excel friendly: ; delimiter
w = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL, lineterminator="\r\n")
w.writerow(cols)
for r in rows:
w.writerow([_flatten_value(r.get(c)) for c in cols])
body = ("" + buf.getvalue()).encode("utf-8") # UTF-8 BOM
return Response(
content=body,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_xlsx(rows: List[Dict[str, Any]], filename: str) -> Response:
if not OPENPYXL_AVAILABLE:
# Graceful fallback: CSV with a warning header.
fallback = filename.rsplit(".", 1)[0] + ".csv"
resp = _build_csv(rows, fallback)
resp.headers["X-Export-Warning"] = "openpyxl unavailable — fell back to CSV"
return resp
cols = _ordered_columns(rows)
wb = Workbook()
ws = wb.active
ws.title = "Export"
# Header row (bold)
bold = Font(bold=True, color="FFFFFFFF")
fill = PatternFill(start_color="FF1F2937", end_color="FF1F2937", fill_type="solid")
align = Alignment(vertical="center", wrap_text=False)
ws.append(cols)
for cell in ws[1]:
cell.font = bold
cell.fill = fill
cell.alignment = align
for r in rows:
ws.append([_flatten_value(r.get(c)) for c in cols])
# Auto column widths (clamped)
for idx, col in enumerate(cols, start=1):
max_len = max(
[len(col)]
+ [len(_flatten_value(r.get(col))) for r in rows[:200]]
+ [10]
)
ws.column_dimensions[ws.cell(row=1, column=idx).column_letter].width = min(
max_len + 2, 60
)
bio = io.BytesIO()
wb.save(bio)
bio.seek(0)
return StreamingResponse(
bio,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
def _build_pdf_html(rows: List[Dict[str, Any]], title: str) -> HTMLResponse:
"""Return a print-friendly HTML page (mock PDF). User invokes
browser's Print → Save as PDF.
<!-- TODO: real PDF via weasyprint/reportlab -->
"""
cols = _ordered_columns(rows)
def _esc(s: Any) -> str:
return (
_flatten_value(s)
.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")
)
head = "".join(f"<th>{_esc(c)}</th>" for c in cols)
body_rows = "".join(
"<tr>" + "".join(f"<td>{_esc(r.get(c))}</td>" for c in cols) + "</tr>"
for r in rows
)
when = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
html = f"""<!doctype html>
<html lang="hr"><head><meta charset="utf-8">
<title>{_esc(title)}</title>
<style>
body{{font-family:Arial,Helvetica,sans-serif;color:#111;margin:18px}}
h1{{font-size:16px;margin:0 0 8px}}
.meta{{font-size:11px;color:#555;margin-bottom:14px}}
table{{width:100%;border-collapse:collapse;font-size:10px}}
th,td{{border:1px solid #999;padding:4px 6px;text-align:left;vertical-align:top}}
th{{background:#1f2937;color:#fff;font-size:9px;text-transform:uppercase;letter-spacing:.5px}}
tr:nth-child(even) td{{background:#f5f5f5}}
.bar{{margin-bottom:14px}}
.bar button{{background:#0b5cff;color:#fff;border:0;padding:8px 14px;border-radius:4px;cursor:pointer;font-size:12px}}
@media print{{ .bar{{display:none}} body{{margin:6mm}} }}
</style>
</head><body>
<div class="bar"><button onclick="window.print()">🖨 Print / Save as PDF</button></div>
<h1>{_esc(title)}</h1>
<div class="meta">PGŽ Sport ERP/CRM — generirano {when}{len(rows)} redaka</div>
<table><thead><tr>{head}</tr></thead><tbody>{body_rows}</tbody></table>
<!-- TODO: real PDF via weasyprint/reportlab -->
</body></html>"""
return HTMLResponse(content=html, status_code=200)
# ───────────────────────────────────────────────────────────────────────────
# Endpoints
# ───────────────────────────────────────────────────────────────────────────
@router.get("/health")
def export_health():
return {
"ok": True,
"openpyxl_available": OPENPYXL_AVAILABLE,
"formats": ["csv", "xlsx", "pdf"],
"version": "1.0.0",
}
@router.get("")
@router.get("/")
def export_dispatch(
format: str = Query("csv", description="csv|xlsx|pdf"),
endpoint: str = Query(..., description="Internal API path, e.g. /api/v2/erp/payments?godina=2026"),
filters: Optional[str] = Query(None, description="Optional JSON of extra filters merged into endpoint"),
filename: Optional[str] = Query(None, description="Optional filename base (no extension)"),
authorization: Optional[str] = Header(None),
):
fmt = (format or "csv").lower().strip()
if fmt not in ("csv", "xlsx", "pdf"):
raise HTTPException(status_code=400, detail="format must be csv|xlsx|pdf")
# Optionally merge `filters` JSON into endpoint querystring.
target = endpoint
if filters:
try:
extra = json.loads(filters)
if isinstance(extra, dict) and extra:
from urllib.parse import urlencode
qs = urlencode({k: _flatten_value(v) for k, v in extra.items()})
sep = "&" if "?" in target else "?"
target = f"{target}{sep}{qs}"
except Exception:
# Ignore malformed filter JSON — still try the raw endpoint.
pass
payload = _fetch_internal(target, authorization)
rows = _coerce_rows(payload)
base = filename or "export"
stamp = _ts()
if fmt == "csv":
return _build_csv(rows, f"{base}_{stamp}.csv")
if fmt == "xlsx":
return _build_xlsx(rows, f"{base}_{stamp}.xlsx")
# pdf (HTML print-to-PDF mock)
return _build_pdf_html(rows, title=f"{base}{stamp}")
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
# Fajl: hns_avatar_harvester.py | v1.0 | 05.05.2026
# Author: Damir Radulić
# Lokacija: /opt/pgz-sport/scripts/hns_avatar_harvester.py
# Svrha: Dohvati avatar URL za svakog igrača sa HNS profila
import os, time, re, json, sys
import psycopg2
import requests
from bs4 import BeautifulSoup
DSN = os.environ.get("RINET_DSN", "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7")
HEADERS = {"User-Agent": "Mozilla/5.0 (Ri.NET PGŽ Sport Bot)"}
conn = psycopg2.connect(DSN); conn.autocommit = True
def fetch_avatar(hns_id, slug=""):
url = f"https://semafor.hns.family/igraci/{hns_id}/"
if slug: url += f"{slug}/"
try:
r = requests.get(url, headers=HEADERS, timeout=15)
if r.status_code != 200: return None
soup = BeautifulSoup(r.text, "html.parser")
# Player photo selectors
for sel in [".playerPhoto img", ".player-photo img", ".playerHeader img", "img.player_photo"]:
img = soup.select_one(sel)
if img and img.get("src"):
src = img["src"]
if src.startswith("/"): src = "https://hns.family" + src
return src
# Generic: first img inside header
hdr = soup.select_one(".playerHeader, .player-header, .basic_info")
if hdr:
img = hdr.find("img")
if img and img.get("src"):
src = img["src"]
if src.startswith("/"): src = "https://hns.family" + src
return src
return None
except Exception as e:
return None
with conn.cursor() as cur:
cur.execute("""
SELECT id, hns_igrac_id, ime, prezime
FROM pgz_sport.clanovi
WHERE hns_igrac_id IS NOT NULL AND foto_url IS NULL
LIMIT 200
""")
rows = cur.fetchall()
print(f"Total: {len(rows)} igrača za avatar fetch")
hits = 0
for i, (cid, hns_id, ime, prezime) in enumerate(rows):
slug = f"{ime}-{prezime}".lower().replace("ć","c").replace("č","c").replace("š","s").replace("ž","z").replace("đ","d").replace(" ","-")
slug = re.sub(r"[^a-z0-9-]", "", slug)
avatar = fetch_avatar(hns_id, slug)
if avatar:
with conn.cursor() as cur:
cur.execute("UPDATE pgz_sport.clanovi SET foto_url=%s WHERE id=%s", (avatar, cid))
hits += 1
if i % 10 == 0: print(f" [{i+1}/{len(rows)}] {ime} {prezime}{avatar[:80]}")
time.sleep(0.5)
print(f"\nDONE: {hits}/{len(rows)} avatar URL-ova spremljen")
+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> <td><button class="btn sm" onclick="event.stopPropagation();showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">Detalji</button></td>
</tr>`).join(''); </tr>`).join('');
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : ''; 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} ${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> <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>`; </div>`;
@@ -1281,7 +1282,8 @@ SECTIONS['pgz:klubovi'] = async () => {
<td>${esc(k.predsjednik||'—')}</td> <td>${esc(k.predsjednik||'—')}</td>
</tr>`).join(''); </tr>`).join('');
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : ''; 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} ${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> <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>`; </div>`;
@@ -1301,7 +1303,8 @@ SECTIONS['pgz:sportasi'] = async () => {
<td>${esc(c.datum_rodjenja||c.datum_rodenja||'—')}</td> <td>${esc(c.datum_rodjenja||c.datum_rodenja||'—')}</td>
</tr>`).join(''); </tr>`).join('');
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : ''; 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} ${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> <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>`; </div>`;
@@ -2493,5 +2496,6 @@ window.renderPGZToggleBtn = function(){
+ (on ? '⭐ PGŽ filter ON' : '☆ PGŽ filter OFF') + '</button>'; + (on ? '⭐ PGŽ filter ON' : '☆ PGŽ filter OFF') + '</button>';
}; };
</script> </script>
<script src="/static/js/export_dropdown.js"></script>
</body> </body>
</html> </html>
+439 -71
View File
@@ -10,6 +10,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport — CRM v2 (Salesforce-Lite)</title> <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> <style>
:root { :root {
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a; --pgz-gold:#fbbf24; --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"> <div class="topbar">
<span class="logo">PGŽ SPORT</span> <span class="logo">PGŽ SPORT</span>
<span class="sep"></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"> <div class="right">
<span id="me"></span> <span id="me"></span>
<a href="/sport/platform">Platform</a> <a href="/sport/platform">Platform</a>
@@ -245,40 +246,21 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</div> </div>
<div class="tabs"> <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="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="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="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="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="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="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>
<div class="main"> <div class="main">
<!-- ────── PIPELINE ────── --> <!-- ────── PIPELINE (legacy tab removed — KPIs + kanban now live in Opportunities tab) ────── -->
<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 ────── --> <!-- ────── ACCOUNTS ────── -->
<div class="tab-c" id="tc-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> </select>
<button class="btn" onclick="loadAccounts()">Pretraži</button> <button class="btn" onclick="loadAccounts()">Pretraži</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openAccountModal()">+ Novi account</button>
</div> </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> <table id="t-accounts"><thead><tr>
<th>Naziv</th><th>Tip</th><th>Grad</th><th>OIB</th><th>Email</th> <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> <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"> <input id="con-acc" type="number" placeholder="Account ID (filter)" style="max-width:170px">
<button class="btn" onclick="loadContacts()">Pretraži</button> <button class="btn" onclick="loadContacts()">Pretraži</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openContactModal()">+ Novi kontakt</button>
</div> </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> <table id="t-contacts"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Account</th><th>Funkcija</th> <th>Ime</th><th>Prezime</th><th>Account</th><th>Funkcija</th>
<th>Email</th><th>Telefon</th><th></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> </select>
<button class="btn" onclick="loadLeads()">Pretraži</button> <button class="btn" onclick="loadLeads()">Pretraži</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openLeadModal()">+ Novi lead</button>
</div> </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> <table id="t-leads"><thead><tr>
<th>Ime</th><th>Prezime</th><th>Organizacija</th><th>Email</th> <th>Ime</th><th>Prezime</th><th>Organizacija</th><th>Email</th>
<th>Izvor</th><th>Status</th><th></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> </select>
<button class="btn" onclick="loadOpps()">Pretraži</button> <button class="btn" onclick="loadOpps()">Pretraži</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openOppModal()">+ Nova prilika</button>
</div> </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> <table id="t-opps"><thead><tr>
<th>Naziv</th><th>Account</th><th>Tip</th><th>Faza</th> <th>Naziv</th><th>Account</th><th>Tip</th><th>Faza</th>
<th>EUR</th><th>%</th><th>Close</th><th></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> </select>
<button class="btn" onclick="loadActivities()">Filtriraj</button> <button class="btn" onclick="loadActivities()">Filtriraj</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openActivityModal()">+ Nova aktivnost</button>
</div> </div>
<div class="card"><div class="card-b" style="padding:0"> <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> </select>
<button class="btn" onclick="loadCases()">Pretraži</button> <button class="btn" onclick="loadCases()">Pretraži</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openCaseModal()">+ Novi case</button>
</div> </div>
<div class="card"><div class="card-b" style="padding:0"> <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> </div>
<!-- ────── ČLANARINE ────── --> <!-- ────── ČLANARINE ────── -->
<div class="tab-c" id="tc-clanarine"> <div class="tab-c on" id="tc-clanarine">
<div class="toolbar"> <div class="toolbar">
<input id="cln-klub" type="number" placeholder="Klub ID" style="max-width:120px"> <input id="cln-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<input id="cln-clan" type="number" placeholder="Član ID" style="max-width:120px"> <input id="cln-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> </select>
<button class="btn" onclick="loadClanarine()">Filtriraj</button> <button class="btn" onclick="loadClanarine()">Filtriraj</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openClanarinaModal()">+ Nova članarina</button>
</div> </div>
<div class="card"><div class="card-b" style="padding:0"> <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> </select>
<button class="btn" onclick="loadLijecnicki()">Filtriraj</button> <button class="btn" onclick="loadLijecnicki()">Filtriraj</button>
<span class="grow"></span> <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> <button class="btn primary" onclick="openLijecnickiModal()">+ Novi pregled</button>
</div> </div>
<div class="card"><div class="card-b" style="padding:0"> <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> </select>
<input id="obr-klub" type="number" placeholder="Klub ID" style="max-width:120px"> <input id="obr-klub" type="number" placeholder="Klub ID" style="max-width:120px">
<button class="btn" onclick="loadObrasciSubmissions()">Filtriraj</button> <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>
<div class="card"><div class="card-b" style="padding:0" id="obr-right-body"> <div class="card"><div class="card-b" style="padding:0" id="obr-right-body">
<table id="t-obr-sub"><thead><tr> <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>
</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 --> </div><!-- /main -->
<footer> <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))); document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
function switchTab(name) { function switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab===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'))); 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')));
if (name==='pipeline') loadPipeline(); // Always refresh KPIs/pipeline counts in background
if (name==='accounts') loadAccounts(); if (name==='accounts') loadAccounts();
if (name==='contacts') loadContacts(); if (name==='contacts') loadContacts();
if (name==='leads') loadLeads(); if (name==='leads') loadLeads();
if (name==='opportunities') loadOpps(); if (name==='opportunities') { loadOpps(); loadPipeline(); }
if (name==='activities') loadActivities(); if (name==='activities') loadActivities();
if (name==='cases') loadCases(); if (name==='cases') loadCases();
if (name==='clanarine') loadClanarine(); if (name==='clanarine') loadClanarine();
if (name==='lijecnicki') loadLijecnicki(); if (name==='lijecnicki') loadLijecnicki();
if (name==='obrasci') { loadObrasciTemplates(); loadObrasciSubmissions(); } 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 ────── // ────── /me ──────
@@ -734,8 +899,28 @@ async function loadAccounts() {
if (t) qs.set('type', t); if (t) qs.set('type', t);
try { try {
const data = await api('/accounts?'+qs.toString()); 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'); const tb = document.querySelector('#t-accounts tbody');
tb.innerHTML = (data.items||[]).map(a => ` if (tb) {
tb.innerHTML = items.map(a => `
<tr onclick="editAccount(${a.id})"> <tr onclick="editAccount(${a.id})">
<td><strong>${esc(a.naziv)}</strong></td> <td><strong>${esc(a.naziv)}</strong></td>
<td>${esc(a.type)}</td> <td>${esc(a.type)}</td>
@@ -747,8 +932,10 @@ async function loadAccounts() {
<td>${esc(a.owner_email||'—')}</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> <td><button class="btn sm" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button></td>
</tr> </tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema accounta — dodajte prvi.</td></tr>'; `).join('') || '<tr><td colspan="9" class="empty">Nema accounta.</td></tr>';
document.getElementById('cnt-accounts').textContent = (data.items||[]).length; }
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'); } } catch (e) { toast('Accounts err: '+e.message, 'err'); }
} }
@@ -823,19 +1010,33 @@ async function loadContacts() {
if (aid) qs.set('account_id', aid); if (aid) qs.set('account_id', aid);
try { try {
const data = await api('/contacts?'+qs.toString()); 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'); const tb = document.querySelector('#t-contacts tbody');
tb.innerHTML = (data.items||[]).map(c => ` if (tb) {
tb.innerHTML = items.map(c => `
<tr onclick="editContact(${c.id})"> <tr onclick="editContact(${c.id})">
<td><strong>${esc(c.ime)}</strong></td> <td><strong>${esc(c.ime)}</strong></td><td>${esc(c.prezime)}</td>
<td>${esc(c.prezime)}</td> <td>${esc(c.account_naziv||'—')}</td><td>${esc(c.funkcija||'—')}</td>
<td>${esc(c.account_naziv||'—')}</td> <td>${esc(c.email||'—')}</td><td>${esc(c.telefon||c.mobitel||'—')}</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> <td><button class="btn sm" onclick="event.stopPropagation();delContact(${c.id})">×</button></td>
</tr> </tr>`).join('');
`).join('') || '<tr><td colspan="7" class="empty">Nema kontakata.</td></tr>'; }
document.getElementById('cnt-contacts').textContent = (data.items||[]).length; 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'); } } catch (e) { toast(e.message, 'err'); }
} }
function contactFormHTML(c={}) { function contactFormHTML(c={}) {
@@ -903,22 +1104,35 @@ async function loadLeads() {
if (s) qs.set('status', s); if (s) qs.set('status', s);
try { try {
const data = await api('/leads?'+qs.toString()); 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'); const tb = document.querySelector('#t-leads tbody');
tb.innerHTML = (data.items||[]).map(l => ` if (tb) {
tb.innerHTML = items.map(l => `
<tr onclick="editLead(${l.id})"> <tr onclick="editLead(${l.id})">
<td>${esc(l.ime||'—')}</td> <td>${esc(l.ime||'—')}</td><td>${esc(l.prezime||'—')}</td>
<td>${esc(l.prezime||'—')}</td> <td>${esc(l.organizacija||'—')}</td><td>${esc(l.email||'—')}</td>
<td>${esc(l.organizacija||'—')}</td> <td>${esc(l.izvor||'—')}</td><td><span class="chip ${l.status}">${l.status}</span></td>
<td>${esc(l.email||'—')}</td> <td><button class="btn sm" onclick="event.stopPropagation();delLead(${l.id})">×</button></td>
<td>${esc(l.izvor||'')}</td> </tr>`).join('');
<td><span class="chip ${l.status}">${l.status}</span></td> }
<td> setExportRows('leads', items.map(l => [l.ime||'', l.prezime||'', l.organizacija||'', l.email||'', l.telefon||'', l.izvor||'', l.status||'']));
${l.status!=='converted' ? `<button class="btn sm gold" onclick="event.stopPropagation();convertLead(${l.id})">→ Konvertiraj</button>` : ''} document.getElementById('cnt-leads').textContent = items.length;
<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'); } } catch (e) { toast(e.message, 'err'); }
} }
function leadFormHTML(l={}) { function leadFormHTML(l={}) {
@@ -1029,20 +1243,36 @@ async function loadOpps() {
if (s) qs.set('stage', s); if (s) qs.set('stage', s);
try { try {
const data = await api('/opportunities?'+qs.toString()); 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'); const tb = document.querySelector('#t-opps tbody');
tb.innerHTML = (data.items||[]).map(o => ` if (tb) {
tb.innerHTML = items.map(o => `
<tr onclick="editOpp(${o.id})"> <tr onclick="editOpp(${o.id})">
<td><strong>${esc(o.naziv)}</strong></td> <td><strong>${esc(o.naziv)}</strong></td><td>${esc(o.account_naziv||'—')}</td>
<td>${esc(o.account_naziv||'—')}</td> <td>${esc(o.type||'—')}</td><td><span class="chip">${esc(o.stage)}</span></td>
<td>${esc(o.type||'—')}</td> <td>${fmtEur(o.amount_eur)}</td><td>${o.probability||0}%</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>${fmtDate(o.close_date)}</td>
<td><button class="btn sm" onclick="event.stopPropagation();delOpp(${o.id})">×</button></td> <td><button class="btn sm" onclick="event.stopPropagation();delOpp(${o.id})">×</button></td>
</tr> </tr>`).join('');
`).join('') || '<tr><td colspan="8" class="empty">Nema prilika.</td></tr>'; }
document.getElementById('cnt-opps').textContent = (data.items||[]).length; 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'); } } catch (e) { toast(e.message, 'err'); }
} }
function oppFormHTML(o={}) { function oppFormHTML(o={}) {
@@ -1131,6 +1361,7 @@ async function loadActivities() {
</td> </td>
</tr> </tr>
`).join('') || '<tr><td colspan="7" class="empty">Nema aktivnosti.</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; document.getElementById('cnt-activities').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); } } 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> <td><button class="btn sm" onclick="event.stopPropagation();delCase(${c.id})">×</button></td>
</tr> </tr>
`).join('') || '<tr><td colspan="6" class="empty">Nema caseova.</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; document.getElementById('cnt-cases').textContent = (data.items||[]).length;
} catch (e) { toast(e.message, 'err'); } } 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> <td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delClanarina(${c.id})">×</button>` : ''}</td>
</tr> </tr>
`).join('') || '<tr><td colspan="9" class="empty">Nema članarina za odabrane filtere.</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; document.getElementById('cnt-clanarine').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Članarine err: '+e.message, 'err'); } } 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> <td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delLijecnicki(${l.id})">×</button>` : ''}</td>
</tr>`; </tr>`;
}).join('') || '<tr><td colspan="9" class="empty">Nema liječničkih pregleda.</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; document.getElementById('cnt-lijecnicki').textContent = data.count ?? (data.items||[]).length;
} catch (e) { toast('Liječnički err: '+e.message, 'err'); } } catch (e) { toast('Liječnički err: '+e.message, 'err'); }
} }
@@ -1642,6 +1876,7 @@ async function loadObrasciSubmissions() {
` : ''}</td> ` : ''}</td>
</tr> </tr>
`).join('') || '<tr><td colspan="8" class="empty">Nema podnesenih obrazaca.</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'); } } 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'); } } 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 ────── // ────── Modal helpers ──────
function showModal(title, bodyHTML, onSave) { function showModal(title, bodyHTML, onSave) {
document.getElementById('m-title').textContent = title; document.getElementById('m-title').textContent = title;
@@ -1731,7 +2080,26 @@ document.getElementById('modal').addEventListener('click', e => {
// ────── Init ────── // ────── Init ──────
loadMe(); loadMe();
ensureMe(); 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>
<script src="/static/js/export_dropdown.js"></script>
</body> </body>
</html> </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>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> <label>Godina <input type="number" id="rac-godina" value="2026" style="width:90px"></label>
<button class="btn" onclick="loadRacuni()">Osvježi</button> <button class="btn" onclick="loadRacuni()">Osvježi</button>
<button id="rac-export-btn" class="export-btn" type="button">Export ▾</button>
</div> </div>
<div class="tbl-wrap"> <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> <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>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> <label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label>
<button class="btn" onclick="loadExpenseReports()">Osvježi</button> <button class="btn" onclick="loadExpenseReports()">Osvježi</button>
<button id="pn-export-btn" class="export-btn" type="button">Export ▾</button>
</div> </div>
<div class="tbl-wrap"> <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> <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>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> <label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
<button class="btn" onclick="loadPayments()">Osvježi</button> <button class="btn" onclick="loadPayments()">Osvježi</button>
<button id="py-export-btn" class="export-btn" type="button">Export ▾</button>
</div> </div>
<div class="tbl-wrap"> <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> <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(); 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>
<script src="/static/js/export_dropdown.js"></script>
</body> </body>
</html> </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> </div>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''} ${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''}
<span class="tb-s" id="sav-cnt"></span> <span class="tb-s" id="sav-cnt"></span>
<button id="sav-export-btn" class="export-btn" type="button">Export ▾</button>
</div> </div>
<div id="sav-out"></div> <div id="sav-out"></div>
`; `;
@@ -1416,6 +1417,20 @@ function renderSaveziShell(){
$('#sav-sport').addEventListener('change', applySaveziFilter); $('#sav-sport').addEventListener('change', applySaveziFilter);
$('#sav-kat').addEventListener('change', applySaveziFilter); $('#sav-kat').addEventListener('change', applySaveziFilter);
$('#sav-pgz').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){ function setSaveziView(v){
_state.viewSavezi = v; _state.viewSavezi = v;
@@ -1535,13 +1550,18 @@ async function loadKlubovi(){
const root = $('#pg-klubovi'); const root = $('#pg-klubovi');
if(!_cache.klubovi){ if(!_cache.klubovi){
root.innerHTML = '<div class="loading">Učitavanje klubova…</div>'; root.innerHTML = '<div class="loading">Učitavanje klubova…</div>';
// BUG-E (2026-05-05): build /api/klubovi URL from explicit _filters.klubovi state. // RUSH-1 (2026-05-05): /api/klubovi URL built from _filters.klubovi state.
// Defaults: financirani=true + godisnjak=true. When BOTH off → load all. // 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 f = _filters.klubovi;
const qs = new URLSearchParams(); const qs = new URLSearchParams();
qs.set('limit','2500'); qs.set('limit','2500');
qs.set('sort','financiran'); qs.set('order','desc'); // sort by potpore DESC (financiran flag) qs.set('sort','potpora'); qs.set('order','desc'); // ukupno_potpora DESC NULLS LAST
// financirani + godisnjak combined with kategorija=priority logic:
if(f.financirani && f.godisnjak){ if(f.financirani && f.godisnjak){
qs.set('kategorija','priority'); // OR semantics → priority = financiran OR godišnjak qs.set('kategorija','priority'); // OR semantics → priority = financiran OR godišnjak
} else if(f.financirani){ } 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-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> <button id="kl-table" class="${_state.viewKlubovi==='table'?'active':''}" onclick="setKluboviView('table')">Tablica</button>
</div> </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> <button class="btn" onclick="enrichBulk('klub', 50, 70)">✨ Obogati (50)</button>
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''} ${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''}
<span class="tb-s" id="kl-cnt"></span> <span class="tb-s" id="kl-cnt"></span>
<button id="kl-export-btn" class="export-btn" type="button">Export ▾</button>
</div> </div>
<div id="kl-out"></div> <div id="kl-out"></div>
`; `;
@@ -1594,6 +1613,25 @@ function renderKluboviShell(){
$('#kl-grad').addEventListener('change', applyKluboviFilter); $('#kl-grad').addEventListener('change', applyKluboviFilter);
$('#kl-kat').addEventListener('change', applyKluboviFilter); $('#kl-kat').addEventListener('change', applyKluboviFilter);
$('#kl-nk').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){ function setKluboviView(v){
_state.viewKlubovi = v; _state.viewKlubovi = v;
@@ -1634,25 +1672,31 @@ function applyKluboviFilter(){
} }
function renderKluboviGrid(rows){ function renderKluboviGrid(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>'; 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})"> <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>':'')} ${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="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="es">${txt(k.razina,'')} · ${txt(k.grad,'—')}</div>
<div class="em"> <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>':''} ${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.registriranih)}</b> reg.</span>
<span><b>${fmtNum(k.trenera)}</b> trenera</span> <span><b>${fmtNum(k.trenera)}</b> trenera</span>
<span><b>${fmtNum(k.reprezentativaca)}</b> repr.</span>
</div> </div>
</div>`).join('')+'</div>'; </div>`;
}).join('')+'</div>';
} }
function renderKluboviTable(rows){ function renderKluboviTable(rows){
if(!rows.length) return '<div class="empty">Nema rezultata</div>'; if(!rows.length) return '<div class="empty">Nema rezultata</div>';
return `<div class="card" style="padding:0;overflow-x:auto"><table> 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> <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 => ` <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> <tr>
<td onclick="event.stopPropagation()"><input type="checkbox" class="kl-pick" data-id="${k.id}"></td> <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> <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.sport)}</td>
<td onclick="openKlub(${k.id})">${txt(k.razina)}</td> <td onclick="openKlub(${k.id})">${txt(k.razina)}</td>
<td onclick="openKlub(${k.id})">${txt(k.grad)}</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.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="'+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>
<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>`;
</tr>`).join('')}</tbody> }).join('')}</tbody>
</table></div>`; </table></div>`;
} }
@@ -2698,77 +2743,131 @@ function openObjekt(id){
} }
//=========== MANIFESTACIJE =========== //=========== 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(){ async function loadManifestacije(){
const root = $('#pg-manifestacije'); 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>'; root.innerHTML = '<div class="loading">Učitavanje manifestacija…</div>';
const d = await api('/manifestacije-full'); _manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[]};
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
_cache.manifestacije = d.rows || (Array.isArray(d) ? d : []);
} }
renderManifShell(); 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(){ function renderManifShell(){
const root = $('#pg-manifestacije'); 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 = ` root.innerHTML = `
<div class="toolbar"> <div class="toolbar">
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…"> <input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…" value="${esc(_manifFilter.q)}">
<select id="mn-raz"><option value="">Sve razine</option>${razine.map(r=>'<option value="'+esc(r)+'">'+esc(r)+'</option>').join('')}</select> <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"> <div class="toggle">
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')">Kartice</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')">Tablica</button> <button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')" title="Tablica">📋 Tablica</button>
</div> </div>
<span class="tb-s" id="mn-cnt"></span> <span class="tb-s" id="mn-cnt"></span>
</div> </div>
<div id="mn-out"></div> <div id="mn-out"></div>
`; `;
$('#mn-q').addEventListener('input', debounce(applyManifFilter, 200)); // Restore selections after re-render
$('#mn-raz').addEventListener('change', applyManifFilter); 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){ function setManifView(v){
_state.viewManif = v; _state.viewManif = v;
$('#mn-card').classList.toggle('active', v==='card'); try{ localStorage.setItem('_manifViewMode', v); }catch(_){}
$('#mn-table').classList.toggle('active', v==='table'); if($('#mn-card')) $('#mn-card').classList.toggle('active', v==='card');
applyManifFilter(); if($('#mn-table')) $('#mn-table').classList.toggle('active', v==='table');
renderManifBody();
} }
function applyManifFilter(){ function renderManifBody(){
const q = (($('#mn-q')?$('#mn-q').value:'') || '').toLowerCase().trim();
const raz = $('#mn-raz') ? $('#mn-raz').value : '';
let rows = _cache.manifestacije || []; 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); if(_sort.manifestacije) rows = sortRows(rows, _sort.manifestacije.key, _sort.manifestacije.dir);
$('#mn-cnt').textContent = rows.length+' manifestacija'; $('#mn-cnt').textContent = rows.length+' manifestacija';
$('#mn-out').innerHTML = _state.viewManif==='card' ? renderManifGrid(rows) : renderManifTable(rows); $('#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){ function renderManifGrid(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="grid">'+rows.map(m => ` 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})"> <div class="entity" onclick="openManif(${m.id})">
${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''} ${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''}
<div class="et">${esc(m.naziv)}</div> <div class="et">${esc(m.naziv)} ${linkIcon}</div>
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}</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"> <div class="em">
${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''} ${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''}
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''} ${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''}
</div> </div>
</div>`).join('')+'</div>'; </div>`;
}).join('')+'</div>';
} }
function renderManifTable(rows){ 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> 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> <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})"> <tr onclick="openManif(${m.id})">
<td><b>${esc(m.naziv)}</b></td> <td><b>${esc(m.naziv)}</b></td>
<td>${txt(m.mjesto)}</td> <td>${txt(m.mjesto)}</td>
<td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td> <td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td>
<td>${txt(m.organizator)}</td> <td>${txt(m.organizator)}</td>
<td>${txt(m.broj_ucesnika)}</td> <td>${txt(m.broj_ucesnika)}</td>
<td>${m.source_url?'<a href="'+esc(m.source_url)+'" target="_blank">↗</a>':'—'}</td> <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> </tr>`;
}).join('')}</tbody>
</table></div>`; </table></div>`;
} }
function openManif(id){ function openManif(id){
@@ -3815,5 +3914,6 @@ window.closePanel = function(){
if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); } if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); }
}; };
</script> </script>
<script src="/static/js/export_dropdown.js"></script>
</body> </body>
</html> </html>