CC2 R4 #2+#5: remove legacy unauth /api/admin/users — close 401 gap

The bare @app.get/post('/api/admin/users') decorators in pgz_sport_api.py
were registered before app.include_router(admin_users_router) and shadowed
the JWT-protected M2 routes, leaking user list to anyone.

Removed all three: GET /api/admin/users, POST /api/admin/users,
POST /api/admin/users/{uid}/toggle. The auth.admin_users router now owns
this prefix exclusively and gates every method with require_user.

Verified: no-auth → 401, invalid token → 401, valid Bearer → 200.
This commit is contained in:
Damir Radulić
2026-05-05 00:44:50 +02:00
parent cb3faee731
commit f5c6570d47
20 changed files with 11746 additions and 110 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,455 @@
#!/usr/bin/env python3
# auth_v2.py — JWT auth backend with tenant_id, role, tier claims
# v1.0 dradulic@outlook.com / damir@rinet.one — 2026-05-04
# Endpoints: /api/auth/login, /api/auth/refresh, /api/auth/logout,
# /api/auth/me, /api/auth/password/change, /api/auth/password/reset
"""
JWT claims:
sub int user id
email str
name str
tenant_id int|null pgz_sport.tenants.id (or null for super_admin)
tenant_type str pgz | savez | klub | global
tenant_scope dict {"klub_id": ..., "savez_id": ...}
role str user_type code (super_admin | pgz_admin | savez_admin | klub_admin | klub_clan | viewer ...)
tier int 0 = PGŽ, 1 = savez, 2 = klub
jti str token id (revocable via user_sessions)
iat / exp / nbf
"""
import os, hashlib, secrets, json, time
from datetime import datetime, timedelta, timezone
from typing import Optional, Dict, List, Any
import jwt as _jwt
import psycopg2, psycopg2.extras
from fastapi import APIRouter, HTTPException, Header, Depends, Request, Body
from pydantic import BaseModel, EmailStr
try:
from passlib.hash import bcrypt as _bcrypt
HAS_BCRYPT = True
except Exception:
HAS_BCRYPT = False
DB = dict(host='10.10.0.2', port=6432, dbname='rinet_v3',
user='rinet', password='R1net2026!SecureDB#v7')
# Persistent JWT secret — read from env, else stable file, else generated.
def _load_secret() -> str:
env_secret = os.environ.get("PGZ_JWT_SECRET")
if env_secret and len(env_secret) >= 32:
return env_secret
secret_file = "/opt/pgz-sport/auth/.jwt_secret"
try:
if os.path.exists(secret_file):
with open(secret_file) as f:
s = f.read().strip()
if len(s) >= 32:
return s
s = "rinet-pgz-" + secrets.token_urlsafe(48)
with open(secret_file, "w") as f:
f.write(s)
os.chmod(secret_file, 0o600)
return s
except Exception:
return "rinet-pgz-jwt-2026-fallback-" + hashlib.sha256(b"pgz-sport").hexdigest()
JWT_SECRET = _load_secret()
JWT_ALG = "HS256"
ACCESS_TTL = timedelta(minutes=int(os.environ.get("PGZ_JWT_ACCESS_MIN", "30")))
REFRESH_TTL = timedelta(days=int(os.environ.get("PGZ_JWT_REFRESH_DAYS", "7")))
router = APIRouter(prefix="/api/auth", tags=["auth_v2"])
# ─────────────────────────── DB helpers ───────────────────────────
def _conn():
return psycopg2.connect(**DB)
def db_query(sql: str, params=()):
with _conn() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, params)
if cur.description: return cur.fetchall()
return []
def db_one(sql: str, params=()):
rows = db_query(sql, params)
return rows[0] if rows else None
def db_exec(sql: str, params=()):
with _conn() as c:
cur = c.cursor()
cur.execute(sql, params)
if cur.description:
r = cur.fetchone()
return r[0] if r else None
c.commit()
# ─────────────────────────── Password helpers ───────────────────────────
def _sha256(pw: str) -> str:
return hashlib.sha256(pw.encode()).hexdigest()
def hash_password(pw: str) -> str:
if HAS_BCRYPT:
return _bcrypt.using(rounds=12).hash(pw)
return _sha256(pw)
def verify_password(pw: str, hashed: Optional[str]) -> bool:
if not hashed: return False
h = hashed.strip()
if h.startswith("$2") and HAS_BCRYPT:
try:
return _bcrypt.verify(pw, h)
except Exception:
return False
return h == _sha256(pw)
def needs_rehash(hashed: Optional[str]) -> bool:
if not hashed: return True
return HAS_BCRYPT and not hashed.startswith("$2")
# ─────────────────────────── Tenant resolution ───────────────────────────
PGZ_USER_TYPES = {"super_admin", "pgz_admin", "pgz_user", "pgz_finance", "pgz_zzjz"}
SAVEZ_USER_TYPES = {"savez_admin", "savez_user"}
KLUB_USER_TYPES = {"klub_admin", "klub_user", "klub_trener", "klub_clan"}
def _tier_for(user_type: str) -> int:
ut = (user_type or "").lower()
if ut in PGZ_USER_TYPES: return 0
if ut in SAVEZ_USER_TYPES: return 1
if ut in KLUB_USER_TYPES: return 2
return 9 # unknown / viewer / guest
def _resolve_tenant(u: Dict) -> Dict:
"""Resolve tenant_id + tenant_type from a user row."""
ut = (u.get("user_type") or "").lower()
klub_id = u.get("klub_id")
savez_id = u.get("savez_id")
if ut in PGZ_USER_TYPES:
row = db_one("SELECT id, slug, display_name FROM pgz_sport.tenants WHERE slug='pgz' LIMIT 1")
return {
"tenant_id": row["id"] if row else None,
"tenant_type": "pgz",
"tenant_name": row["display_name"] if row else "PGŽ",
"tenant_scope": {"klub_id": None, "savez_id": None},
}
if ut in SAVEZ_USER_TYPES and savez_id:
return {
"tenant_id": savez_id,
"tenant_type": "savez",
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.savezi WHERE id=%s",(savez_id,)) or {}).get("naziv"),
"tenant_scope": {"klub_id": None, "savez_id": savez_id},
}
if ut in KLUB_USER_TYPES and klub_id:
return {
"tenant_id": klub_id,
"tenant_type": "klub",
"tenant_name": (db_one("SELECT naziv FROM pgz_sport.klubovi WHERE id=%s",(klub_id,)) or {}).get("naziv"),
"tenant_scope": {"klub_id": klub_id, "savez_id": savez_id},
}
# super_admin without context
if ut == "super_admin":
return {"tenant_id": None, "tenant_type": "global",
"tenant_name": "Global", "tenant_scope": {"klub_id": None, "savez_id": None}}
return {"tenant_id": None, "tenant_type": "viewer",
"tenant_name": None, "tenant_scope": {"klub_id": klub_id, "savez_id": savez_id}}
# ─────────────────────────── JWT issue / verify ───────────────────────────
def _now() -> datetime: return datetime.now(timezone.utc)
def _new_jti() -> str: return secrets.token_urlsafe(16)
def make_access_token(u: Dict, jti: str) -> str:
tenant = _resolve_tenant(u)
tier = _tier_for(u.get("user_type") or "")
now = _now()
payload = {
"sub": str(u["id"]),
"uid": u["id"],
"email": u["email"],
"name": u.get("full_name") or ((u.get("ime") or "") + " " + (u.get("prezime") or "")).strip() or u["email"],
"tenant_id": tenant["tenant_id"],
"tenant_type": tenant["tenant_type"],
"tenant_name": tenant["tenant_name"],
"tenant_scope": tenant["tenant_scope"],
"role": u.get("user_type") or "viewer",
"tier": tier,
"jti": jti,
"typ": "access",
"iat": int(now.timestamp()),
"nbf": int(now.timestamp()),
"exp": int((now + ACCESS_TTL).timestamp()),
}
return _jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALG)
def make_refresh_token(uid: int, jti: str) -> str:
now = _now()
return _jwt.encode({
"sub": str(uid), "uid": uid, "jti": jti, "typ": "refresh",
"iat": int(now.timestamp()),
"exp": int((now + REFRESH_TTL).timestamp()),
}, JWT_SECRET, algorithm=JWT_ALG)
def decode_token(token: str) -> Dict:
try:
return _jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALG])
except _jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expired")
except Exception as e:
raise HTTPException(401, f"Invalid token: {e}")
def _record_session(uid: int, jti: str, expires: datetime, ip: str = None, ua: str = None):
th = hashlib.sha256(jti.encode()).hexdigest()
db_exec("""INSERT INTO pgz_sport.user_sessions
(user_id, token_hash, device_info, ip_address, expires_at, revoked)
VALUES (%s,%s,%s,%s::inet,%s,false)
ON CONFLICT (token_hash) DO NOTHING""",
(uid, th, ua, ip, expires))
def _is_revoked(jti: str) -> bool:
th = hashlib.sha256(jti.encode()).hexdigest()
r = db_one("SELECT revoked FROM pgz_sport.user_sessions WHERE token_hash=%s", (th,))
if not r: return False
return bool(r.get("revoked"))
def _revoke_jti(jti: str):
th = hashlib.sha256(jti.encode()).hexdigest()
db_exec("UPDATE pgz_sport.user_sessions SET revoked=true WHERE token_hash=%s", (th,))
# ─────────────────────────── current_user dep ───────────────────────────
def _extract_token(authorization: Optional[str]) -> Optional[str]:
if not authorization: return None
return authorization.replace("Bearer ", "").strip() or None
def get_current_user(authorization: Optional[str] = Header(None)) -> Optional[Dict]:
token = _extract_token(authorization)
if not token: return None
try:
payload = decode_token(token)
except HTTPException:
return None
if payload.get("typ") not in (None, "access"):
return None
if _is_revoked(payload.get("jti","")):
return None
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
klub_id, savez_id, status, aktivan, must_change_pwd
FROM pgz_sport.users WHERE id=%s""", (uid,))
if not u or u.get("status") != "active" or not u.get("aktivan", True):
return None
u["_jwt"] = payload
u["_token"] = token
return u
def require_user(user = Depends(get_current_user)) -> Dict:
if not user:
raise HTTPException(401, "Authentication required")
return user
def require_role(roles: List[str]):
def dep(user = Depends(require_user)):
if user.get("user_type") not in roles:
raise HTTPException(403, f"Forbidden — required: {','.join(roles)}")
return user
return dep
# ─────────────────────────── Audit ───────────────────────────
def audit(user_id: Optional[int], action: str, resource_type: str = None,
resource_id: int = None, meta: Dict = None, ip: str = None, ua: str = None):
try:
db_exec("""INSERT INTO pgz_sport.audit_events
(user_id, action, resource_type, resource_id, meta, ip_address, user_agent)
VALUES (%s,%s,%s,%s,%s::jsonb,%s::inet,%s)""",
(user_id, action, resource_type, resource_id,
json.dumps(meta or {}), ip, ua))
except Exception as e:
print(f"[AUDIT WARN] {e}")
def _client(req: Request):
ip = (req.headers.get("x-forwarded-for") or req.client.host or "").split(",")[0].strip() or None
ua = req.headers.get("user-agent")
return ip, ua
# ─────────────────────────── Schemas ───────────────────────────
class LoginReq(BaseModel):
email: str
password: str
class RefreshReq(BaseModel):
refresh_token: str
class ChangePwdReq(BaseModel):
old_password: Optional[str] = None
new_password: str
class ResetPwdReq(BaseModel):
email: str
# ─────────────────────────── Endpoints ───────────────────────────
@router.post("/login")
def login(req: LoginReq, request: Request):
ip, ua = _client(request)
email = (req.email or "").lower().strip()
if not email or not req.password:
raise HTTPException(400, "Email i lozinka obavezni")
u = db_one("""SELECT id, email, full_name, ime, prezime, password_hash, status,
user_type, klub_id, savez_id, aktivan, must_change_pwd,
failed_login_count, locked_until
FROM pgz_sport.users WHERE LOWER(email)=%s""", (email,))
if not u:
audit(None, "login.fail", meta={"email": email, "reason": "no_user"}, ip=ip, ua=ua)
raise HTTPException(401, "Neispravni podaci")
if u.get("locked_until"):
lu = u["locked_until"]
if lu.tzinfo is None: lu = lu.replace(tzinfo=timezone.utc)
if lu > _now():
audit(u["id"], "login.locked", ip=ip, ua=ua)
raise HTTPException(423, "Račun privremeno zaključan")
if u.get("status") != "active" or not u.get("aktivan", True):
audit(u["id"], "login.fail", meta={"reason":"inactive"}, ip=ip, ua=ua)
raise HTTPException(403, "Račun nije aktivan")
if not verify_password(req.password, u.get("password_hash")):
db_exec("""UPDATE pgz_sport.users
SET failed_login_count = COALESCE(failed_login_count,0)+1,
locked_until = CASE WHEN COALESCE(failed_login_count,0)+1>=5
THEN now()+interval '15 minutes' ELSE locked_until END
WHERE id=%s""", (u["id"],))
audit(u["id"], "login.fail", meta={"reason":"bad_password"}, ip=ip, ua=ua)
raise HTTPException(401, "Neispravni podaci")
# opportunistic rehash to bcrypt
if needs_rehash(u.get("password_hash")):
try:
db_exec("UPDATE pgz_sport.users SET password_hash=%s WHERE id=%s",
(hash_password(req.password), u["id"]))
except Exception: pass
db_exec("""UPDATE pgz_sport.users
SET failed_login_count=0, locked_until=NULL, last_login=now()
WHERE id=%s""", (u["id"],))
jti = _new_jti()
rjti = _new_jti()
access = make_access_token(u, jti)
refresh = make_refresh_token(u["id"], rjti)
_record_session(u["id"], jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
_record_session(u["id"], rjti, _now() + REFRESH_TTL, ip=ip, ua=(ua or "") + " [refresh]")
audit(u["id"], "login.ok", ip=ip, ua=ua)
tenant = _resolve_tenant(u)
return {
"access_token": access,
"refresh_token": refresh,
"token_type": "Bearer",
"expires_in": int(ACCESS_TTL.total_seconds()),
"user": {
"id": u["id"], "email": u["email"],
"full_name": u.get("full_name") or (u.get("ime","") + " " + u.get("prezime","")).strip(),
"role": u.get("user_type"), "tier": _tier_for(u.get("user_type") or ""),
"must_change_pwd": bool(u.get("must_change_pwd")),
**tenant,
},
}
@router.post("/refresh")
def refresh(req: RefreshReq, request: Request):
payload = decode_token(req.refresh_token)
if payload.get("typ") != "refresh":
raise HTTPException(401, "Invalid refresh token")
if _is_revoked(payload.get("jti","")):
raise HTTPException(401, "Refresh token revoked")
uid = payload.get("uid") or int(payload.get("sub", 0) or 0)
u = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
klub_id, savez_id, status, aktivan, must_change_pwd
FROM pgz_sport.users WHERE id=%s""", (uid,))
if not u or u.get("status") != "active" or not u.get("aktivan", True):
raise HTTPException(401, "User inactive")
ip, ua = _client(request)
new_jti = _new_jti()
access = make_access_token(u, new_jti)
_record_session(u["id"], new_jti, _now() + ACCESS_TTL, ip=ip, ua=ua)
audit(u["id"], "auth.refresh", ip=ip, ua=ua)
return {"access_token": access, "token_type": "Bearer",
"expires_in": int(ACCESS_TTL.total_seconds())}
@router.post("/logout")
def logout(request: Request, user = Depends(require_user)):
jti = (user.get("_jwt") or {}).get("jti")
if jti: _revoke_jti(jti)
# Also revoke refresh tokens for this user (best-effort)
db_exec("""UPDATE pgz_sport.user_sessions SET revoked=true
WHERE user_id=%s AND device_info LIKE %s""",
(user["id"], "%[refresh]%"))
ip, ua = _client(request)
audit(user["id"], "logout", ip=ip, ua=ua)
return {"status": "ok"}
@router.get("/me")
def me(user = Depends(require_user)):
enriched = db_one("""SELECT id, email, full_name, ime, prezime, user_type,
klub_id, savez_id, must_change_pwd, aktivan, status,
last_login, oib, telefon, phone, preferred_language, created_at
FROM pgz_sport.users WHERE id=%s""", (user["id"],))
if not enriched:
raise HTTPException(404, "User not found")
tenant = _resolve_tenant(enriched)
roles = db_query("""SELECT r.code, r.naziv, ur.scope_type, ur.scope_id
FROM pgz_sport.user_roles ur JOIN pgz_sport.roles r ON r.id=ur.role_id
WHERE ur.user_id=%s AND ur.active=true""", (user["id"],))
return {**enriched,
"tier": _tier_for(enriched.get("user_type") or ""),
"must_change_pwd": bool(enriched.get("must_change_pwd")),
**tenant, "roles": roles}
@router.post("/password/change")
def change_password(req: ChangePwdReq, request: Request, user = Depends(require_user)):
if len(req.new_password) < 8:
raise HTTPException(400, "Lozinka mora imati barem 8 znakova")
cur = db_one("SELECT password_hash, must_change_pwd FROM pgz_sport.users WHERE id=%s",
(user["id"],))
if not cur: raise HTTPException(404, "User not found")
if not cur.get("must_change_pwd"):
if not req.old_password:
raise HTTPException(400, "old_password obavezan")
if not verify_password(req.old_password, cur.get("password_hash")):
raise HTTPException(401, "Stara lozinka netočna")
db_exec("""UPDATE pgz_sport.users
SET password_hash=%s, must_change_pwd=false, updated_at=now()
WHERE id=%s""", (hash_password(req.new_password), user["id"]))
ip, ua = _client(request)
audit(user["id"], "password.change", ip=ip, ua=ua)
return {"status": "ok"}
@router.post("/password/reset")
def password_reset(req: ResetPwdReq, request: Request):
"""Issue a temporary password (admin-equivalent self-reset; logged)."""
email = (req.email or "").lower().strip()
u = db_one("SELECT id, email, aktivan FROM pgz_sport.users WHERE LOWER(email)=%s",
(email,))
ip, ua = _client(request)
audit(u["id"] if u else None, "password.reset.request",
meta={"email": email, "found": bool(u)}, ip=ip, ua=ua)
# Generic response — do not leak which emails exist
return {"status": "ok",
"message": "Ako račun postoji, administrator će vam poslati instrukcije."}
# ─────────────────────────── 2FA placeholders (TOTP) ───────────────────────────
@router.post("/2fa/setup")
def twofa_setup(user = Depends(require_user)):
"""Stub — generate TOTP secret + return otpauth URL.
Full TOTP verification will be added in M1.5."""
secret = secrets.token_hex(20).upper()
db_exec("""ALTER TABLE pgz_sport.users
ADD COLUMN IF NOT EXISTS two_factor_secret text,
ADD COLUMN IF NOT EXISTS two_factor_enabled boolean DEFAULT false""")
db_exec("UPDATE pgz_sport.users SET two_factor_secret=%s WHERE id=%s",
(secret, user["id"]))
otpauth = f"otpauth://totp/PGŽ%20Sport:{user['email']}?secret={secret}&issuer=PGZSport"
return {"secret": secret, "otpauth": otpauth, "enabled": False}
@router.post("/2fa/verify")
def twofa_verify(code: str = Body(..., embed=True), user = Depends(require_user)):
return {"status": "stub", "verified": False, "code_received": bool(code)}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,386 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport · ERP — OCR + Putni nalozi</title>
<!--
erp.html — PGŽ Sport ERP UI (M5 OCR + M6 Putni nalozi)
Author: dradulic@outlook.com / damir@rinet.one — 2026-05-04
Real backend: /api/erp/ocr/upload, /parse, /invoices, /putni-nalog
-->
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect width='32' height='32' rx='6' fill='%2306080d'/><text x='16' y='23' text-anchor='middle' font-size='18' font-family='monospace' fill='%2300f0ff'>€</text></svg>">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg:#06080d; --bg-2:#0d1117; --bg-3:#161b22; --border:#1f2937;
--text:#e6edf3; --text-2:#8b949e; --text-3:#6e7681;
--accent:#00f0ff; --green:#56d364; --yellow:#d29922; --red:#f85149; --purple:#bc8cff;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg); color:var(--text); min-height:100vh; font-size:14px; }
.app { display:grid; grid-template-columns:230px 1fr; min-height:100vh; }
.sidebar { background:var(--bg-2); border-right:1px solid var(--border); padding:20px 0; }
.brand { padding:0 20px 18px; border-bottom:1px solid var(--border); margin-bottom:10px; }
.brand h1 { font-size:16px; font-weight:700; color:var(--accent); font-family:'JetBrains Mono',monospace; }
.brand .sub { font-size:11px; color:var(--text-3); margin-top:2px; }
.nav-item { display:flex; gap:10px; padding:10px 20px; cursor:pointer; color:var(--text-2); font-size:13px; border-left:3px solid transparent; align-items:center; }
.nav-item:hover { background:var(--bg-3); color:var(--text); }
.nav-item.active { color:var(--accent); background:rgba(0,240,255,.05); border-left-color:var(--accent); }
.main { padding:24px 30px; overflow-y:auto; }
.header { display:flex; justify-content:space-between; padding-bottom:14px; border-bottom:1px solid var(--border); margin-bottom:18px; align-items:center; }
.header h2 { font-size:22px; font-weight:700; }
.header .meta { color:var(--text-3); font-size:12px; font-family:'JetBrains Mono',monospace; }
.section { background:var(--bg-2); border:1px solid var(--border); border-radius:8px; padding:18px; margin-bottom:16px; }
.section h3 { font-size:14px; font-weight:600; color:var(--accent); margin-bottom:12px; }
table { width:100%; border-collapse:collapse; font-size:13px; }
th { text-align:left; padding:8px 10px; color:var(--text-3); font-size:11px; text-transform:uppercase; letter-spacing:.5px; border-bottom:1px solid var(--border); }
td { padding:10px; border-bottom:1px solid var(--border); }
td.num { font-family:'JetBrains Mono',monospace; text-align:right; }
tr:hover { background:var(--bg-3); }
.badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.badge.green { background:rgba(86,211,100,.15); color:var(--green); }
.badge.yellow { background:rgba(210,153,34,.15); color:var(--yellow); }
.badge.red { background:rgba(248,81,73,.15); color:var(--red); }
.badge.gray { background:rgba(110,118,129,.15); color:var(--text-3); }
input.fld, select.fld { width:100%; background:var(--bg); border:1px solid var(--border); padding:8px 10px; border-radius:4px; color:var(--text); font-family:inherit; font-size:13px; }
input.fld:focus, select.fld:focus { outline:none; border-color:var(--accent); }
label.lbl { font-size:11px; color:var(--text-3); display:block; margin-bottom:4px; text-transform:uppercase; letter-spacing:.5px; }
.btn { padding:9px 18px; background:var(--accent); color:var(--bg); border:0; border-radius:4px; cursor:pointer; font-weight:600; font-family:inherit; font-size:13px; }
.btn.sec { background:var(--bg-3); color:var(--text); border:1px solid var(--border); }
.tab { display:none; }
.tab.active { display:block; }
.grid2 { display:grid; grid-template-columns: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; }
@media(max-width:768px) { .app { grid-template-columns:1fr; } .sidebar { display:none; } .grid2,.grid3 { grid-template-columns:1fr; } }
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><h1>PGŽ ERP</h1><div class="sub">M5 OCR + M6 Putni nalozi</div></div>
<div class="nav-item active" data-tab="ocr"><span>📷</span><span>Skeniraj račun</span></div>
<div class="nav-item" data-tab="invoices"><span>€</span><span>Računi</span></div>
<div class="nav-item" data-tab="putni"><span>🚗</span><span>Novi putni nalog</span></div>
<div class="nav-item" data-tab="putni-list"><span>📋</span><span>Lista putnih naloga</span></div>
</aside>
<main class="main">
<div class="header">
<h2 id="pageTitle">Skeniraj račun (OCR)</h2>
<span class="meta" id="metaInfo">Tesseract + DeepSeek V3 · /api/erp</span>
</div>
<!-- OCR -->
<div class="tab active" id="tab-ocr">
<div class="section">
<h3>📷 Drag-and-drop OCR (PDF / JPG / PNG)</h3>
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:34px;text-align:center;cursor:pointer;background:var(--bg-3)">
<div style="font-size:36px;color:var(--accent);margin-bottom:6px">⤓</div>
<div style="font-size:14px;font-weight:600">Povuci datoteku ovdje ili klikni za odabir</div>
<div style="font-size:11px;color:var(--text-3);margin-top:6px">Tesseract OCR (hrv+eng) + DeepSeek V3 LLM ekstrakcija polja</div>
<input id="ocrFile" type="file" accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff,.webp" style="display:none">
</div>
<div id="ocrStatus" style="margin-top:10px;font-size:12px;color:var(--text-2);min-height:18px"></div>
<div id="ocrResult" style="display:none;margin-top:14px;padding:14px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border)">
<div class="grid2" style="font-size:13px">
<div><label class="lbl">Izdavatelj</label><input id="oc_vendor_name" class="fld"></div>
<div><label class="lbl">OIB izdavatelja</label><input id="oc_vendor_oib" class="fld"></div>
<div><label class="lbl">Broj računa</label><input id="oc_invoice_no" class="fld"></div>
<div><label class="lbl">Datum</label><input id="oc_invoice_date" type="date" class="fld"></div>
<div><label class="lbl">Iznos neto (€)</label><input id="oc_amount_net" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">PDV (€)</label><input id="oc_amount_vat" type="number" step="0.01" class="fld"></div>
<div><label class="lbl" style="color:var(--accent)">Brutto / UKUPNO (€)</label><input id="oc_amount_gross" type="number" step="0.01" class="fld" style="border-color:var(--accent)"></div>
<div><label class="lbl">Stopa PDV (%)</label><input id="oc_vat_rate" type="number" step="0.01" class="fld"></div>
<div><label class="lbl">IBAN</label><input id="oc_iban" class="fld"></div>
<div><label class="lbl">Valuta</label><select id="oc_currency" class="fld"><option>EUR</option><option>HRK</option></select></div>
<div><label class="lbl">Vrsta troška</label>
<select id="oc_kind" class="fld">
<option value="gorivo">Gorivo</option><option value="cestarina">Cestarina</option>
<option value="hotel">Hotel</option><option value="restoran">Restoran</option>
<option value="oprema">Oprema</option><option value="ostalo" selected>Ostalo</option>
</select>
</div>
<div><label class="lbl">Klub</label><select id="oc_klub" class="fld"></select></div>
</div>
<div style="margin-top:10px"><label class="lbl">Opis</label><input id="oc_description" class="fld"></div>
<details style="margin-top:10px"><summary style="cursor:pointer;font-size:12px;color:var(--text-3)">Sirovi OCR tekst (preview)</summary>
<pre id="oc_raw" style="font-size:11px;background:var(--bg);padding:10px;border-radius:4px;margin-top:6px;max-height:200px;overflow:auto;white-space:pre-wrap"></pre>
</details>
<div style="margin-top:14px;display:flex;gap:8px;align-items:center">
<button id="ocSave" class="btn">💾 Spremi račun</button>
<button id="ocCancel" class="btn sec">Odustani</button>
<span id="ocSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
</div>
</div>
</div>
<!-- Invoices list -->
<div class="tab" id="tab-invoices">
<div class="section">
<h3>Računi (svi klubovi)</h3>
<table id="invTable"><thead><tr><th>#</th><th>Vrsta</th><th>Broj</th><th>Dobavljač</th><th>OIB</th><th>Klub</th><th class="num">Brutto</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
<!-- Putni nalog form -->
<div class="tab" id="tab-putni">
<div class="section">
<h3>🚗 Novi putni nalog (HR pravilnik 2025)</h3>
<div class="grid3" style="font-size:13px">
<div><label class="lbl">Klub</label><select id="pn_klub" class="fld"></select></div>
<div><label class="lbl">Voditelj</label><input id="pn_voditelj" class="fld" placeholder="Ime Prezime"></div>
<div><label class="lbl">Putnici (zarez)</label><input id="pn_putnici" class="fld"></div>
<div style="grid-column:span 3"><label class="lbl">Svrha putovanja</label><input id="pn_svrha" class="fld" placeholder="Natjecanje, treninzi, edukacija…"></div>
<div><label class="lbl">Od grada</label><input id="pn_od" class="fld" value="Rijeka"></div>
<div><label class="lbl">Do grada</label><input id="pn_do" class="fld"></div>
<div><label class="lbl">Zemlja</label><input id="pn_country" class="fld" value="Hrvatska"></div>
<div><label class="lbl">Polazak</label><input id="pn_from" type="datetime-local" class="fld"></div>
<div><label class="lbl">Povratak</label><input id="pn_to" type="datetime-local" class="fld"></div>
<div><label class="lbl">Tip vozila</label>
<select id="pn_vehicle" class="fld">
<option>vlastiti automobil</option><option>službeno vozilo</option><option>kombi</option><option>autobus</option><option>vlak</option><option>avion</option>
</select>
</div>
<div><label class="lbl">Registracija</label><input id="pn_plate" class="fld"></div>
<div><label class="lbl">Kilometara</label><input id="pn_km" type="number" step="1" class="fld" value="0"></div>
<div><label class="lbl">€/km</label><input id="pn_kmrate" type="number" step="0.01" class="fld" value="0.50"></div>
</div>
<div id="pn_preview" style="margin-top:14px;padding:12px;background:var(--bg-3);border-radius:6px;border:1px solid var(--border);font-size:13px;color:var(--text-2)">
Unesi datume za live obračun dnevnica…
</div>
<div style="margin-top:12px;display:flex;gap:8px;align-items:center">
<button id="pnSave" class="btn">📝 Kreiraj putni nalog</button>
<span id="pnSaveStatus" style="font-size:12px;color:var(--text-3)"></span>
</div>
<p style="margin-top:14px;font-size:11px;color:var(--text-3);line-height:1.6">
<b>HR pravilnik 2025:</b> domaće 26.54 € (>8h), 13.27 € (58h), 0 € (&lt;5h). Inozemne dnevnice po zemlji
(Italija/Austrija 35 €, Slovenija/Mađarska/BiH/Srbija 30 €). Kilometrina vlastitim automobilom 0.50 €/km.
</p>
</div>
</div>
<!-- Putni nalozi list -->
<div class="tab" id="tab-putni-list">
<div class="section">
<h3>Lista putnih naloga</h3>
<table id="pnTable"><thead><tr><th>#</th><th>Klub</th><th>Destinacija</th><th>Polazak</th><th>Povratak</th><th class="num">Dnevnice</th><th class="num">Transport</th><th class="num">Total</th><th>Status</th></tr></thead><tbody></tbody></table>
</div>
</div>
</main>
</div>
<script>
const ERP_API = '/api/erp';
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
const fmt = n => n == null ? '—' : new Intl.NumberFormat('hr-HR').format(n);
const fmtEur = n => n != null ? '€' + fmt(Math.round(n*100)/100) : '—';
const fmtDate = d => d ? d.substring(0,10) : '—';
function badge(t,c) { return `<span class="badge ${c}">${t||'—'}</span>`; }
function sBadge(s) {
if (!s) return badge('—','gray');
const x = s.toLowerCase();
if (['paid','approved','active','odobren','zatvoren'].includes(x)) return badge(s,'green');
if (['pending','draft','submitted','open','unpaid'].includes(x)) return badge(s,'yellow');
if (['overdue','rejected','cancelled','failed'].includes(x)) return badge(s,'red');
return badge(s,'gray');
}
async function loadKlubovi() {
const r = await fetch('/api/klubovi?limit=400').then(r=>r.json()).catch(()=>null);
if (!r) return;
const arr = Array.isArray(r) ? r : (r.rows || r.items || []);
const opts = '<option value="">— odaberi klub —</option>' + arr
.map(k => ({id: k.id, naziv: (k.naziv || k.klub || k.sport || '#'+k.id).toString().trim()}))
.filter(k => k.naziv)
.sort((a,b) => a.naziv.localeCompare(b.naziv,'hr'))
.map(k => `<option value="${k.id}">${k.naziv.replace(/"/g,'&quot;')}</option>`).join('');
['oc_klub','pn_klub'].forEach(id => { const e=$('#'+id); if (e) e.innerHTML=opts; });
}
let ocrUploadId = null, ocrParsed = null;
function ocrSet(m,c) { const e=$('#ocrStatus'); if(e){e.textContent=m||''; e.style.color=c||'var(--text-2)';} }
async function ocrHandle(file) {
if (!file) return;
ocrSet('⏳ Učitavam datoteku…','var(--yellow)');
const klubVal = $('#oc_klub')?.value || '';
const fd = new FormData();
fd.append('file', file);
if (klubVal) fd.append('klub_id', klubVal);
fd.append('tenant_id', 1);
fd.append('invoice_kind', $('#oc_kind')?.value || 'ostalo');
let r = await fetch(`${ERP_API}/ocr/upload`, {method:'POST',body:fd});
if (!r.ok) { ocrSet('❌ Upload pao: '+r.status,'var(--red)'); return; }
const j = await r.json();
ocrUploadId = j.upload_id;
ocrSet(`✓ Uploaded #${ocrUploadId} (${j.size} B). Pokrećem OCR + DeepSeek V3 ekstrakciju…`,'var(--accent)');
const fd2 = new FormData();
fd2.append('upload_id', ocrUploadId);
fd2.append('use_llm', 'true');
r = await fetch(`${ERP_API}/ocr/parse`, {method:'POST',body:fd2});
const p = await r.json();
if (!p.ok) { ocrSet('❌ '+(p.error||'Parse fail'),'var(--red)'); return; }
ocrParsed = p.extracted || {};
$('#oc_vendor_name').value = ocrParsed.vendor_name || '';
$('#oc_vendor_oib').value = ocrParsed.vendor_oib || '';
$('#oc_invoice_no').value = ocrParsed.invoice_no || '';
$('#oc_invoice_date').value = ocrParsed.invoice_date|| '';
$('#oc_amount_net').value = ocrParsed.amount_net ?? '';
$('#oc_amount_vat').value = ocrParsed.amount_vat ?? '';
$('#oc_amount_gross').value = ocrParsed.amount_gross?? '';
$('#oc_vat_rate').value = ocrParsed.vat_rate ?? '';
$('#oc_iban').value = ocrParsed.iban || '';
$('#oc_kind').value = ocrParsed.category || 'ostalo';
$('#oc_currency').value = ocrParsed.currency || 'EUR';
$('#oc_description').value = ocrParsed.description|| '';
$('#oc_raw').textContent = (p.raw_text_preview||'').slice(0,4000);
$('#ocrResult').style.display = 'block';
ocrSet(`✓ OCR ${p.ocr_method} (${p.raw_chars} znakova). Provjeri polja → "Spremi račun".`,'var(--green)');
}
function ocrInit() {
const drop = $('#ocrDrop'), inp = $('#ocrFile');
drop.addEventListener('click', () => inp.click());
inp.addEventListener('change', e => { if (e.target.files[0]) ocrHandle(e.target.files[0]); });
['dragenter','dragover'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor='var(--accent)'; }));
['dragleave','drop'].forEach(ev => drop.addEventListener(ev, e => { e.preventDefault(); drop.style.borderColor='var(--border)'; }));
drop.addEventListener('drop', e => { e.preventDefault(); const f = e.dataTransfer.files[0]; if (f) ocrHandle(f); });
$('#ocCancel').addEventListener('click', () => { $('#ocrResult').style.display='none'; ocrUploadId=null; ocrParsed=null; ocrSet(''); inp.value=''; });
$('#ocSave').addEventListener('click', async () => {
const klub = $('#oc_klub').value;
if (!klub) { $('#ocSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1, upload_id: ocrUploadId,
invoice_kind: $('#oc_kind').value || 'ostalo',
invoice_no: $('#oc_invoice_no').value, vendor_name: $('#oc_vendor_name').value,
vendor_oib: $('#oc_vendor_oib').value, invoice_date: $('#oc_invoice_date').value,
amount_net: parseFloat($('#oc_amount_net').value)||null,
amount_vat: parseFloat($('#oc_amount_vat').value)||null,
amount_gross: parseFloat($('#oc_amount_gross').value),
vat_rate: parseFloat($('#oc_vat_rate').value)||null,
iban_to: $('#oc_iban').value || null,
currency: $('#oc_currency').value || 'EUR',
category: $('#oc_kind').value || 'ostalo',
description: $('#oc_description').value || null,
};
$('#ocSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/invoices`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
$('#ocSaveStatus').textContent = `✓ Spremljen kao #${j.invoice.id}`;
$('#ocSaveStatus').style.color = 'var(--green)';
setTimeout(() => { $('#ocrResult').style.display='none'; loadInvoices(); }, 1500);
} else {
$('#ocSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#ocSaveStatus').style.color = 'var(--red)';
}
});
}
let pnTimer = null;
async function pnPreview() {
const df = $('#pn_from').value, dt = $('#pn_to').value;
const country = $('#pn_country').value || 'Hrvatska';
const km = parseFloat($('#pn_km').value || 0);
const kr = parseFloat($('#pn_kmrate').value || 0.5);
const tgt = $('#pn_preview');
if (!df || !dt) { tgt.textContent = 'Unesi datume za live obračun dnevnica…'; return; }
const r = await fetch(`${ERP_API}/putni-nalog/dnevnice/preview?date_from=${encodeURIComponent(df)}&date_to=${encodeURIComponent(dt)}&country=${encodeURIComponent(country)}&km=${km}&km_rate=${kr}`).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) { tgt.textContent='⚠ Neuspješan obračun'; return; }
const d = r.preview;
tgt.innerHTML = `
<div class="grid4">
<div><div style="color:var(--text-3);font-size:11px">Sati</div><div style="font-size:18px;font-family:'JetBrains Mono'">${d.hours}h</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pune dnevnice</div><div style="font-size:18px;color:var(--accent);font-family:'JetBrains Mono'">${d.days_full} × €${d.rate_full}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Pola dnevnica</div><div style="font-size:18px;color:var(--yellow);font-family:'JetBrains Mono'">${d.days_half} × €${d.rate_half}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Dnevnice ukupno</div><div style="font-size:18px;color:var(--green);font-family:'JetBrains Mono'">€${d.dnevnica_amount_total}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometara</div><div style="font-size:16px;font-family:'JetBrains Mono'">${d.km_driven} km</div></div>
<div><div style="color:var(--text-3);font-size:11px">Kilometrina</div><div style="font-size:16px;font-family:'JetBrains Mono'">€${d.km_amount}</div></div>
<div><div style="color:var(--text-3);font-size:11px">Zemlja</div><div style="font-size:14px;font-family:'JetBrains Mono'">${d.country}</div></div>
<div><div style="color:var(--text-3);font-size:11px">PROCJENA UKUPNO</div><div style="font-size:22px;color:var(--accent);font-family:'JetBrains Mono';font-weight:700">€${d.total_estimated}</div></div>
</div>`;
}
function pnInit() {
['pn_from','pn_to','pn_country','pn_km','pn_kmrate'].forEach(id => {
const el = $('#'+id); if (el) el.addEventListener('input', () => { clearTimeout(pnTimer); pnTimer = setTimeout(pnPreview, 250); });
});
$('#pnSave').addEventListener('click', async () => {
const klub = $('#pn_klub').value;
if (!klub) { $('#pnSaveStatus').textContent = 'Odaberi klub'; return; }
const body = {
klub_id: parseInt(klub), tenant_id: 1,
voditelj_ime: $('#pn_voditelj').value,
putnici: ($('#pn_putnici').value||'').split(',').map(s=>s.trim()).filter(Boolean),
svrha: $('#pn_svrha').value,
od_grada: $('#pn_od').value, do_grada: $('#pn_do').value,
datum_polaska: $('#pn_from').value, datum_povratka: $('#pn_to').value,
country: $('#pn_country').value,
vehicle_type: $('#pn_vehicle').value,
registracija_vozila: $('#pn_plate').value,
kilometara: parseFloat($('#pn_km').value)||0,
km_rate: parseFloat($('#pn_kmrate').value)||0.5,
};
$('#pnSaveStatus').textContent = '⏳ Spremam…';
const r = await fetch(`${ERP_API}/putni-nalog`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const j = await r.json();
if (j.ok) {
$('#pnSaveStatus').innerHTML = `✓ Putni nalog #${j.putni_nalog.id} kreiran (€${j.putni_nalog.cost_total})`;
$('#pnSaveStatus').style.color = 'var(--green)';
loadPutni();
} else {
$('#pnSaveStatus').textContent = '❌ ' + (j.detail||'Greška');
$('#pnSaveStatus').style.color = 'var(--red)';
}
});
}
async function loadInvoices() {
const r = await fetch(`${ERP_API}/invoices?limit=50`).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#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>
<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>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
async function loadPutni() {
const r = await fetch(`${ERP_API}/putni-nalog?limit=50`).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
$('#pnTable tbody').innerHTML = r.rows.length ? r.rows.map(p=>`
<tr><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 class="num">${fmtEur(p.dnevnice_amount)}</td>
<td class="num">${fmtEur(p.cost_transport)}</td>
<td class="num"><strong>${fmtEur(p.cost_total)}</strong></td>
<td>${sBadge(p.status)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
}
function activate(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
const titles = {ocr:'Skeniraj račun (OCR)',invoices:'Računi',putni:'Novi putni nalog','putni-list':'Lista putnih naloga'};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'invoices') loadInvoices();
if (name === 'putni-list') loadPutni();
}
$$('.nav-item').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
(async () => {
await loadKlubovi();
ocrInit();
pnInit();
})();
</script>
</body>
</html>
+659
View File
@@ -0,0 +1,659 @@
#!/usr/bin/env python3
# erp/ocr.py — PGŽ Sport ERP OCR router (M5)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: /api/erp/ocr/upload + /parse — Tesseract OCR + DeepSeek V3 LLM extraction
# Persists into pgz_sport.invoice_uploads, then offers structured invoice parse.
from __future__ import annotations
import os
import re
import json
import hashlib
import subprocess
import tempfile
import traceback
from datetime import datetime, date
from pathlib import Path
from typing import Optional, List, Any
import psycopg2
import psycopg2.extras
import requests
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body
from fastapi.responses import JSONResponse
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
# === Config ===
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
UPLOAD_DIR = Path("/opt/pgz-sport/_data/uploads/invoices")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY", "sk-33d29054d1ab4377b7d1a84bc0a423c7")
DEEPSEEK_URL = "https://api.deepseek.com/v1/chat/completions"
DEEPSEEK_MODEL = os.getenv("DEEPSEEK_MODEL", "deepseek-chat")
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}
MAX_BYTES = 12 * 1024 * 1024 # 12 MB
ADMIN_TOKEN = "admin-pgz-2026"
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _is_admin(authorization: Optional[str]) -> bool:
if not authorization:
return False
t = authorization.replace("Bearer ", "").strip()
return t == ADMIN_TOKEN
def _safe_filename(orig: str) -> str:
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120]
if not base:
base = "upload"
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{ts}_{base}"
def _extract_text(path: Path) -> tuple[str, str]:
"""Return (text, method). Tries pdftotext first, falls back to tesseract."""
suf = path.suffix.lower()
if suf == ".pdf":
try:
r = subprocess.run(
["pdftotext", "-layout", "-q", str(path), "-"],
capture_output=True, timeout=45,
)
txt = r.stdout.decode("utf-8", "ignore")
if len(txt.strip()) > 80:
return txt, "pdftotext"
except Exception:
pass
# Rasterize + tesseract
try:
with tempfile.TemporaryDirectory(prefix="ocr_") as td:
subprocess.run(
["pdftoppm", "-r", "200", str(path), f"{td}/page"],
timeout=120, check=True,
)
chunks = []
for img in sorted(Path(td).glob("page-*.ppm"))[:5]:
r = subprocess.run(
["tesseract", str(img), "-", "-l", "hrv+eng", "--psm", "6"],
capture_output=True, timeout=90,
)
chunks.append(r.stdout.decode("utf-8", "ignore"))
return "\n".join(chunks), "tesseract"
except Exception as e:
return "", f"pdf_err:{e}"
if suf in {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp"}:
try:
r = subprocess.run(
["tesseract", str(path), "-", "-l", "hrv+eng", "--psm", "6"],
capture_output=True, timeout=120,
)
return r.stdout.decode("utf-8", "ignore"), "tesseract"
except Exception as e:
return "", f"img_err:{e}"
return "", f"unsupported:{suf}"
# === HR invoice regex helpers ===
_OIB = re.compile(r"\b(\d{11})\b")
_IBAN = re.compile(r"\b(HR\d{19})\b")
_DATE_DOT = re.compile(r"\b(\d{1,2})[.\s\-/]+(\d{1,2})[.\s\-/]+(20\d{2})\b")
_DATE_ISO = re.compile(r"\b(20\d{2})[\-/](\d{1,2})[\-/](\d{1,2})\b")
_AMOUNT_TOTAL = re.compile(
r"(?i)(?:UKUPNO|TOTAL|SVEUKUPNO|ZA NAPLATU|ZA PLATITI|ZA UPLATU|IZNOS\s+UKUPNO)[\s:€]*([\d.\s]{1,12}[,.]\d{2})"
)
_AMOUNT_VAT = re.compile(r"(?i)(?:PDV|VAT)[\s:%]*?([\d.\s]{1,8}[,.]\d{2})")
_INVOICE_NO = re.compile(r"(?i)(?:ra[čc]un|invoice|broj|fakture|br\.)\s*[:#]?\s*([A-Z0-9\-/.]{3,30})")
def _parse_amount(s: str) -> Optional[float]:
if not s:
return None
s = s.replace(" ", "").replace("\xa0", "")
# Croatian style "1.234,56" → 1234.56
if "," in s and "." in s:
s = s.replace(".", "").replace(",", ".")
elif "," in s:
s = s.replace(",", ".")
try:
return float(s)
except Exception:
return None
def regex_extract(text: str) -> dict:
out: dict[str, Any] = {"raw_chars": len(text or "")}
if not text:
return out
oibs = list(dict.fromkeys(_OIB.findall(text)))
if oibs:
out["oibs_found"] = oibs
out["vendor_oib"] = oibs[0]
if len(oibs) > 1:
out["customer_oib"] = oibs[1]
m = _IBAN.search(text.replace(" ", ""))
if m:
out["iban"] = m.group(1)
m = _INVOICE_NO.search(text)
if m:
out["invoice_no"] = m.group(1).strip().rstrip(".,;")
for rx, order in [(_DATE_DOT, "dmy"), (_DATE_ISO, "ymd")]:
m = rx.search(text)
if m:
g = m.groups()
try:
if order == "dmy":
out["invoice_date"] = f"{g[2]}-{int(g[1]):02d}-{int(g[0]):02d}"
else:
out["invoice_date"] = f"{g[0]}-{int(g[1]):02d}-{int(g[2]):02d}"
# validate
date.fromisoformat(out["invoice_date"])
break
except Exception:
out.pop("invoice_date", None)
totals = [_parse_amount(x) for x in _AMOUNT_TOTAL.findall(text)]
totals = [t for t in totals if t and t > 0.01]
if totals:
out["amount_gross"] = max(totals)
out["amounts_found"] = totals[:6]
vats = [_parse_amount(x) for x in _AMOUNT_VAT.findall(text)]
vats = [v for v in vats if v and v > 0.01]
if vats:
# smallest plausible PDV (less than gross)
if "amount_gross" in out:
cand = [v for v in vats if v < out["amount_gross"]]
if cand:
out["amount_vat"] = max(cand)
else:
out["amount_vat"] = max(vats)
if "amount_gross" in out and "amount_vat" in out:
out["amount_net"] = round(out["amount_gross"] - out["amount_vat"], 2)
# Vendor name guess: first non-numeric, non-OIB line in header
for line in text.split("\n")[:12]:
ln = line.strip()
if 4 < len(ln) < 80 and not _OIB.search(ln) and not re.match(r"^[\d\s.,\-/€:]+$", ln):
out["vendor_name"] = ln
break
# Crude vendor guess for known HR sellers
upper = text.upper()
for keyword, label in [
("INA d.d.", "INA"), ("INA-MAZIVA", "INA"), ("TIFON", "TIFON"),
("PETROL", "PETROL"), ("HAC", "HAC"), ("BINA-ISTRA", "BINA-ISTRA"),
("HRVATSKE AUTOCESTE", "HAC"),
]:
if keyword in upper:
out.setdefault("vendor_brand", label)
break
return out
# === DeepSeek V3 LLM extraction ===
SYSTEM_PROMPT = (
"Ti si stručnjak za hrvatske račune (R-1, fiskalne, HUB-3). "
"Korisnik daje tekst računa izvučen OCR-om. Vrati ISKLJUČIVO valjani JSON, bez markdowna i komentara. "
"Ako neko polje nije sigurno - vrati null. Iznosi su brojevi (decimal s točkom). Datum je 'YYYY-MM-DD'."
)
LLM_SCHEMA_HINT = """{
"izdavatelj_naziv": str|null,
"izdavatelj_oib": str|null,
"izdavatelj_adresa": str|null,
"kupac_naziv": str|null,
"kupac_oib": str|null,
"datum": "YYYY-MM-DD"|null,
"broj_racuna": str|null,
"iznos_neto": float|null,
"iznos_pdv": float|null,
"iznos_brutto": float|null,
"stopa_pdv": float|null,
"valuta": "EUR"|"HRK"|null,
"nacin_placanja": str|null,
"IBAN": str|null,
"opis_svrhe": str|null,
"vrsta_troska": "gorivo"|"cestarina"|"hotel"|"restoran"|"oprema"|"ostalo"|null,
"stavke": [
{"opis": str, "kolicina": float, "jedinica": str, "cijena": float, "ukupno": float}
]
}"""
def deepseek_extract(text: str, hint: dict | None = None) -> dict:
"""Call DeepSeek chat completions for structured JSON extraction."""
if not DEEPSEEK_API_KEY:
return {"error": "no_api_key"}
if not text or len(text.strip()) < 20:
return {"error": "empty_text"}
user_msg = (
f"Iz teksta računa ispod izvuci polja po shemi:\n{LLM_SCHEMA_HINT}\n\n"
f"REGEX hint (može biti nepotpun ili netočan): {json.dumps(hint or {}, ensure_ascii=False)}\n\n"
f"--- TEKST RAČUNA ---\n{text[:8000]}\n--- KRAJ ---"
)
payload = {
"model": DEEPSEEK_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_msg},
],
"response_format": {"type": "json_object"},
"temperature": 0.0,
"max_tokens": 1200,
}
headers = {
"Authorization": f"Bearer {DEEPSEEK_API_KEY}",
"Content-Type": "application/json",
}
try:
r = requests.post(DEEPSEEK_URL, headers=headers, json=payload, timeout=60)
except Exception as e:
return {"error": f"net:{e}"}
if r.status_code != 200:
return {"error": f"http_{r.status_code}", "detail": r.text[:300]}
try:
body = r.json()
content = body["choices"][0]["message"]["content"]
return json.loads(content)
except Exception as e:
return {"error": f"parse:{e}", "raw": (r.text[:500] if r else "")}
# === Endpoints ===
@router.post("/ocr/upload")
async def ocr_upload(
file: UploadFile = File(...),
klub_id: Optional[int] = Form(None),
tenant_id: int = Form(1),
invoice_kind: str = Form("ostalo"),
authorization: Optional[str] = Header(None),
):
"""Upload an invoice file (PDF/image) → store on disk + insert pgz_sport.invoice_uploads."""
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}. Dozvoljeno: {sorted(ALLOWED_EXT)}")
raw = await file.read()
if not raw:
raise HTTPException(400, "Prazna datoteka")
if len(raw) > MAX_BYTES:
raise HTTPException(400, f"Datoteka prevelika ({len(raw)} > {MAX_BYTES} bajtova)")
sha256 = hashlib.sha256(raw).hexdigest()
fname = _safe_filename(file.filename or "upload")
if not fname.endswith(suffix):
fname += suffix
path = UPLOAD_DIR / fname
path.write_bytes(raw)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""
INSERT INTO pgz_sport.invoice_uploads
(klub_id, file_name, file_path, file_size, mime, sha256, ocr_status, meta)
VALUES (%s, %s, %s, %s, %s, %s, 'pending', %s)
RETURNING id, klub_id, file_name, ocr_status, uploaded_at
""",
(klub_id, file.filename, str(path), len(raw), file.content_type or "",
sha256, json.dumps({"tenant_id": tenant_id, "invoice_kind": invoice_kind})),
)
row = cur.fetchone()
return {"ok": True, "upload_id": row["id"], "file_name": row["file_name"],
"size": len(raw), "sha256": sha256, "status": row["ocr_status"]}
@router.post("/ocr/parse")
async def ocr_parse(
upload_id: Optional[int] = Form(None),
file: Optional[UploadFile] = File(None),
use_llm: bool = Form(True),
authorization: Optional[str] = Header(None),
):
"""Run OCR + (optional) DeepSeek LLM extraction.
Either pass upload_id (parse a previously uploaded file) or send file directly (one-shot)."""
tmp_to_clean: Optional[Path] = None
upload_row = None
try:
if upload_id:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,))
upload_row = cur.fetchone()
if not upload_row:
raise HTTPException(404, f"Upload id={upload_id} ne postoji")
target = Path(upload_row["file_path"])
if not target.exists():
raise HTTPException(404, f"Datoteka ne postoji na disku: {target}")
elif file:
suffix = "." + (file.filename or "").rsplit(".", 1)[-1].lower()
if suffix not in ALLOWED_EXT:
raise HTTPException(400, f"Tip datoteke nije podržan: {suffix}")
raw = await file.read()
if not raw:
raise HTTPException(400, "Prazna datoteka")
tmp = tempfile.NamedTemporaryFile(prefix="parse_", suffix=suffix, delete=False)
tmp.write(raw); tmp.close()
target = Path(tmp.name)
tmp_to_clean = target
else:
raise HTTPException(400, "Treba poslati upload_id ILI file")
text, method = _extract_text(target)
if len(text.strip()) < 20:
return {"ok": False, "ocr_method": method, "raw_chars": len(text),
"error": "OCR nije uspio izvući dovoljno teksta"}
regex_fields = regex_extract(text)
regex_fields["ocr_method"] = method
llm_fields: dict = {}
if use_llm:
llm_fields = deepseek_extract(text, hint=regex_fields)
# Merge: LLM overrides regex when valid
merged = dict(regex_fields)
for k in ("izdavatelj_naziv", "izdavatelj_oib", "kupac_oib", "datum",
"broj_racuna", "iznos_neto", "iznos_pdv", "iznos_brutto",
"stopa_pdv", "valuta", "IBAN", "opis_svrhe", "vrsta_troska",
"izdavatelj_adresa", "nacin_placanja"):
v = llm_fields.get(k) if isinstance(llm_fields, dict) else None
if v not in (None, "", "null"):
merged[k] = v
# Normalize aliases for UI / DB
if "izdavatelj_naziv" in merged: merged.setdefault("vendor_name", merged["izdavatelj_naziv"])
if "izdavatelj_oib" in merged: merged.setdefault("vendor_oib", merged["izdavatelj_oib"])
if "izdavatelj_adresa" in merged: merged.setdefault("vendor_address", merged["izdavatelj_adresa"])
if "kupac_oib" in merged: merged.setdefault("customer_oib", merged["kupac_oib"])
if "datum" in merged: merged.setdefault("invoice_date", merged["datum"])
if "broj_racuna" in merged: merged.setdefault("invoice_no", merged["broj_racuna"])
if "iznos_brutto" in merged: merged.setdefault("amount_gross", merged["iznos_brutto"])
if "iznos_neto" in merged: merged.setdefault("amount_net", merged["iznos_neto"])
if "iznos_pdv" in merged: merged.setdefault("amount_vat", merged["iznos_pdv"])
if "stopa_pdv" in merged: merged.setdefault("vat_rate", merged["stopa_pdv"])
if "valuta" in merged: merged.setdefault("currency", merged["valuta"])
if "IBAN" in merged: merged.setdefault("iban", merged["IBAN"])
if "opis_svrhe" in merged: merged.setdefault("description", merged["opis_svrhe"])
if "vrsta_troska" in merged: merged.setdefault("category", merged["vrsta_troska"])
# Persist back to invoice_uploads when we have upload_row
if upload_row:
try:
with _db() as c:
c.cursor().execute(
"""UPDATE pgz_sport.invoice_uploads
SET ocr_status='done', processed_at=NOW(),
ocr_engine=%s, ocr_text=%s,
ai_invoice_no=%s, ai_invoice_date=%s,
ai_vendor_name=%s, ai_vendor_oib=%s,
ai_amount_gross=%s, ai_currency=%s, ai_iban=%s,
ai_extracted=%s, ai_engine=%s
WHERE id=%s""",
(
method, text[:50000],
merged.get("invoice_no"),
merged.get("invoice_date") if isinstance(merged.get("invoice_date"), str) else None,
merged.get("vendor_name"),
merged.get("vendor_oib"),
merged.get("amount_gross"),
merged.get("currency", "EUR"),
merged.get("iban"),
json.dumps({"regex": regex_fields, "llm": llm_fields, "merged": merged},
ensure_ascii=False, default=str),
("deepseek-v3" if use_llm and "error" not in (llm_fields or {}) else "regex"),
upload_row["id"],
),
)
except Exception as e:
merged["_persist_warn"] = str(e)[:200]
return {
"ok": True,
"upload_id": (upload_row["id"] if upload_row else None),
"ocr_method": method,
"raw_chars": len(text),
"regex": regex_fields,
"llm": llm_fields,
"extracted": merged,
"raw_text_preview": text[:1500],
}
finally:
if tmp_to_clean and tmp_to_clean.exists():
try:
tmp_to_clean.unlink()
except Exception:
pass
# === Invoices CRUD (M5) ===
@router.get("/invoices")
def invoices_list(
tenant_id: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
status: Optional[str] = Query(None),
kind: Optional[str] = Query(None),
limit: int = Query(100, le=500),
offset: int = Query(0),
):
sql = """SELECT i.id, i.klub_id, k.naziv AS klub_naziv,
i.invoice_kind, i.invoice_no, i.internal_no,
i.vendor_name, i.vendor_oib, i.customer_name, i.customer_oib,
i.invoice_date, i.due_date, i.paid_date, i.currency,
i.amount_net, i.amount_vat, i.amount_gross, i.vat_rate,
i.payment_status, i.payment_method, i.iban_to,
i.description, i.category, i.tenant_id,
i.created_at, i.approved_at
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id
WHERE 1=1"""
args: list = []
if tenant_id is not None:
sql += " AND i.tenant_id=%s"; args.append(tenant_id)
if klub_id is not None:
sql += " AND i.klub_id=%s"; args.append(klub_id)
if status:
sql += " AND i.payment_status=%s"; args.append(status)
if kind:
sql += " AND i.invoice_kind=%s"; args.append(kind)
sql += " ORDER BY i.invoice_date DESC NULLS LAST, i.id DESC LIMIT %s OFFSET %s"
args += [limit, offset]
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows, "count": len(rows)}
@router.get("/invoices/{invoice_id}")
def invoices_get(invoice_id: int):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id",
(invoice_id,))
lines = cur.fetchall()
cur.execute("SELECT id, file_name, sha256, ocr_status, uploaded_at FROM pgz_sport.invoice_uploads WHERE invoice_id=%s",
(invoice_id,))
uploads = cur.fetchall()
return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads}
@router.post("/invoices")
def invoices_create(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Create an invoice from parsed OCR result.
Body: {klub_id, tenant_id, invoice_kind, invoice_no, vendor_name, vendor_oib,
invoice_date, amount_gross, amount_net, amount_vat, vat_rate, currency,
iban_to, description, category, lines:[{...}], upload_id?}"""
required = ["invoice_kind", "invoice_no", "invoice_date", "amount_gross"]
for k in required:
if body.get(k) in (None, ""):
raise HTTPException(400, f"Nedostaje polje: {k}")
klub_id = body.get("klub_id")
tenant_id = body.get("tenant_id", 1)
upload_id = body.get("upload_id")
lines = body.get("lines") or []
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.invoices
(klub_id, invoice_kind, invoice_no, internal_no,
vendor_oib, vendor_name, vendor_address,
customer_oib, customer_name,
invoice_date, due_date, currency,
amount_net, amount_vat, amount_gross, vat_rate,
payment_status, payment_method, iban_to,
description, category, account_code, tenant_id, meta)
VALUES (%s,%s,%s,%s, %s,%s,%s, %s,%s,
%s,%s,COALESCE(%s,'EUR'),
%s,%s,%s,%s,
COALESCE(%s,'unpaid'),%s,%s,
%s,%s,%s,%s,%s)
ON CONFLICT (klub_id, invoice_kind, invoice_no, vendor_oib)
DO UPDATE SET amount_gross=EXCLUDED.amount_gross,
amount_net=EXCLUDED.amount_net,
amount_vat=EXCLUDED.amount_vat,
updated_at=NOW()
RETURNING id, invoice_no, amount_gross, payment_status""",
(
klub_id, body["invoice_kind"], body["invoice_no"], body.get("internal_no"),
body.get("vendor_oib"), body.get("vendor_name"), body.get("vendor_address"),
body.get("customer_oib"), body.get("customer_name"),
body["invoice_date"], body.get("due_date"), body.get("currency"),
body.get("amount_net"), body.get("amount_vat"), body["amount_gross"], body.get("vat_rate"),
body.get("payment_status"), body.get("payment_method"), body.get("iban_to"),
body.get("description"), body.get("category"), body.get("account_code"),
tenant_id, json.dumps(body.get("meta", {})),
),
)
inv = cur.fetchone()
inv_id = inv["id"]
# Replace lines
cur.execute("DELETE FROM pgz_sport.invoice_lines WHERE invoice_id=%s", (inv_id,))
for i, ln in enumerate(lines, start=1):
cur.execute(
"""INSERT INTO pgz_sport.invoice_lines
(invoice_id, line_no, description, quantity, unit, unit_price,
vat_rate, line_net, line_vat, line_gross, account_code, cost_center, meta)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)""",
(
inv_id, ln.get("line_no", i), ln.get("description") or ln.get("opis") or "",
ln.get("quantity") or ln.get("kolicina") or 1,
ln.get("unit") or ln.get("jedinica") or "kom",
ln.get("unit_price") or ln.get("cijena"),
ln.get("vat_rate", 25),
ln.get("line_net"), ln.get("line_vat"),
ln.get("line_gross") or ln.get("ukupno"),
ln.get("account_code"), ln.get("cost_center"),
json.dumps(ln.get("meta", {})),
),
)
# Link upload to invoice
if upload_id:
cur.execute(
"UPDATE pgz_sport.invoice_uploads SET invoice_id=%s WHERE id=%s",
(inv_id, upload_id),
)
return {"ok": True, "invoice": inv}
@router.put("/invoices/{invoice_id}")
def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Update / approve invoice. Body may include any of: payment_status, paid_date,
approved (bool), notes, category, account_code, due_date."""
fields = []
args: list = []
for col in ("payment_status", "paid_date", "due_date", "category",
"account_code", "notes", "vat_rate", "amount_net", "amount_vat",
"amount_gross", "payment_method", "iban_to"):
if col in body:
fields.append(f"{col}=%s")
args.append(body[col])
if body.get("approved"):
fields.append("approved_at=NOW()")
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at=NOW()")
args.append(invoice_id)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
return {"ok": True, "invoice": row}
@router.post("/invoices/{invoice_id}/pay")
def invoices_pay(invoice_id: int, body: dict = Body(default={})):
paid_date = body.get("paid_date") or date.today().isoformat()
payment_method = body.get("payment_method", "transfer")
iban_from = body.get("iban_from")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.invoices
SET payment_status='paid', paid_date=%s,
payment_method=COALESCE(%s,payment_method),
iban_from=COALESCE(%s,iban_from), updated_at=NOW()
WHERE id=%s RETURNING id, invoice_no, paid_date, amount_gross""",
(paid_date, payment_method, iban_from, invoice_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
# log payment
cur.execute(
"""INSERT INTO pgz_sport.payments (invoice_id, amount, payment_date, method, iban_from)
VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(invoice_id, row["amount_gross"], paid_date, payment_method, iban_from),
) if False else None # payments table column-set may differ; skip silently
return {"ok": True, "invoice": row}
@router.get("/invoices/uploads/list")
def uploads_list(klub_id: Optional[int] = None, status: Optional[str] = None, limit: int = 50):
sql = """SELECT id, klub_id, file_name, file_size, mime, ocr_status, ocr_engine,
ai_invoice_no, ai_invoice_date, ai_vendor_name, ai_vendor_oib,
ai_amount_gross, ai_currency, invoice_id, uploaded_at, processed_at
FROM pgz_sport.invoice_uploads WHERE 1=1"""
args: list = []
if klub_id is not None:
sql += " AND klub_id=%s"; args.append(klub_id)
if status:
sql += " AND ocr_status=%s"; args.append(status)
sql += " ORDER BY uploaded_at DESC LIMIT %s"; args.append(limit)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows}
@@ -0,0 +1,413 @@
#!/usr/bin/env python3
# erp/putni_nalozi.py — PGŽ Sport ERP putni nalozi (M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: CRUD putnih naloga + obračun dnevnica (HR pravilnik 2025).
from __future__ import annotations
import json
from datetime import datetime, date, timedelta
from typing import Optional, Any
import psycopg2
import psycopg2.extras
from fastapi import APIRouter, Body, HTTPException, Query, Header
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
# === HR pravilnik 2025 — dnevnice ===
# Domaće: 26.54 € (puna) za put >8h, 13.27 € za 5-8h, 0 € za <5h.
# Izvor: NN — Pravilnik o porezu na dohodak, neoporezivi iznosi 2025 (200 kn ≈ 26.54 €).
DNEVNICA_DOM_FULL = 26.54 # EUR
DNEVNICA_DOM_HALF = 13.27 # EUR
KM_RATE_DEFAULT = 0.50 # EUR/km (vlastiti automobil)
# Inozemne dnevnice (Uredba o izdacima službenih putovanja u inozemstvo).
DNEVNICE_INO = {
"Italija": 35.00,
"Italy": 35.00,
"Slovenija": 30.00,
"Slovenia": 30.00,
"Austrija": 35.00,
"Austria": 35.00,
"Mađarska": 30.00,
"Madarska": 30.00,
"Hungary": 30.00,
"Bosna i Hercegovina": 30.00,
"BiH": 30.00,
"Bosnia": 30.00,
"Srbija": 30.00,
"Serbia": 30.00,
"Crna Gora": 30.00,
"Montenegro": 30.00,
"Njemačka": 50.00,
"Germany": 50.00,
"Francuska": 50.00,
"France": 50.00,
"Švicarska": 60.00,
"Switzerland": 60.00,
"SAD": 70.00,
"USA": 70.00,
}
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _parse_dt(v) -> Optional[datetime]:
if v is None or v == "":
return None
if isinstance(v, datetime):
return v
s = str(v).strip().replace("Z", "+00:00")
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(s[:len(fmt) + 5].rstrip("ZZ"), fmt)
except Exception:
continue
try:
return datetime.fromisoformat(s)
except Exception:
return None
def compute_dnevnice(date_from, date_to, country: str = "Hrvatska") -> dict:
"""
Vraća: {hours, days_full, days_half, dnevnica_amount_total, breakdown[]}
Pravila (HR pravilnik 2025, neoporeziv iznos):
- Domaće: <5h = 0; 5-8h = pola; >8h = puna; svaka dodatna pokrivena 24h sekcija = puna.
- Inozemne: pune dnevnice po zemlji (DNEVNICE_INO), inače fallback 50 €.
- Više dana: zaokružujemo po 24h segmentima; završetak <8h = 0, 8-12 = puna (po pravilu zaokruživanja na cijele dane), no koristimo konzervativni izračun po segmentima.
Implementacija (jednostavna, transparentna):
1) ukupne sate računaj kao razliku.
2) full_segments = sati // 24
3) ostatak_sati = sati - full_segments*24
4) ako ostatak >= 8 → +1 puna; ako 5 <= ostatak < 8 → +0.5; ako <5 → +0.
5) puna dnevnica = pun_iznos po zemlji; pola = polovica.
"""
df = _parse_dt(date_from)
dt = _parse_dt(date_to)
if not df or not dt or dt < df:
return {"error": "neispravni datumi", "hours": 0,
"days_full": 0, "days_half": 0,
"dnevnica_amount_total": 0.0, "breakdown": []}
delta = dt - df
hours = round(delta.total_seconds() / 3600, 2)
full_segments = int(delta.total_seconds() // (24 * 3600))
remainder_h = (delta.total_seconds() - full_segments * 24 * 3600) / 3600.0
days_full = full_segments
days_half = 0.0
if remainder_h >= 8:
days_full += 1
elif remainder_h >= 5:
days_half += 1
# else: 0
is_domestic = (country or "").strip().lower() in ("hrvatska", "croatia", "hr")
if is_domestic:
full_amt = DNEVNICA_DOM_FULL
half_amt = DNEVNICA_DOM_HALF
else:
full_amt = DNEVNICE_INO.get(country.strip(), 50.00)
half_amt = full_amt / 2.0
total = round(days_full * full_amt + days_half * half_amt, 2)
return {
"hours": hours,
"days_full": days_full,
"days_half": days_half,
"country": country,
"rate_full": full_amt,
"rate_half": half_amt,
"dnevnica_amount_total": total,
"breakdown": [
f"{days_full} pun{'' if days_full == 1 else 'e'} dnevnice × {full_amt:.2f} €",
f"{days_half} pola dnevnice × {full_amt:.2f} €" if days_half else "",
],
}
def compute_kilometrina(km: float, km_rate: float = KM_RATE_DEFAULT) -> float:
try:
return round(float(km or 0) * float(km_rate or 0), 2)
except Exception:
return 0.0
# === Endpoints ===
@router.get("/putni-nalog/dnevnice/preview")
def preview_dnevnice(date_from: str, date_to: str, country: str = "Hrvatska",
km: float = 0.0, km_rate: float = KM_RATE_DEFAULT):
"""Preview dnevnica + kilometrine bez upisa u DB. Koristi UI za live preview."""
d = compute_dnevnice(date_from, date_to, country)
km_amt = compute_kilometrina(km, km_rate)
d["km_amount"] = km_amt
d["km_driven"] = km
d["km_rate"] = km_rate
d["total_estimated"] = round((d.get("dnevnica_amount_total") or 0) + km_amt, 2)
return {"ok": True, "preview": d}
@router.get("/putni-nalog")
def list_putni_nalozi(klub_id: Optional[int] = None,
status: Optional[str] = None,
limit: int = Query(100, le=500),
offset: int = 0):
sql = """SELECT er.id, er.klub_id, k.naziv AS klub_naziv,
er.user_id, er.clan_id, er.report_type, er.report_no,
er.destination, er.purpose,
er.date_from, er.date_to,
er.vehicle_type, er.vehicle_plate,
er.km_driven, er.km_rate,
er.cost_transport, er.cost_lodging, er.cost_meals,
er.cost_other, er.cost_total,
er.dnevnice_count, er.dnevnice_amount,
er.status, er.approved_at, er.paid_at,
er.created_at, er.tenant_id, er.notes
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.report_type='putni_nalog'"""
args: list = []
if klub_id is not None:
sql += " AND er.klub_id=%s"; args.append(klub_id)
if status:
sql += " AND er.status=%s"; args.append(status)
sql += " ORDER BY er.date_from DESC NULLS LAST, er.id DESC LIMIT %s OFFSET %s"
args += [limit, offset]
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql, args)
rows = cur.fetchall()
return {"ok": True, "rows": rows, "count": len(rows)}
@router.get("/putni-nalog/{nalog_id}")
def get_putni_nalog(nalog_id: int):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("""SELECT er.*, k.naziv AS klub_naziv
FROM pgz_sport.expense_reports er
LEFT JOIN pgz_sport.klubovi k ON k.id = er.klub_id
WHERE er.id=%s AND er.report_type='putni_nalog'""", (nalog_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog")
def create_putni_nalog(body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Kreiraj putni nalog.
Polja: klub_id, user_id, clan_id, voditelj_ime, putnici[],
svrha (purpose), od_grada, do_grada (destination),
datum_polaska (date_from), datum_povratka (date_to),
registracija_vozila (vehicle_plate), vehicle_type,
kilometara (km_driven), km_rate,
predviđeni_troškovi (cost_estimate), country, notes."""
df = body.get("date_from") or body.get("datum_polaska")
dt = body.get("date_to") or body.get("datum_povratka")
if not df or not dt:
raise HTTPException(400, "Datum polaska i povratka su obavezni")
klub_id = body.get("klub_id")
if not klub_id:
raise HTTPException(400, "klub_id je obavezan")
country = body.get("country", "Hrvatska")
km = body.get("km_driven", body.get("kilometara", 0)) or 0
km_rate = body.get("km_rate") or KM_RATE_DEFAULT
dnv = compute_dnevnice(df, dt, country)
dnevnice_count = (dnv.get("days_full") or 0) + 0.5 * (dnv.get("days_half") or 0)
dnevnice_amount = dnv.get("dnevnica_amount_total") or 0
cost_transport = compute_kilometrina(km, km_rate) + (body.get("cost_transport") or 0)
od = body.get("od_grada") or body.get("from_city")
do = body.get("do_grada") or body.get("to_city") or body.get("destination")
destination = " → ".join([x for x in [od, do] if x]) or do
putnici = body.get("putnici") or []
voditelj = body.get("voditelj_ime") or body.get("voditelj")
purpose = body.get("svrha") or body.get("purpose") or ""
meta = {
"voditelj": voditelj,
"putnici": putnici,
"from_city": od, "to_city": do,
"country": country,
"dnevnice_calc": dnv,
"predvideni_troskovi": body.get("predvideni_troskovi") or body.get("cost_estimate") or [],
}
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""INSERT INTO pgz_sport.expense_reports
(klub_id, user_id, clan_id, report_type, report_no, destination, purpose,
date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate,
cost_transport, cost_lodging, cost_meals, cost_other,
dnevnice_count, dnevnice_amount, status, attachments, notes, tenant_id)
VALUES (%s, %s, %s, 'putni_nalog', %s, %s, %s,
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s, COALESCE(%s,'draft'), %s, %s, %s)
RETURNING id, klub_id, status, dnevnice_count, dnevnice_amount,
cost_transport, date_from, date_to, destination""",
(
klub_id, body.get("user_id"), body.get("clan_id"),
body.get("report_no"), destination, purpose,
df, dt, body.get("vehicle_type"), body.get("vehicle_plate") or body.get("registracija_vozila"),
float(km or 0), float(km_rate or 0),
cost_transport,
body.get("cost_lodging") or 0, body.get("cost_meals") or 0,
body.get("cost_other") or 0,
dnevnice_count, dnevnice_amount,
body.get("status"),
json.dumps(meta, ensure_ascii=False, default=str),
body.get("notes"),
body.get("tenant_id", 1),
),
)
row = cur.fetchone()
# cost_total via trigger maybe; recompute here
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s
RETURNING cost_total""", (row["id"],),
)
ct = cur.fetchone()
if ct:
row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row, "dnevnice_calc": dnv}
@router.put("/putni-nalog/{nalog_id}")
def update_putni_nalog(nalog_id: int, body: dict = Body(...)):
"""Update polja putnog naloga (osim odobrenja/zatvaranja - oni imaju vlastite endpointe)."""
cols = []
args: list = []
for col in ("destination", "purpose", "date_from", "date_to", "vehicle_type",
"vehicle_plate", "km_driven", "km_rate", "cost_transport",
"cost_lodging", "cost_meals", "cost_other", "notes",
"dnevnice_count", "dnevnice_amount"):
if col in body:
cols.append(f"{col}=%s"); args.append(body[col])
# Recompute dnevnice if dates provided
if "date_from" in body or "date_to" in body or "country" in body:
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT date_from, date_to, attachments FROM pgz_sport.expense_reports WHERE id=%s", (nalog_id,))
cur_row = cur.fetchone()
if cur_row:
df = body.get("date_from") or cur_row["date_from"]
dt = body.get("date_to") or cur_row["date_to"]
country = body.get("country") or (cur_row["attachments"] or {}).get("country", "Hrvatska")
d = compute_dnevnice(df, dt, country)
cols += ["dnevnice_count=%s", "dnevnice_amount=%s"]
args += [(d.get("days_full") or 0) + 0.5 * (d.get("days_half") or 0),
d.get("dnevnica_amount_total") or 0]
if not cols:
raise HTTPException(400, "Nema polja za izmjenu")
cols.append("updated_at=NOW()")
args.append(nalog_id)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(cols)} WHERE id=%s AND report_type='putni_nalog' RETURNING *", args)
row = cur.fetchone()
if row:
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s""", (nalog_id,),
)
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/odobriti")
def odobriti_putni_nalog(nalog_id: int, body: dict = Body(default={})):
approved_by = body.get("approved_by")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET status='odobren', approved_by=%s, approved_at=NOW(), updated_at=NOW()
WHERE id=%s AND report_type='putni_nalog'
RETURNING id, status, approved_at""", (approved_by, nalog_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Putni nalog ne postoji")
return {"ok": True, "putni_nalog": row}
@router.post("/putni-nalog/{nalog_id}/zatvori")
def zatvori_putni_nalog(nalog_id: int, body: dict = Body(default={})):
"""Zatvori putni nalog: priloži račune i konačan obračun."""
invoice_ids = body.get("invoice_ids") or []
cost_lodging = body.get("cost_lodging")
cost_meals = body.get("cost_meals")
cost_other = body.get("cost_other")
notes = body.get("notes")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.expense_reports WHERE id=%s AND report_type='putni_nalog'", (nalog_id,))
cur_row = cur.fetchone()
if not cur_row:
raise HTTPException(404, "Putni nalog ne postoji")
# Aggregiraj iznose iz računa (ako su poslani)
if invoice_ids:
cur.execute(
"SELECT COALESCE(SUM(amount_gross),0) AS total FROM pgz_sport.invoices WHERE id = ANY(%s)",
(invoice_ids,),
)
invs_total = float(cur.fetchone()["total"] or 0)
else:
invs_total = None
sets = ["status='zatvoren'", "updated_at=NOW()"]
args: list = []
if cost_lodging is not None: sets.append("cost_lodging=%s"); args.append(cost_lodging)
if cost_meals is not None: sets.append("cost_meals=%s"); args.append(cost_meals)
if cost_other is not None: sets.append("cost_other=%s"); args.append(cost_other)
if notes: sets.append("notes=%s"); args.append(notes)
# Pohrani povezane račune u attachments
atts = cur_row["attachments"] or {}
if isinstance(atts, str):
try: atts = json.loads(atts)
except Exception: atts = {}
atts["invoice_ids"] = invoice_ids
if invs_total is not None:
atts["invoices_total"] = invs_total
sets.append("attachments=%s"); args.append(json.dumps(atts, ensure_ascii=False, default=str))
args.append(nalog_id)
cur.execute(f"UPDATE pgz_sport.expense_reports SET {','.join(sets)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
cur.execute(
"""UPDATE pgz_sport.expense_reports
SET cost_total = COALESCE(cost_transport,0)+COALESCE(cost_lodging,0)
+COALESCE(cost_meals,0)+COALESCE(cost_other,0)
+COALESCE(dnevnice_amount,0)
WHERE id=%s RETURNING cost_total""", (nalog_id,),
)
ct = cur.fetchone()
if ct: row["cost_total"] = ct["cost_total"]
return {"ok": True, "putni_nalog": row}
File diff suppressed because it is too large Load Diff
+974
View File
@@ -0,0 +1,974 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PGŽ Sport — CRM (Članarine • Liječnički • Obrasci)</title>
<style>
:root {
--pgz-blue:#1a73e8; --pgz-blue2:#1e3a8a;
--bg:#0f1115; --bg2:#171a21; --bg3:#1f242d;
--rim:#293040; --t1:#e6e8ef; --t2:#9aa3b6; --t3:#6b748b;
--ok:#22c55e; --warn:#f59e0b; --err:#ef4444; --info:#3b82f6;
}
* { box-sizing: border-box; }
body { margin:0; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
background: var(--bg); color: var(--t1); font-size: 14px; }
.topbar {
height: 54px; background: linear-gradient(90deg, var(--pgz-blue2), var(--pgz-blue));
display: flex; align-items: center; padding: 0 18px; gap: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.topbar .logo { font-weight: 700; font-size: 16px; }
.topbar .sep { color: rgba(255,255,255,0.5); }
.topbar .title { font-size: 14px; opacity: 0.95; }
.topbar .right { margin-left: auto; display: flex; gap: 10px; align-items: center; font-size: 12px; }
.topbar a { color: #fff; text-decoration: none; opacity: 0.8; padding: 6px 10px; border-radius: 4px; }
.topbar a:hover { opacity: 1; background: rgba(255,255,255,0.1); }
.tabs { display: flex; background: var(--bg2); border-bottom: 1px solid var(--rim); padding: 0 18px; }
.tab { padding: 14px 20px; cursor: pointer; color: var(--t2); border-bottom: 2px solid transparent;
font-weight: 500; user-select: none; }
.tab:hover { color: var(--t1); }
.tab.active { color: var(--pgz-blue); border-bottom-color: var(--pgz-blue); background: var(--bg3); }
.tab .count { background: var(--bg3); color: var(--t2); padding: 2px 8px; border-radius: 10px;
font-size: 11px; margin-left: 6px; }
.tab.active .count { background: var(--pgz-blue); color: #fff; }
.container { padding: 18px; }
.toolbar { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; align-items: center; }
.toolbar input, .toolbar select {
background: var(--bg2); border: 1px solid var(--rim); color: var(--t1);
padding: 7px 11px; border-radius: 5px; font-size: 13px; min-width: 140px;
}
.toolbar input:focus, .toolbar select:focus { outline: none; border-color: var(--pgz-blue); }
.toolbar .grow { flex: 1; }
.btn { background: var(--bg3); color: var(--t1); border: 1px solid var(--rim);
padding: 7px 13px; border-radius: 5px; cursor: pointer; font-size: 13px;
font-family: inherit; }
.btn:hover { background: var(--bg2); border-color: var(--pgz-blue); }
.btn.primary { background: linear-gradient(135deg, var(--pgz-blue), var(--pgz-blue2)); border-color: var(--pgz-blue); color:#fff; }
.btn.primary:hover { filter: brightness(1.1); }
.btn.danger { color: var(--err); border-color: var(--err); }
.btn.sm { padding: 4px 8px; font-size: 12px; }
.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px; margin-bottom: 14px; }
.kpi { background: var(--bg2); border: 1px solid var(--rim); padding: 12px 14px; border-radius: 8px; }
.kpi.g { border-left: 3px solid var(--ok); }
.kpi.r { border-left: 3px solid var(--err); }
.kpi.a { border-left: 3px solid var(--warn); }
.kpi.b { border-left: 3px solid var(--pgz-blue); }
.kpi-l { font-size: 11px; color: var(--t2); text-transform: uppercase; letter-spacing: 0.5px; }
.kpi-v { font-size: 22px; font-weight: 700; margin-top: 4px; }
.kpi-s { font-size: 11px; color: var(--t3); margin-top: 2px; }
.card { background: var(--bg2); border: 1px solid var(--rim); border-radius: 8px;
margin-bottom: 14px; overflow: hidden; }
.card-h { padding: 12px 16px; border-bottom: 1px solid var(--rim); display: flex; align-items: center;
justify-content: space-between; background: var(--bg3); }
.card-t { font-weight: 600; font-size: 14px; }
.card-b { padding: 14px 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
table th, table td { padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--rim); }
table th { background: var(--bg3); color: var(--t2); font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.4px; }
table tr:hover td { background: rgba(26, 115, 232, 0.05); }
.tag { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.tag.gr { background: rgba(34,197,94,0.2); color: var(--ok); }
.tag.am { background: rgba(245,158,11,0.2); color: var(--warn); }
.tag.rd { background: rgba(239,68,68,0.2); color: var(--err); }
.tag.bl { background: rgba(26,115,232,0.2); color: var(--pgz-blue); }
.tag.gy { background: rgba(154,163,182,0.2); color: var(--t2); }
.empty { text-align: center; padding: 40px; color: var(--t3); }
.loading { text-align: center; padding: 30px; color: var(--t2); }
.modal-bg { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7);
display: none; justify-content: center; align-items: flex-start; padding-top: 5vh; z-index: 1000; }
.modal-bg.open { display: flex; }
.modal { background: var(--bg2); border: 1px solid var(--rim); border-radius: 8px;
width: 92%; max-width: 720px; max-height: 90vh; overflow-y: auto; }
.modal-h { padding: 14px 18px; border-bottom: 1px solid var(--rim); display: flex;
justify-content: space-between; align-items: center; background: var(--bg3); }
.modal-t { font-weight: 600; font-size: 15px; }
.modal-x { cursor: pointer; color: var(--t2); font-size: 22px; line-height: 1; padding: 0 4px; }
.modal-x:hover { color: var(--err); }
.modal-b { padding: 18px; }
.field { margin-bottom: 12px; }
.field label { display: block; font-size: 12px; color: var(--t2); margin-bottom: 4px;
text-transform: uppercase; letter-spacing: 0.3px; }
.field label.req::after { content: " *"; color: var(--err); }
.field input, .field select, .field textarea {
width: 100%; background: var(--bg); border: 1px solid var(--rim); color: var(--t1);
padding: 8px 12px; border-radius: 5px; font-size: 13px; font-family: inherit;
}
.field input:focus, .field select:focus, .field textarea:focus { outline: none; border-color: var(--pgz-blue); }
.field textarea { min-height: 70px; resize: vertical; }
.field .help { font-size: 11px; color: var(--t3); margin-top: 3px; }
.payment-card { background: var(--bg); border: 1px solid var(--rim); border-radius: 6px;
padding: 14px; margin-top: 12px; }
.payment-row { display: flex; justify-content: space-between; padding: 6px 0; border-bottom: 1px dashed var(--rim); }
.payment-row:last-child { border-bottom: none; }
.payment-row .l { color: var(--t2); font-size: 12px; }
.payment-row .v { font-weight: 600; font-family: 'SF Mono', Consolas, monospace; }
.payment-row .v.big { font-size: 18px; color: var(--pgz-blue); }
.qr-box { display: flex; gap: 16px; align-items: center; margin: 14px 0; }
.qr-box img { width: 160px; height: 160px; background: #fff; padding: 8px; border-radius: 6px; }
.qr-box .qr-info { flex: 1; }
.signature-box { background: var(--bg); border: 1px solid var(--rim); border-radius: 6px;
padding: 14px; margin-top: 14px; font-family: 'SF Mono', Consolas, monospace;
font-size: 11px; word-break: break-all; }
.signature-box .sha { color: var(--ok); }
.toast { position: fixed; bottom: 20px; right: 20px; background: var(--bg3); border: 1px solid var(--rim);
padding: 10px 16px; border-radius: 6px; font-size: 13px; z-index: 2000;
border-left: 3px solid var(--ok); transform: translateX(120%); transition: transform 0.3s; }
.toast.show { transform: translateX(0); }
.toast.err { border-left-color: var(--err); }
</style>
</head>
<body>
<div class="topbar">
<div class="logo">⬢ PGŽ SPORT</div>
<div class="sep">·</div>
<div class="title">CRM — Članarine • Liječnički • Obrasci</div>
<div class="right">
<span style="opacity:.7">Round 3 / CC5</span>
<a href="/sport/static/sport2.html">← portal</a>
<a href="/sport/static/app.html">app →</a>
</div>
</div>
<div class="tabs">
<div class="tab active" data-tab="clanarine" onclick="setTab('clanarine')">€ Članarine <span class="count" id="cnt-clanarine">…</span></div>
<div class="tab" data-tab="lijecnicki" onclick="setTab('lijecnicki')">⚕ Liječnički pregledi <span class="count" id="cnt-lijecnicki">…</span></div>
<div class="tab" data-tab="obrasci" onclick="setTab('obrasci')">📝 Obrasci <span class="count" id="cnt-obrasci">…</span></div>
</div>
<div class="container">
<div id="page-clanarine" class="page"></div>
<div id="page-lijecnicki" class="page" style="display:none"></div>
<div id="page-obrasci" class="page" style="display:none"></div>
</div>
<div id="modal-bg" class="modal-bg" onclick="if(event.target===this)closeModal()">
<div class="modal" id="modal"></div>
</div>
<div id="toast" class="toast"></div>
<script>
// ────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────
const API = '/sport/api/crm';
const $ = (s, root=document) => root.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const fmtEur = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR', {minimumFractionDigits:2, maximumFractionDigits:2}) + ' €';
const fmt = v => (v == null) ? '—' : Number(v).toLocaleString('hr-HR');
const fmtDate = d => !d ? '—' : new Date(d).toLocaleDateString('hr-HR');
async function api(path, opts={}) {
const o = Object.assign({headers: {'Content-Type':'application/json'}}, opts);
if (o.body && typeof o.body !== 'string') o.body = JSON.stringify(o.body);
const r = await fetch(API + path, o);
if (!r.ok) {
const msg = await r.text().catch(()=>r.statusText);
throw new Error(`HTTP ${r.status}: ${msg.substring(0,200)}`);
}
return r.json();
}
function toast(msg, isErr=false) {
const t = $('#toast');
t.textContent = msg;
t.classList.toggle('err', isErr);
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 3500);
}
function openModal(html) {
$('#modal').innerHTML = html;
$('#modal-bg').classList.add('open');
}
function closeModal() {
$('#modal-bg').classList.remove('open');
$('#modal').innerHTML = '';
}
function setTab(name) {
$$('.tab').forEach(t => t.classList.toggle('active', t.dataset.tab === name));
$$('.page').forEach(p => p.style.display = (p.id === 'page-' + name) ? 'block' : 'none');
if (name === 'clanarine') loadClanarine();
if (name === 'lijecnicki') loadLijecnicki();
if (name === 'obrasci') loadObrasci();
}
// ════════════════════════════════════════════════════
// MODUL 1 — ČLANARINE (M7)
// ════════════════════════════════════════════════════
async function loadClanarine() {
const root = $('#page-clanarine');
root.innerHTML = '<div class="loading">Učitavanje članarina…</div>';
let data;
try {
data = await api('/clanarine?limit=200');
} catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-clanarine').textContent = data.count;
const s = data.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Ukupno zaduženja</div><div class="kpi-v">${fmt(s.total)}</div></div>
<div class="kpi g"><div class="kpi-l">Naplaćeno</div><div class="kpi-v">${fmtEur(s.total_placen)}</div></div>
<div class="kpi r"><div class="kpi-l">Dug</div><div class="kpi-v">${fmtEur(s.total_dug)}</div></div>
<div class="kpi a"><div class="kpi-l">Nepodmireno</div><div class="kpi-v">${fmt(s.n_nepodmireno)}</div></div>
</div>`;
const tools = `
<div class="toolbar">
<select id="cl-status" onchange="loadClanarineFiltered()">
<option value="">Svi statusi</option>
<option value="nepodmireno">Nepodmireno</option>
<option value="djelomicno">Djelomično</option>
<option value="podmireno">Podmireno</option>
<option value="storno">Storno</option>
</select>
<input id="cl-godina" type="number" placeholder="Godina" min="2020" max="2030" onchange="loadClanarineFiltered()">
<input id="cl-klub" type="number" placeholder="Klub ID" onchange="loadClanarineFiltered()">
<div class="grow"></div>
<button class="btn primary" onclick="bulkNotify()">📧 Notify dužnike</button>
<button class="btn" onclick="newClanarinaModal()">+ Novo zaduženje</button>
</div>`;
const rows = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${esc(r.godina)}</td>
<td>${esc(r.razdoblje || '')}</td>
<td>${fmtEur(r.iznos_propisan)}</td>
<td>${fmtEur(r.iznos_placen)}</td>
<td><b style="color:${r.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(r.dug)}</b></td>
<td><span class="tag ${statusTag(r.status)}">${esc(r.status)}</span></td>
<td>
<button class="btn sm" onclick="openPayment(${r.id})" title="Pregled plaćanja">💳</button>
<button class="btn sm" onclick="openUplata(${r.id})" title="Registriraj uplatu">+€</button>
<a class="btn sm" href="${API}/clanarine/${r.id}/uplatnica.pdf" target="_blank" title="HUB-3 PDF">📄</a>
</td>
</tr>`).join('');
root.innerHTML = kpi + tools + `
<div class="card">
<div class="card-h"><div class="card-t">Lista članarina (${data.count})</div></div>
<table>
<thead><tr><th>Sportaš/Klub</th><th>God.</th><th>Razdoblje</th><th>Propisan</th><th>Plaćeno</th><th>Dug</th><th>Status</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>'}</tbody>
</table>
</div>`;
}
function statusTag(s) {
return ({nepodmireno:'rd', djelomicno:'am', podmireno:'gr', storno:'gy'})[s] || 'gy';
}
async function loadClanarineFiltered() {
const status = $('#cl-status').value;
const godina = $('#cl-godina').value;
const klub = $('#cl-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (godina) params.append('godina', godina);
if (klub) params.append('klub_id', klub);
const data = await api('/clanarine?' + params);
const tbody = $('#page-clanarine table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${esc(r.godina)}</td>
<td>${esc(r.razdoblje || '')}</td>
<td>${fmtEur(r.iznos_propisan)}</td>
<td>${fmtEur(r.iznos_placen)}</td>
<td><b style="color:${r.dug>0?'var(--err)':'var(--ok)'}">${fmtEur(r.dug)}</b></td>
<td><span class="tag ${statusTag(r.status)}">${esc(r.status)}</span></td>
<td>
<button class="btn sm" onclick="openPayment(${r.id})">💳</button>
<button class="btn sm" onclick="openUplata(${r.id})">+€</button>
<a class="btn sm" href="${API}/clanarine/${r.id}/uplatnica.pdf" target="_blank">📄</a>
</td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>';
}
async function openPayment(id) {
let info;
try { info = await api('/clanarine/' + id + '/payment-info'); }
catch (e) { return toast('Greška: ' + e.message, true); }
openModal(`
<div class="modal-h">
<div class="modal-t">💳 Podaci za plaćanje #${id}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="qr-box">
<img src="${API}/clanarine/${id}/qr.png" alt="EPC QR">
<div class="qr-info">
<p style="margin:0 0 8px;color:var(--t2);font-size:12px">Skenirajte QR mobilnom bankom (Zaba / PBZ / Erste / OTP / RBA) — popunit će sve podatke za uplatu.</p>
<a class="btn primary" href="${API}/clanarine/${id}/uplatnica.pdf" target="_blank">📄 HUB-3 PDF (uplatnica)</a>
</div>
</div>
<div class="payment-card">
<div class="payment-row"><div class="l">Iznos za uplatu</div><div class="v big">${fmtEur(info.iznos_eur)}</div></div>
<div class="payment-row"><div class="l">Primatelj</div><div class="v">${esc(info.primatelj)}</div></div>
<div class="payment-row"><div class="l">IBAN</div><div class="v">${esc(info.iban)}</div></div>
<div class="payment-row"><div class="l">Model</div><div class="v">${esc(info.model)}</div></div>
<div class="payment-row"><div class="l">Poziv na broj</div><div class="v">${esc(info.poziv_na_broj)}</div></div>
<div class="payment-row"><div class="l">Opis</div><div class="v">${esc(info.opis)}</div></div>
</div>
<details style="margin-top:14px">
<summary style="cursor:pointer;color:var(--t2);font-size:12px">EPC QR payload (BCD/002 SCT)</summary>
<pre style="background:var(--bg);padding:10px;border-radius:5px;font-size:11px;overflow:auto;margin-top:6px">${esc(info.epc_payload)}</pre>
</details>
</div>`);
}
function openUplata(id) {
openModal(`
<div class="modal-h">
<div class="modal-t">+€ Registriraj uplatu (članarina #${id})</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitUplata(event, ${id})">
<div class="field"><label class="req">Iznos uplate (EUR)</label>
<input name="iznos" type="number" step="0.01" min="0.01" required></div>
<div class="field"><label>Datum uplate</label>
<input name="datum_uplate" type="date" value="${new Date().toISOString().slice(0,10)}"></div>
<div class="field"><label>Način uplate</label>
<select name="nacin_uplate">
<option value="transakcijski">Transakcijski račun</option>
<option value="gotovina">Gotovina</option>
<option value="kartica">Kartica</option>
</select></div>
<div class="field"><label>Referenca / broj naloga</label>
<input name="referenca" type="text"></div>
<div style="text-align:right;margin-top:14px">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Spremi uplatu</button>
</div>
</form>
</div>`);
}
async function submitUplata(e, id) {
e.preventDefault();
const f = e.target;
const body = {
iznos: parseFloat(f.iznos.value),
datum_uplate: f.datum_uplate.value || null,
nacin_uplate: f.nacin_uplate.value,
referenca: f.referenca.value || null,
};
try {
const r = await api('/clanarine/' + id + '/uplata', {method:'POST', body});
closeModal();
toast(`Uplata ${fmtEur(body.iznos)} registrirana. Status: ${r.status}`);
loadClanarine();
} catch (err) { toast('Greška: ' + err.message, true); }
}
function newClanarinaModal() {
openModal(`
<div class="modal-h">
<div class="modal-t">+ Novo zaduženje članarine</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitNewClanarina(event)">
<div class="field"><label class="req">Član ID</label>
<input name="clan_id" type="number" required></div>
<div class="field"><label>Klub ID (auto ako se ne unese)</label>
<input name="klub_id" type="number"></div>
<div class="field"><label class="req">Godina</label>
<input name="godina" type="number" required value="${new Date().getFullYear()}"></div>
<div class="field"><label>Razdoblje</label>
<input name="razdoblje" type="text" value="godišnja"></div>
<div class="field"><label class="req">Iznos propisan (EUR)</label>
<input name="iznos_propisan" type="number" step="0.01" required></div>
<div class="field"><label>Iznos plaćen (ako odmah)</label>
<input name="iznos_placen" type="number" step="0.01" value="0"></div>
<div class="field"><label>Napomena</label>
<textarea name="napomena"></textarea></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Kreiraj</button>
</div>
</form>
</div>`);
}
async function submitNewClanarina(e) {
e.preventDefault();
const f = e.target;
const body = {
clan_id: parseInt(f.clan_id.value),
klub_id: f.klub_id.value ? parseInt(f.klub_id.value) : null,
godina: parseInt(f.godina.value),
razdoblje: f.razdoblje.value,
iznos_propisan: parseFloat(f.iznos_propisan.value),
iznos_placen: parseFloat(f.iznos_placen.value || 0),
napomena: f.napomena.value || null,
};
try {
await api('/clanarine', {method:'POST', body});
closeModal();
toast('Članarina kreirana.');
loadClanarine();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function bulkNotify() {
if (!confirm('Pošalji notifikaciju svim dužnicima?')) return;
try {
const r = await api('/clanarine/notify-bulk', {method:'POST', body: {}});
toast(`Postavljeno ${r.queued} primatelja u red. (Mock — SMTP nije konfiguriran.)`);
} catch (err) { toast('Greška: ' + err.message, true); }
}
// ════════════════════════════════════════════════════
// MODUL 2 — LIJEČNIČKI PREGLEDI (M8)
// ════════════════════════════════════════════════════
async function loadLijecnicki() {
const root = $('#page-lijecnicki');
root.innerHTML = '<div class="loading">Učitavanje pregleda…</div>';
let data;
try { data = await api('/lijecnicki?limit=200'); }
catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-lijecnicki').textContent = data.count;
const s = data.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Ukupno pregleda</div><div class="kpi-v">${fmt(s.total)}</div></div>
<div class="kpi g"><div class="kpi-l">Važeći</div><div class="kpi-v">${fmt(s.vazeci)}</div></div>
<div class="kpi a"><div class="kpi-l">Uskoro istek (30d)</div><div class="kpi-v">${fmt(s.uskoro)}</div></div>
<div class="kpi r"><div class="kpi-l">Istekli</div><div class="kpi-v">${fmt(s.istekli)}</div></div>
</div>`;
const tools = `
<div class="toolbar">
<select id="lj-status" onchange="loadLijecnickiFiltered()">
<option value="">Svi statusi</option>
<option value="vazeci">Važeći</option>
<option value="uskoro">Uskoro istek</option>
<option value="istekao">Istekao</option>
</select>
<input id="lj-klub" type="number" placeholder="Klub ID" onchange="loadLijecnickiFiltered()">
<div class="grow"></div>
<button class="btn" onclick="loadZZJZ()">🏥 ZZJZ PGŽ termini</button>
<button class="btn" onclick="newLijecnickiModal()">+ Novi pregled</button>
</div>`;
const rows = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${fmtDate(r.datum_pregleda)}</td>
<td>${fmtDate(r.vrijedi_do)}</td>
<td><span class="tag ${({vazeci:'gr', uskoro:'am', istekao:'rd'})[r.status_calc]||'gy'}">
${r.status_calc}${r.dana_do_isteka != null ? ' ('+r.dana_do_isteka+'d)' : ''}</span></td>
<td>${esc(r.ustanova || '')}</td>
<td>${esc(r.lijecnik || '')}</td>
<td>${r.placeno ? '<span class="tag gr">DA</span>' : '<span class="tag rd">NE</span>'}</td>
<td>
<button class="btn sm" onclick="openZakaziModal(${r.id}, '${esc(r.clan)}')" title="Zakaži termin">📅</button>
<button class="btn sm" onclick="openLijecnickiDetalji(${r.id})" title="Detalji">👁</button>
</td>
</tr>`).join('');
root.innerHTML = kpi + tools + `
<div class="card">
<div class="card-h"><div class="card-t">Lista pregleda (${data.count})</div></div>
<table>
<thead><tr><th>Sportaš/Klub</th><th>Datum pregleda</th><th>Vrijedi do</th><th>Status</th><th>Ustanova</th><th>Liječnik</th><th>Plaćeno</th><th></th></tr></thead>
<tbody>${rows || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>'}</tbody>
</table>
</div>`;
}
async function loadLijecnickiFiltered() {
const status = $('#lj-status').value;
const klub = $('#lj-klub').value;
const params = new URLSearchParams({limit: 200});
if (status) params.append('status', status);
if (klub) params.append('klub_id', klub);
const data = await api('/lijecnicki?' + params);
const tbody = $('#page-lijecnicki table tbody');
tbody.innerHTML = (data.rows || []).map(r => `
<tr>
<td><b>${esc(r.clan)}</b><div style="font-size:11px;color:var(--t3)">${esc(r.klub || '')}</div></td>
<td>${fmtDate(r.datum_pregleda)}</td>
<td>${fmtDate(r.vrijedi_do)}</td>
<td><span class="tag ${({vazeci:'gr', uskoro:'am', istekao:'rd'})[r.status_calc]||'gy'}">
${r.status_calc}${r.dana_do_isteka != null ? ' ('+r.dana_do_isteka+'d)' : ''}</span></td>
<td>${esc(r.ustanova || '')}</td>
<td>${esc(r.lijecnik || '')}</td>
<td>${r.placeno ? '<span class="tag gr">DA</span>' : '<span class="tag rd">NE</span>'}</td>
<td>
<button class="btn sm" onclick="openZakaziModal(${r.id}, '${esc(r.clan)}')">📅</button>
<button class="btn sm" onclick="openLijecnickiDetalji(${r.id})">👁</button>
</td>
</tr>`).join('') || '<tr><td colspan="8" class="empty">Nema zapisa.</td></tr>';
}
async function loadZZJZ() {
let info, termini;
try {
info = await api('/zzjz/info');
termini = await api('/zzjz/termini');
} catch (e) { return toast('Greška: ' + e.message, true); }
const booking = info.online_booking || {};
const bookingHtml = booking.available
? `<a class="btn primary" target="_blank" href="${esc(booking.url)}">🔗 Otvori online sustav (${esc(booking.kind)})</a>`
: `<div class="tag am">Online sustav nije pronađen — koristi e-mail kontakt</div>
<div style="margin-top:8px"><a class="btn primary" href="mailto:${esc(info.email)}">✉ E-mail: ${esc(info.email)}</a></div>`;
const termHtml = (termini.termini || []).slice(0, 30).map(t => `
<tr>
<td>${esc(t.datum)}</td><td>${esc(t.vrijeme)}</td>
<td>${esc(t.doktor)}</td>
<td>${t.available ? '<span class="tag gr">slobodno</span>' : '<span class="tag rd">zauzeto</span>'}</td>
<td>${fmtEur(t.iznos_eur)}</td>
</tr>`).join('');
openModal(`
<div class="modal-h">
<div class="modal-t">🏥 ZZJZ PGŽ — Sportska medicina</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Naziv</div><div class="v">${esc(info.naziv)}</div></div>
<div class="payment-row"><div class="l">Adresa</div><div class="v">${esc(info.adresa)}</div></div>
<div class="payment-row"><div class="l">Telefon</div><div class="v">${esc(info.telefon)}</div></div>
<div class="payment-row"><div class="l">E-mail</div><div class="v">${esc(info.email)}</div></div>
<div class="payment-row"><div class="l">Web</div><div class="v"><a href="${esc(info.url_sportska_medicina)}" target="_blank" style="color:var(--pgz-blue)">${esc(info.url_sportska_medicina)}</a></div></div>
</div>
<div style="margin:14px 0">${bookingHtml}</div>
<div class="card-h" style="background:transparent;border:none;padding:8px 0">
<div class="card-t">Dostupni termini (mock — tjedan ${esc(termini.week_start)})</div>
<div style="font-size:11px;color:var(--t3)">${termini.available} slobodno / ${termini.count} ukupno</div>
</div>
<table>
<thead><tr><th>Datum</th><th>Vrijeme</th><th>Doktor</th><th>Status</th><th>Iznos</th></tr></thead>
<tbody>${termHtml || '<tr><td colspan="5" class="empty">Nema termina.</td></tr>'}</tbody>
</table>
</div>`);
}
function openZakaziModal(lid, clan) {
openModal(`
<div class="modal-h">
<div class="modal-t">📅 Zakaži pregled — ${esc(clan)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<p style="color:var(--t2);font-size:13px;margin-top:0">Sustav će zakazati termin u ZZJZ PGŽ. Ako online sustav nije dostupan, otvorit će mailto: link.</p>
<form onsubmit="submitZakazi(event, ${lid})">
<div class="field"><label class="req">Datum</label>
<input name="datum" type="date" required value="${new Date(Date.now()+7*86400000).toISOString().slice(0,10)}"></div>
<div class="field"><label>Vrijeme</label>
<input name="vrijeme" type="time" value="09:00"></div>
<div class="field"><label>Ustanova</label>
<input name="ustanova" type="text" value="ZZJZ PGŽ"></div>
<div class="field"><label>Napomena</label>
<textarea name="napomena"></textarea></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">📅 Zakaži</button>
</div>
</form>
</div>`);
}
async function submitZakazi(e, lid) {
e.preventDefault();
const f = e.target;
const body = {
datum: f.datum.value, vrijeme: f.vrijeme.value,
ustanova: f.ustanova.value, napomena: f.napomena.value || null,
};
try {
const r = await api('/lijecnicki/' + lid + '/zakazi', {method:'POST', body});
closeModal();
toast('Termin zakazan: ' + r.zakazano_za);
if (r.booking && r.booking.available) {
window.open(r.booking.url, '_blank');
} else if (r.mailto) {
window.location.href = r.mailto;
}
loadLijecnicki();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function openLijecnickiDetalji(lid) {
let l;
try { l = await api('/lijecnicki/' + lid); }
catch (e) { return toast('Greška: ' + e.message, true); }
openModal(`
<div class="modal-h">
<div class="modal-t">⚕ Pregled #${l.id} — ${esc(l.clan)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Sportaš</div><div class="v">${esc(l.clan)}</div></div>
<div class="payment-row"><div class="l">Klub</div><div class="v">${esc(l.klub || '')}</div></div>
<div class="payment-row"><div class="l">Datum pregleda</div><div class="v">${fmtDate(l.datum_pregleda)}</div></div>
<div class="payment-row"><div class="l">Vrijedi do</div><div class="v">${fmtDate(l.vrijedi_do)}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag ${({vazeci:'gr',uskoro:'am',istekao:'rd'})[l.status_calc]||'gy'}">${l.status_calc} (${l.dana_do_isteka}d)</span></div></div>
<div class="payment-row"><div class="l">Vrsta</div><div class="v">${esc(l.vrsta_pregleda || '')}</div></div>
<div class="payment-row"><div class="l">Ustanova</div><div class="v">${esc(l.ustanova || '')}</div></div>
<div class="payment-row"><div class="l">Liječnik</div><div class="v">${esc(l.lijecnik || '')}</div></div>
<div class="payment-row"><div class="l">EKG / Krv / Spirometrija</div><div class="v">${l.ekg?'✓':'✗'} / ${l.krv?'✓':'✗'} / ${l.spirometrija?'✓':'✗'}</div></div>
<div class="payment-row"><div class="l">Spreman za natjecanje</div><div class="v">${l.spreman_za_natjecanje?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</div></div>
<div class="payment-row"><div class="l">Iznos / plaćeno</div><div class="v">${fmtEur(l.iznos)} ${l.placeno?'<span class="tag gr">DA</span>':'<span class="tag rd">NE</span>'}</div></div>
</div>
${l.komentar_lijecnika ? `<div style="margin-top:12px;padding:10px;background:var(--bg);border-left:3px solid var(--pgz-blue);border-radius:5px"><div style="font-size:11px;color:var(--t3);margin-bottom:4px">KOMENTAR LIJEČNIKA</div>${esc(l.komentar_lijecnika)}</div>` : ''}
${l.napomena ? `<div style="margin-top:8px;padding:10px;background:var(--bg);border-left:3px solid var(--warn);border-radius:5px"><div style="font-size:11px;color:var(--t3);margin-bottom:4px">NAPOMENA</div>${esc(l.napomena)}</div>` : ''}
<div style="text-align:right;margin-top:14px">
<button class="btn" onclick="openZakaziModal(${l.id}, '${esc(l.clan)}')">📅 Zakaži novi termin</button>
</div>
</div>`);
}
function newLijecnickiModal() {
openModal(`
<div class="modal-h">
<div class="modal-t">+ Novi liječnički pregled</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<form onsubmit="submitNewLijecnicki(event)">
<div class="field"><label class="req">Član ID</label>
<input name="clan_id" type="number" required></div>
<div class="field"><label class="req">Datum pregleda</label>
<input name="datum_pregleda" type="date" required value="${new Date().toISOString().slice(0,10)}"></div>
<div class="field"><label>Vrijedi do (auto +1 god)</label>
<input name="vrijedi_do" type="date"></div>
<div class="field"><label>Vrsta pregleda</label>
<select name="vrsta_pregleda">
<option value="temeljni">Temeljni</option>
<option value="kontrolni">Kontrolni</option>
<option value="izvanredni">Izvanredni</option>
</select></div>
<div class="field"><label>Ustanova</label>
<input name="ustanova" type="text" value="ZZJZ PGŽ"></div>
<div class="field"><label>Liječnik</label>
<input name="lijecnik" type="text"></div>
<div class="field"><label>Iznos (EUR)</label>
<input name="iznos" type="number" step="0.01" value="60"></div>
<div style="text-align:right">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="submit" class="btn primary">💾 Spremi pregled</button>
</div>
</form>
</div>`);
}
async function submitNewLijecnicki(e) {
e.preventDefault();
const f = e.target;
const body = {
clan_id: parseInt(f.clan_id.value),
datum_pregleda: f.datum_pregleda.value,
vrijedi_do: f.vrijedi_do.value || null,
vrsta_pregleda: f.vrsta_pregleda.value,
ustanova: f.ustanova.value,
lijecnik: f.lijecnik.value || null,
iznos: parseFloat(f.iznos.value || 0),
};
try {
await api('/lijecnicki', {method:'POST', body});
closeModal();
toast('Pregled spremljen.');
loadLijecnicki();
} catch (err) { toast('Greška: ' + err.message, true); }
}
// ════════════════════════════════════════════════════
// MODUL 3 — OBRASCI (M9)
// ════════════════════════════════════════════════════
async function loadObrasci() {
const root = $('#page-obrasci');
root.innerHTML = '<div class="loading">Učitavanje obrazaca…</div>';
let templates, submissions;
try {
templates = await api('/forms');
submissions = await api('/forms/submissions?limit=50');
} catch (e) { root.innerHTML = `<div class="empty">Greška: ${esc(e.message)}</div>`; return; }
$('#cnt-obrasci').textContent = templates.count;
const ss = submissions.summary || {};
const kpi = `
<div class="kpi-grid">
<div class="kpi b"><div class="kpi-l">Templati</div><div class="kpi-v">${fmt(templates.count)}</div></div>
<div class="kpi g"><div class="kpi-l">Predani</div><div class="kpi-v">${fmt(ss.submitted)}</div></div>
<div class="kpi a"><div class="kpi-l">Draft</div><div class="kpi-v">${fmt(ss.draft)}</div></div>
<div class="kpi b"><div class="kpi-l">Odobreni</div><div class="kpi-v">${fmt(ss.approved)}</div></div>
</div>`;
const cards = (templates.forms || []).map(f => `
<div class="card" style="margin-bottom:10px">
<div class="card-b" style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-weight:600">${esc(f.naziv)}</div>
<div style="font-size:11px;color:var(--t3);margin-top:3px">${esc(f.code)} · ${esc(f.kategorija || '—')} · ${f.field_count} polja${f.opis ? ' · ' + esc(f.opis.substring(0,80)) : ''}</div>
</div>
<button class="btn primary" onclick="openFormFill('${esc(f.code)}')">📝 Otvori obrazac</button>
</div>
</div>`).join('');
const subRows = (submissions.rows || []).map(s => `
<tr>
<td><b>${esc(s.template_naziv || s.template_code)}</b><div style="font-size:11px;color:var(--t3)">${esc(s.reference_no || '')}</div></td>
<td>${esc(s.klub_naziv || '—')}</td>
<td>${fmtDate(s.created_at)}</td>
<td><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[s.status]||'gy'}">${esc(s.status)}</span></td>
<td><code style="font-size:10px;color:var(--ok)">${esc((s.signature_sha256 || '').substring(0,12))}${s.signature_sha256?'…':''}</code></td>
<td>
<button class="btn sm" onclick="openSubmissionDetalji(${s.id})" title="Detalji">👁</button>
<a class="btn sm" href="${API}/forms/submissions/${s.id}/pdf" target="_blank" title="PDF">📄</a>
</td>
</tr>`).join('');
root.innerHTML = kpi + `
<div class="row" style="display:grid;grid-template-columns:1fr 1.4fr;gap:14px">
<div>
<div class="card-h" style="border-radius:8px 8px 0 0;background:var(--bg2);border:1px solid var(--rim);border-bottom:none">
<div class="card-t">📋 Dostupni obrasci (${templates.count})</div>
</div>
<div style="background:var(--bg2);border:1px solid var(--rim);border-top:none;border-radius:0 0 8px 8px;padding:12px;max-height:600px;overflow-y:auto">${cards}</div>
</div>
<div>
<div class="card">
<div class="card-h"><div class="card-t">Predani obrasci (${submissions.count})</div></div>
<table>
<thead><tr><th>Obrazac</th><th>Klub</th><th>Datum</th><th>Status</th><th>SHA-256</th><th></th></tr></thead>
<tbody>${subRows || '<tr><td colspan="6" class="empty">Nema predanih obrazaca.</td></tr>'}</tbody>
</table>
</div>
</div>
</div>`;
}
async function openFormFill(code) {
let tpl, prefill;
try {
tpl = await api('/forms/' + code);
// prefill bez klub_id pretpostavlja prazan
prefill = await api(`/forms/${code}/prefill`);
} catch (e) { return toast('Greška: ' + e.message, true); }
const fields = (tpl.schema_json && tpl.schema_json.fields) || [];
const pre = prefill.prefill || {};
const fieldsHtml = fields.map(f => {
const v = pre[f.name] != null ? pre[f.name] : '';
const reqClass = f.required ? 'req' : '';
let inp = '';
if (f.type === 'textarea') {
inp = `<textarea name="${esc(f.name)}">${esc(v)}</textarea>`;
} else if (f.type === 'select' && Array.isArray(f.options)) {
inp = `<select name="${esc(f.name)}"><option value=""></option>${f.options.map(o => `<option ${o===v?'selected':''}>${esc(o)}</option>`).join('')}</select>`;
} else if (f.type === 'date') {
inp = `<input type="date" name="${esc(f.name)}" value="${esc(v)}">`;
} else if (f.type === 'number') {
inp = `<input type="number" name="${esc(f.name)}" value="${esc(v)}" ${f.required?'required':''}>`;
} else if (f.type === 'file') {
inp = `<input type="text" name="${esc(f.name)}" placeholder="(file upload — TODO)">`;
} else {
inp = `<input type="text" name="${esc(f.name)}" value="${esc(v)}" ${f.required?'required':''}>`;
}
return `<div class="field"><label class="${reqClass}">${esc(f.label || f.name)}</label>${inp}${f.help ? `<div class="help">${esc(f.help)}</div>` : ''}</div>`;
}).join('');
openModal(`
<div class="modal-h">
<div class="modal-t">📝 ${esc(tpl.naziv)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<p style="color:var(--t2);font-size:12px;margin-top:0">${esc(tpl.opis || '')} <br><span style="color:var(--t3)">Polja označena * su obavezna. Submit = digitalni potpis (sha256) + status "submitted".</span></p>
<form onsubmit="submitFormFill(event, '${esc(code)}')">
<div class="field"><label>Klub ID (opcionalno — za bolju autopopulaciju)</label>
<input id="fill-klub" type="number" placeholder="npr. 10" onchange="reloadPrefill('${esc(code)}', this.value)"></div>
${fieldsHtml}
<div class="field"><label>Vaše ime/prezime (digitalni potpis)</label>
<input name="__signer" type="text" placeholder="npr. Damir Radulić" required></div>
<div style="text-align:right;margin-top:14px">
<button type="button" class="btn" onclick="closeModal()">Odustani</button>
<button type="button" class="btn" onclick="saveFormDraft(event, '${esc(code)}', this)">💾 Spremi draft</button>
<button type="submit" class="btn primary">✍ Potpiši i predaj</button>
</div>
</form>
</div>`);
}
async function reloadPrefill(code, klubId) {
if (!klubId) return;
try {
const data = await api(`/forms/${code}/prefill?klub_id=${parseInt(klubId)}`);
Object.entries(data.prefill || {}).forEach(([k, v]) => {
const el = document.querySelector(`[name="${k}"]`);
if (el && !el.value) el.value = v;
});
toast(`Autopopulirano ${data.applied_fields.length} polja iz kluba ${klubId}`);
} catch (err) { toast('Prefill greška: ' + err.message, true); }
}
function _collectFormData(form) {
const data = {};
let signer = null;
let klubId = null;
Array.from(form.elements).forEach(el => {
if (!el.name) return;
if (el.name === '__signer') { signer = el.value; return; }
if (el.id === 'fill-klub') { klubId = el.value ? parseInt(el.value) : null; return; }
data[el.name] = el.value;
});
return {data, signer, klubId};
}
async function submitFormFill(e, code) {
e.preventDefault();
const {data, signer, klubId} = _collectFormData(e.target);
try {
// create draft
const draft = await api('/forms/submissions', {method:'POST', body: {
template_code: code, klub_id: klubId, data,
}});
// submit + sign
const signed = await api('/forms/submissions/' + draft.id + '/submit', {method:'POST', body: {
full_name: signer, confirm: true,
}});
closeModal();
toast('Obrazac potpisan i predan. SHA-256: ' + signed.signature_sha256.substring(0,12) + '…');
showSignatureConfirm(signed);
loadObrasci();
} catch (err) { toast('Greška: ' + err.message, true); }
}
async function saveFormDraft(e, code, btn) {
const form = btn.closest('form');
const {data, klubId} = _collectFormData(form);
try {
const draft = await api('/forms/submissions', {method:'POST', body: {
template_code: code, klub_id: klubId, data,
}});
closeModal();
toast('Spremljen draft #' + draft.id + ' (REF ' + draft.reference_no + ')');
loadObrasci();
} catch (err) { toast('Greška: ' + err.message, true); }
}
function showSignatureConfirm(signed) {
setTimeout(() => openModal(`
<div class="modal-h">
<div class="modal-t">✓ Obrazac digitalno potpisan</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Submission ID</div><div class="v">#${signed.id}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag am">${esc(signed.status)}</span></div></div>
<div class="payment-row"><div class="l">Potpisao</div><div class="v">${esc(signed.signed_by)}</div></div>
<div class="payment-row"><div class="l">Vrijeme</div><div class="v" style="font-size:11px">${esc(signed.signed_at)}</div></div>
</div>
<div class="signature-box">
<div style="color:var(--t2);margin-bottom:6px">DIGITALNI POTPIS — SHA-256</div>
<div class="sha">${esc(signed.signature_sha256)}</div>
</div>
<div style="text-align:right;margin-top:14px">
<a class="btn primary" href="${API}/forms/submissions/${signed.id}/pdf" target="_blank">📄 Preuzmi PDF</a>
</div>
</div>`), 200);
}
async function openSubmissionDetalji(sid) {
let s;
try { s = await api('/forms/submissions/' + sid); }
catch (e) { return toast('Greška: ' + e.message, true); }
const data = s.data || {};
const fields = (s.schema_json && s.schema_json.fields) || [];
const fieldsHtml = fields.filter(f => !f.name.startsWith('__')).map(f => {
const v = data[f.name];
if (v == null || v === '') return '';
return `<div class="payment-row"><div class="l">${esc(f.label || f.name)}</div><div class="v">${esc(v).substring(0,200)}</div></div>`;
}).join('');
const sig = data.__signature_sha256;
openModal(`
<div class="modal-h">
<div class="modal-t">📋 Submission #${s.id} — ${esc(s.template_naziv)}</div>
<div class="modal-x" onclick="closeModal()">×</div>
</div>
<div class="modal-b">
<div class="payment-card">
<div class="payment-row"><div class="l">Reference</div><div class="v">${esc(s.reference_no || '')}</div></div>
<div class="payment-row"><div class="l">Klub</div><div class="v">${esc(s.klub_naziv || '—')}</div></div>
<div class="payment-row"><div class="l">Status</div><div class="v"><span class="tag ${({draft:'gy',submitted:'am',approved:'gr',rejected:'rd'})[s.status]||'gy'}">${esc(s.status)}</span></div></div>
<div class="payment-row"><div class="l">Predano</div><div class="v">${fmtDate(s.submitted_at)}</div></div>
</div>
<div class="card-h" style="background:transparent;border:none;padding:8px 0;margin-top:14px"><div class="card-t">Sadržaj</div></div>
<div class="payment-card">${fieldsHtml || '<div style="color:var(--t3)">Prazno.</div>'}</div>
${sig ? `<div class="signature-box"><div style="color:var(--t2);margin-bottom:6px">DIGITALNI POTPIS — SHA-256</div><div class="sha">${esc(sig)}</div><div style="margin-top:6px;color:var(--t3)">Potpisao: ${esc(data.__signed_by||'')} • ${esc(data.__signed_at||'')}</div></div>` : '<div style="color:var(--err);margin-top:10px;font-size:12px">⚠ Nije digitalno potpisan</div>'}
<div style="text-align:right;margin-top:14px;display:flex;gap:8px;justify-content:flex-end">
${s.status === 'submitted' ? `
<button class="btn" onclick="approveSub(${s.id})">✓ Odobri</button>
<button class="btn danger" onclick="rejectSub(${s.id})">✗ Odbij</button>
` : ''}
<button class="btn" onclick="reSign(${s.id})">✍ Potpiši ponovno</button>
<a class="btn primary" href="${API}/forms/submissions/${s.id}/pdf" target="_blank">📄 PDF</a>
</div>
</div>`);
}
async function approveSub(sid) {
if (!confirm('Odobri submission #' + sid + '?')) return;
try {
await api('/forms/submissions/' + sid + '/approve', {method:'POST', body: {user_id: 1}});
closeModal(); toast('Submission #' + sid + ' odobren.'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function rejectSub(sid) {
const reason = prompt('Razlog odbijanja:');
if (!reason) return;
try {
await api('/forms/submissions/' + sid + '/reject', {method:'POST', body: {user_id: 1, reason}});
closeModal(); toast('Submission #' + sid + ' odbijen.'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
async function reSign(sid) {
const name = prompt('Vaše ime za potpis:');
if (!name) return;
try {
const r = await api('/forms/submissions/' + sid + '/sign', {method:'POST', body: {full_name: name, user_id: 1}});
closeModal(); toast('Potpisano. SHA-256: ' + r.signature_sha256.substring(0,12) + '…'); loadObrasci();
} catch (e) { toast('Greška: ' + e.message, true); }
}
// ────────────────────────────────────────────────────
// init
// ────────────────────────────────────────────────────
loadClanarine();
// preload counts
(async () => {
try {
const lj = await api('/lijecnicki?limit=1');
$('#cnt-lijecnicki').textContent = lj.summary?.total ?? '?';
const fm = await api('/forms');
$('#cnt-obrasci').textContent = fm.count;
} catch (e) {}
})();
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+197 -21
View File
@@ -22,7 +22,28 @@ import psycopg2
import psycopg2.extras
import requests
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body
from fastapi.responses import JSONResponse
from fastapi.responses import JSONResponse, FileResponse
try:
from erp.permissions import (
can_view_invoice, can_edit_invoice, can_pay_invoice, can_comment_invoice,
invoice_actions, audit_invoice, fetch_audit, is_pgz_admin,
)
except Exception:
# Fallback (always-allow) for unauth dev
def can_view_invoice(u, i): return True
def can_edit_invoice(u, i): return True
def can_pay_invoice(u, i): return True
def can_comment_invoice(u, i): return True
def invoice_actions(u, i): return {"view": True, "edit": True, "pay": True, "comment": True, "delete": False}
def audit_invoice(u, iid, op, field=None, old=None, new=None): pass
def fetch_audit(t, r, limit=50): return []
def is_pgz_admin(u): return False
try:
from auth.auth_v2 import get_current_user as _auth_user
except Exception:
_auth_user = None
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
@@ -55,6 +76,20 @@ def _is_admin(authorization: Optional[str]) -> bool:
return t == ADMIN_TOKEN
def _resolve_user(authorization: Optional[str]) -> Optional[dict]:
"""Resolve current user via auth_v2 JWT, fallback to admin token (returns synthetic pgz_admin)."""
if _auth_user:
try:
u = _auth_user(authorization)
if u: return u
except Exception:
pass
if _is_admin(authorization):
return {"id": 0, "email": "admin@token", "user_type": "pgz_admin",
"klub_id": None, "savez_id": None, "_synthetic": True}
return None
def _safe_filename(orig: str) -> str:
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120]
if not base:
@@ -487,20 +522,117 @@ def invoices_list(
@router.get("/invoices/{invoice_id}")
def invoices_get(invoice_id: int):
def invoices_get(invoice_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoices WHERE id=%s", (invoice_id,))
cur.execute(
"""SELECT i.*, k.naziv AS klub_naziv, k.savez_id
FROM pgz_sport.invoices i
LEFT JOIN pgz_sport.klubovi k ON k.id = i.klub_id
WHERE i.id=%s""", (invoice_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
if user and not can_view_invoice(user, row):
raise HTTPException(403, "Nemate ovlasti vidjeti ovaj račun")
cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id",
(invoice_id,))
lines = cur.fetchall()
cur.execute("SELECT id, file_name, sha256, ocr_status, uploaded_at FROM pgz_sport.invoice_uploads WHERE invoice_id=%s",
(invoice_id,))
cur.execute(
"""SELECT id, file_name, file_size, mime, sha256, ocr_status, ocr_engine,
ai_extracted, uploaded_at, processed_at
FROM pgz_sport.invoice_uploads WHERE invoice_id=%s
ORDER BY uploaded_at DESC""", (invoice_id,))
uploads = cur.fetchall()
return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads}
cur.execute(
"""SELECT id, payment_date, amount, currency, payment_method, iban_from,
iban_to, reference, bank_transaction_id, matched_status, created_at
FROM pgz_sport.payments WHERE invoice_id=%s ORDER BY payment_date DESC""",
(invoice_id,))
payments = cur.fetchall()
audit = fetch_audit("pgz_sport.invoices", invoice_id, 50)
actions = invoice_actions(user, row) if user else {"view": True, "edit": False, "pay": False, "comment": False, "delete": False}
return {"ok": True, "invoice": row, "lines": lines, "uploads": uploads,
"payments": payments, "audit": audit, "actions": actions}
@router.get("/invoices/{invoice_id}/file")
def invoices_file(invoice_id: int, authorization: Optional[str] = Header(None)):
"""Streamira originalnu datoteku skena/računa (slika ili PDF)."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_view_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti")
cur.execute(
"""SELECT file_path, file_name, mime FROM pgz_sport.invoice_uploads
WHERE invoice_id=%s ORDER BY uploaded_at DESC LIMIT 1""", (invoice_id,))
up = cur.fetchone()
if not up:
raise HTTPException(404, "Datoteka skena ne postoji za ovaj račun")
p = Path(up["file_path"])
if not p.exists():
raise HTTPException(404, f"Datoteka ne postoji na disku")
return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream",
filename=up.get("file_name") or p.name)
@router.get("/invoices/uploads/{upload_id}/file")
def upload_file(upload_id: int, authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT * FROM pgz_sport.invoice_uploads WHERE id=%s", (upload_id,))
up = cur.fetchone()
if not up:
raise HTTPException(404, "Upload ne postoji")
if user and not is_pgz_admin(user) and user.get("klub_id") != up.get("klub_id"):
raise HTTPException(403, "Nemate ovlasti")
p = Path(up["file_path"])
if not p.exists():
raise HTTPException(404, "Datoteka ne postoji")
return FileResponse(str(p), media_type=up.get("mime") or "application/octet-stream",
filename=up.get("file_name") or p.name)
@router.post("/invoices/{invoice_id}/comment")
def invoices_comment(invoice_id: int, body: dict = Body(...),
authorization: Optional[str] = Header(None)):
"""Savez admin / klub admin / pgz admin može dodati komentar (audit log entry)."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_comment_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti komentirati")
txt = (body.get("comment") or "").strip()
if not txt:
raise HTTPException(400, "Komentar je prazan")
audit_invoice(user, invoice_id, "comment", field="komentar", old=None, new=txt[:500])
return {"ok": True, "invoice_id": invoice_id, "comment": txt}
@router.get("/invoices/{invoice_id}/audit")
def invoices_audit(invoice_id: int, limit: int = 100,
authorization: Optional[str] = Header(None)):
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.id, i.klub_id FROM pgz_sport.invoices i WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_view_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti")
return {"ok": True, "audit": fetch_audit("pgz_sport.invoices", invoice_id, limit)}
@router.post("/invoices")
@@ -590,16 +722,29 @@ def invoices_create(body: dict = Body(...), authorization: Optional[str] = Heade
def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Optional[str] = Header(None)):
"""Update / approve invoice. Body may include any of: payment_status, paid_date,
approved (bool), notes, category, account_code, due_date."""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_edit_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti uređivati ovaj račun")
fields = []
args: list = []
changes = []
for col in ("payment_status", "paid_date", "due_date", "category",
"account_code", "notes", "vat_rate", "amount_net", "amount_vat",
"amount_gross", "payment_method", "iban_to"):
if col in body:
fields.append(f"{col}=%s")
args.append(body[col])
changes.append((col, inv.get(col), body[col]))
if body.get("approved"):
fields.append("approved_at=NOW()")
changes.append(("approved_at", inv.get("approved_at"), "now"))
if not fields:
raise HTTPException(400, "Nema polja za izmjenu")
fields.append("updated_at=NOW()")
@@ -608,36 +753,67 @@ def invoices_update(invoice_id: int, body: dict = Body(...), authorization: Opti
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
for f, o, n in changes:
audit_invoice(user, invoice_id, "update", field=f, old=o, new=n)
return {"ok": True, "invoice": row}
@router.post("/invoices/{invoice_id}/pay")
def invoices_pay(invoice_id: int, body: dict = Body(default={})):
def invoices_pay(invoice_id: int, body: dict = Body(default={}),
authorization: Optional[str] = Header(None)):
"""Označi račun kao plaćen + insert payment record.
Body: {iban_to, iban_from, paid_date, reference, bank_transaction_id, payment_method, amount}
"""
user = _resolve_user(authorization)
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT i.*, k.savez_id FROM pgz_sport.invoices i LEFT JOIN pgz_sport.klubovi k ON k.id=i.klub_id WHERE i.id=%s", (invoice_id,))
inv = cur.fetchone()
if not inv:
raise HTTPException(404, "Račun ne postoji")
if user and not can_pay_invoice(user, inv):
raise HTTPException(403, "Nemate ovlasti označiti račun kao plaćen")
if (inv.get("payment_status") or "").lower() == "paid":
raise HTTPException(409, "Račun je već označen kao plaćen")
paid_date = body.get("paid_date") or date.today().isoformat()
payment_method = body.get("payment_method", "transfer")
payment_method = body.get("payment_method") or "transfer"
iban_from = body.get("iban_from")
iban_to = body.get("iban_to") or inv.get("iban_to")
reference = body.get("reference")
tx_id = body.get("bank_transaction_id") or body.get("tx_id")
amount = body.get("amount") or inv.get("amount_gross")
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""UPDATE pgz_sport.invoices
SET payment_status='paid', paid_date=%s,
payment_method=COALESCE(%s,payment_method),
iban_from=COALESCE(%s,iban_from), updated_at=NOW()
WHERE id=%s RETURNING id, invoice_no, paid_date, amount_gross""",
(paid_date, payment_method, iban_from, invoice_id),
iban_from=COALESCE(%s,iban_from),
iban_to=COALESCE(%s,iban_to),
updated_at=NOW()
WHERE id=%s
RETURNING id, invoice_no, paid_date, amount_gross, payment_status,
iban_from, iban_to, payment_method""",
(paid_date, payment_method, iban_from, iban_to, invoice_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(404, "Račun ne postoji")
# log payment
# Insert payment record
cur.execute(
"""INSERT INTO pgz_sport.payments (invoice_id, amount, payment_date, method, iban_from)
VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
(invoice_id, row["amount_gross"], paid_date, payment_method, iban_from),
) if False else None # payments table column-set may differ; skip silently
return {"ok": True, "invoice": row}
"""INSERT INTO pgz_sport.payments
(klub_id, invoice_id, payment_date, amount, currency, payment_method,
iban_from, iban_to, reference, bank_transaction_id, matched_status)
VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')
RETURNING id""",
(inv.get("klub_id"), invoice_id, paid_date, amount,
inv.get("currency"), payment_method, iban_from, iban_to,
reference, tx_id),
)
pay = cur.fetchone()
audit_invoice(user, invoice_id, "pay", field="payment_status",
old=inv.get("payment_status"), new="paid")
return {"ok": True, "invoice": row, "payment_id": pay["id"] if pay else None}
@router.get("/invoices/uploads/list")
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
# erp/permissions.py — PGŽ Sport ERP RBAC helpers (M5/M6)
# Author: Damir Radulić <damir@rinet.one> / dradulic@outlook.com
# Date: 2026-05-04
# Description: Centralizirane provjere ovlasti za račune i putne naloge.
#
# Uloge (pgz_sport.roles):
# super_admin, pgz_admin, savez_admin, klub_admin, klub_user, clan, viewer
#
# Korisnik (dict iz auth_v2.get_current_user) ima: id, user_type, klub_id, savez_id.
from __future__ import annotations
from typing import Optional, Dict, Any
import psycopg2, psycopg2.extras
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
password="R1net2026!SecureDB#v7")
def _db():
c = psycopg2.connect(**DB); c.autocommit = True; return c
# ── role helpers ──────────────────────────────────────────────────────
def is_super(user) -> bool:
return bool(user) and user.get("user_type") == "super_admin"
def is_pgz_admin(user) -> bool:
return bool(user) and user.get("user_type") in ("super_admin", "pgz_admin")
def is_savez_admin(user) -> bool:
return bool(user) and user.get("user_type") == "savez_admin"
def is_klub_admin(user) -> bool:
return bool(user) and user.get("user_type") == "klub_admin"
def is_klub_user(user) -> bool:
return bool(user) and user.get("user_type") in ("klub_admin", "klub_user")
def klub_savez(klub_id: int) -> Optional[int]:
"""Vraća savez_id kojem klub pripada (preko klubovi.savez_id ili user_klub_links)."""
if not klub_id: return None
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute("SELECT savez_id FROM pgz_sport.klubovi WHERE id=%s", (klub_id,))
r = cur.fetchone()
return r["savez_id"] if r else None
def user_can_see_klub(user, klub_id: Optional[int]) -> bool:
"""Tko može VIDJETI klub: super, pgz, savez (ako klub u savezu), klub_admin/user (ako vlastiti klub)."""
if not user or not klub_id:
return is_pgz_admin(user)
if is_pgz_admin(user):
return True
if is_klub_user(user):
return user.get("klub_id") == klub_id
if is_savez_admin(user):
return klub_savez(klub_id) == user.get("savez_id")
return False
# ── INVOICES ──────────────────────────────────────────────────────────
def can_view_invoice(user, invoice: Dict[str, Any]) -> bool:
"""Pgž admin vidi sve. Savez admin svoje saveze. Klub admin/user vlastiti klub."""
if not invoice: return False
if is_pgz_admin(user): return True
return user_can_see_klub(user, invoice.get("klub_id"))
def can_edit_invoice(user, invoice: Dict[str, Any]) -> bool:
"""
Edit (izmjena polja, korekcija OCR-a) — samo klub_admin vlastitog kluba ILI pgz_admin.
Savez admin može komentirati, ali NE editirati.
Plaćeni računi su read-only osim za pgz_admin.
"""
if not invoice: return False
if is_pgz_admin(user): return True
if invoice.get("payment_status") in ("paid",):
return False
if is_klub_admin(user):
return user.get("klub_id") == invoice.get("klub_id")
return False
def can_pay_invoice(user, invoice: Dict[str, Any]) -> bool:
"""Označi kao plaćen — klub_admin vlastitog kluba ili pgz_admin."""
if not invoice: return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == invoice.get("klub_id")
return False
def can_comment_invoice(user, invoice: Dict[str, Any]) -> bool:
"""Komentirati može pgz_admin, savez_admin (svog saveza) i klub_admin (svog kluba)."""
if not invoice: return False
if is_pgz_admin(user): return True
if is_savez_admin(user):
return klub_savez(invoice.get("klub_id")) == user.get("savez_id")
if is_klub_admin(user):
return user.get("klub_id") == invoice.get("klub_id")
return False
def invoice_actions(user, invoice: Dict[str, Any]) -> Dict[str, bool]:
"""UI hint — koji gumbi su dostupni."""
return {
"view": can_view_invoice(user, invoice),
"edit": can_edit_invoice(user, invoice),
"pay": can_pay_invoice(user, invoice) and invoice.get("payment_status") != "paid",
"comment": can_comment_invoice(user, invoice),
"delete": is_pgz_admin(user),
}
# ── PUTNI NALOZI ──────────────────────────────────────────────────────
def can_view_putni_nalog(user, pn: Dict[str, Any]) -> bool:
if not pn: return False
if is_pgz_admin(user): return True
if is_savez_admin(user):
return klub_savez(pn.get("klub_id")) == user.get("savez_id")
if is_klub_user(user):
if user.get("klub_id") == pn.get("klub_id"):
return True
# Voditelj vidi svoj
return pn.get("user_id") == user.get("id") if user else False
def can_edit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Edit dozvoljen samo na statusima draft/odbijen, i samo voditelju ili klub_admin/pgz."""
if not pn: return False
status = (pn.get("status") or "draft").lower()
if status not in ("draft", "odbijen"):
return is_pgz_admin(user)
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
# Voditelj
return pn.get("user_id") == user.get("id") if user else False
def can_submit_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Slanje (draft → poslan) — voditelj ili klub_admin."""
if not pn: return False
if (pn.get("status") or "draft").lower() not in ("draft",):
return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
return pn.get("user_id") == user.get("id") if user else False
def can_approve_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Odobravanje (poslan → odobren ili odbijen) — klub_admin svog kluba ili pgz_admin."""
if not pn: return False
if (pn.get("status") or "").lower() not in ("poslan", "submitted", "draft"):
return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
return False
def can_pay_putni_nalog(user, pn: Dict[str, Any]) -> bool:
"""Isplata (odobren → isplaćen) — klub_admin ili pgz_admin."""
if not pn: return False
if (pn.get("status") or "").lower() not in ("odobren", "approved", "zatvoren"):
return False
if is_pgz_admin(user): return True
if is_klub_admin(user):
return user.get("klub_id") == pn.get("klub_id")
return False
def putni_nalog_actions(user, pn: Dict[str, Any]) -> Dict[str, bool]:
return {
"view": can_view_putni_nalog(user, pn),
"edit": can_edit_putni_nalog(user, pn),
"submit": can_submit_putni_nalog(user, pn),
"approve": can_approve_putni_nalog(user, pn),
"reject": can_approve_putni_nalog(user, pn),
"pay": can_pay_putni_nalog(user, pn),
"delete": is_pgz_admin(user),
}
# ── Audit logging helper ──────────────────────────────────────────────
def audit_invoice(user, invoice_id: int, op: str, field: Optional[str] = None,
old=None, new=None):
try:
with _db() as c:
c.cursor().execute(
"""INSERT INTO pgz_sport.audit_log
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
stara_vrijednost, nova_vrijednost)
VALUES ('pgz_sport.invoices', %s, %s, %s, %s, %s, %s)""",
(op, invoice_id,
(user.get("email") if user else "anon"),
field,
None if old is None else str(old)[:500],
None if new is None else str(new)[:500]),
)
except Exception:
pass
def audit_putni(user, pn_id: int, op: str, field: Optional[str] = None,
old=None, new=None):
try:
with _db() as c:
c.cursor().execute(
"""INSERT INTO pgz_sport.audit_log
(tablica, operacija, record_id, korisnik, promijenjeno_polje,
stara_vrijednost, nova_vrijednost)
VALUES ('pgz_sport.expense_reports', %s, %s, %s, %s, %s, %s)""",
(op, pn_id,
(user.get("email") if user else "anon"),
field,
None if old is None else str(old)[:500],
None if new is None else str(new)[:500]),
)
except Exception:
pass
def fetch_audit(table: str, record_id: int, limit: int = 50):
with _db() as c:
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(
"""SELECT timestamp, operacija, korisnik, promijenjeno_polje,
stara_vrijednost, nova_vrijednost
FROM pgz_sport.audit_log
WHERE tablica=%s AND record_id=%s
ORDER BY timestamp DESC LIMIT %s""",
(table, record_id, limit),
)
return cur.fetchall()
+37
View File
@@ -14,6 +14,43 @@ import psycopg2
import psycopg2.extras
from fastapi import APIRouter, Body, HTTPException, Query, Header
try:
from erp.permissions import (
can_view_putni_nalog, can_edit_putni_nalog, can_submit_putni_nalog,
can_approve_putni_nalog, can_pay_putni_nalog, putni_nalog_actions,
audit_putni, fetch_audit, is_pgz_admin,
)
except Exception:
def can_view_putni_nalog(u, p): return True
def can_edit_putni_nalog(u, p): return True
def can_submit_putni_nalog(u, p): return True
def can_approve_putni_nalog(u, p): return True
def can_pay_putni_nalog(u, p): return True
def putni_nalog_actions(u, p): return {"view": True, "edit": True, "submit": True, "approve": True, "reject": True, "pay": True, "delete": False}
def audit_putni(u, pid, op, field=None, old=None, new=None): pass
def fetch_audit(t, r, limit=50): return []
def is_pgz_admin(u): return False
try:
from auth.auth_v2 import get_current_user as _auth_user
except Exception:
_auth_user = None
ADMIN_TOKEN = "admin-pgz-2026"
def _resolve_user(authorization):
if _auth_user:
try:
u = _auth_user(authorization)
if u: return u
except Exception:
pass
if authorization and authorization.replace("Bearer ", "").strip() == ADMIN_TOKEN:
return {"id": 0, "email": "admin@token", "user_type": "pgz_admin",
"klub_id": None, "savez_id": None, "_synthetic": True}
return None
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
+10 -57
View File
@@ -991,63 +991,9 @@ def admin_stats():
return {"users_total": ut, "users_active": ua, "permissions_total": pt,
"audit_today": at, "by_type": by_type}
@app.get("/api/admin/users")
def admin_users(q: str = "", user_type: str = "", limit: int = 100):
where = ["1=1"]; args = []
if q: where.append("(email ILIKE %s OR ime ILIKE %s OR prezime ILIKE %s)"); args += [f"%{q}%"]*3
if user_type: where.append("user_type = %s"); args.append(user_type)
args.append(limit)
with db() as conn:
cur = conn.cursor()
cur.execute(f"""SELECT id, email, ime, prezime, user_type, klub_id, savez_id,
aktivan, last_login, created_at FROM pgz_sport.users
WHERE {' AND '.join(where)} ORDER BY id LIMIT %s""", args)
rows = cur.fetchall()
cols = [d[0] for d in cur.description]
results = [{**dict(zip(cols, r)),
'last_login': str(dict(zip(cols, r))['last_login']) if dict(zip(cols, r))['last_login'] else None,
'created_at': str(dict(zip(cols, r))['created_at'])} for r in rows]
return {"count": len(results), "results": results}
@app.post("/api/admin/users")
def admin_user_create(body: dict):
import hashlib
email = (body.get("email") or "").strip().lower()
if not email or "@" not in email:
raise HTTPException(400, "Invalid email")
pwd = body.get("password","")
if not pwd or len(pwd) < 6:
raise HTTPException(400, "Password min 6 chars")
pwd_hash = hashlib.sha256(pwd.encode()).hexdigest()
with db() as conn:
cur = conn.cursor()
try:
cur.execute("""INSERT INTO pgz_sport.users
(email, password_hash, ime, prezime, user_type, klub_id, savez_id, aktivan)
VALUES (%s,%s,%s,%s,%s,%s,%s,true) RETURNING id""",
(email, pwd_hash, body.get("ime"), body.get("prezime"),
body.get("user_type","klub_user"), body.get("klub_id"), body.get("savez_id")))
new_id = cur.fetchone()[0]
cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, target_text, payload)
VALUES ('user.create','sys_users',%s,%s,%s::jsonb)""",
(new_id, email, json.dumps({"user_type": body.get("user_type")})))
conn.commit()
return {"id": new_id, "email": email}
except psycopg2.IntegrityError as e:
conn.rollback()
raise HTTPException(400, f"Email već postoji: {email}")
@app.post("/api/admin/users/{user_id}/toggle")
def admin_user_toggle(user_id: int):
with db() as conn:
cur = conn.cursor()
cur.execute("UPDATE pgz_sport.users SET aktivan = NOT aktivan WHERE id=%s RETURNING aktivan", (user_id,))
r = cur.fetchone()
if not r: raise HTTPException(404, "User not found")
cur.execute("""INSERT INTO pgz_sport.sys_audit (action, target_type, target_id, payload)
VALUES ('user.toggle','sys_users',%s,%s::jsonb)""", (user_id, json.dumps({"aktivan": r[0]})))
conn.commit()
return {"id": user_id, "aktivan": r[0]}
# Legacy unauthenticated /api/admin/users CRUD removed (R4 #5).
# All /api/admin/users* endpoints are now served by auth.admin_users router
# with require_user dependency that returns 401 on missing/invalid JWT.
# ──────── V6 AI GRADOVI / KILOMETRAŽA ────────
@@ -1408,6 +1354,13 @@ try:
except Exception as e:
print(f'[CRM/M9] obrasci router fail: {e}')
try:
from clan_panel_router import router as clan_panel_router
app.include_router(clan_panel_router)
print('[CRM/PANEL] clan_panel router loaded (/api/crm/clanovi/{id}/full|avatar)')
except Exception as e:
print(f'[CRM/PANEL] clan_panel router fail: {e}')
# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR ===
try:
from auth.auth_v2 import router as auth_v2_router
+516
View File
@@ -0,0 +1,516 @@
#!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════
# Fajl: routers/clan_panel_router.py | v1.0.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/clan_panel_router.py
# Svrha: CRM Dashboard člana — full panel (sve), edit s permission gating,
# avatar upload.
# ═══════════════════════════════════════════════════════════════════
"""CRM Član Panel router.
Endpointi (montirani na /api/crm):
GET /clanovi/{id}/full → SVI podaci o članu + povijest svega
PUT /clanovi/{id} → edit (permission gating po roli)
POST /clanovi/{id}/avatar → upload slike
GET /clanovi/search?q=... → quick search za panel
"""
from __future__ import annotations
import os
import io
import shutil
import uuid as _uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Optional, Any
from pathlib import Path
import psycopg2
from psycopg2.extras import RealDictCursor
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Header
from fastapi.responses import JSONResponse
from pydantic import BaseModel
router = APIRouter(prefix="/api/crm", tags=["crm-clan-panel"])
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
UPLOADS_DIR = Path("/opt/pgz-sport/static/uploads/avatars")
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
PUBLIC_AVATAR_PREFIX = "/sport/static/uploads/avatars"
# Polja koja smiju editirati pojedine role.
# Hard rules iz briefa:
# sportas (sebe): kontakt + slike
# klub_admin: sve osim OIB
# savez_admin: pregled + napomene
# pgz_admin: full
# super_admin: full
EDITABLE_BY_ROLE = {
"sportas": {
"email", "telefon", "adresa", "grad", "postanski_broj",
"biografija", "slika_url",
},
"klub_admin": {
# sve osim "oib"
"ime", "prezime", "datum_rodenja", "spol", "adresa", "grad",
"postanski_broj", "email", "telefon", "kategorija", "podkategorija",
"pozicija", "licenca_broj", "licenca_vrijedi_do", "reprezentativac",
"kategoriziran", "kategorija_hoo", "stipendiran", "stipendija_iznos",
"radno_pravni_status", "aktivan", "datum_pristupa", "datum_napustanja",
"napomena", "dominantna_noga", "visina_cm", "tezina_kg", "broj_dresa",
"reprezentacija_kategorija", "biografija", "mjesto_rodenja",
"sport", "uloga", "uloga_detalj", "klub_id", "slika_url",
},
"savez_admin": {
"napomena",
},
"pgz_admin": "ALL",
"super_admin": "ALL",
"klub_trener": {
"kategorija", "podkategorija", "pozicija", "broj_dresa",
"dominantna_noga", "visina_cm", "tezina_kg", "napomena", "biografija",
},
}
def _conn():
return psycopg2.connect(DSN, cursor_factory=RealDictCursor)
def _conv(v):
if isinstance(v, (date, datetime)):
return v.isoformat()
if isinstance(v, Decimal):
return float(v)
if isinstance(v, _uuid.UUID):
return str(v)
return v
def _row(d):
return None if d is None else {k: _conv(v) for k, v in dict(d).items()}
def _resolve_role(authorization: Optional[str]) -> str:
"""
Vrlo pojednostavljeno: dok puni JWT M1 ne propagira context, čitamo
'X-Role' header (postavi UI). Inače: ako je authorization == admin token
→ pgz_admin, inače → viewer.
"""
if not authorization:
return "viewer"
tok = authorization.replace("Bearer ", "").strip()
if tok == "admin-pgz-2026":
return "pgz_admin"
# decode JWT (best-effort)
try:
import jwt as _jwt # type: ignore
# JWT secret iz auth_v2 — bez tvrde ovisnosti
for secret in (os.environ.get("JWT_SECRET"), "rinet-jwt-secret-2026"):
if not secret:
continue
try:
payload = _jwt.decode(tok, secret, algorithms=["HS256"])
return payload.get("role") or payload.get("user_type") or "viewer"
except Exception:
continue
except Exception:
pass
return "viewer"
def _check_field_perm(role: str, fields: set[str]) -> set[str]:
"""Vrati SAMO polja koja role smije editirati."""
allowed = EDITABLE_BY_ROLE.get(role, set())
if allowed == "ALL":
return fields
return fields & allowed
# ───────────── search ─────────────
@router.get("/clanovi/search")
def clanovi_search(q: Optional[str] = Query(None, min_length=2),
klub_id: Optional[int] = Query(None),
limit: int = Query(20, le=100)):
where, params = ["c.aktivan = TRUE"], []
if q:
where.append("(c.ime || ' ' || c.prezime) ILIKE %s OR c.oib ILIKE %s")
params += [f"%{q}%", f"%{q}%"]
if klub_id:
where.append("c.klub_id = %s"); params.append(klub_id)
params.append(limit)
sql = f"""
SELECT c.id, c.ime, c.prezime, c.oib, c.kategorija, c.pozicija,
c.slika_url, c.broj_dresa,
k.id AS klub_id, k.naziv AS klub
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
WHERE {' AND '.join(where)}
ORDER BY c.prezime, c.ime
LIMIT %s
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute(sql, params)
rows = [_row(r) for r in cur.fetchall()]
return {"count": len(rows), "rows": rows}
# ───────────── full panel ─────────────
@router.get("/clanovi/{cid}/full")
def clan_full(cid: int):
"""
Vraća SVE podatke o članu + sve povezane pod-tablice:
- personal, kontakt, sport, status, reprezentacija, stipendija
- klub (trenutni + povijest preko clan_sezona.klub_naziv)
- sezone (clan_sezona)
- utakmice (zadnjih 20 — clan_utakmica)
- lijecnicki (lijecnicki_pregledi po clan_id)
- clanarine (clanarine po clan_id) + dug
- dokumenti (clan_godisnjak ↔ dokumenti)
- obrasci (form_submissions po clan_id)
- nagrade (clan_nagrada)
"""
with _conn() as conn, conn.cursor() as cur:
cur.execute("""
SELECT c.*,
k.id AS klub__id,
k.naziv AS klub__naziv,
k.oib AS klub__oib,
k.iban AS klub__iban,
k.adresa AS klub__adresa,
k.grad AS klub__grad,
k.sport AS klub__sport,
k.savez_id AS klub__savez_id,
s.naziv AS klub__savez_naziv,
EXTRACT(YEAR FROM age(COALESCE(c.datum_rodenja, c.datum_rodjenja)))::int AS dob_calc
FROM pgz_sport.clanovi c
LEFT JOIN pgz_sport.klubovi k ON k.id = c.klub_id
LEFT JOIN pgz_sport.savezi s ON s.id = k.savez_id
WHERE c.id = %s
""", (cid,))
clan_raw = cur.fetchone()
if not clan_raw:
raise HTTPException(404, "Član ne postoji")
# rastavi klub__* u nested objekt
c = {}
klub: dict = {}
for k, v in dict(clan_raw).items():
if k.startswith("klub__"):
klub[k.replace("klub__", "")] = v
else:
c[k] = v
# avatar URL fallback (slika_url može biti relativna)
slika = c.get("slika_url") or ""
if slika and not (slika.startswith("http") or slika.startswith("/")):
slika = f"{PUBLIC_AVATAR_PREFIX}/{slika}"
c["slika_url_full"] = slika or None
# SEZONE
cur.execute("""
SELECT id, sezona, natjecanje, klub_naziv, nastupi, zapoceo, zamjena,
pogoci, asistencije, zuti_kartoni, crveni_kartoni, minute_total,
napomena, scrape_url
FROM pgz_sport.clan_sezona
WHERE clan_id = %s
ORDER BY sezona DESC
LIMIT 50
""", (cid,))
sezone = [_row(r) for r in cur.fetchall()]
# UTAKMICE (zadnjih 20)
cur.execute("""
SELECT id, datum, domacin, gost, rezultat, natjecanje,
pogoci, zuti, crveni, minute, utakmica_url
FROM pgz_sport.clan_utakmica
WHERE clan_id = %s
ORDER BY datum DESC NULLS LAST
LIMIT 20
""", (cid,))
utakmice = [_row(r) for r in cur.fetchall()]
# LIJEČNIČKI
cur.execute("""
SELECT id, datum_pregleda, vrijedi_do, vrsta_pregleda, ustanova, lijecnik,
spreman_za_natjecanje, ekg, krv, spirometrija,
placeno, iznos, datum_placanja,
(vrijedi_do - CURRENT_DATE)::int AS dana_do_isteka,
CASE
WHEN vrijedi_do IS NULL THEN 'nepoznato'
WHEN vrijedi_do < CURRENT_DATE THEN 'istekao'
WHEN vrijedi_do <= (CURRENT_DATE + INTERVAL '30 days') THEN 'uskoro'
ELSE 'vazeci'
END AS status_calc
FROM pgz_sport.lijecnicki_pregledi
WHERE clan_id = %s
ORDER BY datum_pregleda DESC
""", (cid,))
lijecnicki = [_row(r) for r in cur.fetchall()]
# ČLANARINE
cur.execute("""
SELECT id, godina, razdoblje, iznos_propisan, iznos_placen,
(iznos_propisan - COALESCE(iznos_placen,0))::numeric(10,2) AS dug,
datum_uplate, status, racun_broj, referenca, napomena
FROM pgz_sport.clanarine
WHERE clan_id = %s
ORDER BY godina DESC
""", (cid,))
clanarine = [_row(r) for r in cur.fetchall()]
# DOKUMENTI (preko clan_godisnjak)
cur.execute("""
SELECT cg.id AS link_id, cg.godina, cg.snippet, cg.has_medal, cg.has_kategorija,
d.id AS dokument_id, d.title, d.url, d.pdf_url, d.izvor_url,
d.vrsta, d.organizacija, d.izdano_datum
FROM pgz_sport.clan_godisnjak cg
JOIN pgz_sport.dokumenti d ON d.id = cg.dokument_id
WHERE cg.clan_id = %s
ORDER BY cg.godina DESC
LIMIT 50
""", (cid,))
dokumenti = [_row(r) for r in cur.fetchall()]
# OBRASCI (form_submissions)
cur.execute("""
SELECT s.id, s.template_id, s.template_code, s.status, s.reference_no,
s.submitted_at, s.created_at,
t.naziv AS template_naziv, t.kategorija
FROM pgz_sport.form_submissions s
LEFT JOIN pgz_sport.form_templates t ON t.id = s.template_id
WHERE s.clan_id = %s
ORDER BY s.created_at DESC
""", (cid,))
obrasci = [_row(r) for r in cur.fetchall()]
# NAGRADE
cur.execute("""
SELECT id, godina, sezona, natjecanje, razina_natjecanja,
dobna_kategorija, disciplina, plasman, klub_naziv
FROM pgz_sport.clan_nagrada
WHERE clan_id = %s
ORDER BY godina DESC NULLS LAST
LIMIT 50
""", (cid,))
nagrade = [_row(r) for r in cur.fetchall()]
# POVIJEST KLUBOVA (iz clan_sezona.klub_naziv distinct)
cur.execute("""
SELECT klub_naziv, MIN(sezona) AS od, MAX(sezona) AS do_, COUNT(*) AS broj_sezona
FROM pgz_sport.clan_sezona
WHERE clan_id = %s AND klub_naziv IS NOT NULL
GROUP BY klub_naziv
ORDER BY MAX(sezona) DESC
""", (cid,))
povijest_klubova = [_row(r) for r in cur.fetchall()]
# KPI / sažetak za panel
dug_total = sum(float(r.get("dug") or 0) for r in clanarine)
placeno_total = sum(float(r.get("iznos_placen") or 0) for r in clanarine)
propisan_total = sum(float(r.get("iznos_propisan") or 0) for r in clanarine)
last_lij = lijecnicki[0] if lijecnicki else None
nastupi_total = sum(int(r.get("nastupi") or 0) for r in sezone)
pogoci_total = sum(int(r.get("pogoci") or 0) for r in sezone)
return {
"clan": _row(c),
"klub": _row(klub) if klub.get("id") else None,
"kpi": {
"dug_clanarina_eur": round(dug_total, 2),
"placeno_clanarina_eur": round(placeno_total, 2),
"propisan_clanarina_eur": round(propisan_total, 2),
"lijecnicki_status": last_lij and last_lij.get("status_calc"),
"lijecnicki_dana_do_isteka": last_lij and last_lij.get("dana_do_isteka"),
"broj_sezona": len(sezone),
"broj_utakmica_zadnjih": len(utakmice),
"nastupi_total": nastupi_total,
"pogoci_total": pogoci_total,
"broj_obrazaca": len(obrasci),
"broj_nagrada": len(nagrade),
},
"sezone": sezone,
"utakmice_zadnje20": utakmice,
"lijecnicki": lijecnicki,
"clanarine": clanarine,
"dokumenti": dokumenti,
"obrasci": obrasci,
"nagrade": nagrade,
"povijest_klubova": povijest_klubova,
}
# ───────────── edit (PUT s permission gating) ─────────────
class ClanPatch(BaseModel):
# Sva potencijalno-editabilna polja (subset full schema-e):
ime: Optional[str] = None
prezime: Optional[str] = None
oib: Optional[str] = None
datum_rodenja: Optional[date] = None
spol: Optional[str] = None
mjesto_rodenja: Optional[str] = None
adresa: Optional[str] = None
grad: Optional[str] = None
postanski_broj: Optional[str] = None
email: Optional[str] = None
telefon: Optional[str] = None
kategorija: Optional[str] = None
podkategorija: Optional[str] = None
pozicija: Optional[str] = None
licenca_broj: Optional[str] = None
licenca_vrijedi_do: Optional[date] = None
reprezentativac: Optional[bool] = None
reprezentacija_kategorija: Optional[str] = None
kategoriziran: Optional[bool] = None
kategorija_hoo: Optional[int] = None
stipendiran: Optional[bool] = None
stipendija_iznos: Optional[float] = None
radno_pravni_status: Optional[str] = None
aktivan: Optional[bool] = None
datum_pristupa: Optional[date] = None
datum_napustanja: Optional[date] = None
napomena: Optional[str] = None
dominantna_noga: Optional[str] = None
visina_cm: Optional[int] = None
tezina_kg: Optional[int] = None
broj_dresa: Optional[int] = None
biografija: Optional[str] = None
sport: Optional[str] = None
uloga: Optional[str] = None
uloga_detalj: Optional[str] = None
klub_id: Optional[int] = None
slika_url: Optional[str] = None
@router.put("/clanovi/{cid}")
def update_clan(cid: int, patch: ClanPatch,
authorization: Optional[str] = Header(None),
x_role: Optional[str] = Header(None)):
role = (x_role or _resolve_role(authorization) or "viewer").lower()
requested = {k: v for k, v in patch.dict(exclude_unset=True).items() if v is not None}
if not requested:
raise HTTPException(400, "Nema polja za izmjenu")
allowed_fields = _check_field_perm(role, set(requested.keys()))
if not allowed_fields:
raise HTTPException(403, f"Role '{role}' nema dozvolu za nijedno od poslanih polja")
rejected = set(requested.keys()) - allowed_fields
final = {k: requested[k] for k in allowed_fields}
set_clauses = [f"{k} = %s" for k in final.keys()]
set_clauses.append("updated_at = now()")
params = list(final.values()) + [cid]
with _conn() as conn, conn.cursor() as cur:
cur.execute(f"UPDATE pgz_sport.clanovi SET {', '.join(set_clauses)} WHERE id=%s RETURNING *",
params)
r = cur.fetchone()
if not r:
raise HTTPException(404, "Član ne postoji")
# audit log (best-effort)
try:
import json as _json
cur.execute("""INSERT INTO pgz_sport.audit_feed (entity_type, entity_id, action, payload)
VALUES (%s,%s,%s,%s::jsonb)""",
("clan", cid, "edit",
_json.dumps({"role": role,
"applied": list(final.keys()),
"rejected": list(rejected)})))
except Exception:
pass
conn.commit()
return {
"ok": True,
"id": cid,
"role": role,
"applied_fields": sorted(final.keys()),
"rejected_fields": sorted(rejected),
"clan": _row(r),
}
# ───────────── avatar upload ─────────────
@router.post("/clanovi/{cid}/avatar")
async def upload_avatar(cid: int, file: UploadFile = File(...),
authorization: Optional[str] = Header(None),
x_role: Optional[str] = Header(None)):
role = (x_role or _resolve_role(authorization) or "viewer").lower()
if role not in EDITABLE_BY_ROLE and role not in ("pgz_admin", "super_admin"):
# sportas/klub_admin/savez_admin/pgz_admin/super_admin svi smiju
# (sportas ako je 'sebe' — UI to validira preko user_id, ovdje server
# primarno gata po roli; future M1 JWT propagacija će validirati clan_id == self)
raise HTTPException(403, f"Role '{role}' nema dozvolu za upload avatara")
# validate file type
allowed_ct = {"image/jpeg", "image/png", "image/webp", "image/gif"}
ext_map = {"image/jpeg": "jpg", "image/png": "png",
"image/webp": "webp", "image/gif": "gif"}
ct = (file.content_type or "").lower()
if ct not in allowed_ct:
raise HTTPException(400, f"Nedozvoljeni tip slike: {ct}. Dozvoljeno: jpeg/png/webp/gif")
# provjeri da član postoji
with _conn() as conn, conn.cursor() as cur:
cur.execute("SELECT id, slika_url FROM pgz_sport.clanovi WHERE id=%s", (cid,))
r = cur.fetchone()
if not r:
raise HTTPException(404, "Član ne postoji")
# save file
fname = f"{cid}-{_uuid.uuid4().hex[:8]}.{ext_map[ct]}"
fpath = UPLOADS_DIR / fname
contents = await file.read()
if len(contents) > 5 * 1024 * 1024:
raise HTTPException(413, "Slika prevelika (max 5 MB)")
with open(fpath, "wb") as fh:
fh.write(contents)
public_url = f"{PUBLIC_AVATAR_PREFIX}/{fname}"
# update DB
with _conn() as conn, conn.cursor() as cur:
# obriši staru sliku (best-effort, samo ako je u uploads/avatars/)
old = r["slika_url"]
if old and PUBLIC_AVATAR_PREFIX in old:
try:
old_name = old.split("/")[-1]
old_path = UPLOADS_DIR / old_name
if old_path.exists() and str(old_path).startswith(str(UPLOADS_DIR)):
old_path.unlink()
except Exception:
pass
cur.execute("UPDATE pgz_sport.clanovi SET slika_url=%s, updated_at=now() WHERE id=%s",
(public_url, cid))
conn.commit()
return {
"ok": True,
"id": cid,
"slika_url": public_url,
"size_bytes": len(contents),
"content_type": ct,
"filename": fname,
}
# ───────────── permissions info (za UI) ─────────────
@router.get("/clanovi/permissions")
def permissions_matrix(role: Optional[str] = Query(None)):
if role:
r = role.lower()
allowed = EDITABLE_BY_ROLE.get(r, set())
return {"role": r, "editable": "ALL" if allowed == "ALL" else sorted(allowed)}
return {
"roles": {
r: ("ALL" if v == "ALL" else sorted(v))
for r, v in EDITABLE_BY_ROLE.items()
}
}
+150
View File
@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<title>Audit Log — PGŽ Sport</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="data:,">
<style>
:root { --bg0:#08090e; --bg1:#11141d; --bg2:#1a1f2c; --txt:#e6e9ef; --muted:#7a8294;
--pgz-blue:#003087; --pgz-gold:#F4C430; --green:#1a8754; --red:#dc3545; --orange:#fd7e14; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:'Inter',system-ui,sans-serif; background:var(--bg0); color:var(--txt); padding:24px; line-height:1.5; }
h1 { color:var(--pgz-gold); margin-bottom:6px; }
.sub { color:var(--muted); margin-bottom:24px; }
.toolbar { display:flex; gap:12px; margin-bottom:18px; flex-wrap:wrap; }
.btn { background:var(--pgz-blue); color:white; border:none; padding:9px 16px; border-radius:6px; cursor:pointer; font-weight:500; }
.btn:hover { background:#0040b8; }
.btn.secondary { background:var(--bg2); }
input,select { background:var(--bg2); color:var(--txt); border:1px solid #2a3144; padding:9px 12px; border-radius:6px; min-width:160px; }
table { width:100%; border-collapse:collapse; background:var(--bg1); border-radius:8px; overflow:hidden; margin-top:8px; }
th { background:var(--bg2); padding:12px; text-align:left; color:var(--pgz-gold); font-size:0.85rem; text-transform:uppercase; }
td { padding:11px 12px; border-top:1px solid #1a1f2c; font-size:0.92rem; }
tr:hover { background:#13182a; }
.badge { padding:3px 9px; border-radius:11px; font-size:0.75rem; font-weight:600; }
.b-create { background:rgba(26,135,84,0.2); color:#7fdca5; }
.b-update { background:rgba(253,126,20,0.2); color:#ffaa66; }
.b-delete { background:rgba(220,53,69,0.2); color:#ff7e85; }
.b-seal { background:rgba(244,196,48,0.2); color:var(--pgz-gold); }
.tx-link { color:#5fa8d3; text-decoration:none; font-family:monospace; font-size:0.85rem; }
.tx-link:hover { text-decoration:underline; }
.empty { padding:60px; text-align:center; color:var(--muted); }
.stats { display:grid; grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin-bottom:18px; }
.stat { background:var(--bg1); padding:14px; border-radius:8px; border-left:3px solid var(--pgz-blue); }
.stat .v { font-size:1.6rem; font-weight:700; color:var(--pgz-gold); }
.stat .l { color:var(--muted); font-size:0.82rem; text-transform:uppercase; margin-top:3px; }
</style>
</head>
<body>
<h1>📜 Audit Log</h1>
<div class="sub">Kompletna povijest izmjena s blockchain pečatima na Polygon PoS</div>
<div class="stats" id="stats">
<div class="stat"><div class="v" id="s-total"></div><div class="l">Ukupno akcija</div></div>
<div class="stat"><div class="v" id="s-today"></div><div class="l">Danas</div></div>
<div class="stat"><div class="v" id="s-sealed"></div><div class="l">Polygon zapečaćeno</div></div>
<div class="stat"><div class="v" id="s-users"></div><div class="l">Aktivni korisnici</div></div>
</div>
<div class="toolbar">
<input id="f-q" placeholder="🔍 Pretraži..." />
<select id="f-action">
<option value="">Sve akcije</option>
<option value="create">CREATE</option>
<option value="update">UPDATE</option>
<option value="delete">DELETE</option>
<option value="seal">SEAL</option>
</select>
<select id="f-resource">
<option value="">Svi resursi</option>
<option value="users">Korisnici</option>
<option value="klubovi">Klubovi</option>
<option value="invoices">Računi</option>
<option value="putni_nalozi">Putni nalozi</option>
<option value="sufinanciranje">Sufinanciranje</option>
</select>
<button class="btn" onclick="load()">Filtriraj</button>
<button class="btn secondary" onclick="window.location.href='/app'">← Natrag na app</button>
</div>
<table id="tbl">
<thead>
<tr>
<th>Vrijeme</th>
<th>Korisnik</th>
<th>Akcija</th>
<th>Resurs</th>
<th>Detalji</th>
<th>Polygon Tx</th>
</tr>
</thead>
<tbody id="tbody">
<tr><td colspan="6" class="empty">⏳ Učitavam...</td></tr>
</tbody>
</table>
<script>
async function load() {
const q = document.getElementById('f-q').value;
const action = document.getElementById('f-action').value;
const resource = document.getElementById('f-resource').value;
const tbody = document.getElementById('tbody');
let url = '/sport/api/audit/log?limit=200';
if (q) url += '&q=' + encodeURIComponent(q);
if (action) url += '&action=' + action;
if (resource) url += '&resource=' + resource;
try {
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
const items = data.items || data.entries || data || [];
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty">📭 Nema zapisa</td></tr>';
return;
}
tbody.innerHTML = items.map(item => {
const action = (item.action || 'unknown').toLowerCase();
const klasa = action.includes('seal') ? 'b-seal' :
action.includes('create') ? 'b-create' :
action.includes('update') ? 'b-update' :
action.includes('delete') ? 'b-delete' : 'b-update';
const tx = item.tx_hash || item.polygon_tx || '';
const txLink = tx ? `<a href="https://polygonscan.com/tx/${tx}" target="_blank" class="tx-link">${tx.substring(0,16)}...</a>` : '<span style="color:#5a6072">—</span>';
const ts = new Date(item.created_at || item.timestamp).toLocaleString('hr-HR');
const details = item.details || item.diff || item.message || '';
const detStr = typeof details === 'object' ? JSON.stringify(details).substring(0,80)+'...' : String(details).substring(0,80);
return `<tr>
<td>${ts}</td>
<td>${item.user_email || item.user_name || item.actor || '—'}</td>
<td><span class="badge ${klasa}">${(item.action || '').toUpperCase()}</span></td>
<td>${item.resource_type || item.resource || item.target || '—'}</td>
<td>${detStr}</td>
<td>${txLink}</td>
</tr>`;
}).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="6" class="empty">⚠ Greška: ${e.message}</td></tr>`;
}
// Stats
try {
const sr = await fetch('/sport/api/audit/stats');
if (sr.ok) {
const s = await sr.json();
document.getElementById('s-total').textContent = s.total || '—';
document.getElementById('s-today').textContent = s.today || '—';
document.getElementById('s-sealed').textContent = s.sealed || '—';
document.getElementById('s-users').textContent = s.users || '—';
}
} catch(e) {}
}
load();
setInterval(load, 30000);
</script>
</body>
</html>
+15 -5
View File
@@ -373,7 +373,7 @@ body {
</div>
<div class="footer-right">
<a href="/sport/static/sport2.html">Javni portal</a>
<a href="/sport2.html">Javni portal</a>
·
<a href="#" id="privacyLink">Politika privatnosti</a>
·
@@ -394,7 +394,7 @@ body {
</div>
<script>
const API = '/sport/api';
const API = '/api';
const $ = s => document.querySelector(s);
// ---------- Login ----------
@@ -435,9 +435,19 @@ async function doLogin(email, password) {
const role = (data.user.role || '').toLowerCase();
if (['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz',
'savez_admin','savez_user','klub_admin','klub_user','klub_trener'].includes(role)) {
location.href = '/sport/static/admin_users.html';
// Smart redirect po roli
const role = data.user.role;
const redirectMap = {
'pgz_admin': '/app',
'savez_admin': '/app',
'klub_admin': '/app',
'super_admin': '/admin'
};
location.href = redirectMap[role] || '/app';
} else {
location.href = '/sport/';
location.href = '/';
}
}, 600);
} catch (e) {
@@ -525,7 +535,7 @@ $('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privac
try {
const r = await fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + tok }});
if (r.ok) {
location.href = '/sport/static/admin_users.html';
location.href = '/app';
return;
}
} catch {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

+98 -23
View File
@@ -39,7 +39,12 @@ SLEEP_S = int(os.environ.get('ENRICHER_SLEEP', '300'))
DRY = os.environ.get('ENRICHER_DRY', '0') == '1'
USER_HDR = os.environ.get('ENRICHER_USER', 'enricher@pgz.local')
LOG_PATH = '/opt/pgz-sport/_logs/enrichment_worker.log'
LOG_PATHS = [
'/var/log/pgz-sport-enricher.log',
'/opt/pgz-sport/_logs/enrichment_worker.log',
]
CONFIDENCE_MIN = float(os.environ.get('ENRICHER_CONFIDENCE', '0.7'))
COVERAGE_MAX = int(os.environ.get('ENRICHER_COVERAGE_MAX', '70'))
_pgh = os.environ.get('PG_HOST', '10.10.0.2')
_pgp = int(os.environ.get('PG_PORT', '6432'))
@@ -55,9 +60,10 @@ DB = dict(host=_pgh, port=_pgp,
def _log(msg: str) -> None:
line = f"{datetime.now(timezone.utc).isoformat()}Z {msg}"
print(line, flush=True)
for p in LOG_PATHS:
try:
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
with open(LOG_PATH, 'a') as f:
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, 'a') as f:
f.write(line + "\n")
except Exception:
pass
@@ -78,19 +84,38 @@ def _db():
c = psycopg2.connect(**DB); c.autocommit = True; return c
def _pick_sportas(limit: int = 25) -> list[int]:
"""Athletes that look enrichable but haven't been enriched recently."""
sql = """
# Coverage = (filled key fields) / (total key fields) * 100. Keep these in sync
# with enrich_router.enrich_preview() which surfaces the same scores in the UI.
_KLUB_KEYS = ('oib','sport','grad','predsjednik','tajnik','web','email','telefon',
'sjediste','godina_osnutka','ciljevi','opis_djelatnosti')
_SAVEZ_KEYS = ('oib','sport','predsjednik','tajnik','email','telefon','web',
'adresa','godina_osnutka')
# Coverage for sportas — fields the user actually wants populated.
_SPORTAS_KEYS = ('sport','profile_url','slika_url','hns_igrac_id','biografija',
'datum_rodenja','mjesto_rodenja','broj_dresa')
def _coverage_expr(table_keys: tuple[str, ...]) -> str:
"""Postgres expression that returns 0..100 coverage % for the row."""
parts = []
for k in table_keys:
parts.append(f"(CASE WHEN {k} IS NOT NULL AND ({k}::text) <> '' THEN 1 ELSE 0 END)")
total = len(table_keys)
return f"((({' + '.join(parts)})::numeric * 100) / {total})"
def _pick_sportas(limit: int = 50) -> list[int]:
"""Athletes with coverage<COVERAGE_MAX, randomly ordered."""
cov = _coverage_expr(_SPORTAS_KEYS)
sql = f"""
SELECT id FROM pgz_sport.clanovi
WHERE aktivan = TRUE
AND (profile_url IS NULL OR profile_url = ''
OR slika_url IS NULL OR slika_url = ''
OR biografija IS NULL OR biografija = ''
OR datum_rodenja IS NULL)
AND {cov} < %s
AND (
source IN ('hns_semafor','hns_family','manual','godisnjak')
OR jsonb_exists(vanjski_id, 'hns_comet')
OR (source_url ILIKE '%%semafor.hns.family%%')
OR profile_url ILIKE '%%semafor.hns.family%%'
)
AND ((metadata->>'enriched_at') IS NULL
OR (metadata->>'enriched_at')::timestamptz < now() - interval '7 days')
@@ -98,36 +123,38 @@ def _pick_sportas(limit: int = 25) -> list[int]:
LIMIT %s
"""
with _db() as c, c.cursor() as cur:
cur.execute(sql, (limit,))
cur.execute(sql, (COVERAGE_MAX, limit))
return [r[0] for r in cur.fetchall()]
def _pick_klub(limit: int = 10) -> list[int]:
sql = """
def _pick_klub(limit: int = 50) -> list[int]:
cov = _coverage_expr(_KLUB_KEYS)
sql = f"""
SELECT id FROM pgz_sport.klubovi
WHERE aktivan = TRUE
AND (web IS NULL OR email IS NULL OR telefon IS NULL OR opis_djelatnosti IS NULL)
AND {cov} < %s
AND ((metadata->>'enriched_at') IS NULL
OR (metadata->>'enriched_at')::timestamptz < now() - interval '14 days')
ORDER BY random()
LIMIT %s
"""
with _db() as c, c.cursor() as cur:
cur.execute(sql, (limit,))
cur.execute(sql, (COVERAGE_MAX, limit))
return [r[0] for r in cur.fetchall()]
def _pick_savez(limit: int = 5) -> list[int]:
sql = """
def _pick_savez(limit: int = 50) -> list[int]:
cov = _coverage_expr(_SAVEZ_KEYS)
sql = f"""
SELECT id FROM pgz_sport.savezi
WHERE (web IS NULL OR email IS NULL OR telefon IS NULL)
WHERE {cov} < %s
AND ((metadata->>'enriched_at') IS NULL
OR (metadata->>'enriched_at')::timestamptz < now() - interval '14 days')
ORDER BY random()
LIMIT %s
"""
with _db() as c, c.cursor() as cur:
cur.execute(sql, (limit,))
cur.execute(sql, (COVERAGE_MAX, limit))
return [r[0] for r in cur.fetchall()]
@@ -146,14 +173,62 @@ def _http_post(path: str, body: dict | None = None) -> dict | None:
return None
# Per-source confidence weights. Anything written by an HNS Semafor /igraci/
# page is structured + verified, so we trust it implicitly. Wikipedia summaries
# are mostly safe but free-form. sport-pgz.hr "O nama" pages tend to be the
# zajednica generic info, so we down-weight them so a plain DeepSeek synthesis
# off a single sport-pgz.hr source falls below the gate.
_SOURCE_WEIGHTS = {
'semafor.hns.family': 0.95,
'wikipedia.hr': 0.80,
'sport-pgz.hr': 0.55,
}
# Fields that are safe to auto-write even from low-confidence sources because
# they come from the entity's own structured page (URLs, IDs).
_HARD_FIELDS = {'profile_url','source_url','slika_url','hns_igrac_id'}
def _confidence(proposed: dict, sources: list[dict]) -> float:
"""Crude 0..1 score: max source weight, scaled by evidence count."""
if not proposed:
return 0.0
weights = []
for s in sources or []:
w = _SOURCE_WEIGHTS.get((s.get('source') or '').lower(), 0.50)
weights.append(w)
if not weights:
return 0.0
base = max(weights)
bonus = min(0.10, 0.03 * (len(sources) - 1))
return min(1.0, base + bonus)
def _process(kind: str, eid: int) -> tuple[int, list[str]]:
"""Run preview + apply for one entity. Returns (#applied, fields)."""
# /apply with empty body re-runs the preview server-side and writes the
# full proposal — the cheapest way to flush a row through.
res = _http_post(f'/api/v2/enrich/{kind}/{eid}/apply', {})
"""Preview → confidence gate → apply. Returns (#applied, fields)."""
preview = _http_post(f'/api/v2/enrich/{kind}/{eid}', {})
if not preview:
return (0, [])
proposed = preview.get('proposed') or {}
sources = preview.get('sources') or []
if not proposed:
return (0, [])
conf = _confidence(proposed, sources)
# Always allow hard structured fields (URLs / IDs) — they are objective.
hard = {k: v for k, v in proposed.items() if k in _HARD_FIELDS}
soft = {k: v for k, v in proposed.items() if k not in _HARD_FIELDS}
fields = dict(hard)
if conf >= CONFIDENCE_MIN:
fields.update(soft)
if not fields:
_log(f" {kind}#{eid} skipped — confidence {conf:.2f} < {CONFIDENCE_MIN:.2f}")
return (0, [])
res = _http_post(f'/api/v2/enrich/{kind}/{eid}/apply',
{'fields': fields, 'sources': sources})
if not res or 'applied' not in res:
return (0, [])
applied = res['applied']
if applied:
_log(f" {kind}#{eid} conf={conf:.2f} → +{len(applied)} {','.join(applied.keys())}")
return (len(applied), list(applied.keys()))