RUSH 4-sub: filteri Klubovi/Sportaši + manifestacije card view + CRM v2 redesign
RUSH-1 Klubovi: list_klubovi() LEFT JOIN v_klubovi_financiranje (prima_pgz/rss/grad_rijeka, u_godisnjaku, ukupno_potpora). financiran=true sad OR od 3 davatelja (drop legacy klubovi.pgz_sufinanciran s 1312 false-positive). Sort sort=potpora&order=desc. UI: gold ukupno_potpora + tooltip + sortable kolona. Defaults priority view (financirani+godišnjak ON, hns_roster OFF). Test: priority=604, +hns=36, all=1641, financiran=15 sorted ZAMET 80208€. RUSH-2 Sportaši: SELECT widened (slika_url, reprezentativac, kategoriziran, broj_dresa). avatarUrl() helper s 3 forme (apsolutni / lokalni /sport/uploads/avatars / initials fallback) + 32px circular avatar lijevo od imena. Test: priority=3712, no-priority=6086, +hns=1439, 1990-2000=645. RUSH-3 Manifestacije: bugfix razina filter HTTP 500 (ambiguous column nakon LEFT JOIN savezi → m.razina/mjesto/organizator). 3 dropdowna iz meta (26 mjesta / 8 razina / 50 organizatora), view toggle 🃏 Kartice / 📋 Tablica (localStorage), 🔗 link ikona u card+table, source_url → Google fallback. Test: default=3, mjesto=Lošinj=2, razina=Tradicionalna=3, organizator=AK Kvarner=1. RUSH-4 CRM v2: tab strip rewrite (10 taba u spec redu Članarine|Liječnički|Obrasci|E-mail|Accounts|Contacts|Leads|Opps|Activities|Cases, sticky+scrollable+gold underline). Pipeline → Opps tab. Novi e-mail templates tab (5 endpointa, 3 seed templates, +Novi modal). Card layout (.cgrid/.ccard) za Accounts/Contacts/Leads/Opps. Export dropdown 📥 ▾ CSV/XLSX(SheetJS CDN)/PDF na svaki tab. Test: /crm_v2 200, 10/10 tab labela, 10 Export dropdowna + 31 exportTab() handlera. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
#!/usr/bin/env python3
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Fajl: routers/export_router.py | v1.0.0 | 05.05.2026
|
||||
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||
# Lokacija: /opt/pgz-sport/routers/export_router.py
|
||||
# Svrha: Univerzalni Export modul (CSV / XLSX / PDF) za sve tablice u PGŽ Sport
|
||||
# ERP/CRM. Endpoint /api/v2/export?format=...&endpoint=...&filters=...
|
||||
# proxy-aporta unutarnji JSON API i konvertira odgovor u traženi
|
||||
# format. CSV (HR Excel friendly: ; delimiter, UTF-8 BOM), XLSX preko
|
||||
# openpyxla (s graceful fallback na CSV ako openpyxl nije dostupan),
|
||||
# PDF preko HTML print-to-PDF (mock — TODO: weasyprint/reportlab).
|
||||
# Mount: /api/v2/export/*
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import unquote
|
||||
|
||||
import requests
|
||||
from fastapi import APIRouter, Header, HTTPException, Query
|
||||
from fastapi.responses import HTMLResponse, Response, StreamingResponse
|
||||
|
||||
# openpyxl is optional — fall back to CSV if missing.
|
||||
try:
|
||||
from openpyxl import Workbook # type: ignore
|
||||
from openpyxl.styles import Alignment, Font, PatternFill # type: ignore
|
||||
|
||||
OPENPYXL_AVAILABLE = True
|
||||
except Exception: # pragma: no cover
|
||||
OPENPYXL_AVAILABLE = False
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v2/export", tags=["export"])
|
||||
|
||||
INTERNAL_BASE = "http://127.0.0.1:8095"
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Utilities
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
def _ts() -> str:
|
||||
return datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
|
||||
def _flatten_value(v: Any) -> str:
|
||||
"""Normalize a single cell value to a flat string."""
|
||||
if v is None:
|
||||
return ""
|
||||
if isinstance(v, (dict, list, tuple)):
|
||||
try:
|
||||
return json.dumps(v, ensure_ascii=False, default=str)
|
||||
except Exception:
|
||||
return str(v)
|
||||
if isinstance(v, bool):
|
||||
return "true" if v else "false"
|
||||
return str(v)
|
||||
|
||||
|
||||
def _coerce_rows(payload: Any) -> List[Dict[str, Any]]:
|
||||
"""Accept many common JSON shapes and return a list of dicts."""
|
||||
if payload is None:
|
||||
return []
|
||||
# Top-level list
|
||||
if isinstance(payload, list):
|
||||
return [r for r in payload if isinstance(r, dict)]
|
||||
if isinstance(payload, dict):
|
||||
# {rows:[...]} or {data:[...]} or {items:[...]} or {results:[...]}
|
||||
for key in ("rows", "data", "items", "results", "list"):
|
||||
v = payload.get(key)
|
||||
if isinstance(v, list):
|
||||
return [r for r in v if isinstance(r, dict)]
|
||||
# {count, rows}
|
||||
if "rows" in payload and isinstance(payload["rows"], list):
|
||||
return [r for r in payload["rows"] if isinstance(r, dict)]
|
||||
# Fallback: a single record
|
||||
return [payload]
|
||||
return []
|
||||
|
||||
|
||||
def _ordered_columns(rows: List[Dict[str, Any]]) -> List[str]:
|
||||
"""Use first row's keys as primary column order, then append any
|
||||
extra keys discovered later (preserves order, no duplicates)."""
|
||||
seen: List[str] = []
|
||||
seen_set = set()
|
||||
if rows:
|
||||
for k in rows[0].keys():
|
||||
if k not in seen_set:
|
||||
seen.append(k)
|
||||
seen_set.add(k)
|
||||
for r in rows[1:]:
|
||||
for k in r.keys():
|
||||
if k not in seen_set:
|
||||
seen.append(k)
|
||||
seen_set.add(k)
|
||||
return seen
|
||||
|
||||
|
||||
def _fetch_internal(endpoint: str, authorization: Optional[str]) -> Any:
|
||||
"""Call the local FastAPI server with the user's Authorization header
|
||||
forwarded so existing auth/permissions are respected."""
|
||||
if not endpoint:
|
||||
raise HTTPException(status_code=400, detail="endpoint required")
|
||||
# Normalize: must start with / — accept full URL only if it points at us.
|
||||
ep = unquote(endpoint).strip()
|
||||
if ep.startswith(("http://", "https://")):
|
||||
# Only allow our own host to avoid SSRF.
|
||||
if not ep.startswith(INTERNAL_BASE):
|
||||
raise HTTPException(status_code=400, detail="external endpoint not allowed")
|
||||
url = ep
|
||||
else:
|
||||
if not ep.startswith("/"):
|
||||
ep = "/" + ep
|
||||
url = INTERNAL_BASE + ep
|
||||
|
||||
headers: Dict[str, str] = {"Accept": "application/json"}
|
||||
if authorization:
|
||||
headers["Authorization"] = authorization
|
||||
try:
|
||||
r = requests.get(url, headers=headers, timeout=60)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=502, detail=f"upstream fetch failed: {e}")
|
||||
if r.status_code >= 400:
|
||||
raise HTTPException(
|
||||
status_code=r.status_code,
|
||||
detail=f"upstream {r.status_code}: {r.text[:200]}",
|
||||
)
|
||||
try:
|
||||
return r.json()
|
||||
except Exception:
|
||||
raise HTTPException(status_code=502, detail="upstream did not return JSON")
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Format builders
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
def _build_csv(rows: List[Dict[str, Any]], filename: str) -> Response:
|
||||
cols = _ordered_columns(rows)
|
||||
buf = io.StringIO()
|
||||
# HR Excel friendly: ; delimiter
|
||||
w = csv.writer(buf, delimiter=";", quoting=csv.QUOTE_MINIMAL, lineterminator="\r\n")
|
||||
w.writerow(cols)
|
||||
for r in rows:
|
||||
w.writerow([_flatten_value(r.get(c)) for c in cols])
|
||||
body = ("" + buf.getvalue()).encode("utf-8") # UTF-8 BOM
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _build_xlsx(rows: List[Dict[str, Any]], filename: str) -> Response:
|
||||
if not OPENPYXL_AVAILABLE:
|
||||
# Graceful fallback: CSV with a warning header.
|
||||
fallback = filename.rsplit(".", 1)[0] + ".csv"
|
||||
resp = _build_csv(rows, fallback)
|
||||
resp.headers["X-Export-Warning"] = "openpyxl unavailable — fell back to CSV"
|
||||
return resp
|
||||
cols = _ordered_columns(rows)
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Export"
|
||||
# Header row (bold)
|
||||
bold = Font(bold=True, color="FFFFFFFF")
|
||||
fill = PatternFill(start_color="FF1F2937", end_color="FF1F2937", fill_type="solid")
|
||||
align = Alignment(vertical="center", wrap_text=False)
|
||||
ws.append(cols)
|
||||
for cell in ws[1]:
|
||||
cell.font = bold
|
||||
cell.fill = fill
|
||||
cell.alignment = align
|
||||
for r in rows:
|
||||
ws.append([_flatten_value(r.get(c)) for c in cols])
|
||||
# Auto column widths (clamped)
|
||||
for idx, col in enumerate(cols, start=1):
|
||||
max_len = max(
|
||||
[len(col)]
|
||||
+ [len(_flatten_value(r.get(col))) for r in rows[:200]]
|
||||
+ [10]
|
||||
)
|
||||
ws.column_dimensions[ws.cell(row=1, column=idx).column_letter].width = min(
|
||||
max_len + 2, 60
|
||||
)
|
||||
bio = io.BytesIO()
|
||||
wb.save(bio)
|
||||
bio.seek(0)
|
||||
return StreamingResponse(
|
||||
bio,
|
||||
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
def _build_pdf_html(rows: List[Dict[str, Any]], title: str) -> HTMLResponse:
|
||||
"""Return a print-friendly HTML page (mock PDF). User invokes
|
||||
browser's Print → Save as PDF.
|
||||
|
||||
<!-- TODO: real PDF via weasyprint/reportlab -->
|
||||
"""
|
||||
cols = _ordered_columns(rows)
|
||||
|
||||
def _esc(s: Any) -> str:
|
||||
return (
|
||||
_flatten_value(s)
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace('"', """)
|
||||
)
|
||||
|
||||
head = "".join(f"<th>{_esc(c)}</th>" for c in cols)
|
||||
body_rows = "".join(
|
||||
"<tr>" + "".join(f"<td>{_esc(r.get(c))}</td>" for c in cols) + "</tr>"
|
||||
for r in rows
|
||||
)
|
||||
when = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
html = f"""<!doctype html>
|
||||
<html lang="hr"><head><meta charset="utf-8">
|
||||
<title>{_esc(title)}</title>
|
||||
<style>
|
||||
body{{font-family:Arial,Helvetica,sans-serif;color:#111;margin:18px}}
|
||||
h1{{font-size:16px;margin:0 0 8px}}
|
||||
.meta{{font-size:11px;color:#555;margin-bottom:14px}}
|
||||
table{{width:100%;border-collapse:collapse;font-size:10px}}
|
||||
th,td{{border:1px solid #999;padding:4px 6px;text-align:left;vertical-align:top}}
|
||||
th{{background:#1f2937;color:#fff;font-size:9px;text-transform:uppercase;letter-spacing:.5px}}
|
||||
tr:nth-child(even) td{{background:#f5f5f5}}
|
||||
.bar{{margin-bottom:14px}}
|
||||
.bar button{{background:#0b5cff;color:#fff;border:0;padding:8px 14px;border-radius:4px;cursor:pointer;font-size:12px}}
|
||||
@media print{{ .bar{{display:none}} body{{margin:6mm}} }}
|
||||
</style>
|
||||
</head><body>
|
||||
<div class="bar"><button onclick="window.print()">🖨 Print / Save as PDF</button></div>
|
||||
<h1>{_esc(title)}</h1>
|
||||
<div class="meta">PGŽ Sport ERP/CRM — generirano {when} — {len(rows)} redaka</div>
|
||||
<table><thead><tr>{head}</tr></thead><tbody>{body_rows}</tbody></table>
|
||||
<!-- TODO: real PDF via weasyprint/reportlab -->
|
||||
</body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
# Endpoints
|
||||
# ───────────────────────────────────────────────────────────────────────────
|
||||
@router.get("/health")
|
||||
def export_health():
|
||||
return {
|
||||
"ok": True,
|
||||
"openpyxl_available": OPENPYXL_AVAILABLE,
|
||||
"formats": ["csv", "xlsx", "pdf"],
|
||||
"version": "1.0.0",
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
@router.get("/")
|
||||
def export_dispatch(
|
||||
format: str = Query("csv", description="csv|xlsx|pdf"),
|
||||
endpoint: str = Query(..., description="Internal API path, e.g. /api/v2/erp/payments?godina=2026"),
|
||||
filters: Optional[str] = Query(None, description="Optional JSON of extra filters merged into endpoint"),
|
||||
filename: Optional[str] = Query(None, description="Optional filename base (no extension)"),
|
||||
authorization: Optional[str] = Header(None),
|
||||
):
|
||||
fmt = (format or "csv").lower().strip()
|
||||
if fmt not in ("csv", "xlsx", "pdf"):
|
||||
raise HTTPException(status_code=400, detail="format must be csv|xlsx|pdf")
|
||||
|
||||
# Optionally merge `filters` JSON into endpoint querystring.
|
||||
target = endpoint
|
||||
if filters:
|
||||
try:
|
||||
extra = json.loads(filters)
|
||||
if isinstance(extra, dict) and extra:
|
||||
from urllib.parse import urlencode
|
||||
|
||||
qs = urlencode({k: _flatten_value(v) for k, v in extra.items()})
|
||||
sep = "&" if "?" in target else "?"
|
||||
target = f"{target}{sep}{qs}"
|
||||
except Exception:
|
||||
# Ignore malformed filter JSON — still try the raw endpoint.
|
||||
pass
|
||||
|
||||
payload = _fetch_internal(target, authorization)
|
||||
rows = _coerce_rows(payload)
|
||||
|
||||
base = filename or "export"
|
||||
stamp = _ts()
|
||||
if fmt == "csv":
|
||||
return _build_csv(rows, f"{base}_{stamp}.csv")
|
||||
if fmt == "xlsx":
|
||||
return _build_xlsx(rows, f"{base}_{stamp}.xlsx")
|
||||
# pdf (HTML print-to-PDF mock)
|
||||
return _build_pdf_html(rows, title=f"{base} — {stamp}")
|
||||
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env python3
|
||||
# Fajl: hns_avatar_harvester.py | v1.0 | 05.05.2026
|
||||
# Author: Damir Radulić
|
||||
# Lokacija: /opt/pgz-sport/scripts/hns_avatar_harvester.py
|
||||
# Svrha: Dohvati avatar URL za svakog igrača sa HNS profila
|
||||
import os, time, re, json, sys
|
||||
import psycopg2
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
DSN = os.environ.get("RINET_DSN", "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7")
|
||||
HEADERS = {"User-Agent": "Mozilla/5.0 (Ri.NET PGŽ Sport Bot)"}
|
||||
|
||||
conn = psycopg2.connect(DSN); conn.autocommit = True
|
||||
|
||||
def fetch_avatar(hns_id, slug=""):
|
||||
url = f"https://semafor.hns.family/igraci/{hns_id}/"
|
||||
if slug: url += f"{slug}/"
|
||||
try:
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
if r.status_code != 200: return None
|
||||
soup = BeautifulSoup(r.text, "html.parser")
|
||||
# Player photo selectors
|
||||
for sel in [".playerPhoto img", ".player-photo img", ".playerHeader img", "img.player_photo"]:
|
||||
img = soup.select_one(sel)
|
||||
if img and img.get("src"):
|
||||
src = img["src"]
|
||||
if src.startswith("/"): src = "https://hns.family" + src
|
||||
return src
|
||||
# Generic: first img inside header
|
||||
hdr = soup.select_one(".playerHeader, .player-header, .basic_info")
|
||||
if hdr:
|
||||
img = hdr.find("img")
|
||||
if img and img.get("src"):
|
||||
src = img["src"]
|
||||
if src.startswith("/"): src = "https://hns.family" + src
|
||||
return src
|
||||
return None
|
||||
except Exception as e:
|
||||
return None
|
||||
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT id, hns_igrac_id, ime, prezime
|
||||
FROM pgz_sport.clanovi
|
||||
WHERE hns_igrac_id IS NOT NULL AND foto_url IS NULL
|
||||
LIMIT 200
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
|
||||
print(f"Total: {len(rows)} igrača za avatar fetch")
|
||||
hits = 0
|
||||
for i, (cid, hns_id, ime, prezime) in enumerate(rows):
|
||||
slug = f"{ime}-{prezime}".lower().replace("ć","c").replace("č","c").replace("š","s").replace("ž","z").replace("đ","d").replace(" ","-")
|
||||
slug = re.sub(r"[^a-z0-9-]", "", slug)
|
||||
avatar = fetch_avatar(hns_id, slug)
|
||||
if avatar:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("UPDATE pgz_sport.clanovi SET foto_url=%s WHERE id=%s", (avatar, cid))
|
||||
hits += 1
|
||||
if i % 10 == 0: print(f" [{i+1}/{len(rows)}] {ime} {prezime} → {avatar[:80]}")
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f"\nDONE: {hits}/{len(rows)} avatar URL-ova spremljen")
|
||||
+7
-3
@@ -1261,7 +1261,8 @@ SECTIONS['pgz:savezi'] = async () => {
|
||||
<td><button class="btn sm" onclick="event.stopPropagation();showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">Detalji</button></td>
|
||||
</tr>`).join('');
|
||||
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
|
||||
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})${onPGZ?' · ⭐ samo PGŽ-relevantni':''}</div></div>
|
||||
setTimeout(()=>{ const b=document.getElementById('app-exp-savezi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'savezi'); },0);
|
||||
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})${onPGZ?' · ⭐ samo PGŽ-relevantni':''}</div><button id="app-exp-savezi" class="export-btn" type="button">Export ▾</button></div>
|
||||
${tb}
|
||||
<table><thead><tr><th>Naziv</th><th class="num">Klubovi</th><th class="num">Sportaši</th><th>Predsjednik</th><th></th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">Učitavam...</td></tr>'}</tbody></table>
|
||||
</div>`;
|
||||
@@ -1281,7 +1282,8 @@ SECTIONS['pgz:klubovi'] = async () => {
|
||||
<td>${esc(k.predsjednik||'—')}</td>
|
||||
</tr>`).join('');
|
||||
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
|
||||
return `<div class="card"><div class="card-h"><div class="card-t">⬢ Klubovi (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div></div>
|
||||
setTimeout(()=>{ const b=document.getElementById('app-exp-klubovi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'klubovi'); },0);
|
||||
return `<div class="card"><div class="card-h"><div class="card-t">⬢ Klubovi (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div><button id="app-exp-klubovi" class="export-btn" type="button">Export ▾</button></div>
|
||||
${tb}
|
||||
<table><thead><tr><th>Naziv</th><th>Savez</th><th>Grad</th><th class="num">Članova</th><th>Predsjednik</th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">—</td></tr>'}</tbody></table>
|
||||
</div>`;
|
||||
@@ -1301,7 +1303,8 @@ SECTIONS['pgz:sportasi'] = async () => {
|
||||
<td>${esc(c.datum_rodjenja||c.datum_rodenja||'—')}</td>
|
||||
</tr>`).join('');
|
||||
const tb = window.renderPGZToggleBtn ? window.renderPGZToggleBtn() : '';
|
||||
return `<div class="card"><div class="card-h"><div class="card-t">👤 Sportaši (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div></div>
|
||||
setTimeout(()=>{ const b=document.getElementById('app-exp-sportasi'); if(b && window.attachExportDropdown) window.attachExportDropdown(b, ()=>'/sport/api'+url, 'sportasi'); },0);
|
||||
return `<div class="card"><div class="card-h"><div class="card-t">👤 Sportaši (${d.count||0})${onPGZ?' · ⭐ samo PGŽ-prioritet':''}</div><button id="app-exp-sportasi" class="export-btn" type="button">Export ▾</button></div>
|
||||
${tb}
|
||||
<table><thead><tr><th>Ime i prezime</th><th>Klub</th><th>Kategorija</th><th>Spol</th><th>Rođen</th></tr></thead><tbody>${rows||'<tr><td colspan=5 class="empty">—</td></tr>'}</tbody></table>
|
||||
</div>`;
|
||||
@@ -2493,5 +2496,6 @@ window.renderPGZToggleBtn = function(){
|
||||
+ (on ? '⭐ PGŽ filter ON' : '☆ PGŽ filter OFF') + '</button>';
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/export_dropdown.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+439
-71
@@ -10,6 +10,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>PGŽ Sport — CRM v2 (Salesforce-Lite)</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a; --pgz-gold:#fbbf24;
|
||||
@@ -234,7 +235,7 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
<div class="topbar">
|
||||
<span class="logo">PGŽ SPORT</span>
|
||||
<span class="sep">›</span>
|
||||
<span class="title">CRM v2 — Salesforce-Lite (Pipeline)</span>
|
||||
<span class="title">CRM v2 — Salesforce-Lite</span>
|
||||
<div class="right">
|
||||
<span id="me">…</span>
|
||||
<a href="/sport/platform">Platform</a>
|
||||
@@ -245,40 +246,21 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="pipeline">Pipeline</div>
|
||||
<div class="tab active" data-tab="clanarine">Članarine <span class="count" id="cnt-clanarine">·</span></div>
|
||||
<div class="tab" data-tab="lijecnicki">Liječnički <span class="count" id="cnt-lijecnicki">·</span></div>
|
||||
<div class="tab" data-tab="obrasci">Obrasci <span class="count" id="cnt-obrasci">·</span></div>
|
||||
<div class="tab" data-tab="emailtpl">E-mail templates <span class="count" id="cnt-emailtpl">·</span></div>
|
||||
<div class="tab" data-tab="accounts">Accounts <span class="count" id="cnt-accounts">·</span></div>
|
||||
<div class="tab" data-tab="contacts">Contacts <span class="count" id="cnt-contacts">·</span></div>
|
||||
<div class="tab" data-tab="leads">Leads <span class="count" id="cnt-leads">·</span></div>
|
||||
<div class="tab" data-tab="opportunities">Opportunities <span class="count" id="cnt-opps">·</span></div>
|
||||
<div class="tab" data-tab="activities">Activities <span class="count" id="cnt-activities">·</span></div>
|
||||
<div class="tab" data-tab="cases">Cases <span class="count" id="cnt-cases">·</span></div>
|
||||
<div class="tab" data-tab="clanarine">Članarine <span class="count" id="cnt-clanarine">·</span></div>
|
||||
<div class="tab" data-tab="lijecnicki">Liječnički <span class="count" id="cnt-lijecnicki">·</span></div>
|
||||
<div class="tab" data-tab="obrasci">Obrasci <span class="count" id="cnt-obrasci">·</span></div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
|
||||
<!-- ────── PIPELINE ────── -->
|
||||
<div class="tab-c on" id="tc-pipeline">
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi b"><div class="kpi-l">Open opps</div><div class="kpi-v" id="k-opps">·</div><div class="kpi-s" id="k-opps-eur">·</div></div>
|
||||
<div class="kpi gold"><div class="kpi-l">Weighted total</div><div class="kpi-v" id="k-weighted">·</div><div class="kpi-s">prosjek vjerojatnosti</div></div>
|
||||
<div class="kpi g"><div class="kpi-l">Won this quarter</div><div class="kpi-v" id="k-won">·</div><div class="kpi-s" id="k-won-eur">·</div></div>
|
||||
<div class="kpi a"><div class="kpi-l">Leads (new+contacted)</div><div class="kpi-v" id="k-leads">·</div><div class="kpi-s">qualified: <span id="k-leads-q">·</span></div></div>
|
||||
<div class="kpi r"><div class="kpi-l">Overdue activities</div><div class="kpi-v" id="k-overdue">·</div><div class="kpi-s">upcoming: <span id="k-upcoming">·</span></div></div>
|
||||
<div class="kpi"><div class="kpi-l">Open cases</div><div class="kpi-v" id="k-cases">·</div><div class="kpi-s" id="k-cases-urgent">·</div></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<strong>Pipeline kanban</strong>
|
||||
<span class="grow"></span>
|
||||
<button class="btn primary sm" onclick="openOppModal()">+ Nova prilika</button>
|
||||
<button class="btn sm" onclick="loadPipeline()">↻ Refresh</button>
|
||||
</div>
|
||||
|
||||
<div class="kanban" id="kanban"></div>
|
||||
</div>
|
||||
<!-- ────── PIPELINE (legacy tab removed — KPIs + kanban now live in Opportunities tab) ────── -->
|
||||
|
||||
<!-- ────── ACCOUNTS ────── -->
|
||||
<div class="tab-c" id="tc-accounts">
|
||||
@@ -294,9 +276,17 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<button class="btn" onclick="loadAccounts()">Pretraži</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-accounts')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-accounts">
|
||||
<button onclick="exportTab('accounts','csv')">CSV</button>
|
||||
<button onclick="exportTab('accounts','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('accounts','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openAccountModal()">+ Novi account</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
<div id="acc-cards" class="cgrid"></div>
|
||||
<div class="card" style="display:none"><div class="card-b" style="padding:0">
|
||||
<table id="t-accounts"><thead><tr>
|
||||
<th>Naziv</th><th>Tip</th><th>Grad</th><th>OIB</th><th>Email</th>
|
||||
<th>Kontakti</th><th>Opps</th><th>Owner</th><th></th>
|
||||
@@ -311,9 +301,17 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
<input id="con-acc" type="number" placeholder="Account ID (filter)" style="max-width:170px">
|
||||
<button class="btn" onclick="loadContacts()">Pretraži</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-contacts')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-contacts">
|
||||
<button onclick="exportTab('contacts','csv')">CSV</button>
|
||||
<button onclick="exportTab('contacts','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('contacts','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openContactModal()">+ Novi kontakt</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
<div id="con-cards" class="cgrid"></div>
|
||||
<div class="card" style="display:none"><div class="card-b" style="padding:0">
|
||||
<table id="t-contacts"><thead><tr>
|
||||
<th>Ime</th><th>Prezime</th><th>Account</th><th>Funkcija</th>
|
||||
<th>Email</th><th>Telefon</th><th></th>
|
||||
@@ -335,9 +333,17 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<button class="btn" onclick="loadLeads()">Pretraži</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-leads')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-leads">
|
||||
<button onclick="exportTab('leads','csv')">CSV</button>
|
||||
<button onclick="exportTab('leads','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('leads','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openLeadModal()">+ Novi lead</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
<div id="lead-cards" class="cgrid"></div>
|
||||
<div class="card" style="display:none"><div class="card-b" style="padding:0">
|
||||
<table id="t-leads"><thead><tr>
|
||||
<th>Ime</th><th>Prezime</th><th>Organizacija</th><th>Email</th>
|
||||
<th>Izvor</th><th>Status</th><th></th>
|
||||
@@ -360,9 +366,34 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<button class="btn" onclick="loadOpps()">Pretraži</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-opps')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-opps">
|
||||
<button onclick="exportTab('opps','csv')">CSV</button>
|
||||
<button onclick="exportTab('opps','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('opps','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openOppModal()">+ Nova prilika</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
|
||||
<!-- KPIs + Pipeline kanban (migrirano iz Pipeline taba) -->
|
||||
<div class="kpi-grid">
|
||||
<div class="kpi b"><div class="kpi-l">Open opps</div><div class="kpi-v" id="k-opps">·</div><div class="kpi-s" id="k-opps-eur">·</div></div>
|
||||
<div class="kpi gold"><div class="kpi-l">Weighted total</div><div class="kpi-v" id="k-weighted">·</div><div class="kpi-s">prosjek vjerojatnosti</div></div>
|
||||
<div class="kpi g"><div class="kpi-l">Won this quarter</div><div class="kpi-v" id="k-won">·</div><div class="kpi-s" id="k-won-eur">·</div></div>
|
||||
<div class="kpi a"><div class="kpi-l">Leads (new+contacted)</div><div class="kpi-v" id="k-leads">·</div><div class="kpi-s">qualified: <span id="k-leads-q">·</span></div></div>
|
||||
<div class="kpi r"><div class="kpi-l">Overdue activities</div><div class="kpi-v" id="k-overdue">·</div><div class="kpi-s">upcoming: <span id="k-upcoming">·</span></div></div>
|
||||
<div class="kpi"><div class="kpi-l">Open cases</div><div class="kpi-v" id="k-cases">·</div><div class="kpi-s" id="k-cases-urgent">·</div></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-h"><div class="card-t">Pipeline kanban</div>
|
||||
<button class="btn sm" onclick="loadPipeline()">↻ Refresh</button>
|
||||
</div>
|
||||
<div class="card-b"><div class="kanban" id="kanban"></div></div>
|
||||
</div>
|
||||
|
||||
<div id="opp-cards" class="cgrid"></div>
|
||||
<div class="card" style="display:none"><div class="card-b" style="padding:0">
|
||||
<table id="t-opps"><thead><tr>
|
||||
<th>Naziv</th><th>Account</th><th>Tip</th><th>Faza</th>
|
||||
<th>EUR</th><th>%</th><th>Close</th><th></th>
|
||||
@@ -388,6 +419,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<button class="btn" onclick="loadActivities()">Filtriraj</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-activities')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-activities">
|
||||
<button onclick="exportTab('activities','csv')">CSV</button>
|
||||
<button onclick="exportTab('activities','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('activities','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openActivityModal()">+ Nova aktivnost</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
@@ -419,6 +457,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<button class="btn" onclick="loadCases()">Pretraži</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-cases')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-cases">
|
||||
<button onclick="exportTab('cases','csv')">CSV</button>
|
||||
<button onclick="exportTab('cases','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('cases','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openCaseModal()">+ Novi case</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
@@ -430,7 +475,7 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</div>
|
||||
|
||||
<!-- ────── ČLANARINE ────── -->
|
||||
<div class="tab-c" id="tc-clanarine">
|
||||
<div class="tab-c on" id="tc-clanarine">
|
||||
<div class="toolbar">
|
||||
<input id="cln-klub" type="number" placeholder="Klub ID" style="max-width:120px">
|
||||
<input id="cln-clan" type="number" placeholder="Član ID" style="max-width:120px">
|
||||
@@ -444,6 +489,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<button class="btn" onclick="loadClanarine()">Filtriraj</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-clanarine')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-clanarine">
|
||||
<button onclick="exportTab('clanarine','csv')">CSV</button>
|
||||
<button onclick="exportTab('clanarine','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('clanarine','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openClanarinaModal()">+ Nova članarina</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
@@ -465,6 +517,14 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<button class="btn" onclick="loadLijecnicki()">Filtriraj</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-lijecnicki')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-lijecnicki">
|
||||
<button onclick="exportTab('lijecnicki','csv')">CSV</button>
|
||||
<button onclick="exportTab('lijecnicki','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('lijecnicki','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="lij-srv-export-btn" class="export-btn" type="button" title="Server-side export svih redaka iz baze">Export ▾ (svi)</button>
|
||||
<button class="btn primary" onclick="openLijecnickiModal()">+ Novi pregled</button>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0">
|
||||
@@ -501,6 +561,13 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</select>
|
||||
<input id="obr-klub" type="number" placeholder="Klub ID" style="max-width:120px">
|
||||
<button class="btn" onclick="loadObrasciSubmissions()">Filtriraj</button>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-obrasci')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-obrasci">
|
||||
<button onclick="exportTab('obrasci','csv')">CSV</button>
|
||||
<button onclick="exportTab('obrasci','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('obrasci','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card"><div class="card-b" style="padding:0" id="obr-right-body">
|
||||
<table id="t-obr-sub"><thead><tr>
|
||||
@@ -512,6 +579,31 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ────── E-MAIL TEMPLATES (RUSH-4 / 2026-05-05) ────── -->
|
||||
<div class="tab-c" id="tc-emailtpl">
|
||||
<div class="toolbar">
|
||||
<input id="etpl-q" placeholder="Pretraži (kod / naziv)…">
|
||||
<select id="etpl-cat">
|
||||
<option value="">— Sve kategorije —</option>
|
||||
<option value="clanarine">clanarine</option>
|
||||
<option value="lijecnicki">lijecnicki</option>
|
||||
<option value="obrasci">obrasci</option>
|
||||
<option value="opci">opci</option>
|
||||
</select>
|
||||
<button class="btn" onclick="loadEmailTpls()">Pretraži</button>
|
||||
<span class="grow"></span>
|
||||
<div class="exp"><button class="exp-btn" onclick="toggleExp('exp-emailtpl')">📥 Export ▾</button>
|
||||
<div class="exp-menu" id="exp-emailtpl">
|
||||
<button onclick="exportTab('emailtpl','csv')">CSV</button>
|
||||
<button onclick="exportTab('emailtpl','xlsx')">XLSX</button>
|
||||
<button onclick="exportTab('emailtpl','pdf')">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary" onclick="openEmailTplModal()">+ Novi predložak</button>
|
||||
</div>
|
||||
<div id="etpl-grid" class="cgrid"></div>
|
||||
</div>
|
||||
|
||||
</div><!-- /main -->
|
||||
|
||||
<footer>
|
||||
@@ -610,17 +702,90 @@ const esc = s => String(s==null?'':s).replace(/[&<>"']/g, c=>({'&':'&','<':'
|
||||
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => switchTab(t.dataset.tab)));
|
||||
function switchTab(name) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab===name));
|
||||
document.querySelectorAll('.tab-c').forEach(c => c.classList.toggle('on', c.id==='tc-'+name || (name==='opportunities' && c.id==='tc-opps')));
|
||||
if (name==='pipeline') loadPipeline();
|
||||
document.querySelectorAll('.tab-c').forEach(c => c.classList.toggle('on', c.id==='tc-'+name || (name==='opportunities' && c.id==='tc-opps') || (name==='emailtpl' && c.id==='tc-emailtpl')));
|
||||
// Always refresh KPIs/pipeline counts in background
|
||||
if (name==='accounts') loadAccounts();
|
||||
if (name==='contacts') loadContacts();
|
||||
if (name==='leads') loadLeads();
|
||||
if (name==='opportunities') loadOpps();
|
||||
if (name==='opportunities') { loadOpps(); loadPipeline(); }
|
||||
if (name==='activities') loadActivities();
|
||||
if (name==='cases') loadCases();
|
||||
if (name==='clanarine') loadClanarine();
|
||||
if (name==='lijecnicki') loadLijecnicki();
|
||||
if (name==='obrasci') { loadObrasciTemplates(); loadObrasciSubmissions(); }
|
||||
if (name==='emailtpl') loadEmailTpls();
|
||||
}
|
||||
|
||||
// ────── Export dropdown helpers ──────
|
||||
function toggleExp(id) {
|
||||
const m = document.getElementById(id); if (!m) return;
|
||||
document.querySelectorAll('.exp-menu').forEach(x => { if (x.id !== id) x.classList.remove('on'); });
|
||||
m.classList.toggle('on');
|
||||
}
|
||||
document.addEventListener('click', e => {
|
||||
if (!e.target.closest('.exp')) document.querySelectorAll('.exp-menu').forEach(x => x.classList.remove('on'));
|
||||
});
|
||||
|
||||
// Tabular export: pull rows from current cards/tables for a given tab
|
||||
const EXPORT_HEADERS = {
|
||||
accounts: ['Naziv','Tip','Grad','OIB','Email','Telefon','Kontakti','Opps','Owner'],
|
||||
contacts: ['Ime','Prezime','Account','Funkcija','Email','Telefon'],
|
||||
leads: ['Ime','Prezime','Organizacija','Email','Telefon','Izvor','Status'],
|
||||
opps: ['Naziv','Account','Tip','Faza','EUR','Vjerojatnost %','Close'],
|
||||
activities: ['Tip','Subject','Account','Kontakt','Due','Status'],
|
||||
cases: ['Subject','Account','Status','Priority','Stvoren'],
|
||||
clanarine: ['Član','Klub','Godina','Razdoblje','Propisan','Plaćen','Datum uplate','Status'],
|
||||
lijecnicki: ['Član','Klub','Datum','Vrsta','Vrijedi do','Liječnik','Spreman','Plaćeno'],
|
||||
obrasci: ['ID','Predložak','Klub','Član','Status','Submitted','Approved'],
|
||||
emailtpl: ['Code','Naziv','Kategorija','Subject','Active'],
|
||||
};
|
||||
const EXPORT_CACHE = {}; // tab → array of row arrays
|
||||
|
||||
function setExportRows(tab, rows) { EXPORT_CACHE[tab] = rows; }
|
||||
|
||||
function exportTab(tab, fmt) {
|
||||
document.querySelectorAll('.exp-menu').forEach(x => x.classList.remove('on'));
|
||||
const headers = EXPORT_HEADERS[tab] || [];
|
||||
const rows = EXPORT_CACHE[tab] || [];
|
||||
if (!rows.length) { toast('Nema redaka za export', 'err'); return; }
|
||||
const fname = 'crm_' + tab + '_' + new Date().toISOString().slice(0,10);
|
||||
if (fmt === 'csv') {
|
||||
const csv = [headers, ...rows].map(r => r.map(c => {
|
||||
const s = (c==null?'':String(c)).replace(/"/g,'""');
|
||||
return /[",\n;]/.test(s) ? '"'+s+'"' : s;
|
||||
}).join(';')).join('\r\n');
|
||||
const blob = new Blob([''+csv], {type:'text/csv;charset=utf-8'});
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
|
||||
a.download = fname + '.csv'; a.click();
|
||||
setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
|
||||
toast('CSV: '+rows.length+' redaka');
|
||||
} else if (fmt === 'xlsx') {
|
||||
if (typeof XLSX === 'undefined') { toast('SheetJS nije učitan', 'err'); return; }
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, tab);
|
||||
XLSX.writeFile(wb, fname + '.xlsx');
|
||||
toast('XLSX: '+rows.length+' redaka');
|
||||
} else if (fmt === 'pdf') {
|
||||
// Print-friendly window: use a fresh document with table only
|
||||
const w = window.open('', '_blank', 'width=900,height=700');
|
||||
const html = `<!doctype html><html><head><title>${fname}</title>
|
||||
<style>
|
||||
body{font-family:Arial,sans-serif;font-size:11px;margin:18px;color:#000}
|
||||
h2{font-size:15px;margin:0 0 8px}
|
||||
table{width:100%;border-collapse:collapse;font-size:10px}
|
||||
th{background:#eee;text-align:left;padding:5px 7px;border:1px solid #999;text-transform:uppercase;font-size:9px;letter-spacing:.4px}
|
||||
td{padding:4px 7px;border:1px solid #ccc}
|
||||
tr:nth-child(even) td{background:#fafafa}
|
||||
</style></head><body>
|
||||
<h2>PGŽ Sport CRM — ${tab.toUpperCase()} <small style="font-weight:400;color:#666">(${new Date().toLocaleString('hr-HR')})</small></h2>
|
||||
<table><thead><tr>${headers.map(h=>'<th>'+h+'</th>').join('')}</tr></thead>
|
||||
<tbody>${rows.map(r=>'<tr>'+r.map(c=>'<td>'+(c==null?'':String(c).replace(/&/g,'&').replace(/</g,'<'))+'</td>').join('')+'</tr>').join('')}</tbody></table>
|
||||
</body></html>`;
|
||||
w.document.write(html); w.document.close();
|
||||
setTimeout(() => { try { w.focus(); w.print(); } catch(e){} }, 350);
|
||||
toast('PDF print dialog otvoren');
|
||||
}
|
||||
}
|
||||
|
||||
// ────── /me ──────
|
||||
@@ -734,8 +899,28 @@ async function loadAccounts() {
|
||||
if (t) qs.set('type', t);
|
||||
try {
|
||||
const data = await api('/accounts?'+qs.toString());
|
||||
const items = data.items||[];
|
||||
// Card grid (primary)
|
||||
const grid = document.getElementById('acc-cards');
|
||||
if (grid) {
|
||||
grid.innerHTML = items.map(a => `
|
||||
<div class="ccard" onclick="editAccount(${a.id})">
|
||||
<div class="ccard-actions">
|
||||
<button class="btn sm danger" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button>
|
||||
</div>
|
||||
<div class="ccard-h">${esc(a.naziv)}</div>
|
||||
<div class="ccard-sub"><span class="chip ${a.type}">${esc(a.type)}</span> ${esc(a.grad||'')}</div>
|
||||
<div class="ccard-row"><span>OIB</span><strong>${esc(a.oib||'—')}</strong></div>
|
||||
<div class="ccard-row"><span>Email</span><strong>${esc(a.email||'—')}</strong></div>
|
||||
<div class="ccard-row"><span>Kontakti / Opps</span><strong>${a.contacts_n||0} / ${a.opps_n||0}</strong></div>
|
||||
<div class="ccard-row"><span>Owner</span><strong>${esc(a.owner_email||'—')}</strong></div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema accounta — dodajte prvi.</div>';
|
||||
}
|
||||
// Hidden table (compat for legacy code/exports)
|
||||
const tb = document.querySelector('#t-accounts tbody');
|
||||
tb.innerHTML = (data.items||[]).map(a => `
|
||||
if (tb) {
|
||||
tb.innerHTML = items.map(a => `
|
||||
<tr onclick="editAccount(${a.id})">
|
||||
<td><strong>${esc(a.naziv)}</strong></td>
|
||||
<td>${esc(a.type)}</td>
|
||||
@@ -747,8 +932,10 @@ async function loadAccounts() {
|
||||
<td>${esc(a.owner_email||'—')}</td>
|
||||
<td><button class="btn sm" onclick="event.stopPropagation();delAccount(${a.id},'${esc(a.naziv).replace(/'/g,"\\'")}')">×</button></td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="9" class="empty">Nema accounta — dodajte prvi.</td></tr>';
|
||||
document.getElementById('cnt-accounts').textContent = (data.items||[]).length;
|
||||
`).join('') || '<tr><td colspan="9" class="empty">Nema accounta.</td></tr>';
|
||||
}
|
||||
setExportRows('accounts', items.map(a => [a.naziv, a.type, a.grad||'', a.oib||'', a.email||'', a.telefon||'', a.contacts_n||0, a.opps_n||0, a.owner_email||'']));
|
||||
document.getElementById('cnt-accounts').textContent = items.length;
|
||||
} catch (e) { toast('Accounts err: '+e.message, 'err'); }
|
||||
}
|
||||
|
||||
@@ -823,19 +1010,33 @@ async function loadContacts() {
|
||||
if (aid) qs.set('account_id', aid);
|
||||
try {
|
||||
const data = await api('/contacts?'+qs.toString());
|
||||
const items = data.items||[];
|
||||
const grid = document.getElementById('con-cards');
|
||||
if (grid) {
|
||||
grid.innerHTML = items.map(c => `
|
||||
<div class="ccard" onclick="editContact(${c.id})">
|
||||
<div class="ccard-actions">
|
||||
<button class="btn sm danger" onclick="event.stopPropagation();delContact(${c.id})">×</button>
|
||||
</div>
|
||||
<div class="ccard-h">${esc(c.ime)} ${esc(c.prezime)}</div>
|
||||
<div class="ccard-sub">${esc(c.funkcija||'—')} · ${esc(c.account_naziv||'—')}</div>
|
||||
<div class="ccard-row"><span>Email</span><strong>${esc(c.email||'—')}</strong></div>
|
||||
<div class="ccard-row"><span>Telefon</span><strong>${esc(c.telefon||c.mobitel||'—')}</strong></div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema kontakata.</div>';
|
||||
}
|
||||
const tb = document.querySelector('#t-contacts tbody');
|
||||
tb.innerHTML = (data.items||[]).map(c => `
|
||||
if (tb) {
|
||||
tb.innerHTML = items.map(c => `
|
||||
<tr onclick="editContact(${c.id})">
|
||||
<td><strong>${esc(c.ime)}</strong></td>
|
||||
<td>${esc(c.prezime)}</td>
|
||||
<td>${esc(c.account_naziv||'—')}</td>
|
||||
<td>${esc(c.funkcija||'—')}</td>
|
||||
<td>${esc(c.email||'—')}</td>
|
||||
<td>${esc(c.telefon||c.mobitel||'—')}</td>
|
||||
<td><strong>${esc(c.ime)}</strong></td><td>${esc(c.prezime)}</td>
|
||||
<td>${esc(c.account_naziv||'—')}</td><td>${esc(c.funkcija||'—')}</td>
|
||||
<td>${esc(c.email||'—')}</td><td>${esc(c.telefon||c.mobitel||'—')}</td>
|
||||
<td><button class="btn sm" onclick="event.stopPropagation();delContact(${c.id})">×</button></td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="7" class="empty">Nema kontakata.</td></tr>';
|
||||
document.getElementById('cnt-contacts').textContent = (data.items||[]).length;
|
||||
</tr>`).join('');
|
||||
}
|
||||
setExportRows('contacts', items.map(c => [c.ime||'', c.prezime||'', c.account_naziv||'', c.funkcija||'', c.email||'', c.telefon||c.mobitel||'']));
|
||||
document.getElementById('cnt-contacts').textContent = items.length;
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
function contactFormHTML(c={}) {
|
||||
@@ -903,22 +1104,35 @@ async function loadLeads() {
|
||||
if (s) qs.set('status', s);
|
||||
try {
|
||||
const data = await api('/leads?'+qs.toString());
|
||||
const items = data.items||[];
|
||||
const grid = document.getElementById('lead-cards');
|
||||
if (grid) {
|
||||
grid.innerHTML = items.map(l => `
|
||||
<div class="ccard" onclick="editLead(${l.id})">
|
||||
<div class="ccard-actions">
|
||||
${l.status!=='converted' ? `<button class="btn sm gold" onclick="event.stopPropagation();convertLead(${l.id})">→</button>` : ''}
|
||||
<button class="btn sm danger" onclick="event.stopPropagation();delLead(${l.id})">×</button>
|
||||
</div>
|
||||
<div class="ccard-h">${esc(l.ime||'')} ${esc(l.prezime||'')}</div>
|
||||
<div class="ccard-sub"><span class="chip ${l.status}">${l.status}</span> ${esc(l.organizacija||'—')}</div>
|
||||
<div class="ccard-row"><span>Email</span><strong>${esc(l.email||'—')}</strong></div>
|
||||
<div class="ccard-row"><span>Telefon</span><strong>${esc(l.telefon||'—')}</strong></div>
|
||||
<div class="ccard-row"><span>Izvor</span><strong>${esc(l.izvor||'—')}</strong></div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema leadova.</div>';
|
||||
}
|
||||
const tb = document.querySelector('#t-leads tbody');
|
||||
tb.innerHTML = (data.items||[]).map(l => `
|
||||
if (tb) {
|
||||
tb.innerHTML = items.map(l => `
|
||||
<tr onclick="editLead(${l.id})">
|
||||
<td>${esc(l.ime||'—')}</td>
|
||||
<td>${esc(l.prezime||'—')}</td>
|
||||
<td>${esc(l.organizacija||'—')}</td>
|
||||
<td>${esc(l.email||'—')}</td>
|
||||
<td>${esc(l.izvor||'—')}</td>
|
||||
<td><span class="chip ${l.status}">${l.status}</span></td>
|
||||
<td>
|
||||
${l.status!=='converted' ? `<button class="btn sm gold" onclick="event.stopPropagation();convertLead(${l.id})">→ Konvertiraj</button>` : ''}
|
||||
<button class="btn sm" onclick="event.stopPropagation();delLead(${l.id})">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="7" class="empty">Nema leadova.</td></tr>';
|
||||
document.getElementById('cnt-leads').textContent = (data.items||[]).length;
|
||||
<td>${esc(l.ime||'—')}</td><td>${esc(l.prezime||'—')}</td>
|
||||
<td>${esc(l.organizacija||'—')}</td><td>${esc(l.email||'—')}</td>
|
||||
<td>${esc(l.izvor||'—')}</td><td><span class="chip ${l.status}">${l.status}</span></td>
|
||||
<td><button class="btn sm" onclick="event.stopPropagation();delLead(${l.id})">×</button></td>
|
||||
</tr>`).join('');
|
||||
}
|
||||
setExportRows('leads', items.map(l => [l.ime||'', l.prezime||'', l.organizacija||'', l.email||'', l.telefon||'', l.izvor||'', l.status||'']));
|
||||
document.getElementById('cnt-leads').textContent = items.length;
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
function leadFormHTML(l={}) {
|
||||
@@ -1029,20 +1243,36 @@ async function loadOpps() {
|
||||
if (s) qs.set('stage', s);
|
||||
try {
|
||||
const data = await api('/opportunities?'+qs.toString());
|
||||
const items = data.items||[];
|
||||
const grid = document.getElementById('opp-cards');
|
||||
if (grid) {
|
||||
grid.innerHTML = items.map(o => `
|
||||
<div class="ccard" onclick="editOpp(${o.id})">
|
||||
<div class="ccard-actions">
|
||||
<button class="btn sm danger" onclick="event.stopPropagation();delOpp(${o.id})">×</button>
|
||||
</div>
|
||||
<div class="ccard-h">${esc(o.naziv)}</div>
|
||||
<div class="ccard-sub">${esc(o.account_naziv||'—')} · <span class="chip">${esc(o.stage)}</span></div>
|
||||
<div class="ccard-row"><span>Iznos</span><strong style="color:var(--pgz-gold)">${fmtEur(o.amount_eur)}</strong></div>
|
||||
<div class="ccard-row"><span>Vjerojatnost</span><strong>${o.probability||0}%</strong></div>
|
||||
<div class="ccard-row"><span>Close</span><strong>${fmtDate(o.close_date)}</strong></div>
|
||||
<div class="ccard-row"><span>Tip</span><strong>${esc(o.type||'—')}</strong></div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema prilika.</div>';
|
||||
}
|
||||
const tb = document.querySelector('#t-opps tbody');
|
||||
tb.innerHTML = (data.items||[]).map(o => `
|
||||
if (tb) {
|
||||
tb.innerHTML = items.map(o => `
|
||||
<tr onclick="editOpp(${o.id})">
|
||||
<td><strong>${esc(o.naziv)}</strong></td>
|
||||
<td>${esc(o.account_naziv||'—')}</td>
|
||||
<td>${esc(o.type||'—')}</td>
|
||||
<td><span class="chip">${esc(o.stage)}</span></td>
|
||||
<td>${fmtEur(o.amount_eur)}</td>
|
||||
<td>${o.probability||0}%</td>
|
||||
<td><strong>${esc(o.naziv)}</strong></td><td>${esc(o.account_naziv||'—')}</td>
|
||||
<td>${esc(o.type||'—')}</td><td><span class="chip">${esc(o.stage)}</span></td>
|
||||
<td>${fmtEur(o.amount_eur)}</td><td>${o.probability||0}%</td>
|
||||
<td>${fmtDate(o.close_date)}</td>
|
||||
<td><button class="btn sm" onclick="event.stopPropagation();delOpp(${o.id})">×</button></td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="8" class="empty">Nema prilika.</td></tr>';
|
||||
document.getElementById('cnt-opps').textContent = (data.items||[]).length;
|
||||
</tr>`).join('');
|
||||
}
|
||||
setExportRows('opps', items.map(o => [o.naziv||'', o.account_naziv||'', o.type||'', o.stage||'', o.amount_eur||0, o.probability||0, fmtDate(o.close_date)]));
|
||||
document.getElementById('cnt-opps').textContent = items.length;
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
function oppFormHTML(o={}) {
|
||||
@@ -1131,6 +1361,7 @@ async function loadActivities() {
|
||||
</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="7" class="empty">Nema aktivnosti.</td></tr>';
|
||||
setExportRows('activities', (data.items||[]).map(a => [a.type||'', a.subject||'', a.account_naziv||'', a.contact_naziv||'', fmtDT(a.due_at), a.completed_at?'done':'open']));
|
||||
document.getElementById('cnt-activities').textContent = (data.items||[]).length;
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
@@ -1217,6 +1448,7 @@ async function loadCases() {
|
||||
<td><button class="btn sm" onclick="event.stopPropagation();delCase(${c.id})">×</button></td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="6" class="empty">Nema caseova.</td></tr>';
|
||||
setExportRows('cases', (data.items||[]).map(c => [c.subject||'', c.account_naziv||'', c.status||'', c.priority||'', fmtDT(c.created_at)]));
|
||||
document.getElementById('cnt-cases').textContent = (data.items||[]).length;
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
@@ -1323,6 +1555,7 @@ async function loadClanarine() {
|
||||
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delClanarina(${c.id})">×</button>` : ''}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="9" class="empty">Nema članarina za odabrane filtere.</td></tr>';
|
||||
setExportRows('clanarine', (data.items||[]).map(c => [c.clan_naziv||'', c.klub_naziv||'', c.godina||'', c.razdoblje||'', c.iznos_propisan||0, c.iznos_placen||0, fmtDate(c.datum_uplate), c.status||'']));
|
||||
document.getElementById('cnt-clanarine').textContent = data.count ?? (data.items||[]).length;
|
||||
} catch (e) { toast('Članarine err: '+e.message, 'err'); }
|
||||
}
|
||||
@@ -1424,6 +1657,7 @@ async function loadLijecnicki() {
|
||||
<td>${isAdminUser() ? `<button class="btn sm" onclick="event.stopPropagation();delLijecnicki(${l.id})">×</button>` : ''}</td>
|
||||
</tr>`;
|
||||
}).join('') || '<tr><td colspan="9" class="empty">Nema liječničkih pregleda.</td></tr>';
|
||||
setExportRows('lijecnicki', (data.items||[]).map(l => [l.clan_naziv||'', l.klub_naziv||'', fmtDate(l.datum_pregleda), l.vrsta_pregleda||'', fmtDate(l.vrijedi_do), l.lijecnik||'', l.spreman_za_natjecanje?'DA':'NE', l.placeno?'DA':'NE']));
|
||||
document.getElementById('cnt-lijecnicki').textContent = data.count ?? (data.items||[]).length;
|
||||
} catch (e) { toast('Liječnički err: '+e.message, 'err'); }
|
||||
}
|
||||
@@ -1642,6 +1876,7 @@ async function loadObrasciSubmissions() {
|
||||
` : ''}</td>
|
||||
</tr>
|
||||
`).join('') || '<tr><td colspan="8" class="empty">Nema podnesenih obrazaca.</td></tr>';
|
||||
setExportRows('obrasci', (data.items||[]).map(s => [s.id, s.template_naziv||s.template_code||'', s.klub_naziv||'', s.clan_naziv||'', s.status||'', fmtDT(s.submitted_at), fmtDT(s.approved_at)]));
|
||||
} catch (e) { toast('Submissions err: '+e.message, 'err'); }
|
||||
}
|
||||
|
||||
@@ -1712,6 +1947,120 @@ async function subStatus(id, status) {
|
||||
} catch (e) { toast('Greška: '+e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
// RUSH-4 — E-mail templates (CRM v2 GUI redesign, 2026-05-05)
|
||||
// dradulic@outlook.com / damir@rinet.one
|
||||
// Endpoint: /api/v2/crm/email-templates (CRUD)
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
let EMAIL_TPLS = [];
|
||||
|
||||
async function loadEmailTpls() {
|
||||
const q = (document.getElementById('etpl-q')?.value || '').trim().toLowerCase();
|
||||
const cat = document.getElementById('etpl-cat')?.value || '';
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('active_only', 'false');
|
||||
if (cat) qs.set('kategorija', cat);
|
||||
try {
|
||||
const data = await api('/email-templates?'+qs.toString());
|
||||
EMAIL_TPLS = (data.items||[]).filter(t => {
|
||||
if (!q) return true;
|
||||
return (t.code||'').toLowerCase().includes(q)
|
||||
|| (t.naziv||'').toLowerCase().includes(q);
|
||||
});
|
||||
const grid = document.getElementById('etpl-grid');
|
||||
grid.innerHTML = EMAIL_TPLS.map(t => `
|
||||
<div class="tcard" onclick="editEmailTpl(${t.id})">
|
||||
<div class="tcard-code">${esc(t.code)}</div>
|
||||
<div class="tcard-naziv">${esc(t.naziv)} ${t.active===false?'<span class="chip closed" style="margin-left:6px">neaktivan</span>':''}</div>
|
||||
<div class="tcard-cat">${esc(t.kategorija||'—')}</div>
|
||||
<div class="tcard-snip"><strong>Subject:</strong> ${esc((t.subject_tpl||'').slice(0,90))}${(t.subject_tpl||'').length>90?'…':''}<br>${esc((t.body_tpl||'').replace(/\s+/g,' ').slice(0,140))}${(t.body_tpl||'').length>140?'…':''}</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty" style="grid-column:1/-1">Nema predložaka.</div>';
|
||||
setExportRows('emailtpl', EMAIL_TPLS.map(t => [t.code||'', t.naziv||'', t.kategorija||'', t.subject_tpl||'', t.active?'true':'false']));
|
||||
document.getElementById('cnt-emailtpl').textContent = EMAIL_TPLS.length;
|
||||
} catch (e) { toast('Email tpl err: '+e.message, 'err'); }
|
||||
}
|
||||
|
||||
function emailTplFormHTML(t={}) {
|
||||
return `
|
||||
<div class="fld-row">
|
||||
<div class="fld"><label>Code*</label><input id="ef-code" value="${esc(t.code||'')}" placeholder="npr. clanarina_opomena"></div>
|
||||
<div class="fld"><label>Kategorija</label>
|
||||
<select id="ef-cat">
|
||||
<option value="">—</option>
|
||||
${['clanarine','lijecnicki','obrasci','opci'].map(c=>`<option value="${c}" ${t.kategorija===c?'selected':''}>${c}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fld"><label>Naziv*</label><input id="ef-naziv" value="${esc(t.naziv||'')}"></div>
|
||||
<div class="fld"><label>Subject (predmet)*</label><input id="ef-subj" value="${esc(t.subject_tpl||'')}" placeholder="npr. Opomena za {{godina}}"></div>
|
||||
<div class="fld"><label>Body (HTML / tekst)* — placeholderi {{...}}</label>
|
||||
<textarea id="ef-body" style="min-height:160px;font-family:var(--mono)">${esc(t.body_tpl||'')}</textarea>
|
||||
</div>
|
||||
<div class="fld"><label>Variables (JSON, npr. {"naziv_kluba":"string"})</label>
|
||||
<textarea id="ef-vars" style="min-height:60px;font-family:var(--mono)">${esc(t.variables ? JSON.stringify(t.variables, null, 2) : '')}</textarea>
|
||||
</div>
|
||||
<div class="fld"><label><input id="ef-act" type="checkbox" ${t.active===false?'':'checked'}> Aktivan</label></div>
|
||||
`;
|
||||
}
|
||||
|
||||
function readEmailTplForm() {
|
||||
let vars = null;
|
||||
const raw = document.getElementById('ef-vars').value.trim();
|
||||
if (raw) {
|
||||
try { vars = JSON.parse(raw); }
|
||||
catch(e) { toast('Variables JSON nije validan', 'err'); throw e; }
|
||||
}
|
||||
return {
|
||||
code: document.getElementById('ef-code').value.trim(),
|
||||
naziv: document.getElementById('ef-naziv').value.trim(),
|
||||
kategorija: document.getElementById('ef-cat').value || null,
|
||||
subject_tpl: document.getElementById('ef-subj').value,
|
||||
body_tpl: document.getElementById('ef-body').value,
|
||||
variables: vars,
|
||||
active: document.getElementById('ef-act').checked,
|
||||
};
|
||||
}
|
||||
|
||||
function openEmailTplModal(t) {
|
||||
const isEdit = !!(t && t.id);
|
||||
showModal(isEdit?'Uredi predložak':'Novi e-mail predložak',
|
||||
emailTplFormHTML(t||{active:true}),
|
||||
async () => {
|
||||
let body; try { body = readEmailTplForm(); } catch(e) { return; }
|
||||
if (!body.code || !body.naziv || !body.subject_tpl || !body.body_tpl) {
|
||||
toast('Code, naziv, subject i body su obavezni', 'err'); return;
|
||||
}
|
||||
try {
|
||||
if (isEdit) await api('/email-templates/'+t.id, {method:'PUT', body:JSON.stringify(body)});
|
||||
else await api('/email-templates', {method:'POST', body:JSON.stringify(body)});
|
||||
toast('Spremljeno'); closeModal(); loadEmailTpls();
|
||||
} catch (e) { toast('Greška: '+e.message, 'err'); }
|
||||
});
|
||||
}
|
||||
|
||||
async function editEmailTpl(id) {
|
||||
try { const t = await api('/email-templates/'+id);
|
||||
// Add delete button to footer
|
||||
openEmailTplModal(t);
|
||||
setTimeout(() => {
|
||||
const foot = document.getElementById('m-foot');
|
||||
if (foot && !foot.querySelector('.btn.danger')) {
|
||||
const del = document.createElement('button');
|
||||
del.className = 'btn danger'; del.textContent = 'Obriši';
|
||||
del.onclick = () => delEmailTpl(id, t.naziv);
|
||||
foot.insertBefore(del, foot.firstChild);
|
||||
}
|
||||
}, 0);
|
||||
} catch (e) { toast(e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function delEmailTpl(id, naziv) {
|
||||
if (!confirm('Obrisati predložak "'+naziv+'"?')) return;
|
||||
try { await api('/email-templates/'+id, {method:'DELETE'}); toast('Obrisano'); closeModal(); loadEmailTpls(); }
|
||||
catch (e) { toast('Greška: '+e.message, 'err'); }
|
||||
}
|
||||
|
||||
// ────── Modal helpers ──────
|
||||
function showModal(title, bodyHTML, onSave) {
|
||||
document.getElementById('m-title').textContent = title;
|
||||
@@ -1731,7 +2080,26 @@ document.getElementById('modal').addEventListener('click', e => {
|
||||
// ────── Init ──────
|
||||
loadMe();
|
||||
ensureMe();
|
||||
loadPipeline();
|
||||
loadPipeline(); // populates KPI counters + kanban (kanban only visible in Opportunities tab now)
|
||||
loadClanarine(); // default active tab
|
||||
|
||||
// ── Universal Export ▾ — server-side fallback for tabs that load full
|
||||
// record sets from REST (lijecnicki/obrasci). The existing exportTab()
|
||||
// flow above keeps working for client-side cached tabs (accounts,
|
||||
// contacts, leads, opps). attachExportDropdown is a no-op when
|
||||
// export_dropdown.js fails to load.
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
if (!window.attachExportDropdown) return;
|
||||
const lij = document.getElementById('lij-srv-export-btn');
|
||||
if (lij) window.attachExportDropdown(lij, function(){
|
||||
const klub = document.getElementById('lij-klub'); const clan = document.getElementById('lij-clan');
|
||||
const qp = new URLSearchParams(); qp.set('limit','2000');
|
||||
if (klub && klub.value) qp.set('klub_id', klub.value);
|
||||
if (clan && clan.value) qp.set('clan_id', clan.value);
|
||||
return '/sport/api/v2/lijecnicki?'+qp.toString();
|
||||
}, 'lijecnicki');
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/export_dropdown.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -199,6 +199,7 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
<label>Status <select id="rac-status"><option value="">— svi —</option><option value="nacrt">Nacrt</option><option value="knjizen">Knjižen</option><option value="placen">Plaćen</option><option value="otkazan">Otkazan</option></select></label>
|
||||
<label>Godina <input type="number" id="rac-godina" value="2026" style="width:90px"></label>
|
||||
<button class="btn" onclick="loadRacuni()">Osvježi</button>
|
||||
<button id="rac-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table id="rac-tbl"><thead><tr><th>#</th><th>Broj</th><th>Datum</th><th>Partner</th><th>OIB</th><th class="num">Neto</th><th class="num">PDV</th><th class="num">Brutto</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi" za učitavanje…</td></tr></tbody></table>
|
||||
@@ -253,6 +254,7 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
<label>Status <select id="pn-status"><option value="">— svi —</option><option value="draft">draft</option><option value="podnesen">podnesen</option><option value="odobren">odobren</option><option value="isplacen">isplacen</option><option value="rejected">rejected</option></select></label>
|
||||
<label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label>
|
||||
<button class="btn" onclick="loadExpenseReports()">Osvježi</button>
|
||||
<button id="pn-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table id="pn-tbl"><thead><tr><th>#</th><th>Tip</th><th>Klub</th><th>Odredište</th><th>Svrha</th><th>Od</th><th>Do</th><th class="num">Km</th><th class="num">Trošak</th><th class="num">Dnevnice</th><th>Status</th></tr></thead><tbody><tr><td colspan="11" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
|
||||
@@ -278,6 +280,7 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
<label>Način <select id="py-method"><option value="">— svi —</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
|
||||
<label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
|
||||
<button class="btn" onclick="loadPayments()">Osvježi</button>
|
||||
<button id="py-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Način</th><th>IBAN OD</th><th>IBAN ZA</th><th>Referenca</th><th>Račun</th><th>Putni nalog</th><th>Match</th></tr></thead><tbody><tr><td colspan="12" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
|
||||
@@ -1228,6 +1231,49 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
loadDnevnik();
|
||||
});
|
||||
|
||||
// ── Universal Export ▾ — wired to representative ERP tabs (racuni,
|
||||
// putni nalozi, payments). Uses live filter values so the exported
|
||||
// rows match what's on screen.
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
if (!window.attachExportDropdown) return;
|
||||
|
||||
const racBtn = document.getElementById('rac-export-btn');
|
||||
if (racBtn) window.attachExportDropdown(racBtn, function(){
|
||||
const tip = (document.getElementById('rac-tip')||{}).value || 'ulazni';
|
||||
const status = (document.getElementById('rac-status')||{}).value || '';
|
||||
const god = (document.getElementById('rac-godina')||{}).value || '';
|
||||
const qp = new URLSearchParams(); qp.set('limit','2000');
|
||||
if (status) qp.set('status', status);
|
||||
if (god) qp.set('godina', god);
|
||||
return '/api/v2/erp/racuni/'+tip+'?'+qp.toString();
|
||||
}, 'racuni');
|
||||
|
||||
const pnBtn = document.getElementById('pn-export-btn');
|
||||
if (pnBtn) window.attachExportDropdown(pnBtn, function(){
|
||||
const t = (document.getElementById('pn-type')||{}).value || '';
|
||||
const s = (document.getElementById('pn-status')||{}).value || '';
|
||||
const g = (document.getElementById('pn-godina')||{}).value || '';
|
||||
const qp = new URLSearchParams(); qp.set('limit','2000');
|
||||
if (t) qp.set('tip', t);
|
||||
if (s) qp.set('status', s);
|
||||
if (g) qp.set('godina', g);
|
||||
return '/api/v2/erp/expense-reports?'+qp.toString();
|
||||
}, 'expense_reports');
|
||||
|
||||
const pyBtn = document.getElementById('py-export-btn');
|
||||
if (pyBtn) window.attachExportDropdown(pyBtn, function(){
|
||||
const s = (document.getElementById('py-status')||{}).value || '';
|
||||
const m = (document.getElementById('py-method')||{}).value || '';
|
||||
const g = (document.getElementById('py-godina')||{}).value || '';
|
||||
const qp = new URLSearchParams(); qp.set('limit','2000');
|
||||
if (s) qp.set('status', s);
|
||||
if (m) qp.set('metoda', m);
|
||||
if (g) qp.set('godina', g);
|
||||
return '/api/v2/erp/payments?'+qp.toString();
|
||||
}, 'payments');
|
||||
});
|
||||
</script>
|
||||
<script src="/static/js/export_dropdown.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
/* ═══════════════════════════════════════════════════════════════════════
|
||||
* Fajl: static/js/export_dropdown.js | v1.0.0 | 05.05.2026
|
||||
* Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
|
||||
* Lokacija: /opt/pgz-sport/static/js/export_dropdown.js
|
||||
* Svrha: Shared "Export ▾" dropdown — CSV / XLSX / PDF — za sve tablice u
|
||||
* sport2.html, app.html, crm_v2.html, erp_full.html. Iza scene
|
||||
* koristi /api/v2/export?format=...&endpoint=... s autoriziranim
|
||||
* Bearer tokenom iz localStorage / sessionStorage.
|
||||
* Public API:
|
||||
* window.attachExportDropdown(buttonEl, endpointFn, filenameBase)
|
||||
* - buttonEl: <button> element trigger
|
||||
* - endpointFn: function returning the current endpoint+querystring
|
||||
* each time it's called (so filters stay live)
|
||||
* - filenameBase: short name for downloaded file (e.g. 'klubovi')
|
||||
* ═══════════════════════════════════════════════════════════════════════ */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
if (window.attachExportDropdown) return; // idempotent
|
||||
|
||||
// ── inject minimal Palantir-ish CSS once ──────────────────────────────
|
||||
var STYLE_ID = 'pgz-export-dropdown-style';
|
||||
if (!document.getElementById(STYLE_ID)) {
|
||||
var st = document.createElement('style');
|
||||
st.id = STYLE_ID;
|
||||
st.textContent = [
|
||||
'.export-btn{background:var(--bg3,#1a1d24);border:1px solid var(--rim,#2a2e38);',
|
||||
' color:var(--t1,#e0e3e9);padding:6px 11px;border-radius:5px;font-size:11px;',
|
||||
' cursor:pointer;font-family:inherit;letter-spacing:.3px}',
|
||||
'.export-btn:hover{border-color:var(--pgz-gold,#d4a849);color:var(--pgz-gold,#d4a849)}',
|
||||
'.pgz-exp-wrap{position:relative;display:inline-block}',
|
||||
'.pgz-exp-menu{position:absolute;right:0;top:calc(100% + 4px);min-width:130px;',
|
||||
' background:var(--bg2,#15181f);border:1px solid var(--rim,#2a2e38);border-radius:6px;',
|
||||
' box-shadow:0 6px 20px rgba(0,0,0,.45);padding:4px;z-index:9999;display:none;',
|
||||
' font-family:ui-monospace,Menlo,Consolas,monospace}',
|
||||
'.pgz-exp-menu.on{display:block}',
|
||||
'.pgz-exp-menu button{display:block;width:100%;text-align:left;background:transparent;',
|
||||
' border:0;color:var(--t1,#e0e3e9);padding:7px 11px;font-size:11px;cursor:pointer;',
|
||||
' border-radius:4px;font-family:inherit;letter-spacing:.4px}',
|
||||
'.pgz-exp-menu button:hover{background:var(--bg3,#1a1d24);color:var(--pgz-gold,#d4a849)}',
|
||||
'.pgz-exp-menu .sep{border-top:1px solid var(--rim,#2a2e38);margin:3px 0}'
|
||||
].join('\n');
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
|
||||
// Close menus on outside click.
|
||||
document.addEventListener('click', function (ev) {
|
||||
var menus = document.querySelectorAll('.pgz-exp-menu.on');
|
||||
menus.forEach(function (m) {
|
||||
if (!m.contains(ev.target) && !(m.__trigger && m.__trigger.contains(ev.target))) {
|
||||
m.classList.remove('on');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function _token() {
|
||||
return (
|
||||
localStorage.getItem('pgz_access') ||
|
||||
sessionStorage.getItem('pgz_access') ||
|
||||
localStorage.getItem('access_token') ||
|
||||
sessionStorage.getItem('access_token') ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function _resolveEndpoint(endpointFn) {
|
||||
try {
|
||||
var ep = (typeof endpointFn === 'function') ? endpointFn() : String(endpointFn || '');
|
||||
if (!ep) return null;
|
||||
// Normalize: must start with / so the server proxies to localhost.
|
||||
if (!/^https?:\/\//i.test(ep) && !ep.startsWith('/')) ep = '/' + ep;
|
||||
return ep;
|
||||
} catch (e) {
|
||||
console.error('[export] endpointFn threw', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function _trigger(format, endpointFn, filenameBase) {
|
||||
var ep = _resolveEndpoint(endpointFn);
|
||||
if (!ep) {
|
||||
alert('Export: endpoint nije dostupan.');
|
||||
return;
|
||||
}
|
||||
var url = '/api/v2/export?format=' + encodeURIComponent(format) +
|
||||
'&endpoint=' + encodeURIComponent(ep) +
|
||||
'&filename=' + encodeURIComponent(filenameBase || 'export');
|
||||
|
||||
var tok = _token();
|
||||
if (format === 'pdf') {
|
||||
// PDF mock = HTML page. Open in a new tab; the page has a Print button.
|
||||
// If we have a token, push it as a hash so the user stays logged in
|
||||
// when they re-fetch (server still validates from the original GET).
|
||||
var w = window.open('', '_blank');
|
||||
if (!w) { alert('Pop-up blocked — dopusti pop-up za export.'); return; }
|
||||
// Use fetch with auth, then write to the new window.
|
||||
fetch(url, { headers: tok ? { 'Authorization': 'Bearer ' + tok } : {} })
|
||||
.then(function (r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
||||
.then(function (html) { w.document.open(); w.document.write(html); w.document.close(); })
|
||||
.catch(function (e) {
|
||||
w.document.body.innerText = 'Export greška: ' + e.message;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// csv / xlsx — fetch as blob, force download via hidden <a>.
|
||||
fetch(url, { headers: tok ? { 'Authorization': 'Bearer ' + tok } : {} })
|
||||
.then(function (r) {
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
var dispo = r.headers.get('Content-Disposition') || '';
|
||||
var m = dispo.match(/filename="?([^"]+)"?/);
|
||||
var fname = m ? m[1] : (filenameBase || 'export') + '.' + format;
|
||||
return r.blob().then(function (b) { return { blob: b, fname: fname }; });
|
||||
})
|
||||
.then(function (o) {
|
||||
var burl = URL.createObjectURL(o.blob);
|
||||
var a = document.createElement('a');
|
||||
a.href = burl;
|
||||
a.download = o.fname;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(function () {
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(burl);
|
||||
}, 200);
|
||||
})
|
||||
.catch(function (e) {
|
||||
alert('Export greška: ' + e.message);
|
||||
console.error('[export]', e);
|
||||
});
|
||||
}
|
||||
|
||||
function attachExportDropdown(btn, endpointFn, filenameBase) {
|
||||
if (!btn) return;
|
||||
if (btn.__pgzExpAttached) return;
|
||||
btn.__pgzExpAttached = true;
|
||||
|
||||
// Wrap in a positioned container so the menu floats correctly.
|
||||
var wrap;
|
||||
if (btn.parentElement && btn.parentElement.classList.contains('pgz-exp-wrap')) {
|
||||
wrap = btn.parentElement;
|
||||
} else {
|
||||
wrap = document.createElement('span');
|
||||
wrap.className = 'pgz-exp-wrap';
|
||||
btn.parentNode.insertBefore(wrap, btn);
|
||||
wrap.appendChild(btn);
|
||||
}
|
||||
if (!btn.classList.contains('export-btn')) btn.classList.add('export-btn');
|
||||
if (!/▾|▼/.test(btn.textContent)) btn.textContent = (btn.textContent || 'Export') + ' ▾';
|
||||
|
||||
var menu = document.createElement('div');
|
||||
menu.className = 'pgz-exp-menu';
|
||||
menu.innerHTML = [
|
||||
'<button data-fmt="csv">CSV (HR Excel)</button>',
|
||||
'<button data-fmt="xlsx">XLSX</button>',
|
||||
'<div class="sep"></div>',
|
||||
'<button data-fmt="pdf">PDF (print)</button>'
|
||||
].join('');
|
||||
wrap.appendChild(menu);
|
||||
menu.__trigger = btn;
|
||||
|
||||
btn.addEventListener('click', function (ev) {
|
||||
ev.stopPropagation();
|
||||
// Close other open menus first.
|
||||
document.querySelectorAll('.pgz-exp-menu.on').forEach(function (m) {
|
||||
if (m !== menu) m.classList.remove('on');
|
||||
});
|
||||
menu.classList.toggle('on');
|
||||
});
|
||||
|
||||
menu.addEventListener('click', function (ev) {
|
||||
var t = ev.target.closest('button[data-fmt]');
|
||||
if (!t) return;
|
||||
ev.stopPropagation();
|
||||
menu.classList.remove('on');
|
||||
_trigger(t.getAttribute('data-fmt'), endpointFn, filenameBase);
|
||||
});
|
||||
}
|
||||
|
||||
window.attachExportDropdown = attachExportDropdown;
|
||||
})();
|
||||
+144
-44
@@ -1409,6 +1409,7 @@ function renderSaveziShell(){
|
||||
</div>
|
||||
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('savezi') : ''}
|
||||
<span class="tb-s" id="sav-cnt"></span>
|
||||
<button id="sav-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||
</div>
|
||||
<div id="sav-out"></div>
|
||||
`;
|
||||
@@ -1416,6 +1417,20 @@ function renderSaveziShell(){
|
||||
$('#sav-sport').addEventListener('change', applySaveziFilter);
|
||||
$('#sav-kat').addEventListener('change', applySaveziFilter);
|
||||
$('#sav-pgz').addEventListener('change', applySaveziFilter);
|
||||
// Export ▾ — uses same /v2/savezi/priority-sort URL as the table loader.
|
||||
if (window.attachExportDropdown) {
|
||||
window.attachExportDropdown(
|
||||
document.getElementById('sav-export-btn'),
|
||||
function(){
|
||||
const f = _filters.savezi || {};
|
||||
const useOnly = f.financirani || window._pgz_filter_priority;
|
||||
return '/sport/api' + (useOnly
|
||||
? '/v2/savezi/priority-sort?only=true&limit=500'
|
||||
: '/v2/savezi/priority-sort?only=false&limit=500');
|
||||
},
|
||||
'savezi'
|
||||
);
|
||||
}
|
||||
}
|
||||
function setSaveziView(v){
|
||||
_state.viewSavezi = v;
|
||||
@@ -1535,13 +1550,18 @@ async function loadKlubovi(){
|
||||
const root = $('#pg-klubovi');
|
||||
if(!_cache.klubovi){
|
||||
root.innerHTML = '<div class="loading">Učitavanje klubova…</div>';
|
||||
// BUG-E (2026-05-05): build /api/klubovi URL from explicit _filters.klubovi state.
|
||||
// Defaults: financirani=true + godisnjak=true. When BOTH off → load all.
|
||||
// RUSH-1 (2026-05-05): /api/klubovi URL built from _filters.klubovi state.
|
||||
// Spec (CC_FINAL_RUSH slika 4) — 3 checkboxes:
|
||||
// ☑ Samo financirani (PGŽ + RSS + Grad Rijeka) — single combined
|
||||
// ☑ U godišnjaku
|
||||
// ☐ Ima HNS roster
|
||||
// Backend `financiran=true` is OR of all 3 davateljs (single source of truth
|
||||
// = v_klubovi_financiranje view). Default = priority (fin OR godišnjak).
|
||||
// Sort: ukupno_potpora DESC.
|
||||
const f = _filters.klubovi;
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('limit','2500');
|
||||
qs.set('sort','financiran'); qs.set('order','desc'); // sort by potpore DESC (financiran flag)
|
||||
// financirani + godisnjak combined with kategorija=priority logic:
|
||||
qs.set('sort','potpora'); qs.set('order','desc'); // ukupno_potpora DESC NULLS LAST
|
||||
if(f.financirani && f.godisnjak){
|
||||
qs.set('kategorija','priority'); // OR semantics → priority = financiran OR godišnjak
|
||||
} else if(f.financirani){
|
||||
@@ -1581,11 +1601,10 @@ function renderKluboviShell(){
|
||||
<button id="kl-card" class="${_state.viewKlubovi==='card'?'active':''}" onclick="setKluboviView('card')">Kartice</button>
|
||||
<button id="kl-table" class="${_state.viewKlubovi==='table'?'active':''}" onclick="setKluboviView('table')">Tablica</button>
|
||||
</div>
|
||||
<button class="btn" onclick="exportKlubovi('xlsx')">⬇ XLSX</button>
|
||||
<button class="btn" onclick="exportKlubovi('csv')">⬇ CSV</button>
|
||||
<button class="btn" onclick="enrichBulk('klub', 50, 70)">✨ Obogati (50)</button>
|
||||
${window.renderPGZToggleBtn ? window.renderPGZToggleBtn('klubovi') : ''}
|
||||
<span class="tb-s" id="kl-cnt"></span>
|
||||
<button id="kl-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||
</div>
|
||||
<div id="kl-out"></div>
|
||||
`;
|
||||
@@ -1594,6 +1613,25 @@ function renderKluboviShell(){
|
||||
$('#kl-grad').addEventListener('change', applyKluboviFilter);
|
||||
$('#kl-kat').addEventListener('change', applyKluboviFilter);
|
||||
$('#kl-nk').addEventListener('change', applyKluboviFilter);
|
||||
// Export ▾ — rebuilds the same querystring that loadKlubovi uses.
|
||||
if (window.attachExportDropdown) {
|
||||
window.attachExportDropdown(
|
||||
document.getElementById('kl-export-btn'),
|
||||
function(){
|
||||
const f = _filters.klubovi || {};
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('limit','2500');
|
||||
qs.set('sort','potpora'); qs.set('order','desc');
|
||||
if(f.financirani && f.godisnjak) qs.set('kategorija','priority');
|
||||
else if(f.financirani) qs.set('financiran','true');
|
||||
else if(f.godisnjak) qs.set('godisnjak','true');
|
||||
if(f.hns_roster) qs.set('samo_hns_roster','true');
|
||||
if(window._pgz_filter_priority && !qs.has('kategorija')) qs.set('kategorija','priority');
|
||||
return '/sport/api/klubovi?'+qs.toString();
|
||||
},
|
||||
'klubovi'
|
||||
);
|
||||
}
|
||||
}
|
||||
function setKluboviView(v){
|
||||
_state.viewKlubovi = v;
|
||||
@@ -1634,25 +1672,31 @@ function applyKluboviFilter(){
|
||||
}
|
||||
function renderKluboviGrid(rows){
|
||||
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
|
||||
return '<div class="grid-club">'+rows.map(k => `
|
||||
return '<div class="grid-club">'+rows.map(k => {
|
||||
const finTitle = [k.prima_pgz?'PGŽ':null, k.prima_rss?'RSS':null, k.prima_grad_rijeka?'Grad Rijeka':null].filter(Boolean).join(' + ') || 'financiran';
|
||||
const potpora = (k.ukupno_potpora!=null) ? ' <b style="color:var(--pgz-gold)" title="ukupno potpora">'+fmtEur(k.ukupno_potpora)+'</b>' : '';
|
||||
return `
|
||||
<div class="entity" onclick="openKlub(${k.id})">
|
||||
${k.priority?'<div class="et-tag" style="background:#ffd700;color:#1a1a1a">★ PRIO</div>':(k.nositelj_kvalitete?'<div class="et-tag">N.K.</div>':'')}
|
||||
<div class="et">${(window.pgzBadgePrefix?window.pgzBadgePrefix(k,'klub'):'')}${esc(k.klub||k.sport||'(bez naziva)')}</div>
|
||||
<div class="es">${txt(k.razina,'')} · ${txt(k.grad,'—')}</div>
|
||||
<div class="em">
|
||||
${k.financiran?'<span class="tag gd" title="PGŽ sufinanciran">€</span>':''}
|
||||
${k.financiran?'<span class="tag gd" title="'+esc(finTitle)+'">€</span>':''}
|
||||
${k.godisnjak?'<span class="tag b" title="U godišnjaku">G</span>':''}
|
||||
${potpora}
|
||||
<span><b>${fmtNum(k.registriranih)}</b> reg.</span>
|
||||
<span><b>${fmtNum(k.trenera)}</b> trenera</span>
|
||||
<span><b>${fmtNum(k.reprezentativaca)}</b> repr.</span>
|
||||
</div>
|
||||
</div>`).join('')+'</div>';
|
||||
</div>`;
|
||||
}).join('')+'</div>';
|
||||
}
|
||||
function renderKluboviTable(rows){
|
||||
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
|
||||
return `<div class="card" style="padding:0;overflow-x:auto"><table>
|
||||
<thead><tr><th style="width:34px"><input type="checkbox" id="kl-all" title="Označi sve"></th><th title="PGŽ priority">★</th>${sortHeader('klubovi','klub','Klub','')}${sortHeader('klubovi','sport','Sport','')}${sortHeader('klubovi','razina','Razina','')}${sortHeader('klubovi','grad','Grad','')}${sortHeader('klubovi','registriranih','Reg.','num')}${sortHeader('klubovi','trenera','Trenera','num')}${sortHeader('klubovi','nositelj_kvalitete','Status','')}</tr></thead>
|
||||
<tbody>${rows.map(k => `
|
||||
<thead><tr><th style="width:34px"><input type="checkbox" id="kl-all" title="Označi sve"></th><th title="PGŽ priority">★</th>${sortHeader('klubovi','klub','Klub','')}${sortHeader('klubovi','sport','Sport','')}${sortHeader('klubovi','razina','Razina','')}${sortHeader('klubovi','grad','Grad','')}${sortHeader('klubovi','ukupno_potpora','Potpora','num')}${sortHeader('klubovi','registriranih','Reg.','num')}${sortHeader('klubovi','nositelj_kvalitete','Status','')}</tr></thead>
|
||||
<tbody>${rows.map(k => {
|
||||
const finTitle = [k.prima_pgz?'PGŽ':null, k.prima_rss?'RSS':null, k.prima_grad_rijeka?'Grad Rijeka':null].filter(Boolean).join(' + ') || 'financiran';
|
||||
return `
|
||||
<tr>
|
||||
<td onclick="event.stopPropagation()"><input type="checkbox" class="kl-pick" data-id="${k.id}"></td>
|
||||
<td onclick="openKlub(${k.id})">${k.priority?'<span class="tag gd" title="financiran ili u godišnjaku">★</span>':''}</td>
|
||||
@@ -1660,10 +1704,11 @@ function renderKluboviTable(rows){
|
||||
<td onclick="openKlub(${k.id})">${txt(k.sport)}</td>
|
||||
<td onclick="openKlub(${k.id})">${txt(k.razina)}</td>
|
||||
<td onclick="openKlub(${k.id})">${txt(k.grad)}</td>
|
||||
<td onclick="openKlub(${k.id})" class="num"><b style="color:var(--pgz-gold)">${k.ukupno_potpora!=null?fmtEur(k.ukupno_potpora):'—'}</b></td>
|
||||
<td onclick="openKlub(${k.id})" class="num">${fmtNum(k.registriranih)}</td>
|
||||
<td onclick="openKlub(${k.id})" class="num">${fmtNum(k.trenera)}</td>
|
||||
<td onclick="openKlub(${k.id})">${k.financiran?'<span class="tag gd" title="financiran">€</span>':''}${k.godisnjak?'<span class="tag b" title="godišnjak">G</span>':''}${k.nositelj_kvalitete?'<span class="tag gd">N.K.</span>':''}${k.aktivan?'<span class="tag gr">AKT</span>':'<span class="tag rd">NK</span>'}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
<td onclick="openKlub(${k.id})">${k.financiran?'<span class="tag gd" title="'+esc(finTitle)+'">€</span>':''}${k.godisnjak?'<span class="tag b" title="godišnjak">G</span>':''}${k.nositelj_kvalitete?'<span class="tag gd">N.K.</span>':''}${k.aktivan?'<span class="tag gr">AKT</span>':'<span class="tag rd">NK</span>'}</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
|
||||
@@ -2698,77 +2743,131 @@ function openObjekt(id){
|
||||
}
|
||||
|
||||
//=========== MANIFESTACIJE ===========
|
||||
// View mode persisted in localStorage as `_manifViewMode` ('card'|'table')
|
||||
const _manifFilter = {mjesto:'', razina:'', organizator:'', q:''};
|
||||
let _manifMeta = null;
|
||||
let _manifLoadSeq = 0;
|
||||
|
||||
async function loadManifestacije(){
|
||||
const root = $('#pg-manifestacije');
|
||||
if(!_cache.manifestacije){
|
||||
// Restore view mode from localStorage
|
||||
const saved = localStorage.getItem('_manifViewMode');
|
||||
if(saved==='card' || saved==='table') _state.viewManif = saved;
|
||||
if(!_manifMeta){
|
||||
root.innerHTML = '<div class="loading">Učitavanje manifestacija…</div>';
|
||||
const d = await api('/manifestacije-full');
|
||||
if(!d){ root.innerHTML='<div class="empty">Greška pri dohvatu</div>'; return; }
|
||||
_cache.manifestacije = d.rows || (Array.isArray(d) ? d : []);
|
||||
_manifMeta = await api('/v2/manifestacije/meta') || {mjesta:[], razine:[], organizatori:[]};
|
||||
}
|
||||
renderManifShell();
|
||||
applyManifFilter();
|
||||
await reloadManifestacije();
|
||||
}
|
||||
async function reloadManifestacije(){
|
||||
const seq = ++_manifLoadSeq;
|
||||
const out = $('#mn-out');
|
||||
if(out) out.innerHTML = '<div class="loading">Učitavanje…</div>';
|
||||
const cnt = $('#mn-cnt');
|
||||
if(cnt) cnt.textContent = '…';
|
||||
const params = new URLSearchParams();
|
||||
if(_manifFilter.mjesto) params.set('mjesto', _manifFilter.mjesto);
|
||||
if(_manifFilter.razina) params.set('razina', _manifFilter.razina);
|
||||
if(_manifFilter.organizator) params.set('organizator', _manifFilter.organizator);
|
||||
if(_manifFilter.q) params.set('q', _manifFilter.q);
|
||||
params.set('limit', '500');
|
||||
const qs = params.toString();
|
||||
const d = await api('/v2/manifestacije'+(qs?'?'+qs:''));
|
||||
if(seq !== _manifLoadSeq) return; // newer request superseded this one
|
||||
if(!d){
|
||||
if(out) out.innerHTML = '<div class="empty">Greška pri dohvatu</div>';
|
||||
return;
|
||||
}
|
||||
_cache.manifestacije = d.rows || [];
|
||||
renderManifBody();
|
||||
}
|
||||
function renderManifShell(){
|
||||
const root = $('#pg-manifestacije');
|
||||
const razine = Array.from(new Set((_cache.manifestacije||[]).map(m=>m.razina).filter(Boolean))).sort();
|
||||
const meta = _manifMeta || {mjesta:[], razine:[], organizatori:[]};
|
||||
const optList = (arr) => (arr||[]).filter(x=>x!==null && x!==undefined && x!=='').map(v=>'<option value="'+esc(v)+'">'+esc(v)+'</option>').join('');
|
||||
root.innerHTML = `
|
||||
<div class="toolbar">
|
||||
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…">
|
||||
<select id="mn-raz"><option value="">Sve razine</option>${razine.map(r=>'<option value="'+esc(r)+'">'+esc(r)+'</option>').join('')}</select>
|
||||
<input type="search" id="mn-q" placeholder="🔍 Pretraži manifestaciju…" value="${esc(_manifFilter.q)}">
|
||||
<select id="mn-mjesto" title="Mjesto"><option value="">Sva mjesta</option>${optList(meta.mjesta)}</select>
|
||||
<select id="mn-raz" title="Razina"><option value="">Sve razine</option>${optList(meta.razine)}</select>
|
||||
<select id="mn-org" title="Organizator"><option value="">Svi organizatori</option>${optList(meta.organizatori)}</select>
|
||||
<button id="mn-reset" class="btn" type="button" title="Poništi filtere">↺ Reset</button>
|
||||
<div class="toggle">
|
||||
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')">Kartice</button>
|
||||
<button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')">Tablica</button>
|
||||
<button id="mn-card" class="${_state.viewManif==='card'?'active':''}" onclick="setManifView('card')" title="Kartice">🃏 Kartice</button>
|
||||
<button id="mn-table" class="${_state.viewManif==='table'?'active':''}" onclick="setManifView('table')" title="Tablica">📋 Tablica</button>
|
||||
</div>
|
||||
<span class="tb-s" id="mn-cnt"></span>
|
||||
</div>
|
||||
<div id="mn-out"></div>
|
||||
`;
|
||||
$('#mn-q').addEventListener('input', debounce(applyManifFilter, 200));
|
||||
$('#mn-raz').addEventListener('change', applyManifFilter);
|
||||
// Restore selections after re-render
|
||||
if($('#mn-mjesto')) $('#mn-mjesto').value = _manifFilter.mjesto;
|
||||
if($('#mn-raz')) $('#mn-raz').value = _manifFilter.razina;
|
||||
if($('#mn-org')) $('#mn-org').value = _manifFilter.organizator;
|
||||
$('#mn-q').addEventListener('input', debounce(()=>{ _manifFilter.q = $('#mn-q').value.trim(); reloadManifestacije(); }, 250));
|
||||
$('#mn-mjesto').addEventListener('change', ()=>{ _manifFilter.mjesto = $('#mn-mjesto').value; reloadManifestacije(); });
|
||||
$('#mn-raz').addEventListener('change', ()=>{ _manifFilter.razina = $('#mn-raz').value; reloadManifestacije(); });
|
||||
$('#mn-org').addEventListener('change', ()=>{ _manifFilter.organizator = $('#mn-org').value; reloadManifestacije(); });
|
||||
$('#mn-reset').addEventListener('click', ()=>{
|
||||
_manifFilter.mjesto=''; _manifFilter.razina=''; _manifFilter.organizator=''; _manifFilter.q='';
|
||||
$('#mn-q').value=''; $('#mn-mjesto').value=''; $('#mn-raz').value=''; $('#mn-org').value='';
|
||||
reloadManifestacije();
|
||||
});
|
||||
}
|
||||
function setManifView(v){
|
||||
_state.viewManif = v;
|
||||
$('#mn-card').classList.toggle('active', v==='card');
|
||||
$('#mn-table').classList.toggle('active', v==='table');
|
||||
applyManifFilter();
|
||||
try{ localStorage.setItem('_manifViewMode', v); }catch(_){}
|
||||
if($('#mn-card')) $('#mn-card').classList.toggle('active', v==='card');
|
||||
if($('#mn-table')) $('#mn-table').classList.toggle('active', v==='table');
|
||||
renderManifBody();
|
||||
}
|
||||
function applyManifFilter(){
|
||||
const q = (($('#mn-q')?$('#mn-q').value:'') || '').toLowerCase().trim();
|
||||
const raz = $('#mn-raz') ? $('#mn-raz').value : '';
|
||||
function renderManifBody(){
|
||||
let rows = _cache.manifestacije || [];
|
||||
if(q) rows = rows.filter(m => (m.naziv||'').toLowerCase().includes(q) || (m.organizator||'').toLowerCase().includes(q) || (m.mjesto||'').toLowerCase().includes(q));
|
||||
if(raz) rows = rows.filter(m => m.razina===raz);
|
||||
if(_sort.manifestacije) rows = sortRows(rows, _sort.manifestacije.key, _sort.manifestacije.dir);
|
||||
$('#mn-cnt').textContent = rows.length+' manifestacija';
|
||||
$('#mn-out').innerHTML = _state.viewManif==='card' ? renderManifGrid(rows) : renderManifTable(rows);
|
||||
}
|
||||
// Backwards-compat: existing handlers (e.g. sortHeader) call applyManifFilter()
|
||||
function applyManifFilter(){ renderManifBody(); }
|
||||
function manifLinkFor(m){
|
||||
if(m && m.source_url) return m.source_url;
|
||||
const gq = encodeURIComponent(((m&&m.naziv)||'')+' '+((m&&m.mjesto)||'')+' sport');
|
||||
return 'https://www.google.com/search?q='+gq;
|
||||
}
|
||||
function renderManifGrid(rows){
|
||||
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
|
||||
return '<div class="grid">'+rows.map(m => `
|
||||
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
|
||||
return '<div class="grid">'+rows.map(m => {
|
||||
const url = manifLinkFor(m);
|
||||
const linkIcon = '<a class="et-link" href="'+esc(url)+'" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="'+(m.source_url?'Otvori izvor':'Pretraži online')+'">🔗</a>';
|
||||
return `
|
||||
<div class="entity" onclick="openManif(${m.id})">
|
||||
${m.razina?'<div class="et-tag">'+esc(m.razina)+'</div>':''}
|
||||
<div class="et">${esc(m.naziv)}</div>
|
||||
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}</div>
|
||||
<div class="et">${esc(m.naziv)} ${linkIcon}</div>
|
||||
<div class="es">${txt(m.mjesto,'—')}${m.spol_kategorija?' · '+esc(m.spol_kategorija):''}${m.godina_od?' · od '+esc(m.godina_od):''}</div>
|
||||
<div class="em">
|
||||
${m.broj_ucesnika?'<span><b>'+esc(m.broj_ucesnika)+'</b> sudionika</span>':''}
|
||||
${m.organizator?'<span>'+esc((m.organizator||'').slice(0,40))+'</span>':''}
|
||||
</div>
|
||||
</div>`).join('')+'</div>';
|
||||
</div>`;
|
||||
}).join('')+'</div>';
|
||||
}
|
||||
function renderManifTable(rows){
|
||||
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
|
||||
if(!rows.length) return '<div class="empty">Nema manifestacija za zadane filtere</div>';
|
||||
return `<div class="card" style="padding:0;overflow-x:auto"><table>
|
||||
<thead><tr>${sortHeader('manifestacije','naziv','Naziv','')}${sortHeader('manifestacije','mjesto','Mjesto','')}${sortHeader('manifestacije','razina','Razina','')}${sortHeader('manifestacije','organizator','Organizator','')}${sortHeader('manifestacije','broj_ucesnika','Sudionici','')}<th>Link</th></tr></thead>
|
||||
<tbody>${rows.map(m => `
|
||||
<tbody>${rows.map(m => {
|
||||
const url = manifLinkFor(m);
|
||||
return `
|
||||
<tr onclick="openManif(${m.id})">
|
||||
<td><b>${esc(m.naziv)}</b></td>
|
||||
<td>${txt(m.mjesto)}</td>
|
||||
<td>${m.razina?'<span class="tag b">'+esc(m.razina)+'</span>':'—'}</td>
|
||||
<td>${txt(m.organizator)}</td>
|
||||
<td>${txt(m.broj_ucesnika)}</td>
|
||||
<td>${m.source_url?'<a href="'+esc(m.source_url)+'" target="_blank">↗</a>':'—'}</td>
|
||||
</tr>`).join('')}</tbody>
|
||||
<td><a href="${esc(url)}" target="_blank" rel="noopener" onclick="event.stopPropagation()" title="${m.source_url?'Otvori izvor':'Pretraži online'}">🔗</a></td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody>
|
||||
</table></div>`;
|
||||
}
|
||||
function openManif(id){
|
||||
@@ -3815,5 +3914,6 @@ window.closePanel = function(){
|
||||
if(ov){ ov.classList.remove('open'); ov.style.removeProperty('display'); }
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/export_dropdown.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user