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:
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 € (5–8h), 0 € (<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,'"')}</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>
|
||||
@@ -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
@@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
Binary file not shown.
+197
-21
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 |
@@ -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()))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user