CC2 R4 #6: real TOTP 2FA (setup + verify + disable + login flow)

- auth/auth_v2.py:
  - pyotp-based TOTP (RFC 6238, base32 secret, ±30s window)
  - new pgz_sport.user_2fa table (auto-created)
  - QR code embedded as data: URL via qrcode lib
  - 8 single-use recovery codes generated at setup
  - /2fa/setup, /2fa/verify, /2fa/disable, /2fa/status endpoints
  - Login flow: when 2FA enabled, requires totp field; recovery codes
    accepted and consumed on use
- static/login.html: TOTP field appears when login returns 2FA_REQUIRED
- static/admin_users.html: full 2FA panel in Sigurnost tab
  (status badge, QR + secret + recovery code display, verify input)

Live tests pass:
  T1 status (no setup) → enabled:false
  T2 setup → secret + 1.5KB QR PNG + 8 recovery codes
  T3 verify wrong code → 401
  T4 verify real TOTP → enabled:true
  T5 login w/o TOTP after enable → 401 detail=2FA_REQUIRED
  T6 login w/ TOTP → 200
This commit is contained in:
Damir Radulić
2026-05-05 00:50:28 +02:00
parent a0db65fc31
commit bd3773434e
10 changed files with 4594 additions and 225 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+140 -13
View File
@@ -276,6 +276,7 @@ def _client(req: Request):
class LoginReq(BaseModel): class LoginReq(BaseModel):
email: str email: str
password: str password: str
totp: Optional[str] = None # 6-digit TOTP if 2FA enabled (or recovery code)
class RefreshReq(BaseModel): class RefreshReq(BaseModel):
refresh_token: str refresh_token: str
@@ -327,6 +328,32 @@ def login(req: LoginReq, request: Request):
(hash_password(req.password), u["id"])) (hash_password(req.password), u["id"]))
except Exception: pass except Exception: pass
# 2FA gate — if user has enabled 2FA, demand a valid TOTP / recovery code
twofa_row = None
try:
twofa_row = db_one("SELECT secret, enabled, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s",
(u["id"],))
except Exception: pass
if twofa_row and twofa_row.get("enabled"):
code = (req.totp or "").strip().replace(" ", "")
if not code:
audit(u["id"], "login.2fa_required", ip=ip, ua=ua)
raise HTTPException(401, "2FA_REQUIRED")
ok = False
if code.isdigit() and len(code) in (6, 8) and HAS_PYOTP:
ok = _pyotp.TOTP(twofa_row["secret"]).verify(code, valid_window=1)
if not ok and twofa_row.get("recovery_codes"):
up = code.upper()
if up in (twofa_row["recovery_codes"] or []):
ok = True
# consume the recovery code so it can't be reused
remaining = [c for c in twofa_row["recovery_codes"] if c != up]
db_exec("UPDATE pgz_sport.user_2fa SET recovery_codes=%s, updated_at=now() WHERE user_id=%s",
(remaining, u["id"]))
if not ok:
audit(u["id"], "login.2fa_fail", ip=ip, ua=ua)
raise HTTPException(401, "Neispravan 2FA kod")
db_exec("""UPDATE pgz_sport.users db_exec("""UPDATE pgz_sport.users
SET failed_login_count=0, locked_until=NULL, last_login=now() SET failed_login_count=0, locked_until=NULL, last_login=now()
WHERE id=%s""", (u["id"],)) WHERE id=%s""", (u["id"],))
@@ -520,20 +547,120 @@ def password_reset(req: ResetPwdReq, request: Request):
return {"status": "ok", return {"status": "ok",
"message": "Ako račun postoji, administrator će vam poslati instrukcije."} "message": "Ako račun postoji, administrator će vam poslati instrukcije."}
# ─────────────────────────── 2FA placeholders (TOTP) ─────────────────────────── # ─────────────────────────── 2FA — real TOTP (RFC 6238) ───────────────────────────
try:
import pyotp as _pyotp
HAS_PYOTP = True
except Exception:
HAS_PYOTP = False
def _ensure_2fa_table():
db_exec("""CREATE TABLE IF NOT EXISTS pgz_sport.user_2fa (
user_id INTEGER PRIMARY KEY REFERENCES pgz_sport.users(id) ON DELETE CASCADE,
secret TEXT NOT NULL,
enabled BOOLEAN DEFAULT false,
verified_at TIMESTAMPTZ,
recovery_codes TEXT[],
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
)""")
_ensure_2fa_table()
def _build_qr_png(otpauth_url: str) -> str:
"""Return a data: URL containing a base64 PNG of the QR code."""
try:
import qrcode, io, base64
img = qrcode.make(otpauth_url)
buf = io.BytesIO()
img.save(buf, format="PNG")
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
except Exception as e:
return ""
def _gen_recovery_codes(n: int = 8) -> List[str]:
return [secrets.token_hex(4).upper() for _ in range(n)]
@router.post("/2fa/setup") @router.post("/2fa/setup")
def twofa_setup(user = Depends(require_user)): def twofa_setup(user = Depends(require_user)):
"""Stub — generate TOTP secret + return otpauth URL. """Generate a TOTP secret, store unverified, and return otpauth URL + QR + recovery codes.
Full TOTP verification will be added in M1.5.""" The 2FA stays disabled until /2fa/verify confirms a valid TOTP code."""
secret = secrets.token_hex(20).upper() if not HAS_PYOTP:
db_exec("""ALTER TABLE pgz_sport.users raise HTTPException(503, "pyotp not installed on server")
ADD COLUMN IF NOT EXISTS two_factor_secret text, secret = _pyotp.random_base32() # 32-char base32, RFC 4648 — what authenticator apps expect
ADD COLUMN IF NOT EXISTS two_factor_enabled boolean DEFAULT false""") recovery = _gen_recovery_codes()
db_exec("UPDATE pgz_sport.users SET two_factor_secret=%s WHERE id=%s", db_exec("""INSERT INTO pgz_sport.user_2fa (user_id, secret, enabled, recovery_codes, updated_at)
(secret, user["id"])) VALUES (%s,%s,false,%s,now())
otpauth = f"otpauth://totp/PGŽ%20Sport:{user['email']}?secret={secret}&issuer=PGZSport" ON CONFLICT (user_id) DO UPDATE SET
return {"secret": secret, "otpauth": otpauth, "enabled": False} secret=EXCLUDED.secret, enabled=false,
recovery_codes=EXCLUDED.recovery_codes, updated_at=now()""",
(user["id"], secret, recovery))
issuer = "PGŽ Sport"
otpauth = _pyotp.TOTP(secret).provisioning_uri(name=user["email"], issuer_name=issuer)
return {
"secret": secret,
"otpauth_url": otpauth,
"qr_png": _build_qr_png(otpauth),
"issuer": issuer,
"account": user["email"],
"recovery_codes": recovery,
"enabled": False,
"instructions": "Skenirajte QR u Google Authenticator / Authy / 1Password, zatim potvrdite kod kroz POST /api/auth/2fa/verify",
}
class TwoFAVerifyReq(BaseModel):
code: str
@router.post("/2fa/verify") @router.post("/2fa/verify")
def twofa_verify(code: str = Body(..., embed=True), user = Depends(require_user)): def twofa_verify(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)):
return {"status": "stub", "verified": False, "code_received": bool(code)} """Verify TOTP code; on success, mark 2FA enabled."""
if not HAS_PYOTP:
raise HTTPException(503, "pyotp not installed on server")
row = db_one("SELECT secret, enabled FROM pgz_sport.user_2fa WHERE user_id=%s",
(user["id"],))
if not row:
raise HTTPException(400, "2FA nije postavljen — pozovite /2fa/setup prvo")
code = (req.code or "").strip().replace(" ", "")
if not code or not code.isdigit() or len(code) not in (6, 8):
raise HTTPException(400, "Neispravan format koda (6-8 znamenki)")
totp = _pyotp.TOTP(row["secret"])
# valid_window=1 → tolerate ±30s drift
if not totp.verify(code, valid_window=1):
ip, ua = _client(request)
audit(user["id"], "2fa.verify.fail", ip=ip, ua=ua)
raise HTTPException(401, "Neispravan TOTP kod")
db_exec("""UPDATE pgz_sport.user_2fa
SET enabled=true, verified_at=now(), updated_at=now()
WHERE user_id=%s""", (user["id"],))
ip, ua = _client(request)
audit(user["id"], "2fa.verify.ok", ip=ip, ua=ua)
return {"status": "ok", "enabled": True}
@router.post("/2fa/disable")
def twofa_disable(req: TwoFAVerifyReq, request: Request, user = Depends(require_user)):
"""Disable 2FA — must verify a current TOTP code (or recovery code)."""
if not HAS_PYOTP:
raise HTTPException(503, "pyotp not installed on server")
row = db_one("SELECT secret, recovery_codes FROM pgz_sport.user_2fa WHERE user_id=%s",
(user["id"],))
if not row:
raise HTTPException(404, "2FA nije postavljen")
code = (req.code or "").strip().replace(" ", "").upper()
valid = False
if code.isdigit() and len(code) in (6, 8):
valid = _pyotp.TOTP(row["secret"]).verify(code, valid_window=1)
elif row.get("recovery_codes") and code in (row["recovery_codes"] or []):
valid = True
if not valid:
raise HTTPException(401, "Neispravan kod")
db_exec("DELETE FROM pgz_sport.user_2fa WHERE user_id=%s", (user["id"],))
ip, ua = _client(request)
audit(user["id"], "2fa.disable", ip=ip, ua=ua)
return {"status": "ok", "enabled": False}
@router.get("/2fa/status")
def twofa_status(user = Depends(require_user)):
row = db_one("SELECT enabled, verified_at, created_at FROM pgz_sport.user_2fa WHERE user_id=%s",
(user["id"],))
return {"enabled": bool(row and row.get("enabled")),
"configured": bool(row),
"verified_at": row.get("verified_at") if row else None}
+222 -190
View File
@@ -172,22 +172,50 @@ def _find_official_web(text: str, hint: str = '') -> Optional[str]:
# ─── External sources ──────────────────────────────────────────────────── # ─── External sources ────────────────────────────────────────────────────
def _wiki_variants(query: str) -> list[str]:
"""Generate sensible Wikipedia HR title variants for a query.
The summary REST API is title-exact; clubs are often listed under their
abbreviation (KK X, NK X, RK X, OK X), so we try those variants too.
"""
if not query: return []
out, seen = [], set()
raw = query.strip()
def _push(v):
if v and v not in seen: seen.add(v); out.append(v)
_push(raw)
# KK Kvarner 2010 from Košarkaški klub KVARNER 2010
parts = raw.split()
sport_to_abbr = {
'košarkaški': 'KK', 'kosarkaski': 'KK',
'nogometni': 'NK', 'rukometni': 'RK',
'odbojkaški': 'OK', 'odbojkaski': 'OK',
'vaterpolski':'VK', 'plivacki': 'PK', 'plivački': 'PK',
'boćarski': 'BK', 'bocarski': 'BK',
}
if len(parts) >= 3 and parts[0].lower() in sport_to_abbr and parts[1].lower() == 'klub':
_push(sport_to_abbr[parts[0].lower()] + ' ' + ' '.join(p.capitalize() if p.isupper() else p for p in parts[2:]))
return out
def _wiki_summary(query: str) -> Optional[dict]: def _wiki_summary(query: str) -> Optional[dict]:
if not query: return None for variant in _wiki_variants(query):
title = urllib.parse.quote(query.replace(' ', '_'), safe='') title = urllib.parse.quote(variant.replace(' ', '_'), safe='')
body = _http_get(f'https://hr.wikipedia.org/api/rest_v1/page/summary/{title}', timeout=5) body = _http_get(f'https://hr.wikipedia.org/api/rest_v1/page/summary/{title}', timeout=5)
if not body: return None if not body: continue
try: try:
d = json.loads(body) d = json.loads(body)
if d.get('type') == 'disambiguation' or 'extract' not in d: return None except Exception:
continue
if d.get('type') in ('disambiguation', 'no-extract'): continue
if not d.get('extract'): continue
return { return {
'source': 'wikipedia.hr', 'source': 'wikipedia.hr',
'url': d.get('content_urls', {}).get('desktop', {}).get('page'), 'url': d.get('content_urls', {}).get('desktop', {}).get('page'),
'title': d.get('title'), 'title': d.get('title'),
'extract': d.get('extract'), 'extract': d.get('extract'),
'description': d.get('description'), 'description': d.get('description'),
'matched_variant': variant,
} }
except Exception:
return None return None
@@ -616,8 +644,192 @@ def _propose_for_sportas(row: dict) -> dict:
# ─── Endpoints ────────────────────────────────────────────────────────── # ─── Endpoints ──────────────────────────────────────────────────────────
@router.post("/enrich/{kind}/{eid}") # ─── R4 — POST /v2/enrich/forensic/{finding_id} ─────────────────────────
def enrich_preview(kind: str, eid: int): def _extract_pep_name(finding: dict) -> Optional[str]:
"""Pull the primary person name from a forensic_findings row."""
title = (finding.get('title') or '').strip()
desc = (finding.get('description') or '').strip()
payload = finding.get('raw_data') or {}
if isinstance(payload, str):
try: payload = json.loads(payload)
except Exception: payload = {}
if isinstance(payload, dict):
for k in ('person_name', 'name', 'osoba'):
v = payload.get(k)
if v: return str(v).strip()
# Try entities_involved.entity_name
ents = finding.get('entities_involved') or []
if isinstance(ents, str):
try: ents = json.loads(ents)
except Exception: ents = []
if isinstance(ents, list):
for e in ents:
if isinstance(e, dict) and e.get('person_name'):
return str(e['person_name']).strip()
if isinstance(e, dict) and e.get('entity_name') and ' ' in (e.get('entity_name') or ''):
# Some entries store person names as entity_name when entity_type='person'
if (e.get('entity_type') or '').lower() in ('person','osoba'):
return str(e['entity_name']).strip()
# Fallback: extract a "Ime Prezime" from the title
m = re.search(r'\b([A-ZČĆŠĐŽ][a-zčćšđž]+)\s+([A-ZČĆŠĐŽ][a-zčćšđž]+(?:-[A-ZČĆŠĐŽ][a-zčćšđž]+)?)\b', title + ' ' + desc)
if m: return f"{m.group(1)} {m.group(2)}"
return None
def _gather_pep_evidence(name: str) -> list[dict]:
sources: list[dict] = []
wiki = _wiki_summary(name)
if wiki: sources.append(wiki)
# DDG html-lite as a "Google snippet" replacement (often OK for HR PEPs)
ddg = 'https://html.duckduckgo.com/html/?q=' + urllib.parse.quote(f'"{name}" PGŽ Hrvatska')
page = _http_get(ddg, timeout=8)
if page:
# First result block
m = re.search(r'<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]{6,200})</a>', page)
snippet_m = re.search(r'<a[^>]+class="result__snippet"[^>]*>(.*?)</a>', page, re.S)
if m:
sources.append({
'source': 'duckduckgo',
'url': html.unescape(m.group(1))[:500],
'title': html.unescape(m.group(2)).strip()[:300],
'extract': re.sub(r'<[^>]+>', ' ', snippet_m.group(1)).strip()[:600] if snippet_m else None,
})
return sources
def _related_entities_for_pep(name: str) -> list[dict]:
"""Pull civic.persons + their entity links so we have the structured graph."""
out: list[dict] = []
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""SELECT id, name, function, party, county, city, oib, trust_tier
FROM civic.persons
WHERE upper(name) ILIKE upper(%s)
ORDER BY oib NULLS LAST, id LIMIT 10""", ('%'+name+'%',))
for p in cur.fetchall():
p = dict(p)
entry = {
'kind': 'person',
'person_id': p['id'], 'person_name': p['name'],
'function': p.get('function'), 'party': p.get('party'),
'county': p.get('county'), 'city': p.get('city'),
'oib': p.get('oib'), 'trust_tier': p.get('trust_tier'),
'entities': [],
}
if p.get('oib'):
cur.execute("""SELECT pel.entity_id, pel.roles, e.name AS entity_name,
e.oib AS entity_oib, e.entity_type, e.city, e.risk_score
FROM civic.person_entity_links pel
LEFT JOIN civic.entities e ON e.id = pel.entity_id
WHERE pel.person_oib=%s LIMIT 30""", (p['oib'],))
for r in cur.fetchall():
entry['entities'].append(dict(r))
out.append(entry)
return out
@router.post("/enrich/forensic/{finding_id}")
def enrich_forensic_v2(finding_id: int,
body: dict = Body(default=None),
x_user_email: Optional[str] = Header(default=None),
x_user_id: Optional[int] = Header(default=None)):
"""Enrich a forensic finding: gather Wiki + DDG snippets + civic graph,
write back to civic.forensic_findings.related_entities, and seal the
payload hash on Polygon (or queue for sealing).
"""
body = body or {}
explicit_name = (body.get('name') or '').strip() or None
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""SELECT id, finding_type, severity, title, description,
entities_involved, raw_data, related_entities, enrichment_metadata
FROM civic.forensic_findings WHERE id=%s""", (finding_id,))
finding = cur.fetchone()
if not finding:
raise HTTPException(404, "finding not found")
finding = dict(finding)
name = explicit_name or _extract_pep_name(finding)
if not name:
raise HTTPException(400, "could not derive a person/entity name; pass {name: \"\"}")
sources = _gather_pep_evidence(name)
related = _related_entities_for_pep(name)
payload = {
'finding_id': finding_id,
'name': name,
'sources': [{'source': s.get('source'), 'url': s.get('url'),
'title': s.get('title')} for s in sources],
'related_entities': related,
'enriched_at': datetime.now(timezone.utc).isoformat(),
}
# Persist back to the finding
enrichment_meta = finding.get('enrichment_metadata') or {}
if not isinstance(enrichment_meta, dict): enrichment_meta = {}
history = enrichment_meta.get('history') or []
history.append({
'at': payload['enriched_at'],
'sources': payload['sources'],
'related_count': len(related),
'user': x_user_email,
})
enrichment_meta['history'] = history[-10:]
enrichment_meta['enriched_at'] = payload['enriched_at']
enrichment_meta['enriched_by'] = x_user_email or 'system'
enrichment_meta['source_count'] = len(sources)
with _db() as c, c.cursor() as cur:
cur.execute("""UPDATE civic.forensic_findings
SET related_entities = %s::jsonb,
enrichment_metadata = %s::jsonb
WHERE id=%s
RETURNING id""",
(json.dumps(related, default=str, ensure_ascii=False),
json.dumps(enrichment_meta, default=str, ensure_ascii=False),
finding_id))
cur.fetchone()
# Seal the enrichment payload hash on Polygon (or queue if no key)
seal_result: dict[str, Any] = {}
try:
sys_path_added = False
try:
from blockchain import seal as _seal_mod # noqa: E402
except Exception:
import sys as _ssys
_ssys.path.insert(0, '/opt/pgz-sport')
from blockchain import seal as _seal_mod # noqa: E402
sys_path_added = True
del sys_path_added # silence linters
h = _seal_mod.hash_payload(payload)
seal_result = _seal_mod.seal_to_polygon(
data_hash=h,
ref_id=str(finding_id),
action='forensic.enriched',
ref_type='forensic_finding',
payload=payload,
user_id=x_user_id,
user_email=x_user_email,
)
except Exception as e:
seal_result = {'error': f'{type(e).__name__}: {e}'}
return {
'finding_id': finding_id,
'name': name,
'sources': sources,
'related_entities': related,
'related_count': len(related),
'enrichment_metadata': enrichment_meta,
'seal': seal_result,
}
from fastapi import Path as _FPath
@router.post("/enrich/{kind:str}/{eid:int}")
def enrich_preview(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'), eid: int = 0):
row = _load_row(kind, eid) row = _load_row(kind, eid)
if kind == 'klub': res = _propose_for_klub(row) if kind == 'klub': res = _propose_for_klub(row)
elif kind == 'savez': res = _propose_for_savez(row) elif kind == 'savez': res = _propose_for_savez(row)
@@ -736,8 +948,9 @@ def _apply_to_db(kind: str, eid: int, fields: dict, sources: list, user_email: O
'after': {k: after.get(k) for k in snap_keys if k in after}} 'after': {k: after.get(k) for k in snap_keys if k in after}}
@router.post("/enrich/{kind}/{eid}/apply") @router.post("/enrich/{kind:str}/{eid:int}/apply")
def enrich_apply(kind: str, eid: int, def enrich_apply(kind: str = _FPath(..., regex='^(klub|savez|sportas)$'),
eid: int = 0,
body: dict = Body(default=None), body: dict = Body(default=None),
x_user_email: Optional[str] = Header(default=None), x_user_email: Optional[str] = Header(default=None),
x_user_id: Optional[int] = Header(default=None)): x_user_id: Optional[int] = Header(default=None)):
@@ -1001,184 +1214,3 @@ def forensic_scan(req: dict = Body(...)):
'total_findings': total_findings, 'critical_findings': crit_findings, 'total_findings': total_findings, 'critical_findings': crit_findings,
'persons': persons, 'scanned_at': int(time.time())} 'persons': persons, 'scanned_at': int(time.time())}
# ─── R4 — POST /v2/enrich/forensic/{finding_id} ─────────────────────────
def _extract_pep_name(finding: dict) -> Optional[str]:
"""Pull the primary person name from a forensic_findings row."""
title = (finding.get('title') or '').strip()
desc = (finding.get('description') or '').strip()
payload = finding.get('raw_data') or {}
if isinstance(payload, str):
try: payload = json.loads(payload)
except Exception: payload = {}
if isinstance(payload, dict):
for k in ('person_name', 'name', 'osoba'):
v = payload.get(k)
if v: return str(v).strip()
# Try entities_involved.entity_name
ents = finding.get('entities_involved') or []
if isinstance(ents, str):
try: ents = json.loads(ents)
except Exception: ents = []
if isinstance(ents, list):
for e in ents:
if isinstance(e, dict) and e.get('person_name'):
return str(e['person_name']).strip()
if isinstance(e, dict) and e.get('entity_name') and ' ' in (e.get('entity_name') or ''):
# Some entries store person names as entity_name when entity_type='person'
if (e.get('entity_type') or '').lower() in ('person','osoba'):
return str(e['entity_name']).strip()
# Fallback: extract a "Ime Prezime" from the title
m = re.search(r'\b([A-ZČĆŠĐŽ][a-zčćšđž]+)\s+([A-ZČĆŠĐŽ][a-zčćšđž]+(?:-[A-ZČĆŠĐŽ][a-zčćšđž]+)?)\b', title + ' ' + desc)
if m: return f"{m.group(1)} {m.group(2)}"
return None
def _gather_pep_evidence(name: str) -> list[dict]:
sources: list[dict] = []
wiki = _wiki_summary(name)
if wiki: sources.append(wiki)
# DDG html-lite as a "Google snippet" replacement (often OK for HR PEPs)
ddg = 'https://html.duckduckgo.com/html/?q=' + urllib.parse.quote(f'"{name}" PGŽ Hrvatska')
page = _http_get(ddg, timeout=8)
if page:
# First result block
m = re.search(r'<a[^>]+class="result__a"[^>]+href="([^"]+)"[^>]*>([^<]{6,200})</a>', page)
snippet_m = re.search(r'<a[^>]+class="result__snippet"[^>]*>(.*?)</a>', page, re.S)
if m:
sources.append({
'source': 'duckduckgo',
'url': html.unescape(m.group(1))[:500],
'title': html.unescape(m.group(2)).strip()[:300],
'extract': re.sub(r'<[^>]+>', ' ', snippet_m.group(1)).strip()[:600] if snippet_m else None,
})
return sources
def _related_entities_for_pep(name: str) -> list[dict]:
"""Pull civic.persons + their entity links so we have the structured graph."""
out: list[dict] = []
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""SELECT id, name, function, party, county, city, oib, trust_tier
FROM civic.persons
WHERE upper(name) ILIKE upper(%s)
ORDER BY oib NULLS LAST, id LIMIT 10""", ('%'+name+'%',))
for p in cur.fetchall():
p = dict(p)
entry = {
'kind': 'person',
'person_id': p['id'], 'person_name': p['name'],
'function': p.get('function'), 'party': p.get('party'),
'county': p.get('county'), 'city': p.get('city'),
'oib': p.get('oib'), 'trust_tier': p.get('trust_tier'),
'entities': [],
}
if p.get('oib'):
cur.execute("""SELECT pel.entity_id, pel.roles, e.name AS entity_name,
e.oib AS entity_oib, e.entity_type, e.city, e.risk_score
FROM civic.person_entity_links pel
LEFT JOIN civic.entities e ON e.id = pel.entity_id
WHERE pel.person_oib=%s LIMIT 30""", (p['oib'],))
for r in cur.fetchall():
entry['entities'].append(dict(r))
out.append(entry)
return out
@router.post("/enrich/forensic/{finding_id}")
def enrich_forensic(finding_id: int,
body: dict = Body(default=None),
x_user_email: Optional[str] = Header(default=None),
x_user_id: Optional[int] = Header(default=None)):
"""Enrich a forensic finding: gather Wiki + DDG snippets + civic graph,
write back to civic.forensic_findings.related_entities, and seal the
payload hash on Polygon (or queue for sealing).
"""
body = body or {}
explicit_name = (body.get('name') or '').strip() or None
with _db() as c, c.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
cur.execute("""SELECT id, finding_type, severity, title, description,
entities_involved, raw_data, related_entities, enrichment_metadata
FROM civic.forensic_findings WHERE id=%s""", (finding_id,))
finding = cur.fetchone()
if not finding:
raise HTTPException(404, "finding not found")
finding = dict(finding)
name = explicit_name or _extract_pep_name(finding)
if not name:
raise HTTPException(400, "could not derive a person/entity name; pass {name: \"\"}")
sources = _gather_pep_evidence(name)
related = _related_entities_for_pep(name)
payload = {
'finding_id': finding_id,
'name': name,
'sources': [{'source': s.get('source'), 'url': s.get('url'),
'title': s.get('title')} for s in sources],
'related_entities': related,
'enriched_at': datetime.now(timezone.utc).isoformat(),
}
# Persist back to the finding
enrichment_meta = finding.get('enrichment_metadata') or {}
if not isinstance(enrichment_meta, dict): enrichment_meta = {}
history = enrichment_meta.get('history') or []
history.append({
'at': payload['enriched_at'],
'sources': payload['sources'],
'related_count': len(related),
'user': x_user_email,
})
enrichment_meta['history'] = history[-10:]
enrichment_meta['enriched_at'] = payload['enriched_at']
enrichment_meta['enriched_by'] = x_user_email or 'system'
enrichment_meta['source_count'] = len(sources)
with _db() as c, c.cursor() as cur:
cur.execute("""UPDATE civic.forensic_findings
SET related_entities = %s::jsonb,
enrichment_metadata = %s::jsonb
WHERE id=%s
RETURNING id""",
(json.dumps(related, default=str, ensure_ascii=False),
json.dumps(enrichment_meta, default=str, ensure_ascii=False),
finding_id))
cur.fetchone()
# Seal the enrichment payload hash on Polygon (or queue if no key)
seal_result: dict[str, Any] = {}
try:
sys_path_added = False
try:
from blockchain import seal as _seal_mod # noqa: E402
except Exception:
import sys as _ssys
_ssys.path.insert(0, '/opt/pgz-sport')
from blockchain import seal as _seal_mod # noqa: E402
sys_path_added = True
del sys_path_added # silence linters
h = _seal_mod.hash_payload(payload)
seal_result = _seal_mod.seal_to_polygon(
data_hash=h,
ref_id=str(finding_id),
action='forensic.enriched',
ref_type='forensic_finding',
payload=payload,
user_id=x_user_id,
user_email=x_user_email,
)
except Exception as e:
seal_result = {'error': f'{type(e).__name__}: {e}'}
return {
'finding_id': finding_id,
'name': name,
'sources': sources,
'related_entities': related,
'related_count': len(related),
'enrichment_metadata': enrichment_meta,
'seal': seal_result,
}
+59
View File
@@ -271,6 +271,30 @@ td.actions-col .btn { padding: 4px 8px; font-size: 11px; }
<div class="tab-content" id="tab-security"> <div class="tab-content" id="tab-security">
<div class="page-header"><h2>Sigurnost</h2></div> <div class="page-header"><h2>Sigurnost</h2></div>
<div class="kpi-grid" id="secKpi"></div> <div class="kpi-grid" id="secKpi"></div>
<div class="section">
<h3>Two-factor authentication (2FA) <small>moj račun</small></h3>
<div id="twofaPanel" style="display:flex;gap:14px;align-items:center;flex-wrap:wrap">
<span id="twofaStatus" class="badge gray">Učitavam…</span>
<button class="btn primary" id="btnEnable2FA">Omogući 2FA</button>
<button class="btn danger" id="btnDisable2FA" style="display:none">Onemogući 2FA</button>
</div>
<div id="twofaSetup" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div style="display:flex;gap:24px;flex-wrap:wrap;align-items:flex-start">
<div style="flex:0 0 220px"><img id="twofaQr" style="background:#fff;padding:8px;border-radius:6px;width:220px;height:220px"></div>
<div style="flex:1;min-width:220px">
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Skenirajte QR u aplikaciji (Google Authenticator, Authy, 1Password, …) ili upišite secret ručno:</div>
<code id="twofaSecret" style="display:block;padding:10px;background:var(--bg);border:1px solid var(--border);border-radius:5px;font-family:'JetBrains Mono',monospace;word-break:break-all;margin-bottom:14px"></code>
<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">Kodovi za oporavak (sačuvajte ih sigurno):</div>
<div id="twofaRecovery" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px;font-family:'JetBrains Mono',monospace;font-size:12px;margin-bottom:14px"></div>
<div class="field">
<label>Potvrda — kod iz autentifikatora</label>
<input type="text" id="twofaConfirm" maxlength="8" inputmode="numeric" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
</div>
<button class="btn primary" id="btnVerify2FA">Potvrdi i aktiviraj</button>
</div>
</div>
</div>
</div>
<div class="section"><h3>Zaključani / failed-login računi</h3> <div class="section"><h3>Zaključani / failed-login računi</h3>
<table><thead><tr><th>E-mail</th><th>Uloga</th><th class="num">Pokušaja</th><th>Zaključan do</th><th class="actions-col">Akcije</th></tr></thead><tbody id="lockedTbody"></tbody></table> <table><thead><tr><th>E-mail</th><th>Uloga</th><th class="num">Pokušaja</th><th>Zaključan do</th><th class="actions-col">Akcije</th></tr></thead><tbody id="lockedTbody"></tbody></table>
</div> </div>
@@ -692,8 +716,43 @@ async function loadSecurity() {
<button class="btn primary" onclick="toggleSuspend(${u.id}, false)">▶ Otključaj</button> <button class="btn primary" onclick="toggleSuspend(${u.id}, false)">▶ Otključaj</button>
</td></tr> </td></tr>
`).join('') || '<tr><td colspan="5" class="empty">Nema zaključanih računa</td></tr>'; `).join('') || '<tr><td colspan="5" class="empty">Nema zaključanih računa</td></tr>';
load2FAStatus();
} }
// 2FA UI
async function load2FAStatus() {
const r = await apiJson('/auth/2fa/status');
const enabled = !!(r && r.enabled);
$('#twofaStatus').className = 'badge ' + (enabled ? 'green' : 'gray');
$('#twofaStatus').textContent = enabled ? '✓ Omogućen' : 'Onemogućen';
$('#btnEnable2FA').style.display = enabled ? 'none' : '';
$('#btnDisable2FA').style.display = enabled ? '' : 'none';
$('#twofaSetup').style.display = 'none';
}
$('#btnEnable2FA').addEventListener('click', async () => {
const r = await apiJson('/auth/2fa/setup', {method:'POST'});
if (!r || !r.qr_png) return toast(r?.detail || 'Greška', 'error');
$('#twofaQr').src = r.qr_png;
$('#twofaSecret').textContent = r.secret;
$('#twofaRecovery').innerHTML = (r.recovery_codes||[]).map(c => `<code style="background:var(--bg);padding:5px 8px;border-radius:4px;border:1px solid var(--border)">${c}</code>`).join('');
$('#twofaSetup').style.display = '';
$('#twofaConfirm').focus();
});
$('#btnVerify2FA').addEventListener('click', async () => {
const code = ($('#twofaConfirm').value || '').trim().replace(/\s/g,'');
if (!code) return toast('Unesite kod', 'error');
const r = await apiJson('/auth/2fa/verify', {method:'POST', body:{code}});
if (r?.status === 'ok') { toast('2FA omogućen ✓'); load2FAStatus(); }
else toast(r?.detail || 'Neispravan kod', 'error');
});
$('#btnDisable2FA').addEventListener('click', async () => {
const code = prompt('Unesite trenutni kod iz autentifikatora (ili recovery kod) za onemogućavanje 2FA:');
if (!code) return;
const r = await apiJson('/auth/2fa/disable', {method:'POST', body:{code: code.trim()}});
if (r?.status === 'ok') { toast('2FA onemogućen'); load2FAStatus(); }
else toast(r?.detail || 'Greška', 'error');
});
// GDPR // GDPR
async function loadGdpr() { async function loadGdpr() {
const er = await apiJson('/admin/gdpr/erasure-requests'); const er = await apiJson('/admin/gdpr/erasure-requests');
+264 -7
View File
@@ -620,6 +620,7 @@ function logout(){
//=========== SECTION TITLES =========== //=========== SECTION TITLES ===========
const TITLES = { const TITLES = {
pgz: { pgz: {
profil:['Moj profil','Osobni podaci i postavke'],
dashboard:['Dashboard','Pregled stanja PGŽ Sporta'], dashboard:['Dashboard','Pregled stanja PGŽ Sporta'],
korisnici:['Korisnici','Upravljanje korisnicima sustava'], korisnici:['Korisnici','Upravljanje korisnicima sustava'],
savezi:['Savezi','246 sportskih saveza'], savezi:['Savezi','246 sportskih saveza'],
@@ -632,6 +633,7 @@ const TITLES = {
forenzika:['Forenzika','Sumnjive transakcije / PEP'], forenzika:['Forenzika','Sumnjive transakcije / PEP'],
}, },
savez: { savez: {
profil:['Moj profil','Osobni podaci'],
dashboard:['Dashboard','Atletski savez PGŽ'], dashboard:['Dashboard','Atletski savez PGŽ'],
klubovi:['Naši klubovi','Klubovi člana saveza'], klubovi:['Naši klubovi','Klubovi člana saveza'],
sportasi:['Naši sportaši','Registrirani sportaši saveza'], sportasi:['Naši sportaši','Registrirani sportaši saveza'],
@@ -641,6 +643,7 @@ const TITLES = {
racuni:['Računi','Računi saveza'], racuni:['Računi','Računi saveza'],
}, },
klub: { klub: {
profil:['Moj profil','Osobni podaci'],
dashboard:['Dashboard','AK Kvarner Rijeka'], dashboard:['Dashboard','AK Kvarner Rijeka'],
clanovi:['Članovi','Članovi kluba'], clanovi:['Članovi','Članovi kluba'],
clanarine:['Članarine','Stanje članarina'], clanarine:['Članarine','Stanje članarina'],
@@ -650,7 +653,8 @@ const TITLES = {
racuni:['Računi','Troškovi kluba'], racuni:['Računi','Troškovi kluba'],
}, },
sportas: { sportas: {
dashboard:['Moj profil','Luka Horvat'], profil:['Moj profil','Osobni podaci'],
dashboard:['Pregled','Moja aktivnost'],
clanarina:['Članarina','Stanje moje članarine'], clanarina:['Članarina','Stanje moje članarine'],
lijecnicki:['Liječnički','Moj liječnički pregled'], lijecnicki:['Liječnički','Moj liječnički pregled'],
dokumenti:['Moji dokumenti','Suglasnosti, ugovori'], dokumenti:['Moji dokumenti','Suglasnosti, ugovori'],
@@ -673,6 +677,247 @@ function loadSection(){
//=========== SECTION RENDERERS =========== //=========== SECTION RENDERERS ===========
const SECTIONS = {}; const SECTIONS = {};
// ──────────────────────── PROFILE PAGE (shared by all roles) ────────────────────────
function profileMe(){
// Real user if available, else demo from ROLES table
if(_state.me) return _state.me;
const r = ROLES[_state.role] || ROLES.pgz;
const parts = String(r.user||'').split(/\s+/);
return {
id: 0, email:'demo@pgz.hr',
full_name: r.user, ime: parts[0]||'', prezime: parts.slice(1).join(' '),
user_type: _state.role==='pgz'?'pgz_admin':_state.role==='savez'?'savez_admin':_state.role==='klub'?'klub_admin':'klub_clan',
tenant_type: _state.role==='pgz'?'pgz':_state.role,
tenant_name: r.sub, tenant_id: null, tier: _state.role==='pgz'?0:_state.role==='savez'?1:2,
oib: '12345678901', telefon:'+385 91 234 5678', phone:null,
last_login: '2026-05-05T00:08:09', preferred_language:'hr',
avatar_url:null, two_factor_enabled:false,
gdpr_consent_at:null, created_at:'2026-04-01T08:00:00',
roles:[{code:'demo', naziv:r.name}]
};
}
function profileRender(){
const u = profileMe();
const name = u.full_name || ((u.ime||'')+' '+(u.prezime||'')).trim() || u.email || '—';
const av = u.avatar_url ? `<img src="${esc(u.avatar_url)}" alt="">`
: (u.google_picture ? `<img src="${esc(u.google_picture)}" alt="">` : esc(initials(name)));
const lastLogin = u.last_login ? new Date(u.last_login).toLocaleString('hr-HR') : '—';
const created = u.created_at ? new Date(u.created_at).toLocaleString('hr-HR') : '—';
const gdpr = u.gdpr_consent_at ? new Date(u.gdpr_consent_at).toLocaleDateString('hr-HR') : null;
const roleLabel = (ROLES[_state.role]||{}).name || u.user_type || 'Korisnik';
return `
<div class="profile-page">
<div class="profile-banner">
<div class="profile-avatar-big" id="prof-av-big" onclick="pickAvatar()" title="Klik za upload nove slike">
${av}
<div class="upload-hint">📷 Promijeni sliku</div>
</div>
<div class="profile-banner-info">
<h1>${esc(name)}</h1>
<div class="role-line">${esc(roleLabel)} · ${esc(u.tenant_name || u.tenant_type || '')}</div>
<div class="tags-row">
<span class="tag b">${esc(u.user_type||'')}</span>
${u.aktivan!==false ? '<span class="tag gr">Aktivan</span>' : '<span class="tag rd">Suspended</span>'}
${u.two_factor_enabled ? '<span class="tag-2fa-on">2FA ON</span>' : '<span class="tag-2fa-off">2FA OFF</span>'}
${gdpr ? `<span class="tag-gdpr">GDPR ${esc(gdpr)}</span>` : ''}
</div>
</div>
<div class="profile-banner-actions">
<button class="btn" onclick="pickAvatar()">📷 Slika</button>
<button class="btn primary" onclick="profileEditAll()">✏ Uredi profil</button>
</div>
</div>
<div class="profile-section">
<h3>Osobni podaci <span class="edit-link" onclick="profileEditAll()">✏ Uredi sva polja</span></h3>
<div class="profile-row" data-f="ime">
<div class="k">Ime</div>
<div class="v">${esc(u.ime||'')||'<span class="empty">—</span>'}</div>
<div class="a"><button class="btn sm" onclick="profileEditField('ime','Ime')">✏</button></div>
</div>
<div class="profile-row" data-f="prezime">
<div class="k">Prezime</div>
<div class="v">${esc(u.prezime||'')||'<span class="empty">—</span>'}</div>
<div class="a"><button class="btn sm" onclick="profileEditField('prezime','Prezime')">✏</button></div>
</div>
<div class="profile-row" data-f="full_name">
<div class="k">Puno ime</div>
<div class="v">${esc(u.full_name||'')||'<span class="empty">—</span>'}</div>
<div class="a"><button class="btn sm" onclick="profileEditField('full_name','Puno ime')">✏</button></div>
</div>
<div class="profile-row" data-f="email">
<div class="k">Email</div>
<div class="v">${esc(u.email||'—')}</div>
<div class="a"><span class="tag">read-only</span></div>
</div>
<div class="profile-row" data-f="telefon">
<div class="k">Telefon</div>
<div class="v">${esc(u.telefon||u.phone||'')||'<span class="empty">—</span>'}</div>
<div class="a"><button class="btn sm" onclick="profileEditField('telefon','Telefon')">✏</button></div>
</div>
<div class="profile-row" data-f="oib">
<div class="k">OIB</div>
<div class="v">${esc(u.oib||'')||'<span class="empty">—</span>'}</div>
<div class="a"><button class="btn sm" onclick="profileEditField('oib','OIB')">✏</button></div>
</div>
<div class="profile-row" data-f="preferred_language">
<div class="k">Jezik sučelja</div>
<div class="v">${esc(u.preferred_language||'hr')}</div>
<div class="a"><button class="btn sm" onclick="profileEditField('preferred_language','Jezik (hr/en)')">✏</button></div>
</div>
</div>
<div class="profile-section">
<h3>Tenant i ovlasti</h3>
<div class="profile-row"><div class="k">Tenant</div><div class="v">${esc(u.tenant_name || '—')}</div><div class="a"></div></div>
<div class="profile-row"><div class="k">Tip tenanta</div><div class="v"><span class="tag b">${esc(u.tenant_type || '—')}</span></div><div class="a"></div></div>
<div class="profile-row"><div class="k">Tier</div><div class="v">${u.tier!=null?u.tier:'—'} ${u.tier===0?'(PGŽ)':u.tier===1?'(savez)':u.tier===2?'(klub)':''}</div><div class="a"></div></div>
<div class="profile-row"><div class="k">User type</div><div class="v"><span class="tag gd">${esc(u.user_type || '—')}</span></div><div class="a"></div></div>
<div class="profile-row"><div class="k">Dodatne uloge</div><div class="v">${(u.roles||[]).map(r => `<span class="tag b" style="margin-right:4px">${esc(r.code)}</span>`).join('')||'<span class="empty">—</span>'}</div><div class="a"></div></div>
</div>
<div class="profile-section">
<h3>Sigurnost <span class="edit-link" onclick="profileChangePassword()">🔑 Promijeni lozinku</span></h3>
<div class="profile-row"><div class="k">2FA</div><div class="v">${u.two_factor_enabled?'<span class="tag-2fa-on">Uključeno</span>':'<span class="tag-2fa-off">Nije postavljeno</span>'}</div><div class="a"><button class="btn sm primary" onclick="profileSetup2FA()">${u.two_factor_enabled?'Provjeri':'Postavi'}</button></div></div>
<div class="profile-row"><div class="k">Mora promijeniti lozinku</div><div class="v">${u.must_change_pwd?'<span class="tag rd">DA</span>':'<span class="tag gr">NE</span>'}</div><div class="a"></div></div>
<div class="profile-row"><div class="k">GDPR pristanak</div><div class="v">${gdpr || '<span class="empty">Nije zabilježen</span>'}</div><div class="a">${gdpr?'':'<button class="btn sm">Dodijeli</button>'}</div></div>
<div class="profile-row"><div class="k">Status računa</div><div class="v"><span class="tag ${u.aktivan===false?'rd':'gr'}">${u.aktivan===false?'Suspended':'Aktivan'}</span></div><div class="a"></div></div>
</div>
<div class="profile-section">
<h3>Aktivnost</h3>
<div class="profile-row"><div class="k">Zadnji login</div><div class="v" style="font-family:var(--mono);font-size:11.5px">${esc(lastLogin)}</div><div class="a"></div></div>
<div class="profile-row"><div class="k">Račun kreiran</div><div class="v" style="font-family:var(--mono);font-size:11.5px">${esc(created)}</div><div class="a"></div></div>
<div class="profile-row"><div class="k">User ID</div><div class="v" style="font-family:var(--mono)">#${esc(u.id||0)}</div><div class="a"></div></div>
</div>
<div class="profile-section">
<h3>GDPR i podaci</h3>
<div style="font-size:11.5px;color:var(--t2);line-height:1.6;margin-bottom:10px">
Imaš pravo na pristup, izmjenu i brisanje svojih osobnih podataka prema GDPR uredbi (čl. 1517, 20).
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" onclick="alert('Izvoz JSON svih podataka — backend M10')">📤 Izvezi moje podatke (JSON)</button>
<button class="btn" onclick="alert('Pregled audit zapisa o pristupu — M10')">🔍 Audit pristupa mojim podacima</button>
<button class="btn" style="border-color:var(--red);color:var(--red)" onclick="profileDeleteAccount()">🗑 Zatraži brisanje računa</button>
</div>
</div>
</div>`;
}
SECTIONS['pgz:profil'] = profileRender;
SECTIONS['savez:profil'] = profileRender;
SECTIONS['klub:profil'] = profileRender;
SECTIONS['sportas:profil']= profileRender;
// Profile actions
function pickAvatar(){
if(!getToken()){
alert('Avatar upload zahtijeva login (JWT). U demo modu nije dostupan.');
return;
}
$('#avatar-input').click();
}
async function onAvatarPick(input){
const f = input.files && input.files[0];
if(!f) return;
if(f.size > 5*1024*1024){ alert('Slika prevelika (>5 MB)'); return; }
const fd = new FormData(); fd.append('file', f);
const av = $('#prof-av-big');
if(av) av.innerHTML = '<div style="font-size:14px;color:var(--t1)">⏳</div>';
const r = await apiAuth('/auth/me/avatar', {method:'POST', body:fd});
input.value = '';
if(r && r.avatar_url){
if(_state.me) _state.me.avatar_url = r.avatar_url;
applyMeToHeader();
loadSection(); // re-render profile
} else {
alert('Upload failed: '+(r&&r.status||'unknown'));
loadSection();
}
}
async function profileEditField(field, label){
const cur = (_state.me && _state.me[field]) || '';
const v = prompt(`${label}:`, cur);
if(v == null) return;
if(!getToken()){
if(_state.me){ _state.me[field] = v; }
else {
// demo: persist on local copy
if(!window._demoMe) window._demoMe = profileMe();
window._demoMe[field] = v;
_state.me = window._demoMe;
}
applyMeToHeader();
loadSection();
return;
}
const r = await apiAuth('/auth/me', {method:'PUT', body: JSON.stringify({[field]: v})});
if(r && !r.__error){ _state.me = r; applyMeToHeader(); loadSection(); }
else alert('Greška pri spremanju: '+(r&&r.status||'unknown'));
}
async function profileEditAll(){
// Open drill-down panel with full edit form
const u = profileMe();
openDetail('Uredi profil', `
<form id="prof-edit-form" onsubmit="return profileSaveAll(event)">
${['ime','prezime','full_name','telefon','oib'].map(f => `
<div style="margin-bottom:12px">
<label style="display:block;font-size:11px;color:var(--t2);margin-bottom:4px;font-weight:600;text-transform:uppercase">${f}</label>
<input name="${f}" value="${esc(u[f]||'')}" style="background:var(--bg3);border:1px solid var(--rim);border-radius:5px;padding:8px 10px;color:var(--t1);font-size:13px;width:100%">
</div>`).join('')}
<div style="margin-bottom:12px">
<label style="display:block;font-size:11px;color:var(--t2);margin-bottom:4px;font-weight:600;text-transform:uppercase">Jezik</label>
<select name="preferred_language" style="background:var(--bg3);border:1px solid var(--rim);border-radius:5px;padding:8px 10px;color:var(--t1);font-size:13px;width:100%">
<option value="hr" ${(u.preferred_language||'hr')==='hr'?'selected':''}>Hrvatski</option>
<option value="en" ${u.preferred_language==='en'?'selected':''}>English</option>
</select>
</div>
<div style="display:flex;gap:8px;margin-top:18px">
<button type="submit" class="btn primary">💾 Spremi</button>
<button type="button" class="btn" onclick="closeDetail()">Odustani</button>
</div>
</form>`);
}
async function profileSaveAll(ev){
ev.preventDefault();
const fd = new FormData(ev.target);
const obj = {}; fd.forEach((v,k) => { obj[k]=v; });
if(!getToken()){
Object.assign(_state.me || {}, obj);
applyMeToHeader(); closeDetail(); loadSection();
return false;
}
const r = await apiAuth('/auth/me', {method:'PUT', body: JSON.stringify(obj)});
if(r && !r.__error){ _state.me = r; applyMeToHeader(); closeDetail(); loadSection(); }
else alert('Greška: '+(r&&r.status||'unknown'));
return false;
}
async function profileChangePassword(){
if(!getToken()){ alert('Login potreban (demo mode).'); return; }
const oldp = prompt('Stara lozinka:'); if(oldp==null) return;
const newp = prompt('Nova lozinka (min 8 znakova):'); if(newp==null) return;
if(newp.length < 8){ alert('Nova lozinka mora imati barem 8 znakova'); return; }
const r = await apiAuth('/auth/password/change', {method:'POST', body: JSON.stringify({old_password:oldp,new_password:newp})});
if(r && r.status==='ok') alert('Lozinka promijenjena ✓'); else alert('Greška: '+(r&&r.status||'unknown'));
}
async function profileSetup2FA(){
if(!getToken()){ alert('Login potreban (demo mode).'); return; }
const r = await apiAuth('/auth/2fa/setup', {method:'POST'});
if(r && r.qr_url) {
openDetail('Postavi 2FA', `<div style="text-align:center"><img src="${esc(r.qr_url)}" style="max-width:240px"><div style="margin-top:10px;font-size:12px;color:var(--t2)">Skeniraj QR kod u Google Authenticator / Authy</div><div style="margin-top:14px"><input id="totp-code" placeholder="6-cifreni kod" style="background:var(--bg3);border:1px solid var(--rim);border-radius:5px;padding:8px;color:var(--t1);width:140px"><button class="btn primary" onclick="profileVerify2FA()">Potvrdi</button></div></div>`);
} else alert('2FA setup failed');
}
async function profileVerify2FA(){
const code = $('#totp-code')?.value;
const r = await apiAuth('/auth/2fa/verify', {method:'POST', body: JSON.stringify({code})});
if(r && r.status==='ok'){ alert('2FA aktivirano ✓'); closeDetail(); loadSection(); }
else alert('Pogrešan kod.');
}
function profileDeleteAccount(){
if(!confirm('Zaista zatraži brisanje računa? GDPR brisanje je nepovratno.')) return;
alert('Zahtjev za brisanje poslan na PGŽ admin (M10 — backend).');
}
// ======================================================================= // =======================================================================
// PGŽ ADMIN — Dashboard // PGŽ ADMIN — Dashboard
// ======================================================================= // =======================================================================
@@ -689,7 +934,7 @@ SECTIONS['pgz:dashboard'] = async () => {
</div>`; </div>`;
const reqHtml = MOCK.zahtjevi_pending.map(z => ` const reqHtml = MOCK.zahtjevi_pending.map(z => `
<div class="req-i" onclick="alert('Otvaranje zahtjeva ${esc(z.id)} — production: navigira na detalj')"> <div class="req-i" onclick="showDetail('zahtjev','${esc(z.id)}','Zahtjev ${esc(z.id)}')">
<div class="rh"> <div class="rh">
<div> <div>
<div class="rt">${esc(z.naziv)}</div> <div class="rt">${esc(z.naziv)}</div>
@@ -705,7 +950,7 @@ SECTIONS['pgz:dashboard'] = async () => {
</div>`).join(''); </div>`).join('');
const auditHtml = MOCK.audit.map(a => const auditHtml = MOCK.audit.map(a =>
`<div class="audit-i"><div class="ts">${esc(a.ts)}</div><div class="who">${esc(a.who)}</div><div class="what">${a.what}</div></div>` `<div class="audit-i" style="cursor:pointer" onclick='showDetail("audit",${JSON.stringify(a.what)},"Audit zapis")'><div class="ts">${esc(a.ts)}</div><div class="who">${esc(a.who)}</div><div class="what">${a.what}</div></div>`
).join(''); ).join('');
return ` return `
@@ -803,12 +1048,12 @@ SECTIONS['pgz:savezi'] = async () => {
const d = await api('/savezi') || {rows:[]}; const d = await api('/savezi') || {rows:[]};
const top = (d.rows||[]).slice(0,30); const top = (d.rows||[]).slice(0,30);
const rows = top.map(s => ` const rows = top.map(s => `
<tr> <tr style="cursor:pointer" onclick="showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">
<td><b>${esc(s.naziv)}</b></td> <td><b>${esc(s.naziv)}</b></td>
<td class="num">${fmt(s.broj_klubova||'—')}</td> <td class="num">${fmt(s.broj_klubova||'—')}</td>
<td class="num">${fmt(s.broj_sportasa||'—')}</td> <td class="num">${fmt(s.broj_sportasa||'—')}</td>
<td>${esc(s.predsjednik||'—')}</td> <td>${esc(s.predsjednik||'—')}</td>
<td><button class="btn sm" onclick="window.open('/sport/?savez=${s.id}','_blank')">Detalji</button></td> <td><button class="btn sm" onclick="event.stopPropagation();showDetail('savez',${s.id},${JSON.stringify(s.naziv)})">Detalji</button></td>
</tr>`).join(''); </tr>`).join('');
return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})</div></div> return `<div class="card"><div class="card-h"><div class="card-t">🏅 Savezi PGŽ — top 30 (od ${d.count||246})</div></div>
<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> <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>
@@ -818,7 +1063,7 @@ SECTIONS['pgz:savezi'] = async () => {
SECTIONS['pgz:klubovi'] = async () => { SECTIONS['pgz:klubovi'] = async () => {
const d = await api('/klubovi?limit=40') || {rows:[]}; const d = await api('/klubovi?limit=40') || {rows:[]};
const rows = (d.rows||[]).slice(0,40).map(k => ` const rows = (d.rows||[]).slice(0,40).map(k => `
<tr> <tr style="cursor:pointer" onclick="showDetail('klub',${k.id},${JSON.stringify(k.naziv)})">
<td><b>${esc(k.naziv)}</b></td> <td><b>${esc(k.naziv)}</b></td>
<td>${esc(k.savez||'—')}</td> <td>${esc(k.savez||'—')}</td>
<td>${esc(k.grad||'—')}</td> <td>${esc(k.grad||'—')}</td>
@@ -1433,14 +1678,26 @@ const MOCK = {
}; };
//=========== INIT =========== //=========== INIT ===========
function init(){ async function init(){
try { try {
const r = localStorage.getItem('app-role'); const r = localStorage.getItem('app-role');
if(r && ROLES[r]) _state.role = r; if(r && ROLES[r]) _state.role = r;
} catch(e){} } catch(e){}
restoreSidebar(); restoreSidebar();
buildRoleSwitch(); buildRoleSwitch();
// Try real auth (JWT)
const me = await loadCurrentUser();
if(me){
// Real-auth mode — hide demo role switcher (only super_admin can switch personas)
if(me.user_type !== 'super_admin'){
const rs = $('#role-switch'); if(rs) rs.style.display='none';
}
applyMeToHeader();
}
// First page after login: Moj profil
setRole(_state.role); setRole(_state.role);
navTo('profil');
} }
window.addEventListener('DOMContentLoaded', init); window.addEventListener('DOMContentLoaded', init);
</script> </script>
+353 -2
View File
@@ -992,13 +992,364 @@ async function reSign(sid) {
} catch (e) { toast('Greška: ' + e.message, true); } } catch (e) { toast('Greška: ' + e.message, true); }
} }
// ════════════════════════════════════════════════════
// MODUL 4 — ČLANOVI / DASHBOARD osobe (CRM Dashboard)
// ════════════════════════════════════════════════════
let CLANOVI_LAST_QUERY = '';
async function loadClanovi() {
const root = $('#page-clanovi');
root.innerHTML = `
<div class="toolbar">
<input id="cl-q" type="text" placeholder="Pretraži po imenu / OIB-u (min 2 slova)…" style="min-width:340px;flex:1" oninput="searchClanovi(this.value)">
<input id="cl-klub-filter" type="number" placeholder="Klub ID (filter)" onchange="searchClanovi($('#cl-q').value)">
<div class="grow"></div>
<span style="font-size:11px;color:var(--t3)">Klik na karticu → puni dashboard člana</span>
</div>
<div id="cl-results"><div class="loading">Upišite ime za pretragu…</div></div>
`;
// initial: load nekoliko poznatih ID-ova kao primjer
if (!CLANOVI_LAST_QUERY) {
document.getElementById('cl-q').value = 'Mateo';
searchClanovi('Mateo');
}
}
let _searchTimer;
function searchClanovi(q) {
clearTimeout(_searchTimer);
CLANOVI_LAST_QUERY = q;
if (!q || q.length < 2) {
$('#cl-results').innerHTML = '<div class="loading">Upišite ime za pretragu (min 2 slova)…</div>';
return;
}
_searchTimer = setTimeout(async () => {
const klub = $('#cl-klub-filter').value;
const params = new URLSearchParams({q, limit: 30});
if (klub) params.append('klub_id', klub);
try {
const data = await apiR('/clanovi/search?' + params);
$('#cnt-clanovi').textContent = data.count;
const cards = (data.rows || []).map(r => `
<div class="card" style="margin-bottom:8px;cursor:pointer" onclick="openClanPanel(${r.id})">
<div class="card-b" style="display:flex;align-items:center;gap:14px">
<div style="width:48px;height:48px;border-radius:50%;background:var(--bg3);overflow:hidden;display:flex;align-items:center;justify-content:center;flex-shrink:0;border:1px solid var(--rim)">
${r.slika_url ? `<img src="${esc(r.slika_url)}" style="width:100%;height:100%;object-fit:cover" onerror="this.style.display='none'">` : `<span style="font-size:18px;font-weight:600;color:var(--t2)">${esc((r.ime||'?')[0]+(r.prezime||'?')[0])}</span>`}
</div>
<div style="flex:1">
<div style="font-weight:600">${esc(r.ime)} ${esc(r.prezime)}</div>
<div style="font-size:11px;color:var(--t3)">${esc(r.klub || '—')} · ${esc(r.pozicija || '—')}${r.broj_dresa ? ' · #'+r.broj_dresa : ''}</div>
</div>
<div><span class="tag bl">#${r.id}</span></div>
</div>
</div>`).join('');
$('#cl-results').innerHTML = `
<div style="color:var(--t3);font-size:12px;margin-bottom:8px">${data.count} rezultat${data.count==1?'':'a'}</div>
${cards || '<div class="empty">Nema rezultata.</div>'}`;
} catch (e) { $('#cl-results').innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; }
}, 250);
}
window._OPEN_PANEL_CID = null;
async function openClanPanel(cid) {
window._OPEN_PANEL_CID = cid;
loadClanPanel(cid);
}
function closeClanPanel() {
window._OPEN_PANEL_CID = null;
closeModal();
}
async function loadClanPanel(cid) {
let d, perms;
try {
d = await apiR('/clanovi/' + cid + '/full');
perms = await apiR('/clanovi/permissions?role=' + encodeURIComponent(CURRENT_ROLE));
} catch (e) { return toast('Greška: ' + e.message, true); }
const c = d.clan, k = d.klub || {};
const editable = perms.editable; // 'ALL' ili lista polja
const canEdit = (field) => editable === 'ALL' || (Array.isArray(editable) && editable.includes(field));
const canUploadAvatar = canEdit('slika_url');
const av = c.slika_url_full || c.slika_url || '';
const initials = ((c.ime||'?')[0]+(c.prezime||'?')[0]).toUpperCase();
// helper za render polja s edit/no-edit
const f = (key, label, val, type='text') => {
const ed = canEdit(key);
const safe = val == null || val === '' ? '—' : String(val);
return `
<div class="payment-row">
<div class="l">${esc(label)}${ed?'':' <span style="color:var(--t3);font-size:9px">🔒</span>'}</div>
<div class="v" style="display:flex;align-items:center;gap:6px">
<span id="fld-${key}-display" style="font-family:inherit">${esc(safe)}</span>
${ed ? `<button class="btn sm" onclick="editFieldInline('${key}', '${esc(label)}', ${JSON.stringify(val||'').replace(/"/g,'&quot;')}, '${type}', ${cid})">✎</button>` : ''}
</div>
</div>`;
};
const kpiHtml = `
<div class="kpi-grid" style="margin-bottom:12px">
<div class="kpi b"><div class="kpi-l">Sezone</div><div class="kpi-v">${fmt(d.kpi.broj_sezona)}</div></div>
<div class="kpi g"><div class="kpi-l">Nastupi</div><div class="kpi-v">${fmt(d.kpi.nastupi_total)}</div></div>
<div class="kpi a"><div class="kpi-l">Pogoci</div><div class="kpi-v">${fmt(d.kpi.pogoci_total)}</div></div>
<div class="kpi r"><div class="kpi-l">Dug članarina</div><div class="kpi-v">${fmtEur(d.kpi.dug_clanarina_eur)}</div></div>
<div class="kpi ${d.kpi.lijecnicki_status==='vazeci'?'g':d.kpi.lijecnicki_status==='uskoro'?'a':'r'}"><div class="kpi-l">Liječnički</div><div class="kpi-v" style="font-size:14px">${esc(d.kpi.lijecnicki_status||'—')}</div><div class="kpi-s">${d.kpi.lijecnicki_dana_do_isteka != null ? d.kpi.lijecnicki_dana_do_isteka+' dana' : ''}</div></div>
</div>`;
const sectionPersonal = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📋 Osobni podaci</div></div>
<div class="payment-card">
${f('ime','Ime',c.ime)}
${f('prezime','Prezime',c.prezime)}
${f('oib','OIB',c.oib)}
${f('datum_rodenja','Datum rođenja',c.datum_rodenja||c.datum_rodjenja,'date')}
${f('mjesto_rodenja','Mjesto rođenja',c.mjesto_rodenja||c.mjesto_rodjenja)}
${f('spol','Spol',c.spol)}
</div>`;
const sectionKontakt = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📞 Kontakt</div></div>
<div class="payment-card">
${f('email','E-mail',c.email)}
${f('telefon','Telefon',c.telefon)}
${f('adresa','Adresa',c.adresa)}
${f('grad','Grad',c.grad)}
${f('postanski_broj','Pošt. broj',c.postanski_broj)}
</div>`;
const sectionSport = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">⚽ Sport</div></div>
<div class="payment-card">
${f('sport','Sport',c.sport)}
${f('kategorija','Kategorija',c.kategorija)}
${f('podkategorija','Podkategorija',c.podkategorija)}
${f('pozicija','Pozicija',c.pozicija)}
${f('dominantna_noga','Dominantna noga',c.dominantna_noga)}
${f('visina_cm','Visina (cm)',c.visina_cm,'number')}
${f('tezina_kg','Težina (kg)',c.tezina_kg,'number')}
${f('broj_dresa','Broj dresa',c.broj_dresa,'number')}
${f('uloga','Uloga',c.uloga)}
${f('uloga_detalj','Uloga (detalj)',c.uloga_detalj)}
</div>`;
const sectionStatus = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📊 Status</div></div>
<div class="payment-card">
${f('aktivan','Aktivan',c.aktivan)}
${f('datum_pristupa','Datum pristupa',c.datum_pristupa,'date')}
${f('datum_napustanja','Datum napuštanja',c.datum_napustanja,'date')}
${f('kategoriziran','Kategoriziran',c.kategoriziran)}
${f('kategorija_hoo','HOO kategorija',c.kategorija_hoo,'number')}
${f('reprezentativac','Reprezentativac',c.reprezentativac)}
${f('reprezentacija_kategorija','Reprezentacija (kat.)',c.reprezentacija_kategorija)}
${f('stipendiran','Stipendiran',c.stipendiran)}
${f('stipendija_iznos','Stipendija (€)',c.stipendija_iznos,'number')}
${f('licenca_broj','Licenca broj',c.licenca_broj)}
${f('licenca_vrijedi_do','Licenca vrijedi do',c.licenca_vrijedi_do,'date')}
${f('radno_pravni_status','Radno-pravni status',c.radno_pravni_status)}
</div>`;
const sectionKlub = `
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">⬢ Klub</div></div>
<div class="payment-card">
<div class="payment-row"><div class="l">Trenutni klub</div><div class="v">${esc(k.naziv || '—')}</div></div>
${k.savez_naziv ? `<div class="payment-row"><div class="l">Savez</div><div class="v">${esc(k.savez_naziv)}</div></div>` : ''}
${k.oib ? `<div class="payment-row"><div class="l">OIB kluba</div><div class="v">${esc(k.oib)}</div></div>` : ''}
${k.iban ? `<div class="payment-row"><div class="l">IBAN</div><div class="v">${esc(k.iban)}</div></div>` : ''}
</div>
${d.povijest_klubova && d.povijest_klubova.length ? `
<div style="margin-top:8px"><b>Povijest klubova (${d.povijest_klubova.length}):</b></div>
<table style="margin-top:6px"><thead><tr><th>Klub</th><th>Od</th><th>Do</th><th># sezona</th></tr></thead>
<tbody>${d.povijest_klubova.map(p=>`<tr><td>${esc(p.klub_naziv)}</td><td>${esc(p.od)}</td><td>${esc(p.do_)}</td><td>${p.broj_sezona}</td></tr>`).join('')}</tbody>
</table>` : ''}
`;
const tabSezone = `
<table><thead><tr><th>Sezona</th><th>Klub</th><th>Natjecanje</th><th>Nast.</th><th>Pog.</th><th>Asist.</th><th>Žuti</th><th>Crv.</th><th>Min.</th></tr></thead>
<tbody>${(d.sezone||[]).map(s=>`<tr><td><b>${esc(s.sezona)}</b></td><td>${esc(s.klub_naziv||'—')}</td><td>${esc(s.natjecanje||'—')}</td><td>${fmt(s.nastupi)}</td><td>${fmt(s.pogoci)}</td><td>${fmt(s.asistencije)}</td><td>${fmt(s.zuti_kartoni)}</td><td>${fmt(s.crveni_kartoni)}</td><td>${fmt(s.minute_total)}</td></tr>`).join('') || '<tr><td colspan="9" class="empty">Nema podataka.</td></tr>'}</tbody>
</table>`;
const tabUtakmice = `
<table><thead><tr><th>Datum</th><th>Domaćin</th><th>Gost</th><th>Rezultat</th><th>Natj.</th><th>Pog.</th><th>Min.</th><th></th></tr></thead>
<tbody>${(d.utakmice_zadnje20||[]).map(u=>`<tr><td>${fmtDate(u.datum)}</td><td>${esc(u.domacin||'—')}</td><td>${esc(u.gost||'—')}</td><td><b>${esc(u.rezultat||'—')}</b></td><td>${esc(u.natjecanje||'—')}</td><td>${fmt(u.pogoci)}</td><td>${fmt(u.minute)}</td><td>${u.utakmica_url?`<a class="btn sm" href="${esc(u.utakmica_url)}" target="_blank">↗</a>`:''}</td></tr>`).join('') || '<tr><td colspan="8" class="empty">Nema utakmica.</td></tr>'}</tbody>
</table>`;
const tabLij = `
<table><thead><tr><th>Datum</th><th>Vrijedi do</th><th>Status</th><th>Vrsta</th><th>Ustanova</th><th>Liječnik</th><th>Plaćeno</th></tr></thead>
<tbody>${(d.lijecnicki||[]).map(l=>`<tr><td>${fmtDate(l.datum_pregleda)}</td><td>${fmtDate(l.vrijedi_do)}</td><td><span class="tag ${({vazeci:'gr',uskoro:'am',istekao:'rd'})[l.status_calc]||'gy'}">${esc(l.status_calc)} (${l.dana_do_isteka}d)</span></td><td>${esc(l.vrsta_pregleda||'—')}</td><td>${esc(l.ustanova||'—')}</td><td>${esc(l.lijecnik||'—')}</td><td>${l.placeno?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</td></tr>`).join('') || '<tr><td colspan="7" class="empty">Nema pregleda.</td></tr>'}</tbody>
</table>`;
const tabClanarine = `
<table><thead><tr><th>God.</th><th>Razdoblje</th><th>Propisan</th><th>Plaćeno</th><th>Dug</th><th>Status</th><th>Datum upl.</th><th></th></tr></thead>
<tbody>${(d.clanarine||[]).map(cl=>`<tr><td>${esc(cl.godina)}</td><td>${esc(cl.razdoblje||'—')}</td><td>${fmtEur(cl.iznos_propisan)}</td><td>${fmtEur(cl.iznos_placen)}</td><td><b style="color:${cl.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(cl.dug)}</b></td><td><span class="tag ${statusTag(cl.status)}">${esc(cl.status)}</span></td><td>${fmtDate(cl.datum_uplate)}</td><td><a class="btn sm" href="${API}/clanarine/${cl.id}/uplatnica.pdf" target="_blank">📄</a></td></tr>`).join('') || '<tr><td colspan="8" class="empty">Nema članarina.</td></tr>'}</tbody>
</table>`;
const tabDokumenti = `
<table><thead><tr><th>God.</th><th>Naslov</th><th>Vrsta</th><th>Snippet</th><th></th></tr></thead>
<tbody>${(d.dokumenti||[]).map(dk=>`<tr><td>${esc(dk.godina)}</td><td><b>${esc(dk.title||'—')}</b></td><td>${esc(dk.vrsta||'—')}</td><td style="font-size:11px;color:var(--t3);max-width:280px">${esc((dk.snippet||'').substring(0,140))}</td><td>${dk.pdf_url?`<a class="btn sm" href="${esc(dk.pdf_url)}" target="_blank">📄</a>`:dk.url?`<a class="btn sm" href="${esc(dk.url)}" target="_blank">↗</a>`:''}</td></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema dokumenata.</td></tr>'}</tbody>
</table>`;
const tabObrasci = `
<table><thead><tr><th>Obrazac</th><th>Ref.</th><th>Status</th><th>Predano</th><th></th></tr></thead>
<tbody>${(d.obrasci||[]).map(o=>`<tr><td><b>${esc(o.template_naziv||o.template_code)}</b></td><td><code style="font-size:10px">${esc(o.reference_no||'')}</code></td><td><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[o.status]||'gy'}">${esc(o.status)}</span></td><td>${fmtDate(o.submitted_at||o.created_at)}</td><td><a class="btn sm" href="${API}/forms/submissions/${o.id}/pdf" target="_blank">📄</a></td></tr>`).join('') || '<tr><td colspan="5" class="empty">Nema obrazaca.</td></tr>'}</tbody>
</table>`;
const tabNagrade = (d.nagrade && d.nagrade.length) ? `
<table><thead><tr><th>Godina</th><th>Natjecanje</th><th>Razina</th><th>Disciplina</th><th>Plasman</th><th>Klub</th></tr></thead>
<tbody>${d.nagrade.map(n=>`<tr><td>${esc(n.godina)}</td><td>${esc(n.natjecanje||'—')}</td><td>${esc(n.razina_natjecanja||'—')}</td><td>${esc(n.disciplina||'—')}</td><td><b>${n.plasman||'—'}</b></td><td>${esc(n.klub_naziv||'—')}</td></tr>`).join('')}</tbody>
</table>` : '';
// Modal — širi nego standardno
$('#modal').style.maxWidth = '1100px';
openModal(`
<div class="modal-h">
<div class="modal-t">👤 ${esc(c.ime)} ${esc(c.prezime)} <span style="color:var(--t3);font-size:11px;font-weight:400">#${cid} · ${esc(CURRENT_ROLE)} (${editable === 'ALL' ? 'full edit' : Array.isArray(editable) ? editable.length+' edit polja' : 'no edit'})</span></div>
<div class="modal-x" onclick="closeClanPanel()">×</div>
</div>
<div class="modal-b">
<div style="display:grid;grid-template-columns:140px 1fr;gap:18px;margin-bottom:14px">
<div style="text-align:center">
<div id="avatar-display" style="width:140px;height:140px;border-radius:8px;background:var(--bg3);border:1px solid var(--rim);display:flex;align-items:center;justify-content:center;overflow:hidden">
${av ? `<img src="${esc(av)}?_=${Date.now()}" style="width:100%;height:100%;object-fit:cover">` : `<span style="font-size:48px;font-weight:700;color:var(--t2)">${esc(initials)}</span>`}
</div>
${canUploadAvatar ? `
<input type="file" id="avatar-file" accept="image/*" style="display:none" onchange="uploadAvatar(${cid})">
<button class="btn primary sm" style="margin-top:8px;width:100%" onclick="document.getElementById('avatar-file').click()">📷 Upload</button>
` : `<div style="font-size:10px;color:var(--t3);margin-top:6px">🔒 Bez dozvole</div>`}
</div>
<div>
${kpiHtml}
<div style="font-size:12px;color:var(--t3)">Dob: <b style="color:var(--t1)">${c.dob_calc != null ? c.dob_calc + ' god.' : '—'}</b> · Slika: <code style="font-size:10px">${esc(c.slika_url || '—').substring(0,60)}</code></div>
</div>
</div>
<div class="cp-tabs" style="display:flex;gap:0;border-bottom:1px solid var(--rim);margin-bottom:12px;flex-wrap:wrap">
<div class="cp-tab active" onclick="cpTab('osobni')" data-cpt="osobni" style="padding:10px 14px;cursor:pointer;border-bottom:2px solid var(--pgz-blue);font-weight:600">Osobni</div>
<div class="cp-tab" onclick="cpTab('sezone')" data-cpt="sezone" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Sezone (${d.sezone.length})</div>
<div class="cp-tab" onclick="cpTab('utakmice')" data-cpt="utakmice" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Utakmice (${d.utakmice_zadnje20.length})</div>
<div class="cp-tab" onclick="cpTab('lij')" data-cpt="lij" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Liječnički (${d.lijecnicki.length})</div>
<div class="cp-tab" onclick="cpTab('clanarine')" data-cpt="clanarine" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Članarine (${d.clanarine.length})</div>
<div class="cp-tab" onclick="cpTab('dokumenti')" data-cpt="dokumenti" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Dokumenti (${d.dokumenti.length})</div>
<div class="cp-tab" onclick="cpTab('obrasci')" data-cpt="obrasci" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Obrasci (${d.obrasci.length})</div>
${tabNagrade ? `<div class="cp-tab" onclick="cpTab('nagrade')" data-cpt="nagrade" style="padding:10px 14px;cursor:pointer;color:var(--t2)">Nagrade (${d.nagrade.length})</div>` : ''}
</div>
<div id="cp-osobni" class="cp-page">
${sectionPersonal}
${sectionKontakt}
${sectionSport}
${sectionStatus}
${sectionKlub}
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📝 Napomena</div></div>
<div class="payment-card">${f('napomena','Napomena',c.napomena)}</div>
<div class="card-h" style="background:transparent;border:none;padding:6px 0;margin-top:6px"><div class="card-t">📖 Biografija</div></div>
<div class="payment-card">${f('biografija','Biografija',c.biografija,'textarea')}</div>
</div>
<div id="cp-sezone" class="cp-page" style="display:none">${tabSezone}</div>
<div id="cp-utakmice" class="cp-page" style="display:none">${tabUtakmice}</div>
<div id="cp-lij" class="cp-page" style="display:none">${tabLij}</div>
<div id="cp-clanarine" class="cp-page" style="display:none">${tabClanarine}</div>
<div id="cp-dokumenti" class="cp-page" style="display:none">${tabDokumenti}</div>
<div id="cp-obrasci" class="cp-page" style="display:none">${tabObrasci}</div>
${tabNagrade ? `<div id="cp-nagrade" class="cp-page" style="display:none">${tabNagrade}</div>` : ''}
</div>
`);
}
function cpTab(name) {
$$('.cp-tab').forEach(t => {
const active = t.dataset.cpt === name;
t.style.borderBottom = active ? '2px solid var(--pgz-blue)' : '';
t.style.color = active ? 'var(--t1)' : 'var(--t2)';
t.style.fontWeight = active ? '600' : '500';
t.classList.toggle('active', active);
});
$$('.cp-page').forEach(p => p.style.display = (p.id === 'cp-' + name) ? 'block' : 'none');
}
function editFieldInline(key, label, currentVal, type, cid) {
const inputType = type === 'date' ? 'date' : type === 'number' ? 'number' : 'text';
const isTextarea = type === 'textarea';
const isBool = typeof currentVal === 'boolean';
let inputHtml;
if (isBool) {
inputHtml = `<select id="ef-input"><option value="true" ${currentVal===true?'selected':''}>true</option><option value="false" ${currentVal===false?'selected':''}>false</option></select>`;
} else if (isTextarea) {
inputHtml = `<textarea id="ef-input" style="width:100%;min-height:80px">${esc(currentVal||'')}</textarea>`;
} else {
inputHtml = `<input id="ef-input" type="${inputType}" value="${esc(currentVal||'')}" style="width:100%">`;
}
const promptHtml = `
<div class="modal-h">
<div class="modal-t">✎ ${esc(label)}</div>
<div class="modal-x" onclick="closeModal();loadClanPanel(${cid})">×</div>
</div>
<div class="modal-b">
<div class="field"><label>${esc(label)} (${esc(key)})</label>${inputHtml}</div>
<div style="text-align:right;margin-top:14px">
<button class="btn" onclick="closeModal();loadClanPanel(${cid})">Odustani</button>
<button class="btn primary" onclick="saveField('${key}', ${cid}, ${isBool}, ${isTextarea ? 'false' : type==='number'?'true':'false'})">💾 Spremi</button>
</div>
</div>`;
$('#modal').style.maxWidth = '500px';
openModal(promptHtml);
setTimeout(() => $('#ef-input')?.focus(), 50);
}
async function saveField(key, cid, isBool, isNumber) {
const el = $('#ef-input');
let val = el.value;
if (isBool) val = val === 'true';
else if (isNumber) val = val === '' ? null : Number(val);
else if (val === '') val = null;
try {
const r = await apiR('/clanovi/' + cid, {method:'PUT', body: {[key]: val}});
if (r.rejected_fields && r.rejected_fields.includes(key)) {
toast(`Polje "${key}" odbijeno za rolu ${CURRENT_ROLE}`, true);
} else {
toast(`✓ Polje ${key} spremljeno (rola ${CURRENT_ROLE})`);
}
closeModal();
$('#modal').style.maxWidth = '1100px';
loadClanPanel(cid);
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function uploadAvatar(cid) {
const inp = $('#avatar-file');
if (!inp.files || !inp.files[0]) return;
const fd = new FormData();
fd.append('file', inp.files[0]);
try {
const r = await fetch(API + '/clanovi/' + cid + '/avatar', {
method: 'POST',
headers: {'X-Role': CURRENT_ROLE},
body: fd,
});
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
const d = await r.json();
toast(`✓ Avatar uploaded: ${d.size_bytes} bytes`);
loadClanPanel(cid);
} catch (e) { toast('Greška upload-a: ' + e.message, true); }
}
// ──────────────────────────────────────────────────── // ────────────────────────────────────────────────────
// init // init
// ──────────────────────────────────────────────────── // ────────────────────────────────────────────────────
loadClanarine(); // postavi role iz localStorage u dropdown
// preload counts const _roleSel = document.getElementById('g-role');
if (_roleSel) _roleSel.value = CURRENT_ROLE;
loadClanovi();
// preload counts za sve tabove
(async () => { (async () => {
try { try {
const cl = await api('/clanarine?limit=1');
$('#cnt-clanarine').textContent = cl.summary?.total ?? '?';
const lj = await api('/lijecnicki?limit=1'); const lj = await api('/lijecnicki?limit=1');
$('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?'; $('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?';
const fm = await api('/forms'); const fm = await api('/forms');
+467 -5
View File
@@ -53,7 +53,31 @@ label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4p
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; } .grid2 { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; } .grid3 { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; }
.grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; } .grid4 { display:grid; grid-template-columns:repeat(4,1fr); gap:14px; }
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } } tr.clickable { cursor:pointer; }
tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--accent); }
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:100; display:none; align-items:flex-start; justify-content:center; padding:30px; overflow-y:auto; }
.modal-bg.show { display:flex; }
.modal { background:var(--bg-2); border:1px solid var(--border); border-radius:10px; max-width:1100px; width:100%; padding:0; box-shadow:0 12px 48px rgba(0,0,0,.6); }
.modal-h { display:flex; justify-content:space-between; align-items:center; padding:16px 22px; border-bottom:1px solid var(--border); }
.modal-h h3 { color:var(--accent); font-size:16px; }
.modal-h .x { background:transparent; border:0; color:var(--text-2); font-size:22px; cursor:pointer; }
.modal-h .x:hover { color:var(--red); }
.modal-body { padding:18px 22px; max-height:80vh; overflow-y:auto; }
.col2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
.kv { display:grid; grid-template-columns:140px 1fr; gap:6px 12px; font-size:13px; }
.kv > div:nth-child(odd) { color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; align-self:center; }
.kv > div:nth-child(even) { font-family:'JetBrains Mono',monospace; }
.preview-img { max-width:100%; max-height:480px; border:1px solid var(--border); border-radius:6px; background:var(--bg); }
.audit-row { display:grid; grid-template-columns:140px 110px 130px 1fr; gap:8px; padding:6px 0; border-bottom:1px dashed var(--border); font-size:12px; }
.audit-row:last-child { border-bottom:0; }
.audit-row .ts { color:var(--text-3); font-family:'JetBrains Mono',monospace; font-size:11px; }
.audit-row .op { color:var(--accent); font-weight:600; }
.audit-row .who { color:var(--text-2); }
.btn.green { background:var(--green); color:var(--bg); }
.btn.red { background:var(--red); color:#fff; }
.btn.yellow { background:var(--yellow); color:var(--bg); }
.actions-row { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; padding-top:14px; border-top:1px solid var(--border); }
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } .col2 { grid-template-columns:1fr; } .audit-row { grid-template-columns:1fr; } }
</style> </style>
</head> </head>
<body> <body>
@@ -173,6 +197,153 @@ label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4p
</main> </main>
</div> </div>
<!-- ============ INVOICE DETAIL MODAL (M5.5) ============ -->
<div id="invModal" class="modal-bg" onclick="if(event.target===this)closeModal('invModal')">
<div class="modal">
<div class="modal-h">
<h3 id="invModalTitle">Račun</h3>
<button class="x" onclick="closeModal('invModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Skenirana datoteka</h4>
<div id="inv_preview" style="text-align:center"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Podaci računa</h4>
<div class="kv" id="inv_kv"></div>
<div id="inv_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div class="actions-row" id="inv_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="inv_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY INVOICE MODAL (M5.5) ============ -->
<div id="payModal" class="modal-bg" onclick="if(event.target===this)closeModal('payModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Označi kao plaćen</h3>
<button class="x" onclick="closeModal('payModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="pay_iban_to" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">IBAN platitelja</label><input id="pay_iban_from" class="fld" placeholder="HRxxxxxxxxxxxxxxxxxxx"></div>
<div><label class="lbl">Datum uplate</label><input id="pay_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="pay_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Poziv na broj / referenca</label><input id="pay_ref" class="fld" placeholder="HR00 12345-67890"></div>
<div><label class="lbl">Tx ID (banka)</label><input id="pay_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="payConfirm">✓ Potvrdi plaćanje</button>
<button class="btn sec" onclick="closeModal('payModal')">Odustani</button>
<span id="payStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ COMMENT MODAL (M5.5) ============ -->
<div id="commentModal" class="modal-bg" onclick="if(event.target===this)closeModal('commentModal')">
<div class="modal" style="max-width:520px">
<div class="modal-h">
<h3>💬 Komentar (savez/admin)</h3>
<button class="x" onclick="closeModal('commentModal')">×</button>
</div>
<div class="modal-body">
<textarea id="commentText" class="fld" rows="5" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn" id="commentSave">Spremi komentar</button>
<button class="btn sec" onclick="closeModal('commentModal')">Odustani</button>
<span id="commentStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ PUTNI NALOG DETAIL MODAL (M6.3) ============ -->
<div id="pnModal" class="modal-bg" onclick="if(event.target===this)closeModal('pnModal')">
<div class="modal">
<div class="modal-h">
<h3 id="pnModalTitle">Putni nalog</h3>
<button class="x" onclick="closeModal('pnModal')">×</button>
</div>
<div class="modal-body">
<div class="col2">
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Voditelj + putnici, ruta, vozilo</h4>
<div class="kv" id="pn_kv"></div>
</div>
<div>
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Obračun (HR pravilnik 2025)</h4>
<div class="kv" id="pn_obracun"></div>
<div id="pn_status_block" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)"></div>
</div>
</div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">📎 Vezani računi (gorivo, cestarina, hotel...)</h4>
<table id="pn_invoices_table"><thead><tr><th>#</th><th>Vrsta</th><th>Dobavljač</th><th>OIB</th><th>Datum</th><th class="num">Brutto</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
<div class="actions-row" id="pn_actions"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Audit log</h4>
<div id="pn_audit"></div>
</div>
</div>
</div>
</div>
<!-- ============ PAY PUTNI NALOG MODAL ============ -->
<div id="payPnModal" class="modal-bg" onclick="if(event.target===this)closeModal('payPnModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Isplata putnog naloga</h3>
<button class="x" onclick="closeModal('payPnModal')">×</button>
</div>
<div class="modal-body">
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN primatelja</label><input id="ppn_iban_to" class="fld"></div>
<div><label class="lbl">IBAN platitelja</label><input id="ppn_iban_from" class="fld"></div>
<div><label class="lbl">Datum uplate</label><input id="ppn_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos (€)</label><input id="ppn_amount" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">Referenca</label><input id="ppn_ref" class="fld"></div>
<div><label class="lbl">Tx ID</label><input id="ppn_tx" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="ppnConfirm">✓ Potvrdi isplatu</button>
<button class="btn sec" onclick="closeModal('payPnModal')">Odustani</button>
<span id="ppnStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ REJECT PUTNI NALOG MODAL ============ -->
<div id="rejectModal" class="modal-bg" onclick="if(event.target===this)closeModal('rejectModal')">
<div class="modal" style="max-width:480px">
<div class="modal-h">
<h3>❌ Odbij putni nalog</h3>
<button class="x" onclick="closeModal('rejectModal')">×</button>
</div>
<div class="modal-body">
<label class="lbl">Razlog odbijanja</label>
<textarea id="rejectText" class="fld" rows="4" style="resize:vertical;font-family:inherit"></textarea>
<div class="actions-row">
<button class="btn red" id="rejectConfirm">Odbij</button>
<button class="btn sec" onclick="closeModal('rejectModal')">Odustani</button>
<span id="rejectStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<script> <script>
const ERP_API = '/api/erp'; const ERP_API = '/api/erp';
const $ = s => document.querySelector(s); const $ = s => document.querySelector(s);
@@ -343,10 +514,10 @@ function pnInit() {
} }
async function loadInvoices() { async function loadInvoices() {
const r = await fetch(`${ERP_API}/invoices?limit=50`).then(r=>r.json()).catch(()=>null); const r = await fetch(`${ERP_API}/invoices?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return; if (!r || !r.rows) return;
$('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>` $('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>`
<tr><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td> <tr class="clickable" onclick="openInvoice(${i.id})"><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td>
<td>${i.vendor_name||'—'}</td><td style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td> <td>${i.vendor_name||'—'}</td><td style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
<td>${i.klub_naziv||'—'}</td><td class="num">${fmtEur(i.amount_gross)}</td> <td>${i.klub_naziv||'—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('') <td>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('')
@@ -354,10 +525,10 @@ async function loadInvoices() {
} }
async function loadPutni() { async function loadPutni() {
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`).then(r=>r.json()).catch(()=>null); const r = await fetch(`${ERP_API}/putni-nalog?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return; if (!r || !r.rows) return;
$('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>` $('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>`
<tr><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td> <tr class="clickable" onclick="openPutni(${p.id})"><td>${p.id}</td><td>${p.klub_naziv||'—'}</td><td>${p.destination||'—'}</td>
<td>${fmtDate(p.date_from)}</td><td>${fmtDate(p.date_to)}</td> <td>${fmtDate(p.date_from)}</td><td>${fmtDate(p.date_to)}</td>
<td class="num">${fmtEur(p.dnevnice_amount)}</td> <td class="num">${fmtEur(p.dnevnice_amount)}</td>
<td class="num">${fmtEur(p.cost_transport)}</td> <td class="num">${fmtEur(p.cost_transport)}</td>
@@ -366,6 +537,297 @@ async function loadPutni() {
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>'; : '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
} }
// ===== AUTH (JWT iz localStorage ili admin token fallback) =====
function AUTH_HDR(extra) {
const h = Object.assign({}, extra || {});
let t = null;
try { t = localStorage.getItem('jwt') || sessionStorage.getItem('jwt'); } catch(e){}
if (!t) t = 'admin-pgz-2026';
h['Authorization'] = 'Bearer ' + t;
return h;
}
function AUTH_HDR_JSON() { return AUTH_HDR({'Content-Type': 'application/json'}); }
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function escHtml(s) {
if (s == null) return '';
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function renderAudit(audit) {
if (!audit || !audit.length) return '<div style="color:var(--text-3);font-size:12px">Nema audit zapisa.</div>';
return audit.map(a => `
<div class="audit-row">
<div class="ts">${(a.timestamp||'').replace('T',' ').substring(0,19)}</div>
<div class="op">${escHtml(a.operacija)}</div>
<div class="who">${escHtml(a.korisnik||'—')}</div>
<div>${escHtml(a.promijenjeno_polje||'')}: <span style="color:var(--text-3)">${escHtml(a.stara_vrijednost||'∅')}</span> → <span style="color:var(--green)">${escHtml(a.nova_vrijednost||'∅')}</span></div>
</div>`).join('');
}
// ===== INVOICE DETAIL =====
let _currentInvoice = null;
async function openInvoice(id) {
const r = await fetch(`${ERP_API}/invoices/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju računa #' + id); return; }
_currentInvoice = r;
const i = r.invoice;
$('#invModalTitle').textContent = `Račun #${i.id} · ${i.invoice_no || '—'}`;
// Preview slike
const pv = $('#inv_preview');
if (r.uploads && r.uploads.length) {
const up = r.uploads[0];
const fileUrl = `${ERP_API}/invoices/${id}/file`;
const isPdf = (up.mime || '').includes('pdf') || (up.file_name || '').toLowerCase().endsWith('.pdf');
if (isPdf) {
pv.innerHTML = `<embed src="${fileUrl}" type="application/pdf" style="width:100%;height:480px;border:1px solid var(--border);border-radius:6px"><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · ${escHtml(up.mime||'')}</div>`;
} else {
pv.innerHTML = `<a href="${fileUrl}" target="_blank"><img class="preview-img" src="${fileUrl}" alt="skena"></a><div style="margin-top:6px;font-size:11px;color:var(--text-3)">${escHtml(up.file_name)} · OCR ${escHtml(up.ocr_engine||up.ocr_status||'')}</div>`;
}
} else {
pv.innerHTML = '<div style="padding:60px;background:var(--bg-3);border-radius:6px;color:var(--text-3);font-size:12px">Bez priložene datoteke</div>';
}
// KV polja
$('#inv_kv').innerHTML = `
<div>Izdavatelj</div><div>${escHtml(i.vendor_name||'—')}</div>
<div>OIB izdavatelja</div><div>${escHtml(i.vendor_oib||'—')}</div>
<div>Broj računa</div><div>${escHtml(i.invoice_no||'—')}</div>
<div>Datum</div><div>${fmtDate(i.invoice_date)}</div>
<div>Klub</div><div>${escHtml(i.klub_naziv||'—')}</div>
<div>Vrsta</div><div>${escHtml(i.invoice_kind||'—')}</div>
<div>Iznos neto</div><div>${fmtEur(i.amount_net)}</div>
<div>PDV (${i.vat_rate||'—'}%)</div><div>${fmtEur(i.amount_vat)}</div>
<div>Brutto</div><div style="color:var(--accent);font-weight:700">${fmtEur(i.amount_gross)}</div>
<div>Valuta</div><div>${escHtml(i.currency||'EUR')}</div>
<div>Opis</div><div>${escHtml(i.description||'—')}</div>
`;
// Status block
const status = (i.payment_status||'unpaid').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(i.payment_status)}</div>`;
if (status === 'paid') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to || i.iban_to || '—')}</span></div>
<div><span style="color:var(--text-3)">IBAN platitelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_from || i.iban_from || '—')}</span></div>
<div><span style="color:var(--text-3)">Datum uplate:</span> ${fmtDate(i.paid_date) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos uplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount || i.amount_gross)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'cancelled' || status === 'otkazan') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">Račun je otkazan.</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">Neplaćen — čeka uplatu.</div>`;
}
$('#inv_status_block').innerHTML = sb;
// Actions po permission-ima
const a = r.actions || {};
const acts = [];
if (a.pay && status !== 'paid') acts.push(`<button class="btn green" onclick="openPayModal(${id})">💰 Označi kao plaćen</button>`);
if (a.edit && status !== 'paid') acts.push(`<button class="btn yellow" onclick="alert('Edit u UI: koristi M5 OCR formu — ovaj panel je read-only za prikaz')">✏ Korekcija polja</button>`);
if (a.comment) acts.push(`<button class="btn sec" onclick="openCommentModal(${id})">💬 Komentar</button>`);
if (r.uploads && r.uploads.length) acts.push(`<a href="${ERP_API}/invoices/${id}/file" target="_blank" class="btn sec" style="text-decoration:none">📥 Preuzmi sken</a>`);
if (a.delete) acts.push(`<button class="btn red" onclick="if(confirm('Obrisati račun #${id}?')){alert('Brisanje: TODO endpoint')}">🗑 Obriši</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#inv_actions').innerHTML = acts.join('');
$('#inv_audit').innerHTML = renderAudit(r.audit);
openModal('invModal');
}
function openPayModal(id) {
const inv = _currentInvoice && _currentInvoice.invoice;
if (inv) {
$('#pay_iban_to').value = inv.iban_to || '';
$('#pay_amount').value = inv.amount_gross || '';
}
$('#pay_date').value = new Date().toISOString().substring(0,10);
$('#payStatus').textContent = '';
openModal('payModal');
$('#payConfirm').onclick = async () => {
const body = {
iban_to: $('#pay_iban_to').value.trim(),
iban_from: $('#pay_iban_from').value.trim(),
paid_date: $('#pay_date').value,
amount: parseFloat($('#pay_amount').value) || undefined,
reference: $('#pay_ref').value.trim(),
bank_transaction_id: $('#pay_tx').value.trim(),
payment_method: 'transfer',
};
$('#payStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices/${id}/pay`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#payStatus').textContent = '✓ Plaćeno';
$('#payStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('payModal'); openInvoice(id); loadInvoices(); }, 700);
} else {
$('#payStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#payStatus').style.color = 'var(--red)';
}
};
}
function openCommentModal(id) {
$('#commentText').value = '';
$('#commentStatus').textContent = '';
openModal('commentModal');
$('#commentSave').onclick = async () => {
const txt = $('#commentText').value.trim();
if (!txt) { $('#commentStatus').textContent = 'Komentar je prazan'; return; }
$('#commentStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/invoices/${id}/comment`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({comment: txt})}).then(r=>r.json()).catch(()=>({ok:false,detail:'net'}));
if (r.ok) {
$('#commentStatus').textContent = '✓ Spremljeno';
$('#commentStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('commentModal'); openInvoice(id); }, 600);
} else {
$('#commentStatus').textContent = '❌ ' + (r.detail || 'Greška');
$('#commentStatus').style.color = 'var(--red)';
}
};
}
// ===== PUTNI NALOG DETAIL =====
let _currentPn = null;
async function openPutni(id) {
const r = await fetch(`${ERP_API}/putni-nalog/${id}`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { alert('Greška pri učitavanju putnog naloga #' + id); return; }
_currentPn = r;
const p = r.putni_nalog;
$('#pnModalTitle').textContent = `Putni nalog #${p.id} · ${p.klub_naziv||'—'}`;
const att = p.attachments || {};
const dnv = att.dnevnice_calc || {};
const putnici = (att.putnici || []).join(', ');
const voditelj = att.voditelj || '—';
const country = att.country || '—';
const fromCity = att.from_city || '—', toCity = att.to_city || '—';
$('#pn_kv').innerHTML = `
<div>Voditelj</div><div>${escHtml(voditelj)}</div>
<div>Putnici</div><div>${escHtml(putnici||'—')}</div>
<div>Svrha</div><div>${escHtml(p.purpose||'—')}</div>
<div>Ruta</div><div>${escHtml(fromCity)}${escHtml(toCity)}</div>
<div>Zemlja</div><div>${escHtml(country)}</div>
<div>Polazak</div><div>${fmtDate(p.date_from)}</div>
<div>Povratak</div><div>${fmtDate(p.date_to)}</div>
<div>Vozilo</div><div>${escHtml(p.vehicle_type||'—')} ${escHtml(p.vehicle_plate||'')}</div>
<div>Kilometara</div><div>${p.km_driven||0} km ×${p.km_rate||0.5}</div>
`;
$('#pn_obracun').innerHTML = `
<div>Pune dnevnice</div><div style="color:var(--accent)">${dnv.days_full||0} ×${dnv.rate_full||0}</div>
<div>Pola dnevnica</div><div style="color:var(--yellow)">${dnv.days_half||0} ×${dnv.rate_half||0}</div>
<div>Dnevnice ukupno</div><div style="color:var(--green)">${fmtEur(p.dnevnice_amount)}</div>
<div>Kilometrina</div><div>${fmtEur(p.cost_transport)}</div>
<div>Smještaj</div><div>${fmtEur(p.cost_lodging)}</div>
<div>Hrana / ostalo</div><div>${fmtEur((p.cost_meals||0)+(p.cost_other||0))}</div>
<div style="font-weight:700">UKUPNO</div><div style="color:var(--accent);font-weight:700;font-size:18px">${fmtEur(p.cost_total)}</div>
`;
// Status block
const status = (p.status||'draft').toLowerCase();
let sb = `<div style="display:flex;align-items:center;gap:10px"><span style="font-size:11px;color:var(--text-3)">STATUS</span> ${sBadge(p.status)}</div>`;
if (status === 'isplacen') {
const lastPay = (r.payments && r.payments.length) ? r.payments[0] : {};
sb += `<div style="margin-top:10px;font-size:12px;line-height:1.7">
<div><span style="color:var(--text-3)">IBAN primatelja:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.iban_to||'—')}</span></div>
<div><span style="color:var(--text-3)">Datum isplate:</span> ${fmtDate(p.paid_at) || fmtDate(lastPay.payment_date)}</div>
<div><span style="color:var(--text-3)">Iznos isplate:</span> <strong style="color:var(--green)">${fmtEur(lastPay.amount||p.cost_total)}</strong></div>
<div><span style="color:var(--text-3)">Referenca:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.reference||'—')}</span></div>
<div><span style="color:var(--text-3)">Tx ID:</span> <span style="font-family:'JetBrains Mono'">${escHtml(lastPay.bank_transaction_id||'—')}</span></div>
</div>`;
} else if (status === 'odbijen') {
sb += `<div style="margin-top:8px;color:var(--red);font-size:12px">${escHtml(p.notes||'Odbijen').slice(-200)}</div>`;
} else {
sb += `<div style="margin-top:8px;color:var(--yellow);font-size:12px">${status === 'odobren' || status === 'zatvoren' ? 'Čeka isplatu.' : status === 'poslan' ? 'Čeka odobrenje.' : 'Draft — još nije poslan na odobrenje.'}</div>`;
}
$('#pn_status_block').innerHTML = sb;
// Vezani računi
const invs = r.invoices || [];
$('#pn_invoices_table tbody').innerHTML = invs.length ? invs.map(i => `
<tr class="clickable" onclick="closeModal('pnModal'); setTimeout(()=>openInvoice(${i.id}), 100)">
<td>${i.id}</td><td>${escHtml(i.invoice_kind||'—')}</td><td>${escHtml(i.vendor_name||'—')}</td>
<td style="font-family:'JetBrains Mono'">${escHtml(i.vendor_oib||'—')}</td>
<td>${fmtDate(i.invoice_date)}</td>
<td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td>
</tr>`).join('') : '<tr><td colspan="7" style="color:var(--text-3);text-align:center;padding:14px">Nema vezanih računa</td></tr>';
// Actions
const a = r.actions || {};
const acts = [];
if (a.submit) acts.push(`<button class="btn yellow" onclick="submitPn(${id})">📤 Pošalji na odobrenje</button>`);
if (a.approve) acts.push(`<button class="btn green" onclick="approvePn(${id})">✓ Odobri</button>`);
if (a.reject) acts.push(`<button class="btn red" onclick="openRejectModal(${id})">✗ Odbij</button>`);
if (a.pay) acts.push(`<button class="btn green" onclick="openPayPnModal(${id})">💰 Isplati</button>`);
if (a.edit) acts.push(`<button class="btn sec" onclick="alert('Edit drafta — koristi M6 formu \\'Novi putni nalog\\' s prefilanim poljima (TODO UI)')">✏ Edit</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#pn_actions').innerHTML = acts.join('');
$('#pn_audit').innerHTML = renderAudit(r.audit);
openModal('pnModal');
}
async function submitPn(id) {
if (!confirm('Poslati putni nalog #' + id + ' na odobrenje?')) return;
const r = await fetch(`${ERP_API}/putni-nalog/${id}/posalji`, {method:'POST', headers: AUTH_HDR_JSON()}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
}
async function approvePn(id) {
if (!confirm('Odobriti putni nalog #' + id + '?')) return;
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odobriti`, {method:'POST', headers: AUTH_HDR_JSON(), body: '{}'}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { openPutni(id); loadPutni(); } else alert('Greška: ' + (r && r.detail || ''));
}
function openRejectModal(id) {
$('#rejectText').value = '';
$('#rejectStatus').textContent = '';
openModal('rejectModal');
$('#rejectConfirm').onclick = async () => {
const reason = $('#rejectText').value.trim();
if (!reason) { $('#rejectStatus').textContent = 'Razlog je obavezan'; return; }
const r = await fetch(`${ERP_API}/putni-nalog/${id}/odbij`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({razlog: reason})}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { closeModal('rejectModal'); openPutni(id); loadPutni(); }
else $('#rejectStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
};
}
function openPayPnModal(id) {
const pn = _currentPn && _currentPn.putni_nalog;
if (pn) $('#ppn_amount').value = pn.cost_total || '';
$('#ppn_date').value = new Date().toISOString().substring(0,10);
$('#ppnStatus').textContent = '';
openModal('payPnModal');
$('#ppnConfirm').onclick = async () => {
const body = {
iban_to: $('#ppn_iban_to').value.trim(),
iban_from: $('#ppn_iban_from').value.trim(),
paid_date: $('#ppn_date').value,
amount: parseFloat($('#ppn_amount').value) || undefined,
reference: $('#ppn_ref').value.trim(),
bank_transaction_id: $('#ppn_tx').value.trim(),
};
$('#ppnStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/putni-nalog/${id}/isplati`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) {
$('#ppnStatus').textContent = '✓ Isplaćeno';
$('#ppnStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('payPnModal'); openPutni(id); loadPutni(); }, 700);
} else {
$('#ppnStatus').textContent = '❌ ' + (r && r.detail || 'Greška');
$('#ppnStatus').style.color = 'var(--red)';
}
};
}
function activate(name) { function activate(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name)); $$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name)); $$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
+17 -3
View File
@@ -352,6 +352,10 @@ body {
<label for="password">Lozinka</label> <label for="password">Lozinka</label>
<input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••"> <input type="password" id="password" name="password" required autocomplete="current-password" placeholder="••••••••">
</div> </div>
<div class="field" id="totpField" style="display:none">
<label for="totp">Kod autentifikatora (2FA)</label>
<input type="text" id="totp" name="totp" inputmode="numeric" pattern="[0-9 ]*" autocomplete="one-time-code" placeholder="123456" maxlength="8" style="font-family:'JetBrains Mono',monospace;letter-spacing:4px;text-align:center;font-size:18px">
</div>
<div class="row"> <div class="row">
<label><input type="checkbox" id="remember" checked> Zapamti me</label> <label><input type="checkbox" id="remember" checked> Zapamti me</label>
<a href="#" id="forgotLink">Zaboravljena lozinka?</a> <a href="#" id="forgotLink">Zaboravljena lozinka?</a>
@@ -407,19 +411,28 @@ function showAlert(msg, type) {
} }
} }
async function doLogin(email, password) { async function doLogin(email, password, totp) {
const btn = $('#submitBtn'); const btn = $('#submitBtn');
btn.disabled = true; btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span>Prijavljujem…'; btn.innerHTML = '<span class="spinner"></span>Prijavljujem…';
try { try {
const body = { email, password };
if (totp) body.totp = totp;
const r = await fetch(API + '/auth/login', { const r = await fetch(API + '/auth/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }) body: JSON.stringify(body)
}); });
const data = await r.json(); const data = await r.json();
if (!r.ok) { if (!r.ok) {
if (r.status === 401 && (data.detail === '2FA_REQUIRED' || /2FA/i.test(data.detail||''))) {
// Show TOTP field and stop
$('#totpField').style.display = '';
$('#totp').focus();
showAlert('Unesite kod iz autentifikatora.');
} else {
showAlert(data.detail || 'Neispravni podaci'); showAlert(data.detail || 'Neispravni podaci');
}
btn.disabled = false; btn.disabled = false;
btn.textContent = 'Prijavi se'; btn.textContent = 'Prijavi se';
return; return;
@@ -461,8 +474,9 @@ $('#loginForm').addEventListener('submit', e => {
e.preventDefault(); e.preventDefault();
const email = $('#email').value.trim().toLowerCase(); const email = $('#email').value.trim().toLowerCase();
const pwd = $('#password').value; const pwd = $('#password').value;
const totp = ($('#totp').value || '').trim().replace(/\s/g,'') || null;
if (!email || !pwd) return; if (!email || !pwd) return;
doLogin(email, pwd); doLogin(email, pwd, totp);
}); });
document.querySelectorAll('.demo').forEach(el => { document.querySelectorAll('.demo').forEach(el => {

Before

Width:  |  Height:  |  Size: 176 B

After

Width:  |  Height:  |  Size: 176 B