From 9b0ed43b92fb2c96a28ddff34745446fea9bd7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 18:33:20 +0200 Subject: [PATCH] =?UTF-8?q?RUSH=204-sub:=20filteri=20Klubovi/Sporta=C5=A1i?= =?UTF-8?q?=20+=20manifestacije=20card=20view=20+=20CRM=20v2=20redesign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUSH-1 Klubovi: list_klubovi() LEFT JOIN v_klubovi_financiranje (prima_pgz/rss/grad_rijeka, u_godisnjaku, ukupno_potpora). financiran=true sad OR od 3 davatelja (drop legacy klubovi.pgz_sufinanciran s 1312 false-positive). Sort sort=potpora&order=desc. UI: gold ukupno_potpora + tooltip + sortable kolona. Defaults priority view (financirani+godiΕ‘njak ON, hns_roster OFF). Test: priority=604, +hns=36, all=1641, financiran=15 sorted ZAMET 80208€. RUSH-2 SportaΕ‘i: SELECT widened (slika_url, reprezentativac, kategoriziran, broj_dresa). avatarUrl() helper s 3 forme (apsolutni / lokalni /sport/uploads/avatars / initials fallback) + 32px circular avatar lijevo od imena. Test: priority=3712, no-priority=6086, +hns=1439, 1990-2000=645. RUSH-3 Manifestacije: bugfix razina filter HTTP 500 (ambiguous column nakon LEFT JOIN savezi β†’ m.razina/mjesto/organizator). 3 dropdowna iz meta (26 mjesta / 8 razina / 50 organizatora), view toggle πŸƒ Kartice / πŸ“‹ Tablica (localStorage), πŸ”— link ikona u card+table, source_url β†’ Google fallback. Test: default=3, mjesto=LoΕ‘inj=2, razina=Tradicionalna=3, organizator=AK Kvarner=1. RUSH-4 CRM v2: tab strip rewrite (10 taba u spec redu Članarine|Liječnički|Obrasci|E-mail|Accounts|Contacts|Leads|Opps|Activities|Cases, sticky+scrollable+gold underline). Pipeline β†’ Opps tab. Novi e-mail templates tab (5 endpointa, 3 seed templates, +Novi modal). Card layout (.cgrid/.ccard) za Accounts/Contacts/Leads/Opps. Export dropdown πŸ“₯ β–Ύ CSV/XLSX(SheetJS CDN)/PDF na svaki tab. Test: /crm_v2 200, 10/10 tab labela, 10 Export dropdowna + 31 exportTab() handlera. Co-Authored-By: Claude Opus 4.7 (1M context) --- pgz_sport_api.py | 8 + routers/export_router.py | 297 +++++++++++++++++ scripts/hns_avatar_harvester.py | 64 ++++ static/app.html | 10 +- static/crm_v2.html | 544 ++++++++++++++++++++++++++------ static/erp_full.html | 46 +++ static/js/export_dropdown.js | 181 +++++++++++ static/sport2.html | 188 ++++++++--- 8 files changed, 1203 insertions(+), 135 deletions(-) create mode 100644 routers/export_router.py create mode 100644 scripts/hns_avatar_harvester.py create mode 100644 static/js/export_dropdown.js diff --git a/pgz_sport_api.py b/pgz_sport_api.py index 9fb1360..49e9b1c 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -1864,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(): 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/scripts/hns_avatar_harvester.py b/scripts/hns_avatar_harvester.py new file mode 100644 index 0000000..ffa84c0 --- /dev/null +++ b/scripts/hns_avatar_harvester.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# Fajl: hns_avatar_harvester.py | v1.0 | 05.05.2026 +# Author: Damir RaduliΔ‡ +# Lokacija: /opt/pgz-sport/scripts/hns_avatar_harvester.py +# Svrha: Dohvati avatar URL za svakog igrača sa HNS profila +import os, time, re, json, sys +import psycopg2 +import requests +from bs4 import BeautifulSoup + +DSN = os.environ.get("RINET_DSN", "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7") +HEADERS = {"User-Agent": "Mozilla/5.0 (Ri.NET PGΕ½ Sport Bot)"} + +conn = psycopg2.connect(DSN); conn.autocommit = True + +def fetch_avatar(hns_id, slug=""): + url = f"https://semafor.hns.family/igraci/{hns_id}/" + if slug: url += f"{slug}/" + try: + r = requests.get(url, headers=HEADERS, timeout=15) + if r.status_code != 200: return None + soup = BeautifulSoup(r.text, "html.parser") + # Player photo selectors + for sel in [".playerPhoto img", ".player-photo img", ".playerHeader img", "img.player_photo"]: + img = soup.select_one(sel) + if img and img.get("src"): + src = img["src"] + if src.startswith("/"): src = "https://hns.family" + src + return src + # Generic: first img inside header + hdr = soup.select_one(".playerHeader, .player-header, .basic_info") + if hdr: + img = hdr.find("img") + if img and img.get("src"): + src = img["src"] + if src.startswith("/"): src = "https://hns.family" + src + return src + return None + except Exception as e: + return None + +with conn.cursor() as cur: + cur.execute(""" + SELECT id, hns_igrac_id, ime, prezime + FROM pgz_sport.clanovi + WHERE hns_igrac_id IS NOT NULL AND foto_url IS NULL + LIMIT 200 + """) + rows = cur.fetchall() + +print(f"Total: {len(rows)} igrača za avatar fetch") +hits = 0 +for i, (cid, hns_id, ime, prezime) in enumerate(rows): + slug = f"{ime}-{prezime}".lower().replace("Δ‡","c").replace("č","c").replace("Ε‘","s").replace("ΕΎ","z").replace("Δ‘","d").replace(" ","-") + slug = re.sub(r"[^a-z0-9-]", "", slug) + avatar = fetch_avatar(hns_id, slug) + if avatar: + with conn.cursor() as cur: + cur.execute("UPDATE pgz_sport.clanovi SET foto_url=%s WHERE id=%s", (avatar, cid)) + hits += 1 + if i % 10 == 0: print(f" [{i+1}/{len(rows)}] {ime} {prezime} β†’ {avatar[:80]}") + time.sleep(0.5) + +print(f"\nDONE: {hits}/{len(rows)} avatar URL-ova spremljen") 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 b68a9b7..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 ────── @@ -734,21 +899,43 @@ async function loadAccounts() { if (t) qs.set('type', t); try { const data = await api('/accounts?'+qs.toString()); + const items = data.items||[]; + // Card grid (primary) + const grid = document.getElementById('acc-cards'); + if (grid) { + grid.innerHTML = items.map(a => ` +
+
+ +
+
${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'); } } @@ -823,19 +1010,33 @@ async function loadContacts() { if (aid) qs.set('account_id', aid); try { const data = await api('/contacts?'+qs.toString()); + const items = data.items||[]; + const grid = document.getElementById('con-cards'); + if (grid) { + grid.innerHTML = items.map(c => ` +
+
+ +
+
${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={}) { @@ -903,22 +1104,35 @@ async function loadLeads() { if (s) qs.set('status', s); try { const data = await api('/leads?'+qs.toString()); + const items = data.items||[]; + const grid = document.getElementById('lead-cards'); + if (grid) { + grid.innerHTML = items.map(l => ` +
+
+ ${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={}) { @@ -1029,20 +1243,36 @@ async function loadOpps() { if (s) qs.set('stage', s); try { const data = await api('/opportunities?'+qs.toString()); + const items = data.items||[]; + const grid = document.getElementById('opp-cards'); + if (grid) { + grid.innerHTML = items.map(o => ` +
+
+ +
+
${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={}) { @@ -1131,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'); } } @@ -1217,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'); } } @@ -1323,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'); } } @@ -1424,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'); } } @@ -1642,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'); } } @@ -1712,6 +1947,120 @@ async function subStatus(id, status) { } catch (e) { toast('GreΕ‘ka: '+e.message, 'err'); } } +// ══════════════════════════════════════════════════════════════════ +// RUSH-4 β€” E-mail templates (CRM v2 GUI redesign, 2026-05-05) +// dradulic@outlook.com / damir@rinet.one +// Endpoint: /api/v2/crm/email-templates (CRUD) +// ══════════════════════════════════════════════════════════════════ +let EMAIL_TPLS = []; + +async function loadEmailTpls() { + const q = (document.getElementById('etpl-q')?.value || '').trim().toLowerCase(); + const cat = document.getElementById('etpl-cat')?.value || ''; + const qs = new URLSearchParams(); + qs.set('active_only', 'false'); + if (cat) qs.set('kategorija', cat); + try { + const data = await api('/email-templates?'+qs.toString()); + EMAIL_TPLS = (data.items||[]).filter(t => { + if (!q) return true; + return (t.code||'').toLowerCase().includes(q) + || (t.naziv||'').toLowerCase().includes(q); + }); + const grid = document.getElementById('etpl-grid'); + grid.innerHTML = EMAIL_TPLS.map(t => ` +
+
${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; @@ -1731,7 +2080,26 @@ document.getElementById('modal').addEventListener('click', e => { // ────── Init ────── loadMe(); ensureMe(); -loadPipeline(); +loadPipeline(); // populates KPI counters + kanban (kanban only visible in Opportunities tab now) +loadClanarine(); // default active tab + +// ── Universal Export β–Ύ β€” server-side fallback for tabs that load full +// record sets from REST (lijecnicki/obrasci). The existing exportTab() +// flow above keeps working for client-side cached tabs (accounts, +// contacts, leads, opps). attachExportDropdown is a no-op when +// export_dropdown.js fails to load. +document.addEventListener('DOMContentLoaded', function(){ + if (!window.attachExportDropdown) return; + const lij = document.getElementById('lij-srv-export-btn'); + if (lij) window.attachExportDropdown(lij, function(){ + const klub = document.getElementById('lij-klub'); const clan = document.getElementById('lij-clan'); + const qp = new URLSearchParams(); qp.set('limit','2000'); + if (klub && klub.value) qp.set('klub_id', klub.value); + if (clan && clan.value) qp.set('clan_id', clan.value); + return '/sport/api/v2/lijecnicki?'+qp.toString(); + }, 'lijecnicki'); +}); + 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 378953d..e43a791 100644 --- a/static/sport2.html +++ b/static/sport2.html @@ -1409,6 +1409,7 @@ function renderSaveziShell(){
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''} +
`; @@ -1416,6 +1417,20 @@ function renderSaveziShell(){ $('#sav-sport').addEventListener('change', applySaveziFilter); $('#sav-kat').addEventListener('change', applySaveziFilter); $('#sav-pgz').addEventListener('change', applySaveziFilter); + // Export β–Ύ β€” uses same /v2/savezi/priority-sort URL as the table loader. + if (window.attachExportDropdown) { + window.attachExportDropdown( + document.getElementById('sav-export-btn'), + function(){ + const f = _filters.savezi || {}; + const useOnly = f.financirani || window._pgz_filter_priority; + return '/sport/api' + (useOnly + ? '/v2/savezi/priority-sort?only=true&limit=500' + : '/v2/savezi/priority-sort?only=false&limit=500'); + }, + 'savezi' + ); + } } function setSaveziView(v){ _state.viewSavezi = v; @@ -1535,13 +1550,18 @@ async function loadKlubovi(){ const root = $('#pg-klubovi'); if(!_cache.klubovi){ root.innerHTML = '
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){ @@ -1581,11 +1601,10 @@ function renderKluboviShell(){
- - ${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''} +
`; @@ -1594,6 +1613,25 @@ function renderKluboviShell(){ $('#kl-grad').addEventListener('change', applyKluboviFilter); $('#kl-kat').addEventListener('change', applyKluboviFilter); $('#kl-nk').addEventListener('change', applyKluboviFilter); + // Export β–Ύ β€” rebuilds the same querystring that loadKlubovi uses. + if (window.attachExportDropdown) { + window.attachExportDropdown( + document.getElementById('kl-export-btn'), + function(){ + const f = _filters.klubovi || {}; + const qs = new URLSearchParams(); + qs.set('limit','2500'); + qs.set('sort','potpora'); qs.set('order','desc'); + if(f.financirani && f.godisnjak) qs.set('kategorija','priority'); + else if(f.financirani) qs.set('financiran','true'); + else if(f.godisnjak) qs.set('godisnjak','true'); + if(f.hns_roster) qs.set('samo_hns_roster','true'); + if(window._pgz_filter_priority && !qs.has('kategorija')) qs.set('kategorija','priority'); + return '/sport/api/klubovi?'+qs.toString(); + }, + 'klubovi' + ); + } } function setKluboviView(v){ _state.viewKlubovi = v; @@ -1634,25 +1672,31 @@ function applyKluboviFilter(){ } function renderKluboviGrid(rows){ if(!rows.length) return '
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 ` @@ -1660,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'}
`; } @@ -2698,77 +2743,131 @@ function openObjekt(id){ } //=========== MANIFESTACIJE =========== +// View mode persisted in localStorage as `_manifViewMode` ('card'|'table') +const _manifFilter = {mjesto:'', razina:'', organizator:'', q:''}; +let _manifMeta = null; +let _manifLoadSeq = 0; + async function loadManifestacije(){ const root = $('#pg-manifestacije'); - if(!_cache.manifestacije){ + // Restore view mode from localStorage + const saved = localStorage.getItem('_manifViewMode'); + if(saved==='card' || saved==='table') _state.viewManif = saved; + if(!_manifMeta){ root.innerHTML = '
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){ @@ -3815,5 +3914,6 @@ window.closePanel = function(){ if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); } }; +