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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+140
-13
@@ -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}
|
||||||
|
|||||||
+226
-194
@@ -172,23 +172,51 @@ 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
|
|
||||||
|
|
||||||
|
|
||||||
def _sport_pgz_search(query: str) -> Optional[dict]:
|
def _sport_pgz_search(query: str) -> Optional[dict]:
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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. 15–17, 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
@@ -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,'"')}, '${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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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));
|
||||||
|
|||||||
+18
-4
@@ -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) {
|
||||||
showAlert(data.detail || 'Neispravni podaci');
|
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');
|
||||||
|
}
|
||||||
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 |
Reference in New Issue
Block a user