Task 4: Universal Export ▾ — CSV/XLSX/PDF dropdown across all screens

- routers/export_router.py: /api/v2/export?format=...&endpoint=...&filters=...
- static/js/export_dropdown.js: shared attachExportDropdown helper
- sport2/app/crm_v2/erp_full: Export ▾ button wired to representative tables
- pgz_sport_api.py: mount export_router with try/except

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