From 38383d07c5e4fe794355cfae6ddf3ec6533f1b09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 18:33:36 +0200 Subject: [PATCH] =?UTF-8?q?Task=204:=20Universal=20Export=20=E2=96=BE=20?= =?UTF-8?q?=E2=80=94=20CSV/XLSX/PDF=20dropdown=20across=20all=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- pgz_sport_api.py | 70 +++- routers/export_router.py | 297 +++++++++++++++++ static/app.html | 10 +- static/crm_v2.html | 603 +++++++++++++++++++++++++++++------ static/erp_full.html | 46 +++ static/js/export_dropdown.js | 181 +++++++++++ static/sport2.html | 222 ++++++++++--- 7 files changed, 1272 insertions(+), 157 deletions(-) create mode 100644 routers/export_router.py create mode 100644 static/js/export_dropdown.js diff --git a/pgz_sport_api.py b/pgz_sport_api.py index bb0c21b..49e9b1c 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -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", "") diff --git a/routers/export_router.py b/routers/export_router.py new file mode 100644 index 0000000..a6939da --- /dev/null +++ b/routers/export_router.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +# ═══════════════════════════════════════════════════════════════════════════ +# Fajl: routers/export_router.py | v1.0.0 | 05.05.2026 +# Autor: Damir Radulić / 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. + + + """ + cols = _ordered_columns(rows) + + def _esc(s: Any) -> str: + return ( + _flatten_value(s) + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + head = "".join(f"{_esc(c)}" for c in cols) + body_rows = "".join( + "" + "".join(f"{_esc(r.get(c))}" for c in cols) + "" + for r in rows + ) + when = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + html = f""" + +{_esc(title)} + + +
+

{_esc(title)}

+
PGŽ Sport ERP/CRM — generirano {when} — {len(rows)} redaka
+{head}{body_rows}
+ +""" + 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}") diff --git a/static/app.html b/static/app.html index df1bcc3..519dffe 100644 --- a/static/app.html +++ b/static/app.html @@ -1261,7 +1261,8 @@ SECTIONS['pgz:savezi'] = async () => { `).join(''); const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : ''; - return `
🏅 Savezi PGŽ — top 30 (od ${d.count||246})${onPGZ?' · ⭐ samo PGŽ-relevantni':''}
+ setTimeout(()=>{ const b=document.getElementById('app-exp-savezi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'savezi'); },0); + return `
🏅 Savezi PGŽ — top 30 (od ${d.count||246})${onPGZ?' · ⭐ samo PGŽ-relevantni':''}
${tb} ${rows||''}
NazivKluboviSportašiPredsjednik
Učitavam...
`; @@ -1281,7 +1282,8 @@ SECTIONS['pgz:klubovi'] = async () => { ${esc(k.predsjednik||'—')} `).join(''); const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : ''; - return `
⬢ Klubovi (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}
+ setTimeout(()=>{ const b=document.getElementById('app-exp-klubovi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'klubovi'); },0); + return `
⬢ Klubovi (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}
${tb} ${rows||''}
NazivSavezGradČlanovaPredsjednik
`; @@ -1301,7 +1303,8 @@ SECTIONS['pgz:sportasi'] = async () => { ${esc(c.datum_rodjenja||c.datum_rodenja||'—')} `).join(''); const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : ''; - return `
👤 Sportaši (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}
+ setTimeout(()=>{ const b=document.getElementById('app-exp-sportasi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'sportasi'); },0); + return `
👤 Sportaši (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}
${tb} ${rows||''}
Ime i prezimeKlubKategorijaSpolRođen
`; @@ -2493,5 +2496,6 @@ window.renderPGZToggleBtn = function(){ + (on ? '⭐ PGŽ filter ON' : '☆ PGŽ filter OFF') + ''; }; + diff --git a/static/crm_v2.html b/static/crm_v2.html index bb0b704..4fba537 100644 --- a/static/crm_v2.html +++ b/static/crm_v2.html @@ -10,6 +10,7 @@ PGŽ Sport — CRM v2 (Salesforce-Lite) + +

PGŽ Sport CRM — ${tab.toUpperCase()} (${new Date().toLocaleString('hr-HR')})

+ ${headers.map(h=>'').join('')} + ${rows.map(r=>''+r.map(c=>'').join('')}
'+h+'
'+(c==null?'':String(c).replace(/&/g,'&').replace(/').join('')+'
+ `; + 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 => ` +
+
+ +
+
${esc(a.naziv)}
+
${esc(a.type)} ${esc(a.grad||'')}
+
OIB${esc(a.oib||'—')}
+
Email${esc(a.email||'—')}
+
Kontakti / Opps${a.contacts_n||0} / ${a.opps_n||0}
+
Owner${esc(a.owner_email||'—')}
+
+ `).join('') || '
Nema accounta — dodajte prvi.
'; + } + // Hidden table (compat for legacy code/exports) const tb = document.querySelector('#t-accounts tbody'); - tb.innerHTML = (data.items||[]).map(a => ` - - ${esc(a.naziv)} - ${esc(a.type)} - ${esc(a.grad||'—')} - ${esc(a.oib||'—')} - ${esc(a.email||'—')} - ${a.contacts_n||0} - ${a.opps_n||0} - ${esc(a.owner_email||'—')} - - - `).join('') || 'Nema accounta — dodajte prvi.'; - document.getElementById('cnt-accounts').textContent = (data.items||[]).length; + if (tb) { + tb.innerHTML = items.map(a => ` + + ${esc(a.naziv)} + ${esc(a.type)} + ${esc(a.grad||'—')} + ${esc(a.oib||'—')} + ${esc(a.email||'—')} + ${a.contacts_n||0} + ${a.opps_n||0} + ${esc(a.owner_email||'—')} + + + `).join('') || 'Nema accounta.'; + } + 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 => ` +
+
+ +
+
${esc(c.ime)} ${esc(c.prezime)}
+
${esc(c.funkcija||'—')} · ${esc(c.account_naziv||'—')}
+
Email${esc(c.email||'—')}
+
Telefon${esc(c.telefon||c.mobitel||'—')}
+
+ `).join('') || '
Nema kontakata.
'; + } const tb = document.querySelector('#t-contacts tbody'); - tb.innerHTML = (data.items||[]).map(c => ` - - ${esc(c.ime)} - ${esc(c.prezime)} - ${esc(c.account_naziv||'—')} - ${esc(c.funkcija||'—')} - ${esc(c.email||'—')} - ${esc(c.telefon||c.mobitel||'—')} - - - `).join('') || 'Nema kontakata.'; - document.getElementById('cnt-contacts').textContent = (data.items||[]).length; + if (tb) { + tb.innerHTML = items.map(c => ` + + ${esc(c.ime)}${esc(c.prezime)} + ${esc(c.account_naziv||'—')}${esc(c.funkcija||'—')} + ${esc(c.email||'—')}${esc(c.telefon||c.mobitel||'—')} + + `).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 => ` +
+
+ ${l.status!=='converted' ? `` : ''} + +
+
${esc(l.ime||'')} ${esc(l.prezime||'')}
+
${l.status} ${esc(l.organizacija||'—')}
+
Email${esc(l.email||'—')}
+
Telefon${esc(l.telefon||'—')}
+
Izvor${esc(l.izvor||'—')}
+
+ `).join('') || '
Nema leadova.
'; + } const tb = document.querySelector('#t-leads tbody'); - tb.innerHTML = (data.items||[]).map(l => ` - - ${esc(l.ime||'—')} - ${esc(l.prezime||'—')} - ${esc(l.organizacija||'—')} - ${esc(l.email||'—')} - ${esc(l.izvor||'—')} - ${l.status} - - ${l.status!=='converted' ? `` : ''} - - - - `).join('') || 'Nema leadova.'; - document.getElementById('cnt-leads').textContent = (data.items||[]).length; + if (tb) { + tb.innerHTML = items.map(l => ` + + ${esc(l.ime||'—')}${esc(l.prezime||'—')} + ${esc(l.organizacija||'—')}${esc(l.email||'—')} + ${esc(l.izvor||'—')}${l.status} + + `).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 => ` +
+
+ +
+
${esc(o.naziv)}
+
${esc(o.account_naziv||'—')} · ${esc(o.stage)}
+
Iznos${fmtEur(o.amount_eur)}
+
Vjerojatnost${o.probability||0}%
+
Close${fmtDate(o.close_date)}
+
Tip${esc(o.type||'—')}
+
+ `).join('') || '
Nema prilika.
'; + } const tb = document.querySelector('#t-opps tbody'); - tb.innerHTML = (data.items||[]).map(o => ` - - ${esc(o.naziv)} - ${esc(o.account_naziv||'—')} - ${esc(o.type||'—')} - ${esc(o.stage)} - ${fmtEur(o.amount_eur)} - ${o.probability||0}% - ${fmtDate(o.close_date)} - - - `).join('') || 'Nema prilika.'; - document.getElementById('cnt-opps').textContent = (data.items||[]).length; + if (tb) { + tb.innerHTML = items.map(o => ` + + ${esc(o.naziv)}${esc(o.account_naziv||'—')} + ${esc(o.type||'—')}${esc(o.stage)} + ${fmtEur(o.amount_eur)}${o.probability||0}% + ${fmtDate(o.close_date)} + + `).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() { `).join('') || 'Nema aktivnosti.'; + 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() { `).join('') || 'Nema caseova.'; + 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() { ${isAdminUser() ? `` : ''} `).join('') || 'Nema članarina za odabrane filtere.'; + 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() { ${isAdminUser() ? `` : ''} `; }).join('') || 'Nema liječničkih pregleda.'; + 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() { ` : ''} `).join('') || 'Nema podnesenih obrazaca.'; + 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 => ` +
+
${esc(t.code)}
+
${esc(t.naziv)} ${t.active===false?'neaktivan':''}
+
${esc(t.kategorija||'—')}
+
Subject: ${esc((t.subject_tpl||'').slice(0,90))}${(t.subject_tpl||'').length>90?'…':''}
${esc((t.body_tpl||'').replace(/\s+/g,' ').slice(0,140))}${(t.body_tpl||'').length>140?'…':''}
+
+ `).join('') || '
Nema predložaka.
'; + 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 ` +
+
+
+ +
+
+
+
+
+ +
+
+ +
+
+ `; +} + +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'); +}); + diff --git a/static/erp_full.html b/static/erp_full.html index ab32bfd..32b595c 100644 --- a/static/erp_full.html +++ b/static/erp_full.html @@ -199,6 +199,7 @@ table tbody tr:hover{background:var(--bg3)} +
#BrojDatumPartnerOIBNetoPDVBruttoStatusAkcije
Klikni "Osvježi" za učitavanje…
@@ -253,6 +254,7 @@ table tbody tr:hover{background:var(--bg3)} +
#TipKlubOdredišteSvrhaOdDoKmTrošakDnevniceStatus
Klikni "Osvježi"…
@@ -278,6 +280,7 @@ table tbody tr:hover{background:var(--bg3)} +
#DatumKlubIznosValutaNačinIBAN ODIBAN ZAReferencaRačunPutni nalogMatch
Klikni "Osvježi"…
@@ -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'); +}); + diff --git a/static/js/export_dropdown.js b/static/js/export_dropdown.js new file mode 100644 index 0000000..1475ede --- /dev/null +++ b/static/js/export_dropdown.js @@ -0,0 +1,181 @@ +/* ═══════════════════════════════════════════════════════════════════════ + * Fajl: static/js/export_dropdown.js | v1.0.0 | 05.05.2026 + * Autor: Damir Radulić / 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: ', + '', + '
', + '' + ].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; +})(); diff --git a/static/sport2.html b/static/sport2.html index fbc1b61..e43a791 100644 --- a/static/sport2.html +++ b/static/sport2.html @@ -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(){
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''} +
`; @@ -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 = '
Učitavanje klubova…
'; - // 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(){
- - ${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''} +
`; @@ -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 '
Nema rezultata
'; - return '
'+rows.map(k => ` + return '
'+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) ? ' '+fmtEur(k.ukupno_potpora)+'' : ''; + return `
${k.priority?'
★ PRIO
':(k.nositelj_kvalitete?'
N.K.
':'')}
${(window.pgzBadgePrefix?window.pgzBadgePrefix(k,'klub'):'')}${esc(k.klub||k.sport||'(bez naziva)')}
${txt(k.razina,'')} · ${txt(k.grad,'—')}
- ${k.financiran?'':''} + ${k.financiran?'':''} ${k.godisnjak?'G':''} + ${potpora} ${fmtNum(k.registriranih)} reg. ${fmtNum(k.trenera)} trenera - ${fmtNum(k.reprezentativaca)} repr.
-
`).join('')+'
'; +
`; + }).join('')+''; } function renderKluboviTable(rows){ if(!rows.length) return '
Nema rezultata
'; return `
- ${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','')} - ${rows.map(k => ` + ${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','')} + ${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 ` @@ -1654,10 +1704,11 @@ function renderKluboviTable(rows){ + - - - `).join('')} + + `; + }).join('')}
${k.priority?'':''}${txt(k.sport)} ${txt(k.razina)} ${txt(k.grad)}${k.ukupno_potpora!=null?fmtEur(k.ukupno_potpora):'—'} ${fmtNum(k.registriranih)}${fmtNum(k.trenera)}${k.financiran?'':''}${k.godisnjak?'G':''}${k.nositelj_kvalitete?'N.K.':''}${k.aktivan?'AKT':'NK'}
${k.financiran?'':''}${k.godisnjak?'G':''}${k.nositelj_kvalitete?'N.K.':''}${k.aktivan?'AKT':'NK'}
`; } @@ -2112,17 +2163,39 @@ function renderSportasiGrid(rows){ if(!rows.length) return '
Nema rezultata
'; return '
'+rows.map(c => buildPlayerCard(c)).join('')+'
'; } +// 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 ''; + } + return ''+initials+''; +} function buildPlayerCard(c){ const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase(); - const photo = c.slika_url ? '' : '
'+initials+'
'; + const photoSrc = avatarUrl(c) || c.slika_url; + const photo = photoSrc ? '' : '
'+initials+'
'; const hooCat = c.hoo_kategorija || c.kategorija_hoo; + const smallAv = avatarHTML(c, 32); return `
${photo}
-
${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}
+
${smallAv}
${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}
${txt(c.sport,'—')} · ${txt(c.pozicija,'')}
-
${txt(c.klub_naziv_godisnjak,'')}
+
${txt(c.klub_naziv_godisnjak||c.klub_naziv,'')}
${c.reprezentativac?'REPR':''} ${hooCat?'HOO '+esc(hooCat)+'':''} @@ -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 = '
Učitavanje manifestacija…
'; - const d = await api('/manifestacije-full'); - if(!d){ root.innerHTML='
Greška pri dohvatu
'; 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 = '
Učitavanje…
'; + 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 = '
Greška pri dohvatu
'; + 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=>'').join(''); root.innerHTML = `
- - + + + + +
- - + +
`; - $('#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 '
Nema rezultata
'; - return '
'+rows.map(m => ` + if(!rows.length) return '
Nema manifestacija za zadane filtere
'; + return '
'+rows.map(m => { + const url = manifLinkFor(m); + const linkIcon = '🔗'; + return `
${m.razina?'
'+esc(m.razina)+'
':''} -
${esc(m.naziv)}
-
${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}
+
${esc(m.naziv)} ${linkIcon}
+
${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}${m.godina_od?' · od '+esc(m.godina_od):''}
${m.broj_ucesnika?''+esc(m.broj_ucesnika)+' sudionika':''} ${m.organizator?''+esc((m.organizator||'').slice(0,40))+'':''}
-
`).join('')+'
'; +
`; + }).join('')+'
'; } function renderManifTable(rows){ - if(!rows.length) return '
Nema rezultata
'; + if(!rows.length) return '
Nema manifestacija za zadane filtere
'; return `
${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','razina','Razina','')}${sortHeader('manifestacije','organizator','Organizator','')}${sortHeader('manifestacije','broj_ucesnika','Sudionici','')} - ${rows.map(m => ` + ${rows.map(m => { + const url = manifLinkFor(m); + return ` - - `).join('')} + + `; + }).join('')}
Link
${esc(m.naziv)} ${txt(m.mjesto)} ${m.razina?''+esc(m.razina)+'':'—'} ${txt(m.organizator)} ${txt(m.broj_ucesnika)}${m.source_url?'':'—'}
🔗
`; } function openManif(id){ @@ -3787,5 +3914,6 @@ window.closePanel = function(){ if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); } }; +