CRITICAL FIX (Slika 11, 12): /api/v2/auth/me alias + frontend fix
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')
This commit is contained in:
+1
Submodule .claude/worktrees/agent-a2230c7d02a7c02f4 added at 8127e2ef22
+1
Submodule .claude/worktrees/agent-a54ff6ad4250d2734 added at 8127e2ef22
+1
Submodule .claude/worktrees/agent-a70769f0db14302aa added at 8127e2ef22
+1
Submodule .claude/worktrees/agent-af39fdf2dbfd08afe added at 8127e2ef22
@@ -0,0 +1,58 @@
|
|||||||
|
-- ═══════════════════════════════════════════════════════════════════
|
||||||
|
-- email_templates_20260505.sql | v1.0.0 | 2026-05-05
|
||||||
|
-- Author: Damir Radulić <dradulic@outlook.com> / 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;
|
||||||
+48
-12
@@ -677,8 +677,13 @@ def get_savez(savez_id: int, authorization: Optional[str] = Header(None)):
|
|||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
# Endpoint: GET /api/klubovi
|
# Endpoint: GET /api/klubovi
|
||||||
# Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one)
|
# Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one)
|
||||||
# Date: 2026-05-05 (BUG-E filter sprint)
|
# Date: 2026-05-05 (RUSH-1 klubovi filter sprint)
|
||||||
# Note: `samo_hns_roster` added — keeps priority-sort behaviour but
|
# 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.
|
# lets UI filter to klubs that have at least 1 HNS roster row.
|
||||||
# ─────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────
|
||||||
@app.get("/api/klubovi")
|
@app.get("/api/klubovi")
|
||||||
@@ -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,
|
kategorija: Optional[str] = None, godisnjak: Optional[bool] = None, financiran: Optional[bool] = None,
|
||||||
samo_hns_roster: Optional[bool] = None,
|
samo_hns_roster: Optional[bool] = None,
|
||||||
sort: str = "naziv", order: str = "asc"):
|
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"]
|
where = ["v.aktivan"]
|
||||||
params = []
|
params = []
|
||||||
if q:
|
if q:
|
||||||
@@ -703,29 +715,38 @@ def list_klubovi(authorization: Optional[str] = Header(None), q: Optional[str] =
|
|||||||
if sport:
|
if sport:
|
||||||
where.append("v.sport ILIKE %s"); params.append(f"%{sport}%")
|
where.append("v.sport ILIKE %s"); params.append(f"%{sport}%")
|
||||||
if financiran is not None:
|
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 is not None:
|
||||||
if godisnjak:
|
where.append(f"{god_expr} = {'TRUE' if godisnjak else 'FALSE'}")
|
||||||
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)")
|
|
||||||
if kategorija and kategorija.strip().lower() == "priority":
|
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:
|
if samo_hns_roster:
|
||||||
where.append("EXISTS (SELECT 1 FROM pgz_sport.hns_klub_roster r WHERE r.klub_id = k.id)")
|
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",
|
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"
|
order_sql = "DESC" if order.lower() == "desc" else "ASC"
|
||||||
where_sql = " AND ".join(where) if where else "TRUE"
|
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 ""
|
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.*,
|
rows = fetch(f"""SELECT v.*,
|
||||||
COALESCE(k.pgz_sufinanciran,false) AS financiran,
|
{fin_expr} AS financiran,
|
||||||
(k.godisnjak_godine IS NOT NULL AND array_length(k.godisnjak_godine,1) > 0) AS godisnjak,
|
{god_expr} AS godisnjak,
|
||||||
{priority_expr} AS priority,
|
{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
|
k.godisnjak_godine, k.godisnjak_prvi, k.godisnjak_zadnji
|
||||||
FROM pgz_sport.v_klubovi_pregled v
|
FROM pgz_sport.v_klubovi_pregled v
|
||||||
LEFT JOIN pgz_sport.klubovi k ON k.id = v.id
|
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}
|
WHERE {where_sql}
|
||||||
ORDER BY {priority_expr} DESC NULLS LAST,
|
ORDER BY {priority_expr} DESC NULLS LAST,
|
||||||
{sort_col}{collate} {order_sql} NULLS LAST""", params)
|
{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))
|
""", (savez_id, savez_id, savez_id, savez_id, savez_id, savez_id))
|
||||||
return rows[0] if rows else {}
|
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("/")
|
@app.get("/")
|
||||||
def root(request: Request):
|
def root(request: Request):
|
||||||
host = request.headers.get("host", "")
|
host = request.headers.get("host", "")
|
||||||
|
|||||||
@@ -1446,3 +1446,97 @@ def get_obrazac_template(tid: int, user=Depends(_require_user)):
|
|||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "not found")
|
raise HTTPException(404, "not found")
|
||||||
return _row(row)
|
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}
|
||||||
|
|||||||
+53
-6
@@ -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 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; }
|
.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;
|
.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: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 .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; }
|
.main { padding:14px 18px; height:calc(100vh - 50px - 36px); overflow:auto; }
|
||||||
.tab-c { display:none; }
|
.tab-c { display:none; }
|
||||||
@@ -580,7 +627,7 @@ function switchTab(name) {
|
|||||||
async function loadMe() {
|
async function loadMe() {
|
||||||
try {
|
try {
|
||||||
const tok = getToken();
|
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');
|
document.getElementById('me').textContent = (me.email || me.full_name || 'user');
|
||||||
} catch { document.getElementById('me').textContent='?'; }
|
} catch { document.getElementById('me').textContent='?'; }
|
||||||
}
|
}
|
||||||
@@ -1234,7 +1281,7 @@ async function delCase(id) {
|
|||||||
let CURRENT_USER = null;
|
let CURRENT_USER = null;
|
||||||
async function ensureMe() {
|
async function ensureMe() {
|
||||||
if (CURRENT_USER) return CURRENT_USER;
|
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) {
|
for (const url of candidates) {
|
||||||
try {
|
try {
|
||||||
const r = await fetch(url, {headers:{'Authorization':'Bearer '+TOKEN}});
|
const r = await fetch(url, {headers:{'Authorization':'Bearer '+TOKEN}});
|
||||||
|
|||||||
+31
-3
@@ -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{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.repr{background:var(--pgz-gold);color:var(--bg0)}
|
||||||
.player-card .badge.hoo{background:var(--pgz-blue2);color:#fff}
|
.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{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}
|
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 '<div class="empty">Nema rezultata</div>';
|
if(!rows.length) return '<div class="empty">Nema rezultata</div>';
|
||||||
return '<div class="grid-player">'+rows.map(c => buildPlayerCard(c)).join('')+'</div>';
|
return '<div class="grid-player">'+rows.map(c => buildPlayerCard(c)).join('')+'</div>';
|
||||||
}
|
}
|
||||||
|
// RUSH-2 (2026-05-05): avatarUrl + avatarHTML helpers. Small circular avatar
|
||||||
|
// to the left of the name in player cards (per Damir slika 6 spec).
|
||||||
|
// Author: Damir Radulić (dradulic@outlook.com / damir@rinet.one)
|
||||||
|
function avatarUrl(c){
|
||||||
|
if(!c) return null;
|
||||||
|
const u = c.slika_url || c.avatar || c.photo_url;
|
||||||
|
if(!u) return null;
|
||||||
|
if(/^https?:/i.test(u)) return u;
|
||||||
|
if(u.startsWith('/')) return u;
|
||||||
|
return '/sport/uploads/avatars/'+u;
|
||||||
|
}
|
||||||
|
function avatarHTML(c, sizePx){
|
||||||
|
const sz = sizePx || 36;
|
||||||
|
const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase();
|
||||||
|
const url = avatarUrl(c);
|
||||||
|
if(url){
|
||||||
|
return '<span class="rush2-avatar" style="width:'+sz+'px;height:'+sz+'px;font-size:'+Math.round(sz*0.4)+'px"><img src="'+esc(url)+'" alt="" onerror="this.style.display=\'none\';this.parentElement.classList.add(\'r2a-fb\');this.parentElement.innerHTML=\''+initials+'\'"></span>';
|
||||||
|
}
|
||||||
|
return '<span class="rush2-avatar r2a-fb" style="width:'+sz+'px;height:'+sz+'px;font-size:'+Math.round(sz*0.4)+'px">'+initials+'</span>';
|
||||||
|
}
|
||||||
function buildPlayerCard(c){
|
function buildPlayerCard(c){
|
||||||
const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase();
|
const initials = (((c.ime||'?')[0]||'?')+((c.prezime||'?')[0]||'?')).toUpperCase();
|
||||||
const photo = c.slika_url ? '<img src="'+esc(c.slika_url)+'" alt="" onerror="this.style.display=\'none\';if(this.parentElement)this.parentElement.innerHTML=\'<div class=\\\'no\\\'>'+initials+'</div>\'">' : '<div class="no">'+initials+'</div>';
|
const photoSrc = avatarUrl(c) || c.slika_url;
|
||||||
|
const photo = photoSrc ? '<img src="'+esc(photoSrc)+'" alt="" onerror="this.style.display=\'none\';if(this.parentElement)this.parentElement.innerHTML=\'<div class=\\\'no\\\'>'+initials+'</div>\'">' : '<div class="no">'+initials+'</div>';
|
||||||
const hooCat = c.hoo_kategorija || c.kategorija_hoo;
|
const hooCat = c.hoo_kategorija || c.kategorija_hoo;
|
||||||
|
const smallAv = avatarHTML(c, 32);
|
||||||
return `
|
return `
|
||||||
<div class="player-card" onclick="openSportas(${c.id})">
|
<div class="player-card" onclick="openSportas(${c.id})">
|
||||||
<div class="ph">${photo}</div>
|
<div class="ph">${photo}</div>
|
||||||
<div class="pb">
|
<div class="pb">
|
||||||
<div class="pn">${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}</div>
|
<div class="pn-row">${smallAv}<div class="pn">${(window.pgzBadgePrefix?window.pgzBadgePrefix(c,'sportas'):'')}${esc(c.ime||'')} ${esc(c.prezime||'')}</div></div>
|
||||||
<div class="pp">${txt(c.sport,'—')} · ${txt(c.pozicija,'')}</div>
|
<div class="pp">${txt(c.sport,'—')} · ${txt(c.pozicija,'')}</div>
|
||||||
<div class="pk">${txt(c.klub_naziv_godisnjak,'')}</div>
|
<div class="pk">${txt(c.klub_naziv_godisnjak||c.klub_naziv,'')}</div>
|
||||||
<div class="badges">
|
<div class="badges">
|
||||||
${c.reprezentativac?'<span class="badge repr">REPR</span>':''}
|
${c.reprezentativac?'<span class="badge repr">REPR</span>':''}
|
||||||
${hooCat?'<span class="badge hoo">HOO '+esc(hooCat)+'</span>':''}
|
${hooCat?'<span class="badge hoo">HOO '+esc(hooCat)+'</span>':''}
|
||||||
|
|||||||
Reference in New Issue
Block a user