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 psycopg2.extras
|
||||||
import requests
|
import requests
|
||||||
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Header, Query, Body
|
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"])
|
router = APIRouter(prefix="/api/erp", tags=["erp-ocr"])
|
||||||
|
|
||||||
@@ -55,6 +76,20 @@ def _is_admin(authorization: Optional[str]) -> bool:
|
|||||||
return t == ADMIN_TOKEN
|
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:
|
def _safe_filename(orig: str) -> str:
|
||||||
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120]
|
base = re.sub(r"[^A-Za-z0-9._-]+", "_", (orig or "upload").strip())[:120]
|
||||||
if not base:
|
if not base:
|
||||||
@@ -487,20 +522,117 @@ def invoices_list(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/invoices/{invoice_id}")
|
@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:
|
with _db() as c:
|
||||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
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()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Račun ne postoji")
|
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",
|
cur.execute("SELECT * FROM pgz_sport.invoice_lines WHERE invoice_id=%s ORDER BY line_no, id",
|
||||||
(invoice_id,))
|
(invoice_id,))
|
||||||
lines = cur.fetchall()
|
lines = cur.fetchall()
|
||||||
cur.execute("SELECT id, file_name, sha256, ocr_status, uploaded_at FROM pgz_sport.invoice_uploads WHERE invoice_id=%s",
|
cur.execute(
|
||||||
(invoice_id,))
|
"""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()
|
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")
|
@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)):
|
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,
|
"""Update / approve invoice. Body may include any of: payment_status, paid_date,
|
||||||
approved (bool), notes, category, account_code, due_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 = []
|
fields = []
|
||||||
args: list = []
|
args: list = []
|
||||||
|
changes = []
|
||||||
for col in ("payment_status", "paid_date", "due_date", "category",
|
for col in ("payment_status", "paid_date", "due_date", "category",
|
||||||
"account_code", "notes", "vat_rate", "amount_net", "amount_vat",
|
"account_code", "notes", "vat_rate", "amount_net", "amount_vat",
|
||||||
"amount_gross", "payment_method", "iban_to"):
|
"amount_gross", "payment_method", "iban_to"):
|
||||||
if col in body:
|
if col in body:
|
||||||
fields.append(f"{col}=%s")
|
fields.append(f"{col}=%s")
|
||||||
args.append(body[col])
|
args.append(body[col])
|
||||||
|
changes.append((col, inv.get(col), body[col]))
|
||||||
if body.get("approved"):
|
if body.get("approved"):
|
||||||
fields.append("approved_at=NOW()")
|
fields.append("approved_at=NOW()")
|
||||||
|
changes.append(("approved_at", inv.get("approved_at"), "now"))
|
||||||
if not fields:
|
if not fields:
|
||||||
raise HTTPException(400, "Nema polja za izmjenu")
|
raise HTTPException(400, "Nema polja za izmjenu")
|
||||||
fields.append("updated_at=NOW()")
|
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 = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args)
|
cur.execute(f"UPDATE pgz_sport.invoices SET {','.join(fields)} WHERE id=%s RETURNING *", args)
|
||||||
row = cur.fetchone()
|
row = cur.fetchone()
|
||||||
if not row:
|
for f, o, n in changes:
|
||||||
raise HTTPException(404, "Račun ne postoji")
|
audit_invoice(user, invoice_id, "update", field=f, old=o, new=n)
|
||||||
return {"ok": True, "invoice": row}
|
return {"ok": True, "invoice": row}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/invoices/{invoice_id}/pay")
|
@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()
|
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_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:
|
with _db() as c:
|
||||||
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
cur = c.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""UPDATE pgz_sport.invoices
|
"""UPDATE pgz_sport.invoices
|
||||||
SET payment_status='paid', paid_date=%s,
|
SET payment_status='paid', paid_date=%s,
|
||||||
payment_method=COALESCE(%s,payment_method),
|
payment_method=COALESCE(%s,payment_method),
|
||||||
iban_from=COALESCE(%s,iban_from), updated_at=NOW()
|
iban_from=COALESCE(%s,iban_from),
|
||||||
WHERE id=%s RETURNING id, invoice_no, paid_date, amount_gross""",
|
iban_to=COALESCE(%s,iban_to),
|
||||||
(paid_date, payment_method, iban_from, invoice_id),
|
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()
|
row = cur.fetchone()
|
||||||
if not row:
|
# Insert payment record
|
||||||
raise HTTPException(404, "Račun ne postoji")
|
|
||||||
# log payment
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""INSERT INTO pgz_sport.payments (invoice_id, amount, payment_date, method, iban_from)
|
"""INSERT INTO pgz_sport.payments
|
||||||
VALUES (%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING""",
|
(klub_id, invoice_id, payment_date, amount, currency, payment_method,
|
||||||
(invoice_id, row["amount_gross"], paid_date, payment_method, iban_from),
|
iban_from, iban_to, reference, bank_transaction_id, matched_status)
|
||||||
) if False else None # payments table column-set may differ; skip silently
|
VALUES (%s,%s,%s,%s,COALESCE(%s,'EUR'),%s,%s,%s,%s,%s,'matched')
|
||||||
return {"ok": True, "invoice": row}
|
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")
|
@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
|
import psycopg2.extras
|
||||||
from fastapi import APIRouter, Body, HTTPException, Query, Header
|
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"])
|
router = APIRouter(prefix="/api/erp", tags=["erp-putni-nalozi"])
|
||||||
|
|
||||||
DB = dict(host="10.10.0.2", port=6432, dbname="rinet_v3", user="rinet",
|
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,
|
return {"users_total": ut, "users_active": ua, "permissions_total": pt,
|
||||||
"audit_today": at, "by_type": by_type}
|
"audit_today": at, "by_type": by_type}
|
||||||
|
|
||||||
@app.get("/api/admin/users")
|
# Legacy unauthenticated /api/admin/users CRUD removed (R4 #5).
|
||||||
def admin_users(q: str = "", user_type: str = "", limit: int = 100):
|
# All /api/admin/users* endpoints are now served by auth.admin_users router
|
||||||
where = ["1=1"]; args = []
|
# with require_user dependency that returns 401 on missing/invalid JWT.
|
||||||
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]}
|
|
||||||
|
|
||||||
|
|
||||||
# ──────── V6 AI GRADOVI / KILOMETRAŽA ────────
|
# ──────── V6 AI GRADOVI / KILOMETRAŽA ────────
|
||||||
@@ -1408,6 +1354,13 @@ try:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f'[CRM/M9] obrasci router fail: {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 ===
|
# === Round 3 / CC2 — M1 Auth + M2 Admin Users + M10 GDPR ===
|
||||||
try:
|
try:
|
||||||
from auth.auth_v2 import router as auth_v2_router
|
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>
|
||||||
|
|
||||||
<div class="footer-right">
|
<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>
|
<a href="#" id="privacyLink">Politika privatnosti</a>
|
||||||
·
|
·
|
||||||
@@ -394,7 +394,7 @@ body {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API = '/sport/api';
|
const API = '/api';
|
||||||
const $ = s => document.querySelector(s);
|
const $ = s => document.querySelector(s);
|
||||||
|
|
||||||
// ---------- Login ----------
|
// ---------- Login ----------
|
||||||
@@ -435,9 +435,19 @@ async function doLogin(email, password) {
|
|||||||
const role = (data.user.role || '').toLowerCase();
|
const role = (data.user.role || '').toLowerCase();
|
||||||
if (['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz',
|
if (['super_admin','pgz_admin','pgz_user','pgz_finance','pgz_zzjz',
|
||||||
'savez_admin','savez_user','klub_admin','klub_user','klub_trener'].includes(role)) {
|
'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 {
|
} else {
|
||||||
location.href = '/sport/';
|
location.href = '/';
|
||||||
}
|
}
|
||||||
}, 600);
|
}, 600);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -525,7 +535,7 @@ $('#cookieMore').addEventListener('click', e => { e.preventDefault(); $('#privac
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + tok }});
|
const r = await fetch(API + '/auth/me', { headers: { Authorization: 'Bearer ' + tok }});
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
location.href = '/sport/static/admin_users.html';
|
location.href = '/app';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 154 B |
+102
-27
@@ -39,7 +39,12 @@ SLEEP_S = int(os.environ.get('ENRICHER_SLEEP', '300'))
|
|||||||
DRY = os.environ.get('ENRICHER_DRY', '0') == '1'
|
DRY = os.environ.get('ENRICHER_DRY', '0') == '1'
|
||||||
USER_HDR = os.environ.get('ENRICHER_USER', 'enricher@pgz.local')
|
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')
|
_pgh = os.environ.get('PG_HOST', '10.10.0.2')
|
||||||
_pgp = int(os.environ.get('PG_PORT', '6432'))
|
_pgp = int(os.environ.get('PG_PORT', '6432'))
|
||||||
@@ -55,12 +60,13 @@ DB = dict(host=_pgh, port=_pgp,
|
|||||||
def _log(msg: str) -> None:
|
def _log(msg: str) -> None:
|
||||||
line = f"{datetime.now(timezone.utc).isoformat()}Z {msg}"
|
line = f"{datetime.now(timezone.utc).isoformat()}Z {msg}"
|
||||||
print(line, flush=True)
|
print(line, flush=True)
|
||||||
try:
|
for p in LOG_PATHS:
|
||||||
os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True)
|
try:
|
||||||
with open(LOG_PATH, 'a') as f:
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||||
f.write(line + "\n")
|
with open(p, 'a') as f:
|
||||||
except Exception:
|
f.write(line + "\n")
|
||||||
pass
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _heartbeat() -> None:
|
def _heartbeat() -> None:
|
||||||
@@ -78,19 +84,38 @@ def _db():
|
|||||||
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
c = psycopg2.connect(**DB); c.autocommit = True; return c
|
||||||
|
|
||||||
|
|
||||||
def _pick_sportas(limit: int = 25) -> list[int]:
|
# Coverage = (filled key fields) / (total key fields) * 100. Keep these in sync
|
||||||
"""Athletes that look enrichable but haven't been enriched recently."""
|
# with enrich_router.enrich_preview() which surfaces the same scores in the UI.
|
||||||
sql = """
|
_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
|
SELECT id FROM pgz_sport.clanovi
|
||||||
WHERE aktivan = TRUE
|
WHERE aktivan = TRUE
|
||||||
AND (profile_url IS NULL OR profile_url = ''
|
AND {cov} < %s
|
||||||
OR slika_url IS NULL OR slika_url = ''
|
|
||||||
OR biografija IS NULL OR biografija = ''
|
|
||||||
OR datum_rodenja IS NULL)
|
|
||||||
AND (
|
AND (
|
||||||
source IN ('hns_semafor','hns_family','manual','godisnjak')
|
source IN ('hns_semafor','hns_family','manual','godisnjak')
|
||||||
OR jsonb_exists(vanjski_id, 'hns_comet')
|
OR jsonb_exists(vanjski_id, 'hns_comet')
|
||||||
OR (source_url ILIKE '%%semafor.hns.family%%')
|
OR (source_url ILIKE '%%semafor.hns.family%%')
|
||||||
|
OR profile_url ILIKE '%%semafor.hns.family%%'
|
||||||
)
|
)
|
||||||
AND ((metadata->>'enriched_at') IS NULL
|
AND ((metadata->>'enriched_at') IS NULL
|
||||||
OR (metadata->>'enriched_at')::timestamptz < now() - interval '7 days')
|
OR (metadata->>'enriched_at')::timestamptz < now() - interval '7 days')
|
||||||
@@ -98,36 +123,38 @@ def _pick_sportas(limit: int = 25) -> list[int]:
|
|||||||
LIMIT %s
|
LIMIT %s
|
||||||
"""
|
"""
|
||||||
with _db() as c, c.cursor() as cur:
|
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()]
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def _pick_klub(limit: int = 10) -> list[int]:
|
def _pick_klub(limit: int = 50) -> list[int]:
|
||||||
sql = """
|
cov = _coverage_expr(_KLUB_KEYS)
|
||||||
|
sql = f"""
|
||||||
SELECT id FROM pgz_sport.klubovi
|
SELECT id FROM pgz_sport.klubovi
|
||||||
WHERE aktivan = TRUE
|
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
|
AND ((metadata->>'enriched_at') IS NULL
|
||||||
OR (metadata->>'enriched_at')::timestamptz < now() - interval '14 days')
|
OR (metadata->>'enriched_at')::timestamptz < now() - interval '14 days')
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
"""
|
"""
|
||||||
with _db() as c, c.cursor() as cur:
|
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()]
|
return [r[0] for r in cur.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
def _pick_savez(limit: int = 5) -> list[int]:
|
def _pick_savez(limit: int = 50) -> list[int]:
|
||||||
sql = """
|
cov = _coverage_expr(_SAVEZ_KEYS)
|
||||||
|
sql = f"""
|
||||||
SELECT id FROM pgz_sport.savezi
|
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
|
AND ((metadata->>'enriched_at') IS NULL
|
||||||
OR (metadata->>'enriched_at')::timestamptz < now() - interval '14 days')
|
OR (metadata->>'enriched_at')::timestamptz < now() - interval '14 days')
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT %s
|
LIMIT %s
|
||||||
"""
|
"""
|
||||||
with _db() as c, c.cursor() as cur:
|
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()]
|
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
|
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]]:
|
def _process(kind: str, eid: int) -> tuple[int, list[str]]:
|
||||||
"""Run preview + apply for one entity. Returns (#applied, fields)."""
|
"""Preview → confidence gate → apply. Returns (#applied, fields)."""
|
||||||
# /apply with empty body re-runs the preview server-side and writes the
|
preview = _http_post(f'/api/v2/enrich/{kind}/{eid}', {})
|
||||||
# full proposal — the cheapest way to flush a row through.
|
if not preview:
|
||||||
res = _http_post(f'/api/v2/enrich/{kind}/{eid}/apply', {})
|
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:
|
if not res or 'applied' not in res:
|
||||||
return (0, [])
|
return (0, [])
|
||||||
applied = res['applied']
|
applied = res['applied']
|
||||||
|
if applied:
|
||||||
|
_log(f" {kind}#{eid} conf={conf:.2f} → +{len(applied)} {','.join(applied.keys())}")
|
||||||
return (len(applied), list(applied.keys()))
|
return (len(applied), list(applied.keys()))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user