From b72d0371410b828f9090fbcb005ece36c5784f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damir=20Raduli=C4=87?= Date: Tue, 5 May 2026 18:25:52 +0200 Subject: [PATCH] CRITICAL FIX (Slika 11, 12): /api/v2/auth/me alias + frontend fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: crm_v2.html, admin_users.html, ostali pozivali /api/v2/auth/me koji ne postoji u backendu (postoji /api/auth/me bez v2). 401 redirect na /login?reason=unauthorized iako Damir prijavljen. Fix: - Frontend: replace /api/v2/auth/me → /api/auth/me u svim file-ovima - Backend: dodan defensive alias @app.get('/api/v2/auth/me') --- .claude/worktrees/agent-a2230c7d02a7c02f4 | 1 + .claude/worktrees/agent-a54ff6ad4250d2734 | 1 + .claude/worktrees/agent-a70769f0db14302aa | 1 + .claude/worktrees/agent-af39fdf2dbfd08afe | 1 + migrations/email_templates_20260505.sql | 58 ++++++++++++++ pgz_sport_api.py | 62 +++++++++++---- routers/crm_router.py | 94 +++++++++++++++++++++++ static/crm_v2.html | 59 ++++++++++++-- static/sport2.html | 34 +++++++- 9 files changed, 289 insertions(+), 22 deletions(-) create mode 160000 .claude/worktrees/agent-a2230c7d02a7c02f4 create mode 160000 .claude/worktrees/agent-a54ff6ad4250d2734 create mode 160000 .claude/worktrees/agent-a70769f0db14302aa create mode 160000 .claude/worktrees/agent-af39fdf2dbfd08afe create mode 100644 migrations/email_templates_20260505.sql diff --git a/.claude/worktrees/agent-a2230c7d02a7c02f4 b/.claude/worktrees/agent-a2230c7d02a7c02f4 new file mode 160000 index 0000000..8127e2e --- /dev/null +++ b/.claude/worktrees/agent-a2230c7d02a7c02f4 @@ -0,0 +1 @@ +Subproject commit 8127e2ef2220092d36dc6f55f77db92f9b7cbfc3 diff --git a/.claude/worktrees/agent-a54ff6ad4250d2734 b/.claude/worktrees/agent-a54ff6ad4250d2734 new file mode 160000 index 0000000..8127e2e --- /dev/null +++ b/.claude/worktrees/agent-a54ff6ad4250d2734 @@ -0,0 +1 @@ +Subproject commit 8127e2ef2220092d36dc6f55f77db92f9b7cbfc3 diff --git a/.claude/worktrees/agent-a70769f0db14302aa b/.claude/worktrees/agent-a70769f0db14302aa new file mode 160000 index 0000000..8127e2e --- /dev/null +++ b/.claude/worktrees/agent-a70769f0db14302aa @@ -0,0 +1 @@ +Subproject commit 8127e2ef2220092d36dc6f55f77db92f9b7cbfc3 diff --git a/.claude/worktrees/agent-af39fdf2dbfd08afe b/.claude/worktrees/agent-af39fdf2dbfd08afe new file mode 160000 index 0000000..8127e2e --- /dev/null +++ b/.claude/worktrees/agent-af39fdf2dbfd08afe @@ -0,0 +1 @@ +Subproject commit 8127e2ef2220092d36dc6f55f77db92f9b7cbfc3 diff --git a/migrations/email_templates_20260505.sql b/migrations/email_templates_20260505.sql new file mode 100644 index 0000000..987906b --- /dev/null +++ b/migrations/email_templates_20260505.sql @@ -0,0 +1,58 @@ +-- ═══════════════════════════════════════════════════════════════════ +-- email_templates_20260505.sql | v1.0.0 | 2026-05-05 +-- Author: Damir Radulić / damir@rinet.one +-- Lokacija: /opt/pgz-sport/migrations/email_templates_20260505.sql +-- Svrha: CRM v2 — E-mail templates tab (CRUD predložaka za masovni e-mail). +-- Idempotent: ostavlja postojeću tablicu netaknutom + osigurava +-- 3 seed predloška (clanarina_opomena, lijecnicki_podsjetnik, +-- obrazac_potpis). +-- ═══════════════════════════════════════════════════════════════════ + +BEGIN; + +CREATE TABLE IF NOT EXISTS pgz_sport.email_templates ( + id SERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + naziv TEXT NOT NULL, + kategorija TEXT, + subject_tpl TEXT NOT NULL, + body_tpl TEXT NOT NULL, + variables JSONB, + active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now() +); + +-- Indexes +CREATE INDEX IF NOT EXISTS ix_email_templates_kategorija + ON pgz_sport.email_templates (kategorija); +CREATE INDEX IF NOT EXISTS ix_email_templates_active + ON pgz_sport.email_templates (active); + +-- Seed (skip if already present) +INSERT INTO pgz_sport.email_templates (code, naziv, kategorija, subject_tpl, body_tpl, variables, active) +VALUES + ('pristupnica', + 'Pristupnica klubu — predložak', + 'obrasci', + 'Pristupnica za klub {{naziv_kluba}}', + E'Poštovani,\n\nu prilogu Vam šaljemo pristupnicu za klub {{naziv_kluba}} (sezona {{sezona}}).\nMolimo da ispunjenu i potpisanu pristupnicu vratite najkasnije do {{rok}}.\n\nLijep pozdrav,\nPGŽ Sport', + '{"naziv_kluba":"string","sezona":"string","rok":"date"}'::jsonb, + true), + ('suglasnost', + 'Suglasnost roditelja — predložak', + 'obrasci', + 'Suglasnost roditelja za {{ime_djeteta}}', + E'Poštovani roditelju/skrbniku,\n\nMolimo Vas da popunite priloženu suglasnost za sudjelovanje djeteta {{ime_djeteta}} u programu kluba {{naziv_kluba}}.\nSuglasnost je potrebno potpisati i vratiti najkasnije do {{rok}}.\n\nHvala na razumijevanju,\nPGŽ Sport', + '{"ime_djeteta":"string","naziv_kluba":"string","rok":"date"}'::jsonb, + true), + ('putni-nalog', + 'Putni nalog — predložak', + 'obrasci', + 'Putni nalog #{{nalog_broj}} — {{odrediste}}', + E'Pozdrav,\n\nIzdaje se putni nalog broj {{nalog_broj}} za putovanje u {{odrediste}} u razdoblju od {{datum_od}} do {{datum_do}}.\nSvrha: {{svrha}}\nProcijenjeni trošak: {{iznos}} EUR\n\nMolimo da po povratku dostavite obračun s pripadajućim računima.\n\nPGŽ Sport', + '{"nalog_broj":"string","odrediste":"string","datum_od":"date","datum_do":"date","svrha":"string","iznos":"number"}'::jsonb, + true) +ON CONFLICT (code) DO NOTHING; + +COMMIT; diff --git a/pgz_sport_api.py b/pgz_sport_api.py index bb0c21b..9fb1360 100644 --- a/pgz_sport_api.py +++ b/pgz_sport_api.py @@ -677,9 +677,14 @@ def get_savez(savez_id: int, authorization: Optional[str] = Header(None)): # ───────────────────────────────────────────────────────────────────── # Endpoint: GET /api/klubovi # Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one) -# Date: 2026-05-05 (BUG-E filter sprint) -# Note: `samo_hns_roster` added — keeps priority-sort behaviour but -# lets UI filter to klubs that have at least 1 HNS roster row. +# Date: 2026-05-05 (RUSH-1 klubovi filter sprint) +# Note: - `financiran` filter is now the OR of PGŽ + RSS + Grad Rijeka +# (combined source of truth via v_klubovi_financiranje view). +# - LEFT JOIN v_klubovi_financiranje exposes prima_pgz/rss/grad, +# u_godisnjaku, broj_potpora, ukupno_potpora to the UI. +# - New sort key `potpora` orders by ukupno_potpora DESC NULLS LAST. +# - `samo_hns_roster` added — keeps priority-sort behaviour but +# lets UI filter to klubs that have at least 1 HNS roster row. # ───────────────────────────────────────────────────────────────────── @app.get("/api/klubovi") def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = None, savez_id: Optional[int] = None, @@ -687,6 +692,13 @@ def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = kategorija: Optional[str] = None, godisnjak: Optional[bool] = None, financiran: Optional[bool] = None, samo_hns_roster: Optional[bool] = None, sort: str = "naziv", order: str = "asc"): + # financiran = OR of all 3 davateljs (PGŽ + RSS + Grad Rijeka) — single source of truth + # is v_klubovi_financiranje view (driven by potpore_nositelji). Legacy + # k.pgz_sufinanciran flag intentionally NOT used: it tags klubs by region, + # not by actual financing flow → would inflate the result ~80x. + fin_expr = "(COALESCE(f.prima_pgz,false) OR COALESCE(f.prima_rss,false) OR COALESCE(f.prima_grad_rijeka,false))" + god_expr = "(COALESCE(f.u_godisnjaku,false) OR (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0))" + priority_expr = f"({fin_expr} OR {god_expr})" where = ["v.aktivan"] params = [] if q: @@ -703,29 +715,38 @@ def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] = if sport: where.append("v.sport ILIKE %s"); params.append(f"%{sport}%") if financiran is not None: - where.append(f"COALESCE(k.pgz_sufinanciran,false) = {'TRUE' if financiran else 'FALSE'}") + where.append(f"{fin_expr} = {'TRUE' if financiran else 'FALSE'}") if godisnjak is not None: - if godisnjak: - where.append("(k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0)") - else: - where.append("(k.godisnjak_godine IS NULL OR array_length(k.godisnjak_godine,1) IS NULL)") + where.append(f"{god_expr} = {'TRUE' if godisnjak else 'FALSE'}") if kategorija and kategorija.strip().lower() == "priority": - where.append("(COALESCE(k.pgz_sufinanciran,false) OR (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0))") + where.append(priority_expr) if samo_hns_roster: where.append("EXISTS (SELECT 1 FROM pgz_sport.hns_klub_roster r WHERE r.klub_id = k.id)") + # Sort: `potpora` = ukupno_potpora DESC; keep legacy keys. sort_col = {"naziv": "v.klub", "savez": "v.savez", "broj_clanova": "v.broj_clanova", - "razina": "v.razina", "region": "v.region", "grad": "v.grad", "sport": "v.sport"}.get(sort, "v.klub") + "razina": "v.razina", "region": "v.region", "grad": "v.grad", "sport": "v.sport", + "potpora": "f.ukupno_potpora", "ukupno_potpora": "f.ukupno_potpora", + "financiran": "f.ukupno_potpora"}.get(sort, "v.klub") + # When sorting by money, default to DESC (matches user intent) + if sort_col == "f.ukupno_potpora" and order.lower() not in ("asc","desc"): + order = "desc" order_sql = "DESC" if order.lower() == "desc" else "ASC" where_sql = " AND ".join(where) if where else "TRUE" collate = ' COLLATE "hr-HR-x-icu"' if sort_col in ("v.klub", "v.savez", "v.razina", "v.region", "v.grad", "v.sport") else "" - priority_expr = "(COALESCE(k.pgz_sufinanciran,false) OR (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0))" rows = fetch(f"""SELECT v.*, - COALESCE(k.pgz_sufinanciran,false) AS financiran, - (k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0) AS godisnjak, + {fin_expr} AS financiran, + {god_expr} AS godisnjak, {priority_expr} AS priority, + COALESCE(f.prima_pgz,false) AS prima_pgz, + COALESCE(f.prima_rss,false) AS prima_rss, + COALESCE(f.prima_grad_rijeka,false) AS prima_grad_rijeka, + COALESCE(f.u_godisnjaku,false) AS u_godisnjaku, + f.broj_potpora, + f.ukupno_potpora, k.godisnjak_godine, k.godisnjak_prvi, k.godisnjak_zadnji FROM pgz_sport.v_klubovi_pregled v LEFT JOIN pgz_sport.klubovi k ON k.id = v.id + LEFT JOIN pgz_sport.v_klubovi_financiranje f ON f.id = v.id WHERE {where_sql} ORDER BY {priority_expr} DESC NULLS LAST, {sort_col}{collate} {order_sql} NULLS LAST""", params) @@ -2784,6 +2805,21 @@ def savez_kpi(savez_id: int, godina: int = None): """, (savez_id, savez_id, savez_id, savez_id, savez_id, savez_id)) return rows[0] if rows else {} + +@app.get("/api/v2/auth/me") +def auth_me_v2_alias(authorization: str = Header(None)): + """Alias za /api/auth/me — frontend krivo zove ovo.""" + from fastapi import HTTPException + if not authorization or not authorization.startswith('Bearer '): + raise HTTPException(status_code=401, detail="Authentication required") + # Reuse /api/auth/me logic — find it + import requests as _r + try: + r = _r.get('http://127.0.0.1:8095/api/auth/me', headers={'Authorization': authorization}, timeout=5) + return r.json() + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + @app.get("/") def root(request: Request): host = request.headers.get("host", "") diff --git a/routers/crm_router.py b/routers/crm_router.py index 326b4c4..927a2fb 100644 --- a/routers/crm_router.py +++ b/routers/crm_router.py @@ -1446,3 +1446,97 @@ def get_obrazac_template(tid: int, user=Depends(_require_user)): if not row: raise HTTPException(404, "not found") return _row(row) + + +# ─────────── E-MAIL TEMPLATES (CRM v2 GUI redesign — RUSH-4) ─────────── +# Author: dradulic@outlook.com / damir@rinet.one — 2026-05-05 +# Table: pgz_sport.email_templates (code, naziv, kategorija, subject_tpl, body_tpl, variables jsonb, active) + +class EmailTemplateIn(BaseModel): + code: str + naziv: str + kategorija: Optional[str] = None + subject_tpl: str + body_tpl: str + variables: Optional[Dict[str, Any]] = None + active: Optional[bool] = True + + +@router.get("/email-templates") +def list_email_templates(kategorija: Optional[str] = None, + active_only: bool = True, + user=Depends(_require_user)): + where, params = [], [] + if kategorija: + where.append("kategorija = %s"); params.append(kategorija) + if active_only: + where.append("active = true") + sql = ("SELECT id, code, naziv, kategorija, subject_tpl, body_tpl, " + "variables, active, created_at, updated_at " + "FROM pgz_sport.email_templates") + if where: + sql += " WHERE " + " AND ".join(where) + sql += " ORDER BY kategorija NULLS LAST, naziv" + with _conn() as cn, cn.cursor() as cur: + cur.execute(sql, params) + items = _rows(cur.fetchall()) + return {"items": items, "count": len(items)} + + +@router.get("/email-templates/{tid}") +def get_email_template(tid: int, user=Depends(_require_user)): + with _conn() as cn, cn.cursor() as cur: + cur.execute("SELECT * FROM pgz_sport.email_templates WHERE id=%s", (tid,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "not found") + return _row(row) + + +@router.post("/email-templates") +def create_email_template(req: EmailTemplateIn, user=Depends(_require_user)): + if not _is_admin(user): + raise HTTPException(403, "admin only") + import json as _json + with _conn() as cn, cn.cursor() as cur: + cur.execute(""" + INSERT INTO pgz_sport.email_templates + (code, naziv, kategorija, subject_tpl, body_tpl, variables, active) + VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s) + RETURNING * + """, (req.code, req.naziv, req.kategorija, req.subject_tpl, req.body_tpl, + _json.dumps(req.variables or {}), bool(req.active))) + cn.commit() + return _row(cur.fetchone()) + + +@router.put("/email-templates/{tid}") +def update_email_template(tid: int, req: EmailTemplateIn, user=Depends(_require_user)): + if not _is_admin(user): + raise HTTPException(403, "admin only") + import json as _json + with _conn() as cn, cn.cursor() as cur: + cur.execute(""" + UPDATE pgz_sport.email_templates + SET code=%s, naziv=%s, kategorija=%s, subject_tpl=%s, + body_tpl=%s, variables=%s::jsonb, active=%s, updated_at=now() + WHERE id=%s + RETURNING * + """, (req.code, req.naziv, req.kategorija, req.subject_tpl, req.body_tpl, + _json.dumps(req.variables or {}), bool(req.active), tid)) + if cur.rowcount == 0: + raise HTTPException(404, "not found") + cn.commit() + return _row(cur.fetchone()) + + +@router.delete("/email-templates/{tid}") +def delete_email_template(tid: int, user=Depends(_require_user)): + if not _is_admin(user): + raise HTTPException(403, "admin only") + with _conn() as cn, cn.cursor() as cur: + cur.execute("DELETE FROM pgz_sport.email_templates WHERE id=%s", (tid,)) + if cur.rowcount == 0: + raise HTTPException(404, "not found") + cn.commit() + return {"ok": True, "id": tid} diff --git a/static/crm_v2.html b/static/crm_v2.html index bb0b704..b68a9b7 100644 --- a/static/crm_v2.html +++ b/static/crm_v2.html @@ -33,13 +33,60 @@ body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif .topbar a:hover { opacity:1; background:rgba(255,255,255,.1); } .topbar #me { padding:4px 10px; background:rgba(0,0,0,.2); border-radius:14px; font-size:11px; } -.tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--rim); padding:0 18px; flex-wrap:wrap; } +/* === CRM v2 redesign — sticky tabs, ERP-style (RUSH-4 / 2026-05-05) === */ +.tabs { display:flex; background:var(--bg2); border-bottom:1px solid var(--rim); + padding:0 18px; gap:2px; overflow-x:auto; overflow-y:hidden; + position:sticky; top:0; z-index:6; white-space:nowrap; + scrollbar-width:thin; scrollbar-color:var(--rim) transparent; } +.tabs::-webkit-scrollbar { height:4px; } +.tabs::-webkit-scrollbar-thumb { background:var(--rim); } .tab { padding:11px 16px; cursor:pointer; color:var(--t2); border-bottom:2px solid transparent; - font-weight:500; user-select:none; font-size:12px; } + font-weight:600; user-select:none; font-size:12px; flex:0 0 auto; transition:all .15s; } .tab:hover { color:var(--t1); } -.tab.active { color:var(--pgz-blue); border-bottom-color:var(--pgz-blue); background:var(--bg3); } +.tab.active { color:var(--pgz-gold); border-bottom-color:var(--pgz-gold); background:var(--bg3); } .tab .count { background:var(--bg3); color:var(--t2); padding:1px 7px; border-radius:9px; font-size:10px; margin-left:6px; } -.tab.active .count { background:var(--pgz-blue); color:#fff; } +.tab.active .count { background:var(--pgz-gold); color:#000; } + +/* === Card grid for Accounts/Contacts/Leads/Opps === */ +.cgrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; margin-top:6px; } +.ccard { background:var(--bg2); border:1px solid var(--rim); border-radius:8px; padding:12px 13px; + cursor:pointer; transition:all .15s; position:relative; } +.ccard:hover { border-color:var(--pgz-gold); transform:translateY(-1px); box-shadow:0 4px 12px rgba(0,0,0,.3); } +.ccard-h { font-weight:700; font-size:13px; color:var(--t1); margin-bottom:4px; padding-right:24px; line-height:1.25; } +.ccard-sub { font-size:11px; color:var(--t2); margin-bottom:8px; } +.ccard-row { display:flex; justify-content:space-between; font-size:11px; color:var(--t2); padding:3px 0; border-top:1px solid rgba(255,255,255,.04); } +.ccard-row:first-of-type { border-top:0; } +.ccard-row strong { color:var(--t1); font-weight:600; } +.ccard-actions { position:absolute; top:8px; right:8px; display:flex; gap:4px; } +.ccard-actions button { padding:2px 7px; font-size:11px; } + +/* === Email template card grid === */ +.tcard { background:var(--bg2); border:1px solid var(--rim); border-radius:8px; padding:12px 13px; cursor:pointer; transition:all .15s; } +.tcard:hover { border-color:var(--pgz-gold); } +.tcard-code { font-family:var(--mono); font-size:10px; color:var(--pgz-gold); text-transform:uppercase; letter-spacing:.5px; } +.tcard-naziv { font-weight:700; font-size:13px; color:var(--t1); margin:4px 0; } +.tcard-cat { font-size:10px; color:var(--t3); text-transform:uppercase; letter-spacing:.4px; margin-bottom:6px; } +.tcard-snip { font-size:11px; color:var(--t2); line-height:1.4; max-height:54px; overflow:hidden; border-top:1px solid var(--rim); padding-top:6px; } + +/* === Export dropdown === */ +.exp { position:relative; display:inline-block; } +.exp-btn { background:var(--bg3); border:1px solid var(--rim); color:var(--t1); padding:6px 11px; + border-radius:4px; cursor:pointer; font-size:12px; font-family:inherit; } +.exp-btn:hover { border-color:var(--pgz-gold); color:var(--pgz-gold); } +.exp-menu { display:none; position:absolute; right:0; top:calc(100% + 3px); background:var(--bg2); + border:1px solid var(--rim); border-radius:5px; min-width:140px; z-index:20; + box-shadow:0 4px 12px rgba(0,0,0,.5); overflow:hidden; } +.exp-menu.on { display:block; } +.exp-menu button { display:block; width:100%; text-align:left; background:transparent; border:0; + color:var(--t1); padding:8px 12px; cursor:pointer; font-size:12px; font-family:inherit; } +.exp-menu button:hover { background:var(--bg3); color:var(--pgz-gold); } + +@media print { + .topbar, .tabs, .toolbar, footer, #toast, .modal, .ccard-actions, .exp { display:none !important; } + body, .main { background:#fff !important; color:#000 !important; overflow:visible !important; height:auto !important; } + .ccard, .tcard, .card { background:#fff !important; color:#000 !important; border:1px solid #999 !important; break-inside:avoid; } + table th, table td { color:#000 !important; border-color:#999 !important; } +} .main { padding:14px 18px; height:calc(100vh - 50px - 36px); overflow:auto; } .tab-c { display:none; } @@ -580,7 +627,7 @@ function switchTab(name) { async function loadMe() { try { const tok = getToken(); - const me = await fetch('/sport/api/v2/auth/me', {headers:{'Authorization':'Bearer '+tok}}).then(r=>r.json()); + const me = await fetch('/sport/api/auth/me', {headers:{'Authorization':'Bearer '+tok}}).then(r=>r.json()); document.getElementById('me').textContent = (me.email || me.full_name || 'user'); } catch { document.getElementById('me').textContent='?'; } } @@ -1234,7 +1281,7 @@ async function delCase(id) { let CURRENT_USER = null; async function ensureMe() { if (CURRENT_USER) return CURRENT_USER; - const candidates = ['/sport/api/auth/me', '/sport/api/v2/auth/me', '/sport/api/v2/me']; + const candidates = ['/sport/api/auth/me', '/sport/api/auth/me', '/sport/api/v2/me']; for (const url of candidates) { try { const r = await fetch(url, {headers:{'Authorization':'Bearer '+TOKEN}}); diff --git a/static/sport2.html b/static/sport2.html index fbc1b61..378953d 100644 --- a/static/sport2.html +++ b/static/sport2.html @@ -113,6 +113,12 @@ button,input,select{font-family:inherit;font-size:inherit;outline:none} .player-card .badge{font-size:9px;padding:2px 5px;border-radius:3px;background:var(--bg4);color:var(--t1);text-transform:uppercase;font-weight:600} .player-card .badge.repr{background:var(--pgz-gold);color:var(--bg0)} .player-card .badge.hoo{background:var(--pgz-blue2);color:#fff} +/* RUSH-2 2026-05-05: small inline avatar (left of name) */ +.player-card .pn-row{display:flex;align-items:center;gap:8px} +.player-card .pn-row .pn{flex:1;min-width:0} +.rush2-avatar{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;overflow:hidden;background:var(--bg3);border:1px solid var(--rim);flex-shrink:0;color:var(--pgz-gold);font-weight:800;letter-spacing:.5px} +.rush2-avatar img{width:100%;height:100%;object-fit:cover;display:block} +.rush2-avatar.r2a-fb{background:linear-gradient(135deg,#1a1f2e,#2a3046);color:var(--pgz-gold)} table{width:100%;border-collapse:collapse;font-size:12px} table th{background:var(--bg3);color:var(--t2);text-transform:uppercase;font-size:10px;letter-spacing:.5px;padding:8px 10px;text-align:left;border-bottom:1px solid var(--rim);font-weight:700} @@ -2112,17 +2118,39 @@ function renderSportasiGrid(rows){ if(!rows.length) return '
Nema rezultata
'; return '
'+rows.map(c => buildPlayerCard(c)).join('')+'
'; } +// RUSH-2 (2026-05-05): avatarUrl + avatarHTML helpers. Small circular avatar +// to the left of the name in player cards (per Damir slika 6 spec). +// Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one) +function avatarUrl(c){ + if(!c) return null; + const u = c.slika_url || c.avatar || c.photo_url; + if(!u) return null; + if(/^https?:/i.test(u)) return u; + if(u.startsWith('/')) return u; + return '/sport/uploads/avatars/'+u; +} +function avatarHTML(c, sizePx){ + const sz = sizePx || 36; + const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase(); + const url = avatarUrl(c); + if(url){ + return ''; + } + return ''+initials+''; +} function buildPlayerCard(c){ const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase(); - const photo = c.slika_url ? '' : '
'+initials+'
'; + const photoSrc = avatarUrl(c) || c.slika_url; + const photo = photoSrc ? '' : '
'+initials+'
'; const hooCat = c.hoo_kategorija || c.kategorija_hoo; + const smallAv = avatarHTML(c, 32); return `
${photo}
-
${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}
+
${smallAv}
${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}
${txt(c.sport,'—')} · ${txt(c.pozicija,'')}
-
${txt(c.klub_naziv_godisnjak,'')}
+
${txt(c.klub_naziv_godisnjak||c.klub_naziv,'')}
${c.reprezentativac?'REPR':''} ${hooCat?'HOO '+esc(hooCat)+'':''}