Compare commits

..

9 Commits

Author SHA1 Message Date
damir ae9c4e2bfd Sportski objekti: API + Leaflet map page + address enrichment
DB: pgz_sport.sportski_objekti (103 objekti, 103 s geo, 60 s adresom, 31 tip)

API:
- /api/v2/sportski-objekti (filter: tip, grad, sport, q)
- /api/v2/sportski-objekti/meta (tipovi, gradovi, sportovi, ukupno)

Frontend:
- /static/objekti.html — Leaflet (OpenStreetMap) interactive map
- 3 dropdown filter (tip, grad, sport) + search
- Side panel s listom + map markers s ikonama (🏟️🏊🎿🎳⛸️🎯🥌🏃)
- Popup: naziv, tip, kapacitet, adresa, upravitelj, izgradeno, sportovi, web link, Google Maps link
- /objekti, /sport/objekti, /sport/api/v2/sportski-objekti routes

Sidebar app.html: +Sportski objekti link
Background: scripts/objekti_enrich_address.py (Nominatim reverse-geocode 60 objekata bez adrese)
2026-05-05 18:35:04 +02:00
Damir Radulić 6e5ada8517 Merge agent3-payments: SEPA + CSV import + match workflow 2026-05-05 18:35:01 +02:00
Damir Radulić 47df057270 Merge agent2-putni: Putni nalozi CRUD + status workflow 2026-05-05 18:34:56 +02:00
Damir Radulić 7625e59173 Merge agent4-export: Universal Export ▾ CSV/XLSX/PDF 2026-05-05 18:34:51 +02:00
Damir Radulić c4640ca3af Merge agent1-ocr: OCR u ERP/CRM 2026-05-05 18:34:46 +02:00
Damir Radulić 38383d07c5 Task 4: Universal Export ▾ — CSV/XLSX/PDF dropdown across all screens
- routers/export_router.py: /api/v2/export?format=...&endpoint=...&filters=...
- static/js/export_dropdown.js: shared attachExportDropdown helper
- sport2/app/crm_v2/erp_full: Export ▾ button wired to representative tables
- pgz_sport_api.py: mount export_router with try/except

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:33:36 +02:00
Damir Radulić 55a27fb315 Task 3: Plaćanja — POST/PATCH + CSV batch import + SEPA XML mock
- routers/erp_full_router.py: POST/PATCH/import-csv/sepa-export
- static/erp_full.html: high-end UI s match workflow + SEPA export + summary tiles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:29:51 +02:00
Damir Radulić efa15d0086 Task 2: Putni nalozi — full CRUD + status workflow
- routers/erp_full_router.py: GET/POST/PATCH/DELETE /api/v2/erp/putni-nalozi
  - status workflow: draft → poslano → odobreno/odbijeno → isplaceno
  - cost_total auto-calc, approved_at/paid_at on transitions
  - alias under /expense-reports/* preserved
- static/erp_full.html: novi UI lista + modal + status buttons

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:28:53 +02:00
Damir Radulić f488623920 Task 1: OCR u ERP/CRM — /api/ocr/upload + tab Računi (OCR)
- routers/ocr_router.py: POST /api/ocr/upload (Tesseract+pdf2image, regex field extraction)
- pgz_sport_api.py: mount ocr_router with try/except guard
- static/erp_full.html: nova tab "📷 OCR" + panel
- static/crm_v2.html: OCR upload modal/tab

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 18:28:22 +02:00
19 changed files with 2440 additions and 54 deletions
Submodule .claude/worktrees/agent-a2230c7d02a7c02f4 updated: 8127e2ef22...f488623920
Submodule .claude/worktrees/agent-a54ff6ad4250d2734 updated: 8127e2ef22...38383d07c5
Submodule .claude/worktrees/agent-a70769f0db14302aa updated: 8127e2ef22...55a27fb315
Submodule .claude/worktrees/agent-af39fdf2dbfd08afe updated: 8127e2ef22...efa15d0086
+57
View File
@@ -1716,6 +1716,13 @@ try:
except Exception as e: except Exception as e:
print(f'[ERP/OCR] router fail: {e}') print(f'[ERP/OCR] router fail: {e}')
try:
from routers.ocr_router import router as ocr_router
app.include_router(ocr_router)
print('[startup] ocr_router mounted')
except Exception as e:
print(f'[startup] ocr_router skipped: {e}')
try: try:
from erp.putni_nalozi import router as erp_putni_router from erp.putni_nalozi import router as erp_putni_router
app.include_router(erp_putni_router) app.include_router(erp_putni_router)
@@ -2828,6 +2835,56 @@ def auth_me_v2_alias(authorization: str = Header(None)):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/v2/sportski-objekti")
def sportski_objekti_v2_list(tip: str = None, grad: str = None, sport: str = None, q: str = None, limit: int = 500):
"""Sportski objekti PGŽ s filterima."""
where = ["aktivan = true"]
params = []
if tip:
where.append("tip = %s"); params.append(tip)
if grad:
where.append("grad = %s"); params.append(grad)
if sport:
where.append("%s = ANY(sportovi)"); params.append(sport)
if q:
where.append("(naziv ILIKE %s OR adresa ILIKE %s OR upravitelj ILIKE %s)")
params.extend([f"%{q}%"]*3)
rows = fetch(f"""
SELECT id, naziv, tip, grad, adresa, lat, lng, upravitelj, kapacitet,
sportovi, izgradeno, obnovljeno_god, "veličina" AS velicina, natkrita,
napomena, web
FROM pgz_sport.sportski_objekti
WHERE {' AND '.join(where)}
ORDER BY grad, naziv
LIMIT %s
""", tuple(params) + (limit,))
return {"count": len(rows), "rows": rows}
@app.get("/api/v2/sportski-objekti/meta")
def sportski_objekti_meta():
"""Dropdown options za filter."""
tipovi = fetch("SELECT tip, count(*) AS broj FROM pgz_sport.sportski_objekti WHERE aktivan = true AND tip IS NOT NULL GROUP BY tip ORDER BY broj DESC")
gradovi = fetch("SELECT grad, count(*) AS broj FROM pgz_sport.sportski_objekti WHERE aktivan = true AND grad IS NOT NULL GROUP BY grad ORDER BY broj DESC")
sportovi = fetch("SELECT DISTINCT unnest(sportovi) AS sport, count(*) AS broj FROM pgz_sport.sportski_objekti WHERE aktivan = true AND sportovi IS NOT NULL GROUP BY sport ORDER BY broj DESC LIMIT 50")
return {
"tipovi": tipovi,
"gradovi": gradovi,
"sportovi": sportovi,
"ukupno": (fetch("SELECT count(*) AS n FROM pgz_sport.sportski_objekti WHERE aktivan = true")[0])["n"]
}
@app.get("/objekti")
@app.get("/objekti/")
@app.get("/sport/objekti")
@app.get("/sport/objekti/")
def serve_objekti():
from fastapi.responses import FileResponse
return FileResponse("/opt/pgz-sport/static/objekti.html")
@app.get("/") @app.get("/")
def root(request: Request): def root(request: Request):
host = request.headers.get("host", "") host = request.headers.get("host", "")
+660 -11
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# Fajl: routers/erp_full_router.py | v1.1.0 | 05.05.2026 # Fajl: routers/erp_full_router.py | v1.2.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one # Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/erp_full_router.py # Lokacija: /opt/pgz-sport/routers/erp_full_router.py
# Svrha: FULL ERP (SAP-Lite) — kontni plan, dnevnik, glavna knjiga, # Svrha: FULL ERP (SAP-Lite) — kontni plan, dnevnik, glavna knjiga,
@@ -8,25 +8,33 @@
# PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export, # PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export,
# invoice_uploads (OCR), expense_reports (Putni nalozi), payments. # invoice_uploads (OCR), expense_reports (Putni nalozi), payments.
# v1.1.0 (2026-05-05): + POST /invoice-uploads multipart upload (Agent E). # v1.1.0 (2026-05-05): + POST /invoice-uploads multipart upload (Agent E).
# v1.2.0 (2026-05-05): + Full CRUD za Putni nalozi (/putni-nalozi + alias
# /expense-reports): GET/POST/PATCH/DELETE + status workflow
# (draft → poslano → odobreno/odbijeno → isplaceno) + auto cost_total
# + approved_at/paid_at na prijelazima (Agent 2).
# Mount: /api/v2/erp/* # Mount: /api/v2/erp/*
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
from __future__ import annotations from __future__ import annotations
import csv
import hashlib import hashlib
import io
import os import os
import re import re
import uuid
from datetime import date, datetime from datetime import date, datetime
from decimal import Decimal from decimal import Decimal, InvalidOperation
from io import BytesIO from io import BytesIO, StringIO
from pathlib import Path from pathlib import Path
from typing import Optional, List from typing import Optional, List
from xml.etree import ElementTree as ET from xml.etree import ElementTree as ET
from xml.sax.saxutils import escape as xml_escape
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse, Response
from pydantic import BaseModel from pydantic import BaseModel, Field
# ── Upload destination (relative to web root /uploads/...) ────────── # ── Upload destination (relative to web root /uploads/...) ──────────
UPLOAD_BASE = Path("/opt/pgz-sport/uploads") UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
@@ -1223,12 +1231,7 @@ async def invoice_uploads_create(
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# 12) PUTNI NALOZI / EXPENSE REPORTS # 12) PUTNI NALOZI / EXPENSE REPORTS
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
@router.get("/expense-reports") def _expense_reports_list_impl(klub_id, status, report_type, godina, limit):
def expense_reports_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
where = ["1=1"] where = ["1=1"]
params: list = [] params: list = []
if klub_id: if klub_id:
@@ -1252,6 +1255,248 @@ def expense_reports_list(klub_id: Optional[int] = None,
return {"count": len(rows), "rows": rows} return {"count": len(rows), "rows": rows}
@router.get("/expense-reports")
def expense_reports_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
return _expense_reports_list_impl(klub_id, status, report_type, godina, limit)
@router.get("/putni-nalozi")
def putni_nalozi_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
return _expense_reports_list_impl(klub_id, status, report_type, godina, limit)
# ── Putni nalog single + CRUD ──────────────────────────────────────
class PutniNalogIn(BaseModel):
klub_id: int
user_id: Optional[int] = None
clan_id: Optional[int] = None
report_type: str = "sluzbeno_putovanje"
report_no: Optional[str] = None
destination: str
purpose: str
date_from: date
date_to: date
vehicle_type: Optional[str] = None
vehicle_plate: Optional[str] = None
km_driven: float = 0
km_rate: float = 0.42
cost_transport: float = 0
cost_lodging: float = 0
cost_meals: float = 0
cost_other: float = 0
dnevnice_count: float = 0
dnevnice_amount: float = 30.00
notes: Optional[str] = None
class PutniNalogPatch(BaseModel):
klub_id: Optional[int] = None
user_id: Optional[int] = None
clan_id: Optional[int] = None
report_type: Optional[str] = None
report_no: Optional[str] = None
destination: Optional[str] = None
purpose: Optional[str] = None
date_from: Optional[date] = None
date_to: Optional[date] = None
vehicle_type: Optional[str] = None
vehicle_plate: Optional[str] = None
km_driven: Optional[float] = None
km_rate: Optional[float] = None
cost_transport: Optional[float] = None
cost_lodging: Optional[float] = None
cost_meals: Optional[float] = None
cost_other: Optional[float] = None
dnevnice_count: Optional[float] = None
dnevnice_amount: Optional[float] = None
notes: Optional[str] = None
status: Optional[str] = None
# Allowed status transitions
_PN_TRANSITIONS = {
"draft": {"poslano", "odbijeno"},
"poslano": {"odobreno", "odbijeno"},
"odobreno": {"isplaceno"},
"isplaceno": set(),
"odbijeno": set(),
}
def _pn_calc_total(km_driven, km_rate, c_tr, c_lo, c_me, c_ot, dn_c, dn_a):
return (
_f(km_driven) * _f(km_rate)
+ _f(c_tr) + _f(c_lo) + _f(c_me) + _f(c_ot)
+ _f(dn_c) * _f(dn_a)
)
def _pn_get_one(pid: int):
head = db_one(
"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", (pid,))
if not head:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
racuni = db_query(
"SELECT pnr.id, pnr.invoice_id, pnr.kategorija, pnr.napomena, pnr.attached_at, "
"i.invoice_no, i.vendor_name, i.amount_gross, i.currency "
"FROM pgz_sport.putni_nalog_racuni pnr "
"LEFT JOIN pgz_sport.invoices i ON i.id=pnr.invoice_id "
"WHERE pnr.putni_nalog_id=%s ORDER BY pnr.id DESC", (pid,))
return {"head": head, "racuni": racuni}
def _pn_create(body: PutniNalogIn):
cost_total = _pn_calc_total(
body.km_driven, body.km_rate,
body.cost_transport, body.cost_lodging, body.cost_meals, body.cost_other,
body.dnevnice_count, body.dnevnice_amount)
rid = db_exec(
"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, cost_total, "
" dnevnice_count, dnevnice_amount, status, notes, created_at, updated_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'draft',%s, now(), now()) "
"RETURNING id",
(body.klub_id, body.user_id, body.clan_id, body.report_type, body.report_no,
body.destination, body.purpose, body.date_from, body.date_to,
body.vehicle_type, body.vehicle_plate,
_f(body.km_driven), _f(body.km_rate),
_f(body.cost_transport), _f(body.cost_lodging),
_f(body.cost_meals), _f(body.cost_other), cost_total,
_f(body.dnevnice_count), _f(body.dnevnice_amount),
body.notes),
returning=True)
row = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (rid,))
return {"ok": True, "id": rid, "row": row}
def _pn_patch(pid: int, body: PutniNalogPatch):
current = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
if not current:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
data = body.dict(exclude_unset=True)
# Status workflow validation
new_status = data.get("status")
if new_status is not None and new_status != current["status"]:
allowed = _PN_TRANSITIONS.get(current["status"], set())
if new_status not in allowed:
raise HTTPException(
400,
f"Nedozvoljen prijelaz statusa: {current['status']}{new_status}. "
f"Dozvoljeni: {sorted(allowed) or '(nijedan)'}")
sets = []
params: list = []
cost_fields = {"km_driven", "km_rate", "cost_transport", "cost_lodging",
"cost_meals", "cost_other", "dnevnice_count", "dnevnice_amount"}
cost_changed = bool(cost_fields.intersection(data.keys()))
for col in ("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", "notes", "status"):
if col in data:
sets.append(f"{col}=%s")
params.append(data[col])
if cost_changed:
# Recompute using merged values
merged = dict(current)
merged.update(data)
new_total = _pn_calc_total(
merged["km_driven"], merged["km_rate"],
merged["cost_transport"], merged["cost_lodging"],
merged["cost_meals"], merged["cost_other"],
merged["dnevnice_count"], merged["dnevnice_amount"])
sets.append("cost_total=%s")
params.append(new_total)
if new_status == "odobreno":
sets.append("approved_at=now()")
if new_status == "isplaceno":
sets.append("paid_at=now()")
sets.append("updated_at=now()")
if not sets:
return {"ok": True, "no_changes": True, "row": current}
params.append(pid)
db_exec(f"UPDATE pgz_sport.expense_reports SET {', '.join(sets)} WHERE id=%s",
tuple(params))
row = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
return {"ok": True, "id": pid, "row": row}
def _pn_delete(pid: int):
cur = db_one("SELECT status FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
if not cur:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
if cur["status"] != "draft":
raise HTTPException(
400,
f"Brisanje dopušteno samo za status='draft' (trenutni: {cur['status']})")
db_exec("DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s", (pid,))
db_exec("DELETE FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
return {"ok": True, "deleted": pid}
# ── Routes (both /putni-nalozi and /expense-reports prefixes) ─────
@router.get("/putni-nalozi/{pid}")
def putni_nalog_get(pid: int):
return _pn_get_one(pid)
@router.get("/expense-reports/{pid}")
def expense_report_get(pid: int):
return _pn_get_one(pid)
@router.post("/putni-nalozi")
def putni_nalog_create(body: PutniNalogIn):
return _pn_create(body)
@router.post("/expense-reports")
def expense_report_create(body: PutniNalogIn):
return _pn_create(body)
@router.patch("/putni-nalozi/{pid}")
def putni_nalog_patch(pid: int, body: PutniNalogPatch):
return _pn_patch(pid, body)
@router.patch("/expense-reports/{pid}")
def expense_report_patch(pid: int, body: PutniNalogPatch):
return _pn_patch(pid, body)
@router.delete("/putni-nalozi/{pid}")
def putni_nalog_delete(pid: int):
return _pn_delete(pid)
@router.delete("/expense-reports/{pid}")
def expense_report_delete(pid: int):
return _pn_delete(pid)
@router.get("/putni-nalog-racuni") @router.get("/putni-nalog-racuni")
def putni_nalog_racuni_list(putni_nalog_id: Optional[int] = None, def putni_nalog_racuni_list(putni_nalog_id: Optional[int] = None,
invoice_id: Optional[int] = None, invoice_id: Optional[int] = None,
@@ -1308,6 +1553,410 @@ def payments_list(klub_id: Optional[int] = None,
return {"count": len(rows), "rows": rows} return {"count": len(rows), "rows": rows}
# ── GET /payments/sepa-export — must be before /payments/{id} ───────
def _parse_ids_param(ids: Optional[str]) -> List[int]:
if not ids:
return []
out: List[int] = []
for chunk in ids.split(","):
chunk = chunk.strip()
if not chunk:
continue
try:
out.append(int(chunk))
except ValueError:
raise HTTPException(400, f"Bad id in 'ids': {chunk!r}")
return out
@router.get("/payments/sepa-export")
def payments_sepa_export(
ids: Optional[str] = Query(None, description="CSV id list, e.g. 1,2,3"),
godina: Optional[int] = None,
klub_id: Optional[int] = None,
matched_status: Optional[str] = None,
payment_method: Optional[str] = None,
):
"""Generate pain.001.001.03 SEPA Credit Transfer XML — MOCK / placeholder.
Either pass ?ids=1,2,3 OR filter (godina/klub_id/matched_status/payment_method)."""
where = ["1=1"]
params: list = []
id_list = _parse_ids_param(ids)
if id_list:
where.append("p.id = ANY(%s)")
params.append(id_list)
else:
if godina:
where.append("EXTRACT(YEAR FROM p.payment_date)=%s"); params.append(godina)
if klub_id:
where.append("p.klub_id=%s"); params.append(klub_id)
if matched_status:
where.append("p.matched_status=%s"); params.append(matched_status)
if payment_method:
where.append("p.payment_method=%s"); params.append(payment_method)
rows = db_query(
"SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.payment_date, p.amount, "
"p.currency, p.iban_from, p.iban_to, p.reference, p.description "
"FROM pgz_sport.payments p "
"LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
f"WHERE {' AND '.join(where)} ORDER BY p.payment_date, p.id LIMIT 5000",
tuple(params))
debtor_name = os.environ.get("PGZ_SEPA_DEBTOR_NAME", "PGŽ Sportski savez")
debtor_iban = os.environ.get("PGZ_SEPA_DEBTOR_IBAN", "HR0000000000000000000")
debtor_bic = os.environ.get("PGZ_SEPA_DEBTOR_BIC", "NOTPROVIDED")
now = datetime.now()
msg_id = f"PGZ-{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8].upper()}"
pmt_inf_id = f"{msg_id}-PMT01"
creation_dt = now.strftime("%Y-%m-%dT%H:%M:%S")
nb_of_txs = len(rows)
ctrl_sum = sum((Decimal(str(r["amount"])) for r in rows if r["amount"] is not None), Decimal("0"))
ctrl_sum_str = f"{ctrl_sum:.2f}"
requested_exec_date = (rows[0]["payment_date"] if rows and rows[0]["payment_date"] else now.date()).isoformat()
parts: List[str] = []
parts.append('<?xml version="1.0" encoding="UTF-8"?>')
parts.append('<!-- MOCK / placeholder — for future banking integration -->')
parts.append('<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03" '
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">')
parts.append(' <CstmrCdtTrfInitn>')
# Group Header
parts.append(' <GrpHdr>')
parts.append(f' <MsgId>{xml_escape(msg_id)}</MsgId>')
parts.append(f' <CreDtTm>{creation_dt}</CreDtTm>')
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
parts.append(' <InitgPty>')
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
parts.append(' </InitgPty>')
parts.append(' </GrpHdr>')
# Payment Information
parts.append(' <PmtInf>')
parts.append(f' <PmtInfId>{xml_escape(pmt_inf_id)}</PmtInfId>')
parts.append(' <PmtMtd>TRF</PmtMtd>')
parts.append(f' <NbOfTxs>{nb_of_txs}</NbOfTxs>')
parts.append(f' <CtrlSum>{ctrl_sum_str}</CtrlSum>')
parts.append(' <PmtTpInf><SvcLvl><Cd>SEPA</Cd></SvcLvl></PmtTpInf>')
parts.append(f' <ReqdExctnDt>{requested_exec_date}</ReqdExctnDt>')
parts.append(' <Dbtr>')
parts.append(f' <Nm>{xml_escape(debtor_name)}</Nm>')
parts.append(' </Dbtr>')
parts.append(' <DbtrAcct>')
parts.append(f' <Id><IBAN>{xml_escape(debtor_iban)}</IBAN></Id>')
parts.append(' </DbtrAcct>')
parts.append(' <DbtrAgt>')
parts.append(f' <FinInstnId><BIC>{xml_escape(debtor_bic)}</BIC></FinInstnId>')
parts.append(' </DbtrAgt>')
parts.append(' <ChrgBr>SLEV</ChrgBr>')
for r in rows:
amt = r["amount"] if r["amount"] is not None else Decimal("0")
try:
amt_str = f"{Decimal(str(amt)):.2f}"
except (InvalidOperation, TypeError):
amt_str = "0.00"
ccy = (r.get("currency") or "EUR")[:3]
cdtr_name = r.get("klub_naziv") or (f"Klub #{r['klub_id']}" if r.get("klub_id") else "Beneficiary")
cdtr_iban = (r.get("iban_to") or "").strip() or "HR0000000000000000000"
end_to_end = (r.get("reference") or f"PGZ-PAY-{r['id']}")[:35]
rmt = (r.get("description") or r.get("reference") or f"Payment #{r['id']}")[:140]
parts.append(' <CdtTrfTxInf>')
parts.append(' <PmtId>')
parts.append(f' <EndToEndId>{xml_escape(end_to_end)}</EndToEndId>')
parts.append(' </PmtId>')
parts.append(' <Amt>')
parts.append(f' <InstdAmt Ccy="{xml_escape(ccy)}">{amt_str}</InstdAmt>')
parts.append(' </Amt>')
parts.append(' <Cdtr>')
parts.append(f' <Nm>{xml_escape(cdtr_name)}</Nm>')
parts.append(' </Cdtr>')
parts.append(' <CdtrAcct>')
parts.append(f' <Id><IBAN>{xml_escape(cdtr_iban)}</IBAN></Id>')
parts.append(' </CdtrAcct>')
parts.append(' <RmtInf>')
parts.append(f' <Ustrd>{xml_escape(rmt)}</Ustrd>')
parts.append(' </RmtInf>')
parts.append(' </CdtTrfTxInf>')
parts.append(' </PmtInf>')
parts.append(' </CstmrCdtTrfInitn>')
parts.append('</Document>')
xml_body = "\n".join(parts).encode("utf-8")
fname = f"sepa_export_{now.strftime('%Y%m%d_%H%M%S')}.xml"
return Response(
content=xml_body,
media_type="application/xml",
headers={"Content-Disposition": f'attachment; filename="{fname}"'},
)
# ── GET /payments/{id} — single record + linked info ────────────────
@router.get("/payments/{rid}")
def payments_get(rid: int):
row = db_one(
"SELECT p.id, p.klub_id, k.naziv AS klub_naziv, p.invoice_id, "
"p.expense_report_id, p.clanarina_id, p.payment_date, p.amount, p.currency, "
"p.payment_method, p.iban_from, p.iban_to, p.reference, p.description, "
"p.bank_statement_no, p.bank_transaction_id, p.matched_status, "
"p.matched_by, p.matched_at, p.created_at, "
"i.invoice_no, i.vendor_name, i.amount_gross AS invoice_amount, "
"er.report_no, er.purpose AS expense_purpose, er.cost_total AS expense_total "
"FROM pgz_sport.payments p "
"LEFT JOIN pgz_sport.klubovi k ON k.id=p.klub_id "
"LEFT JOIN pgz_sport.invoices i ON i.id=p.invoice_id "
"LEFT JOIN pgz_sport.expense_reports er ON er.id=p.expense_report_id "
"WHERE p.id=%s",
(rid,))
if not row:
raise HTTPException(404, f"Payment id={rid} not found")
# Try clanarina (table may exist optionally)
if row.get("clanarina_id"):
try:
cl = db_one(
"SELECT id, godina, iznos, status FROM pgz_sport.clanarine WHERE id=%s",
(row["clanarina_id"],))
if cl:
row["clanarina"] = cl
except Exception:
row["clanarina"] = None
return row
# ── POST /payments — create ─────────────────────────────────────────
class PaymentIn(BaseModel):
klub_id: Optional[int] = None
invoice_id: Optional[int] = None
expense_report_id: Optional[int] = None
clanarina_id: Optional[int] = None
payment_date: date
amount: Decimal
currency: str = "EUR"
payment_method: Optional[str] = None
iban_from: Optional[str] = None
iban_to: Optional[str] = None
reference: Optional[str] = None
description: Optional[str] = None
bank_statement_no: Optional[str] = None
bank_transaction_id: Optional[str] = None
_ALLOWED_METHODS = {"iban", "cash", "card", "sepa", "transfer"}
@router.post("/payments")
def payments_create(body: PaymentIn):
if body.amount is None or Decimal(str(body.amount)) <= 0:
raise HTTPException(400, "amount must be > 0")
if body.payment_method and body.payment_method not in _ALLOWED_METHODS:
raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}")
new_id = db_exec(
"INSERT INTO pgz_sport.payments "
"(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, amount, "
" currency, payment_method, iban_from, iban_to, reference, description, "
" bank_statement_no, bank_transaction_id, matched_status, created_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now()) "
"RETURNING id",
(body.klub_id, body.invoice_id, body.expense_report_id, body.clanarina_id,
body.payment_date, body.amount, body.currency or "EUR",
body.payment_method, body.iban_from, body.iban_to,
body.reference, body.description,
body.bank_statement_no, body.bank_transaction_id),
returning=True,
)
return payments_get(new_id)
# ── PATCH /payments/{id} — partial update + manual matching ─────────
class PaymentPatch(BaseModel):
klub_id: Optional[int] = None
invoice_id: Optional[int] = None
expense_report_id: Optional[int] = None
clanarina_id: Optional[int] = None
payment_date: Optional[date] = None
amount: Optional[Decimal] = None
currency: Optional[str] = None
payment_method: Optional[str] = None
iban_from: Optional[str] = None
iban_to: Optional[str] = None
reference: Optional[str] = None
description: Optional[str] = None
bank_statement_no: Optional[str] = None
bank_transaction_id: Optional[str] = None
matched_status: Optional[str] = None
matched_by: Optional[str] = None
@router.patch("/payments/{rid}")
def payments_patch(rid: int, body: PaymentPatch):
existing = db_one("SELECT id FROM pgz_sport.payments WHERE id=%s", (rid,))
if not existing:
raise HTTPException(404, f"Payment id={rid} not found")
data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
if "amount" in data and data["amount"] is not None and Decimal(str(data["amount"])) <= 0:
raise HTTPException(400, "amount must be > 0")
if "payment_method" in data and data["payment_method"] and data["payment_method"] not in _ALLOWED_METHODS:
raise HTTPException(400, f"payment_method must be one of {sorted(_ALLOWED_METHODS)}")
if not data:
return payments_get(rid)
set_parts: List[str] = []
params: list = []
for col, val in data.items():
set_parts.append(f"{col}=%s")
params.append(val)
# Manual matching: when matched_status flips to 'matched', stamp matched_at
if data.get("matched_status") == "matched":
set_parts.append("matched_at=now()")
params.append(rid)
db_exec(
f"UPDATE pgz_sport.payments SET {', '.join(set_parts)} WHERE id=%s",
tuple(params),
)
return payments_get(rid)
# ── POST /payments/import-csv — batch import ────────────────────────
_CSV_REQUIRED = ["payment_date", "amount", "currency", "payment_method",
"iban_from", "iban_to", "reference", "description"]
_CSV_OPTIONAL = ["klub_id", "invoice_id", "expense_report_id",
"clanarina_id", "bank_statement_no", "bank_transaction_id"]
def _parse_csv_date(s: str) -> Optional[date]:
if not s or not s.strip():
return None
s = s.strip()
for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%d.%m.%Y.", "%d/%m/%Y", "%Y/%m/%d"):
try:
return datetime.strptime(s, fmt).date()
except ValueError:
continue
return None
def _parse_csv_decimal(s: str) -> Optional[Decimal]:
if s is None or not str(s).strip():
return None
raw = str(s).strip().replace(" ", "")
# Croatian-style: 1.234,56 → 1234.56
if "," in raw and "." in raw:
raw = raw.replace(".", "").replace(",", ".")
elif "," in raw:
raw = raw.replace(",", ".")
try:
return Decimal(raw)
except (InvalidOperation, ValueError):
return None
def _parse_csv_int(s) -> Optional[int]:
if s is None or not str(s).strip():
return None
try:
return int(str(s).strip())
except ValueError:
return None
@router.post("/payments/import-csv")
async def payments_import_csv(file: UploadFile = File(...)):
"""Accepts CSV (UTF-8, header row). Required columns: payment_date,amount,currency,
payment_method,iban_from,iban_to,reference,description.
Optional: klub_id,invoice_id,expense_report_id,clanarina_id,bank_statement_no,
bank_transaction_id. All rows imported in a single transaction; bad rows are
skipped with an error entry."""
raw = await file.read()
if not raw:
raise HTTPException(400, "Empty file")
if len(raw) > 10 * 1024 * 1024:
raise HTTPException(413, "File > 10 MB")
text: Optional[str] = None
for enc in ("utf-8-sig", "utf-8", "cp1250", "iso-8859-2", "latin-1"):
try:
text = raw.decode(enc)
break
except UnicodeDecodeError:
continue
if text is None:
raise HTTPException(400, "Cannot decode CSV (try UTF-8)")
# Auto-detect delimiter (, vs ;)
sample = text[:4096]
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;|\t")
except csv.Error:
dialect = csv.excel
reader = csv.DictReader(StringIO(text), dialect=dialect)
if not reader.fieldnames:
raise HTTPException(400, "CSV has no header row")
missing = [c for c in _CSV_REQUIRED if c not in reader.fieldnames]
if missing:
raise HTTPException(400, f"CSV missing columns: {missing}")
errors: List[dict] = []
valid_rows: List[tuple] = []
for idx, row in enumerate(reader, start=2): # header is row 1
pd = _parse_csv_date(row.get("payment_date", ""))
if pd is None:
errors.append({"row": idx, "msg": f"bad payment_date: {row.get('payment_date')!r}"})
continue
amt = _parse_csv_decimal(row.get("amount", ""))
if amt is None or amt <= 0:
errors.append({"row": idx, "msg": f"bad amount: {row.get('amount')!r}"})
continue
method = (row.get("payment_method") or "").strip() or None
if method and method not in _ALLOWED_METHODS:
errors.append({"row": idx, "msg": f"bad payment_method: {method!r}"})
continue
valid_rows.append((
_parse_csv_int(row.get("klub_id")),
_parse_csv_int(row.get("invoice_id")),
_parse_csv_int(row.get("expense_report_id")),
_parse_csv_int(row.get("clanarina_id")),
pd,
amt,
(row.get("currency") or "EUR").strip() or "EUR",
method,
(row.get("iban_from") or "").strip() or None,
(row.get("iban_to") or "").strip() or None,
(row.get("reference") or "").strip() or None,
(row.get("description") or "").strip() or None,
(row.get("bank_statement_no") or "").strip() or None,
(row.get("bank_transaction_id") or "").strip() or None,
))
inserted = 0
if valid_rows:
def _do(cur):
nonlocal inserted
for tup in valid_rows:
cur.execute(
"INSERT INTO pgz_sport.payments "
"(klub_id, invoice_id, expense_report_id, clanarina_id, payment_date, "
" amount, currency, payment_method, iban_from, iban_to, reference, "
" description, bank_statement_no, bank_transaction_id, matched_status, "
" created_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'unmatched',now())",
tup,
)
inserted += 1
return inserted
db_tx(_do)
return {"ok": True, "inserted": inserted, "errors": errors,
"total_rows": inserted + len(errors)}
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# 14) HEALTH/DEBUG # 14) HEALTH/DEBUG
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
+403
View File
@@ -0,0 +1,403 @@
#!/usr/bin/env python3
# routers/ocr_router.py
# Name: PGŽ Sport OCR router (lightweight)
# Version: 1.0.0
# Authors: Damir Radulić <dradulic@outlook.com> / <damir@rinet.one>
# Date: 2026-05-05
# Description: FastAPI APIRouter exposing POST /api/ocr/upload and
# GET /api/ocr/health. Accepts PDF/JPG/PNG, runs Tesseract
# (pdf2image for PDF), extracts vendor / OIB / invoice_no /
# date / amount via simple regex, persists into
# pgz_sport.invoice_uploads when possible. Designed to
# degrade gracefully if pytesseract / pdf2image are not
# installed (returns ocr_status='ocr_unavailable').
from __future__ import annotations
import os
import re
import io
import hashlib
import json
import traceback
from pathlib import Path
from datetime import datetime
from typing import Optional, Tuple, Dict, Any, List
from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse
import psycopg2
import psycopg2.extras
# ── Optional OCR deps ────────────────────────────────────────────────────────
_TESS_OK = False
_PDF2IMG_OK = False
_PIL_OK = False
try:
import pytesseract # type: ignore
_TESS_OK = True
except Exception:
pytesseract = None # type: ignore
try:
from pdf2image import convert_from_bytes # type: ignore
_PDF2IMG_OK = True
except Exception:
convert_from_bytes = None # type: ignore
try:
from PIL import Image # type: ignore
_PIL_OK = True
except Exception:
Image = None # type: ignore
# ── 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/uploads/ocr")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png"}
ALLOWED_MIME = {
"application/pdf",
"image/jpeg",
"image/jpg",
"image/png",
}
MAX_BYTES = 25 * 1024 * 1024 # 25 MB
TEXT_CAP = 8 * 1024 # 8 KB cap for response text payload
router = APIRouter(prefix="/api/ocr", tags=["ocr"])
# ── DB helpers ───────────────────────────────────────────────────────────────
def _db():
c = psycopg2.connect(**DB)
c.autocommit = True
return c
def _table_columns(schema: str, table: str) -> List[str]:
try:
with _db() as c, c.cursor() as cur:
cur.execute(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = %s AND table_name = %s
""",
(schema, table),
)
return [r[0] for r in cur.fetchall()]
except Exception:
return []
# ── Regex extractors ─────────────────────────────────────────────────────────
RE_OIB_HR = re.compile(r"\bHR\s*(\d{11})\b")
RE_OIB_BARE = re.compile(r"\b(\d{11})\b")
RE_INVOICE = re.compile(
r"(?im)^.*\b(?:Ra[čc]un|Invoice)\b[^\n\r]{0,80}$"
)
RE_DATE_DMY = re.compile(r"\b(\d{2})[./](\d{2})[./](\d{4})\b")
RE_DATE_YMD = re.compile(r"\b(\d{4})-(\d{2})-(\d{2})\b")
# Amount candidates (1.234,56 or 1234,56 or 1234.56 or 1,234.56), at least 2 digits
RE_AMOUNT = re.compile(
r"(?<![\w.,])"
r"(\d{1,3}(?:[.\s]\d{3})+,\d{2}|\d+,\d{2}|\d{1,3}(?:,\d{3})+\.\d{2}|\d+\.\d{2})"
r"(?![\w])"
)
def _norm_amount(raw: str) -> Optional[float]:
s = raw.strip().replace(" ", "")
# If both . and , present, assume , decimal if last separator is ,
if "," in s and "." in s:
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
# 1.234,56 or 1234,56 → swap
s = s.replace(".", "").replace(",", ".")
try:
return float(s)
except Exception:
return None
def _first_nonempty_line(text: str) -> Optional[str]:
for ln in (text or "").splitlines():
v = ln.strip()
if v:
return v[:200]
return None
def _parse_date(text: str) -> Optional[str]:
m = RE_DATE_YMD.search(text or "")
if m:
try:
return datetime(int(m.group(1)), int(m.group(2)), int(m.group(3))).date().isoformat()
except Exception:
pass
m = RE_DATE_DMY.search(text or "")
if m:
try:
return datetime(int(m.group(3)), int(m.group(2)), int(m.group(1))).date().isoformat()
except Exception:
pass
return None
def _parse_oib(text: str) -> Optional[str]:
m = RE_OIB_HR.search(text or "")
if m:
return m.group(1)
m = RE_OIB_BARE.search(text or "")
if m:
return m.group(1)
return None
def _parse_invoice_no(text: str) -> Optional[str]:
m = RE_INVOICE.search(text or "")
if not m:
return None
line = m.group(0).strip()
# Try to grab the right-most token that looks like an invoice id
cand = re.findall(r"[A-Z0-9][A-Z0-9\-/_.]{1,40}", line)
if cand:
# Drop pure words like "Račun"/"Invoice"
for c in reversed(cand):
if any(ch.isdigit() for ch in c):
return c[:64]
return line[:120]
def _parse_amount(text: str) -> Optional[float]:
if not text:
return None
best: Optional[float] = None
for m in RE_AMOUNT.finditer(text):
v = _norm_amount(m.group(1))
if v is None:
continue
if best is None or v > best:
best = v
return best
def _extract_fields(text: str) -> Dict[str, Any]:
return {
"vendor": _first_nonempty_line(text),
"oib": _parse_oib(text),
"invoice_no": _parse_invoice_no(text),
"date": _parse_date(text),
"amount": _parse_amount(text),
}
# ── OCR engine ───────────────────────────────────────────────────────────────
def _ocr_image_bytes(data: bytes) -> Tuple[Optional[str], Optional[float]]:
if not (_TESS_OK and _PIL_OK):
return None, None
try:
img = Image.open(io.BytesIO(data))
img.load()
text = pytesseract.image_to_string(img, lang=os.getenv("OCR_LANG", "hrv+eng"))
# Confidence (best-effort)
conf = None
try:
d = pytesseract.image_to_data(img, output_type=pytesseract.Output.DICT,
lang=os.getenv("OCR_LANG", "hrv+eng"))
confs = [int(c) for c in d.get("conf", []) if str(c).lstrip("-").isdigit() and int(c) >= 0]
if confs:
conf = round(sum(confs) / len(confs), 2)
except Exception:
pass
return text, conf
except Exception:
return None, None
def _ocr_pdf_bytes(data: bytes) -> Tuple[Optional[str], Optional[float]]:
if not (_TESS_OK and _PDF2IMG_OK):
return None, None
try:
pages = convert_from_bytes(data, dpi=200, fmt="png")
except Exception:
return None, None
if not pages:
return None, None
out: List[str] = []
confs: List[float] = []
for p in pages[:8]: # cap to 8 pages
try:
out.append(pytesseract.image_to_string(p, lang=os.getenv("OCR_LANG", "hrv+eng")))
try:
d = pytesseract.image_to_data(p, output_type=pytesseract.Output.DICT,
lang=os.getenv("OCR_LANG", "hrv+eng"))
cs = [int(c) for c in d.get("conf", []) if str(c).lstrip("-").isdigit() and int(c) >= 0]
if cs:
confs.append(sum(cs) / len(cs))
except Exception:
pass
except Exception:
continue
text = "\n\f\n".join(out) if out else None
conf = round(sum(confs) / len(confs), 2) if confs else None
return text, conf
# ── Persistence ──────────────────────────────────────────────────────────────
def _maybe_insert_upload(payload: Dict[str, Any]) -> Optional[int]:
"""Insert into pgz_sport.invoice_uploads — only writes columns that exist."""
cols = set(_table_columns("pgz_sport", "invoice_uploads"))
if not cols:
return None
# Map our payload keys to potential DB columns
candidates: Dict[str, Any] = {
"file_name": payload.get("file_name"),
"file_path": payload.get("file_path"),
"file_size": payload.get("file_size"),
"mime": payload.get("mime"),
"sha256": payload.get("sha256"),
"ocr_status": payload.get("ocr_status"),
"ocr_engine": payload.get("ocr_engine"),
"ocr_text": payload.get("ocr_text_full"),
"ocr_confidence": payload.get("ocr_confidence"),
"ai_invoice_no": (payload.get("extracted") or {}).get("invoice_no"),
"ai_invoice_date": (payload.get("extracted") or {}).get("date"),
"ai_vendor_name": (payload.get("extracted") or {}).get("vendor"),
"ai_vendor_oib": (payload.get("extracted") or {}).get("oib"),
"ai_amount_gross": (payload.get("extracted") or {}).get("amount"),
"ai_engine": payload.get("ai_engine") or "regex-v1",
"ai_extracted": json.dumps(payload.get("extracted") or {}),
}
insert_cols: List[str] = []
insert_vals: List[Any] = []
for k, v in candidates.items():
if k in cols and v is not None:
insert_cols.append(k)
insert_vals.append(v)
if not insert_cols:
return None
sql = "INSERT INTO pgz_sport.invoice_uploads ({c}) VALUES ({p}) RETURNING id".format(
c=", ".join(insert_cols),
p=", ".join(["%s"] * len(insert_cols)),
)
try:
with _db() as c, c.cursor() as cur:
cur.execute(sql, insert_vals)
row = cur.fetchone()
return int(row[0]) if row else None
except Exception as e:
print(f"[ocr_router] insert failed: {e}")
return None
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("/health")
def health():
return {
"ok": True,
"tesseract_available": bool(_TESS_OK and _PIL_OK),
"pdf2image_available": bool(_PDF2IMG_OK),
"upload_dir": str(UPLOAD_DIR),
}
@router.post("/upload")
async def upload(file: UploadFile = File(...)):
if not file or not file.filename:
raise HTTPException(400, "no file")
# Validate extension/mime
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(400, f"extension not allowed: {ext}")
# Read full body (bounded)
data = await file.read()
if not data:
raise HTTPException(400, "empty file")
if len(data) > MAX_BYTES:
raise HTTPException(413, f"file too large: {len(data)} > {MAX_BYTES}")
sha = hashlib.sha256(data).hexdigest()
save_name = f"{sha}{ext}"
abs_path = UPLOAD_DIR / save_name
if not abs_path.exists():
try:
abs_path.write_bytes(data)
except Exception as e:
raise HTTPException(500, f"could not persist file: {e}")
rel_path = f"uploads/ocr/{save_name}"
# Run OCR
ocr_text: Optional[str] = None
ocr_conf: Optional[float] = None
ocr_engine = "tesseract"
if ext == ".pdf":
if not (_TESS_OK and _PDF2IMG_OK and _PIL_OK):
ocr_status = "ocr_unavailable"
else:
ocr_text, ocr_conf = _ocr_pdf_bytes(data)
ocr_status = "ocr_done" if ocr_text else "ocr_failed"
else:
if not (_TESS_OK and _PIL_OK):
ocr_status = "ocr_unavailable"
else:
ocr_text, ocr_conf = _ocr_image_bytes(data)
ocr_status = "ocr_done" if ocr_text else "ocr_failed"
extracted = _extract_fields(ocr_text or "")
# Truncated text for response
text_resp = (ocr_text or "")
if len(text_resp) > TEXT_CAP:
text_resp = text_resp[:TEXT_CAP]
payload: Dict[str, Any] = {
"file_name": file.filename,
"file_path": rel_path,
"file_size": len(data),
"mime": file.content_type or "application/octet-stream",
"sha256": sha,
"ocr_status": ocr_status,
"ocr_engine": ocr_engine if ocr_status == "ocr_done" else None,
"ocr_text_full": ocr_text,
"ocr_confidence": ocr_conf,
"extracted": extracted,
"ai_engine": "regex-v1",
}
inserted_id = _maybe_insert_upload(payload)
return JSONResponse(
{
"ok": True,
"id": inserted_id,
"file_path": rel_path,
"file_name": file.filename,
"file_size": len(data),
"mime": payload["mime"],
"sha256": sha,
"ocr_status": ocr_status,
"ocr_confidence": ocr_conf,
"ocr_text": text_resp if ocr_text else None,
"extracted": extracted,
}
)
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""UNIRI akademski repozitorij + znanstveni radovi."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
ACADEMIC = {
"uniri_repozitorij": ["https://repozitorij.uniri.hr/"],
"portal_znanstveni": ["https://portal.uniri.hr/"],
"hrčak_uniri": ["https://hrcak.srce.hr/"],
"pfri_radovi": ["https://repository.pfri.uniri.hr/"],
"medri_radovi": ["https://medri.uniri.hr/znanstveni-radovi/"],
"tfr_radovi": ["https://www.riteh.uniri.hr/"],
"ffri_radovi": ["https://www.ffri.uniri.hr/znanstveni-radovi/"],
}
def crawl(name, urls, max_pages=15):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=20)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 300: continue
ff = []
if title and len(title) > 15:
ff.append({"fact": f"[Academic] {name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 900):
if len(c) > 150:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="akademski_pgz", confidence=0.90)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40:
queue.append(link)
time.sleep(0.7)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in ACADEMIC.items():
try:
r = crawl(name, urls, max_pages=12)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"academic_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+67
View File
@@ -0,0 +1,67 @@
#!/usr%bin/env python3
"""Lokalne firme i obrti PGŽ — HGK/HOK članovi."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
COMMERCE = {
"hgk_rijeka_deep": ["https://www.hgk.hr/zupanijske-komore/primorsko-goranska-zupanijska-komora"],
"hok_pgz": ["https://www.hok.hr/"],
"poduzetnistvo_ri": ["https://www.pgz.hr/gospodarstvo/"],
"poslovni_inkubator": ["https://www.step-ri.hr/"],
"izvoz_import_pgz": ["https://www.hgk.hr/izvoz-uvoz"],
"tehnopolis_firme": ["https://www.tehnopolis.hr/"],
"start_up_ri": ["https://www.startup-rijeka.hr/"],
"obrtnička_komora": ["https://www.hok.hr/pgz"],
"tz_pgz_biznis": ["https://www.kvarner.hr/biznis"],
"free_zone_rijeka": ["https://www.rfind.hr/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="commerce_pgz", confidence=0.84)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 35:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in COMMERCE.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"commerce_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Ekologija i zaštita okoliša PGŽ."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
ECOLOGY = {
"np_risnjak_full": ["https://www.np-risnjak.hr/"],
"pp_ucka": ["https://pp-ucka.hr/"],
"zelena_akcija": ["https://zelena-akcija.hr/"],
"eko_kvarner": ["https://www.eko-kvarner.hr/"],
"fundacija_adris": ["https://www.adris.hr/"],
"plava_zastava": ["https://www.plava-zastava.hr/"],
"cistoca_pgz": ["https://www.cistoca.hr/"],
"vodoopskrba_pgz": ["https://www.kdvik-rijeka.hr/"],
"otpad_pgz": ["https://www.komunalac.hr/"],
"More_cisto": ["https://more-cisto.hr/"],
"zzjz_okolisa": ["https://www.zzjzpgz.hr/zastita-okolisa/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="ekologija_pgz", confidence=0.86)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 35:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in ECOLOGY.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"ecology_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""NGO i udruge PGŽ — mladi, veterani, humanitarne."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
NGO = {
"mladi_pgz": ["https://www.pgz.hr/mladi/"],
"udruge_pgz": ["https://www.udruge.hr/pgz"],
"humanitarne_pgz": ["https://www.volonterski-centar-ri.hr/"],
"crveni_kriz_full": ["https://www.crveni-kriz-rijeka.hr/"],
"veterani_pgz": ["https://www.veterani.hr/pgz"],
"umirovljenici_pgz": ["https://www.savez-umirovljenika.hr/"],
"invalidi_pgz": ["https://www.invalidi-rijeka.hr/"],
"zivotrodi_pgz": ["https://www.zivotrodi.hr/"],
"omladina_ri": ["https://www.omladina-rijeka.hr/"],
}
def crawl(name, urls, max_pages=10):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="ngo_pgz", confidence=0.84)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 25:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in NGO.items():
try:
r = crawl(name, urls, max_pages=8)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"ngo_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Pomorstvo — luke, marine, brodogradnja, nautika."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
MARITIME = {
"luka_rijeka_full": ["https://www.lukarijeka.hr/", "https://www.portauthority.hr/"],
"aci_marine_kvarner": ["https://www.aci-marinas.com/"],
"marina_opatija": ["https://www.marina-opatija.hr/"],
"marina_punat": ["https://www.marina-punat.hr/"],
"marina_cres": ["https://www.aci-marinas.com/marina/aci-cres"],
"yachting_kvarner": ["https://www.yachting-kvarner.com/"],
"brod_3maj_deep": ["https://www.3maj.hr/"],
"viktor_lenac_deep": ["https://www.lenac.hr/"],
"pfri_maritime": ["https://www.pfri.uniri.hr/"],
"luka_baska": ["https://www.luka-baska.hr/"],
"luke_kvarner": ["https://www.luke-hrvatska.hr/"],
}
def crawl(name, urls, max_pages=15):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="pomorstvo_pgz", confidence=0.87)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 40:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in MARITIME.items():
try:
r = crawl(name, urls, max_pages=12)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"maritime_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Osnovne i srednje škole + vrtići PGŽ."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
SCHOOLS = {
"srednje_skole_ri": ["https://www.skole.hr/skole/primorsko-goranska-zupanija"],
"osnove_skole_ri": ["https://www.rijeka.hr/skole/"],
"vrtici_pgz": ["https://www.pula.hr/hr/gradski-vrtic/"],
"gimnazija_ri": ["https://www.gimnazija-rijeka.hr/"],
"tehnicka_skola_ri": ["https://www.tehnicka-rijeka.hr/"],
"ekonomska_skola_ri": ["https://www.ekonomska-rijeka.hr/"],
"medicinska_skola_ri": ["https://www.medicinska-skola-rijeka.hr/"],
"skole_opatija": ["https://www.opatija.hr/skole"],
"skole_crikvenica": ["https://www.skole-crikvenica.hr/"],
}
def crawl(name, urls, max_pages=10):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="skole_pgz", confidence=0.86)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 30:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in SCHOOLS.items():
try:
r = crawl(name, urls, max_pages=8)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"schools_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
@@ -0,0 +1,68 @@
#!/usr/bin/env python3
"""Sport federacije i savezi PGŽ — all sports."""
import sys, json, time
sys.path.insert(0, "/opt/pgz-sport/scrapers/harvesters")
from _common import (fetch, extract_text, extract_title, chunk_text,
upsert_facts, find_internal_links, DSN)
from urllib.parse import urlparse
import psycopg2
FEDERATIONS = {
"zns_pgz": ["https://www.pgzns.hr/"],
"kss_pgz": ["https://www.kss-pgz.hr/"],
"handball_pgz": ["https://www.hrs-pgz.hr/"],
"odbojka_savez_pgz": ["https://www.odbojka-pgz.hr/"],
"atletika_savez_pgz": ["https://www.atletika-pgz.hr/"],
"plivanje_savez_pgz": ["https://www.plivanje-pgz.hr/"],
"skijaski_savez_pgz": ["https://www.ski-pgz.hr/"],
"tenis_savez_pgz": ["https://www.tenis-pgz.hr/"],
"judo_savez_pgz": ["https://www.judo-pgz.hr/"],
"karate_savez_pgz": ["https://www.karate-pgz.hr/"],
"kuglanje_savez_pgz": ["https://www.kuglanje-pgz.hr/"],
}
def crawl(name, urls, max_pages=12):
conn = psycopg2.connect(DSN); conn.autocommit = True
visited = set(); queue = list(urls); facts = 0
while queue and len(visited) < max_pages:
url = queue.pop(0)
if url in visited: continue
visited.add(url)
html, status = fetch(url, timeout=15)
if not html or status != 200: continue
title = extract_title(html); text = extract_text(html)
if not text or len(text) < 200: continue
ff = []
if title and len(title) > 8:
ff.append({"fact": f"{name} - {title}", "url": url, "title": title})
for c in chunk_text(text, 800):
if len(c) > 100:
ff.append({"fact": c, "url": url, "title": title})
facts += upsert_facts(conn, ff, source_name=name,
category="sport_federacije_pgz", confidence=0.88)
base = urlparse(url).hostname
for link in find_internal_links(html, url):
if link not in visited and (urlparse(link).hostname or "") == base and len(queue) < 35:
queue.append(link)
time.sleep(0.5)
conn.close()
return {"name": name, "visited": len(visited), "facts": facts}
def main():
results = []
for name, urls in FEDERATIONS.items():
try:
r = crawl(name, urls, max_pages=10)
print(f" {name:25} {r['visited']:>3}p {r['facts']:>5}f")
results.append(r)
except Exception as e:
print(f" {name:25} FAIL: {str(e)[:60]}")
total = sum(r.get("facts", 0) for r in results)
print(f"=== TOTAL: {total} ===")
print(json.dumps({"fed_count": len(results), "total_facts": total}))
if __name__ == "__main__":
main()
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env python3
# Fajl: objekti_enrich_address.py | v1.0 | 05.05.2026
# Author: Damir Radulić
# Svrha: Reverse-geocode lat/lng → adresa za sportski_objekti
import os, time, json
import psycopg2, requests
DSN = "host=10.10.0.2 port=6432 dbname=rinet_v3 user=rinet password=R1net2026!SecureDB#v7"
HEADERS = {"User-Agent": "Ri.NET PGŽ Sport (dradulic@outlook.com)"}
conn = psycopg2.connect(DSN); conn.autocommit = True
with conn.cursor() as cur:
cur.execute("""
SELECT id, naziv, lat, lng FROM pgz_sport.sportski_objekti
WHERE aktivan = true AND lat IS NOT NULL AND lng IS NOT NULL
AND (adresa IS NULL OR adresa = '')
LIMIT 60
""")
rows = cur.fetchall()
print(f"Total: {len(rows)} objekata bez adrese")
for i, (oid, naziv, lat, lng) in enumerate(rows):
try:
# Nominatim reverse geocoding
r = requests.get(
f"https://nominatim.openstreetmap.org/reverse",
params={"lat": lat, "lon": lng, "format": "json", "accept-language": "hr"},
headers=HEADERS, timeout=10
)
if r.status_code == 200:
d = r.json()
addr = d.get("display_name", "")
# Krat: ulica + broj + grad
a = d.get("address", {})
short = []
for k in ["road", "house_number", "suburb", "city", "town", "village"]:
if a.get(k): short.append(a[k])
addr_short = ", ".join(short[:4]) or addr[:100]
with conn.cursor() as cur:
cur.execute("UPDATE pgz_sport.sportski_objekti SET adresa = %s WHERE id = %s", (addr_short, oid))
print(f" [{i+1}/{len(rows)}] {naziv}{addr_short}")
time.sleep(1.1) # Nominatim rate-limit 1 req/s
except Exception as e:
print(f" [FAIL] {naziv}: {e}")
print("DONE")
+1
View File
@@ -506,6 +506,7 @@ const NAV_BY_ROLE = {
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard'}, {id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard'},
{id:'korisnici', ic:'\u{1F465}', label:'Korisnici', href:'/admin/users'}, {id:'korisnici', ic:'\u{1F465}', label:'Korisnici', href:'/admin/users'},
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi'}, {id:'savezi', ic:'\u{1F3C5}', label:'Savezi'},
{id:'objekti', ic:'\u{1F3DF}', label:'Sportski objekti', href:'/objekti'},
{id:'klubovi', ic:'⬢', label:'Klubovi'}, {id:'klubovi', ic:'⬢', label:'Klubovi'},
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši'}, {id:'sportasi', ic:'\u{1F464}', label:'Sportaši'},
{id:'financije', ic:'€', label:'Financije'}, {id:'financije', ic:'€', label:'Financije'},
+80
View File
@@ -623,6 +623,33 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
</div> </div>
</div> </div>
<!-- ━━━ OCR floating button + modal ━━━ -->
<button id="ocr-fab" onclick="ocrOpen()"
style="position:fixed;right:18px;bottom:18px;z-index:60;
background:#1f6feb;color:#fff;border:none;border-radius:24px;
padding:10px 16px;font-size:13px;cursor:pointer;
box-shadow:0 6px 18px rgba(0,0,0,0.4)">
📷 OCR Upload
</button>
<div id="ocr-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.55);z-index:80;align-items:center;justify-content:center">
<div style="background:#0f1620;color:#dbe2ee;border:1px solid #25334a;border-radius:10px;width:min(720px,94vw);max-height:90vh;overflow:auto;padding:14px">
<div style="display:flex;justify-content:space-between;align-items:center;border-bottom:1px solid #25334a;padding-bottom:8px;margin-bottom:10px">
<h3 style="margin:0;font-size:14px">📷 OCR Upload (PDF / JPG / PNG)</h3>
<button onclick="ocrClose()" style="background:none;border:none;color:#dbe2ee;font-size:18px;cursor:pointer">×</button>
</div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" id="ocr-crm-file" accept="application/pdf,image/jpeg,image/jpg,image/png">
<button class="btn primary" onclick="ocrCrmUpload()">Upload</button>
<button class="btn" onclick="ocrCrmHealth()">Health</button>
<span id="ocr-crm-status" style="font-size:11px;color:#8aa0bd"></span>
</div>
<div id="ocr-crm-health" style="font-size:11px;color:#8aa0bd;margin-top:6px"></div>
<div id="ocr-crm-fields" style="margin-top:10px;font-size:12px"></div>
<pre id="ocr-crm-text" style="margin-top:10px;max-height:300px;overflow:auto;background:#0a1018;padding:10px;border-radius:6px;font-size:11px;white-space:pre-wrap">— prazno —</pre>
</div>
</div>
<div id="toast"></div> <div id="toast"></div>
<script> <script>
@@ -2077,6 +2104,59 @@ document.getElementById('modal').addEventListener('click', e => {
if (e.target.id === 'modal') closeModal(); if (e.target.id === 'modal') closeModal();
}); });
// ────── OCR (lightweight /api/ocr) ──────
const OCR_API = '/sport/api/ocr';
function ocrOpen(){ document.getElementById('ocr-modal').style.display = 'flex'; }
function ocrClose(){ document.getElementById('ocr-modal').style.display = 'none'; }
async function ocrCrmHealth(){
const out = document.getElementById('ocr-crm-health');
if(out) out.textContent = '...checking';
try {
const r = await fetch(OCR_API + '/health');
const j = await r.json();
if(out){
out.textContent = 'tesseract: ' + (j.tesseract_available ? 'OK' : 'NO') +
' · pdf2image: ' + (j.pdf2image_available ? 'OK' : 'NO');
}
} catch(e){
if(out) out.textContent = 'health err: ' + (e && e.message || e);
}
}
async function ocrCrmUpload(){
const f = document.getElementById('ocr-crm-file').files[0];
const stat = document.getElementById('ocr-crm-status');
const fields = document.getElementById('ocr-crm-fields');
const txt = document.getElementById('ocr-crm-text');
if(!f){ if(stat) stat.textContent = 'odaberi datoteku'; return; }
if(stat) stat.textContent = 'uploading…';
const fd = new FormData();
fd.append('file', f);
try {
const r = await fetch(OCR_API + '/upload', { method: 'POST', body: fd });
const j = await r.json();
if(!r.ok){ if(stat) stat.textContent = 'err ' + r.status; return; }
const ex = j.extracted || {};
fields.innerHTML =
'<table style="width:100%;font-size:12px">'
+ '<tr><th style="text-align:left;width:140px">vendor</th><td>'+(ex.vendor||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">OIB</th><td>'+(ex.oib||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">invoice_no</th><td>'+(ex.invoice_no||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">date</th><td>'+(ex.date||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">amount</th><td>'+(ex.amount==null?'—':ex.amount)+'</td></tr>'
+ '<tr><th style="text-align:left">ocr_status</th><td>'+(j.ocr_status||'—')+'</td></tr>'
+ '<tr><th style="text-align:left">confidence</th><td>'+(j.ocr_confidence==null?'—':j.ocr_confidence)+'</td></tr>'
+ '<tr><th style="text-align:left">file</th><td>'+((j.file_name||'?')+' · '+(j.file_size||0)+' B')+'</td></tr>'
+ '</table>';
txt.textContent = j.ocr_text || '— (prazno / OCR nije izvršen) —';
if(stat) stat.textContent = 'done · id=' + (j.id == null ? 'n/a' : j.id);
} catch(e){
if(stat) stat.textContent = 'err: ' + (e && e.message || e);
}
}
// ────── Init ────── // ────── Init ──────
loadMe(); loadMe();
ensureMe(); ensureMe();
+523 -39
View File
@@ -90,6 +90,11 @@ table tbody tr:hover{background:var(--bg3)}
.badge.knjizen{background:var(--green);color:var(--bg0)} .badge.knjizen{background:var(--green);color:var(--bg0)}
.badge.placen{background:var(--pgz-gold);color:var(--bg0)} .badge.placen{background:var(--pgz-gold);color:var(--bg0)}
.badge.otkazan{background:var(--red);color:#fff} .badge.otkazan{background:var(--red);color:#fff}
.badge.draft{background:var(--bg4);color:var(--t1)}
.badge.poslano{background:var(--cyan);color:var(--bg0)}
.badge.odobreno{background:var(--green);color:var(--bg0)}
.badge.odbijeno{background:var(--red);color:#fff}
.badge.isplaceno{background:var(--pgz-gold);color:var(--bg0)}
.dnev-line-row{display:grid;grid-template-columns:140px 1fr 100px 100px 1fr 30px;gap:6px;margin-bottom:6px;align-items:center} .dnev-line-row{display:grid;grid-template-columns:140px 1fr 100px 100px 1fr 30px;gap:6px;margin-bottom:6px;align-items:center}
.dnev-line-row input,.dnev-line-row select{background:var(--bg2);border:1px solid var(--rim);border-radius:4px;padding:5px 8px;color:var(--t1);font-size:12px;width:100%} .dnev-line-row input,.dnev-line-row select{background:var(--bg2);border:1px solid var(--rim);border-radius:4px;padding:5px 8px;color:var(--t1);font-size:12px;width:100%}
@@ -118,6 +123,7 @@ table tbody tr:hover{background:var(--bg3)}
<button class="tab" data-panel="partneri">🤝 Partneri</button> <button class="tab" data-panel="partneri">🤝 Partneri</button>
<button class="tab" data-panel="racuni">🧾 Računi</button> <button class="tab" data-panel="racuni">🧾 Računi</button>
<button class="tab" data-panel="uploads">📎 Uploads (OCR)</button> <button class="tab" data-panel="uploads">📎 Uploads (OCR)</button>
<button class="tab" data-panel="ocr">📷 OCR</button>
<button class="tab" data-panel="putni">✈ Putni nalozi</button> <button class="tab" data-panel="putni">✈ Putni nalozi</button>
<button class="tab" data-panel="payments">💰 Plaćanja</button> <button class="tab" data-panel="payments">💰 Plaćanja</button>
<button class="tab" data-panel="pdv">% PDV</button> <button class="tab" data-panel="pdv">% PDV</button>
@@ -245,19 +251,71 @@ table tbody tr:hover{background:var(--bg3)}
</div> </div>
</section> </section>
<!-- ============ OCR (Računi) — lightweight /api/ocr/upload ============ -->
<section class="panel" id="panel-ocr">
<div class="card">
<div class="card-h">
<div class="card-t">📷 OCR — Računi (Tesseract + regex extrakcija)</div>
<div style="display:flex;gap:6px">
<button class="btn" onclick="ocrHealth()">🩺 Health</button>
</div>
</div>
<div style="padding:10px;border:2px dashed var(--rim2);border-radius:8px;background:var(--bg3);margin-bottom:10px">
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input type="file" id="ocr-file" accept="application/pdf,image/jpeg,image/jpg,image/png">
<button class="btn primary" onclick="ocrUpload()">⬆ Upload</button>
<span id="ocr-status" style="color:var(--t2);font-size:11px"></span>
</div>
<div id="ocr-health" style="margin-top:6px;font-size:11px;color:var(--t1)"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div class="card">
<div class="card-h"><div class="card-t">Ekstrahirana polja</div></div>
<div class="tbl-wrap" style="padding:6px">
<table id="ocr-fields"><tbody>
<tr><th style="text-align:left;width:140px">vendor</th><td id="ocr-vendor"></td></tr>
<tr><th style="text-align:left">OIB</th><td id="ocr-oib"></td></tr>
<tr><th style="text-align:left">invoice_no</th><td id="ocr-invno"></td></tr>
<tr><th style="text-align:left">date</th><td id="ocr-date"></td></tr>
<tr><th style="text-align:left">amount</th><td id="ocr-amount"></td></tr>
<tr><th style="text-align:left">ocr_status</th><td id="ocr-ostatus"></td></tr>
<tr><th style="text-align:left">confidence</th><td id="ocr-conf"></td></tr>
<tr><th style="text-align:left">file</th><td id="ocr-file-info"></td></tr>
</tbody></table>
</div>
<div style="padding:10px;display:flex;gap:8px">
<!-- TODO: stvarna integracija sa pgz_sport.racuni_ulazni (real save) -->
<button class="btn primary" onclick="ocrSaveRacun()">💾 Spremi u racuni_ulazni</button>
<button class="btn" onclick="ocrReset()">↺ Reset</button>
</div>
</div>
<div class="card">
<div class="card-h"><div class="card-t">Prepoznati tekst (OCR)</div></div>
<pre id="ocr-text" style="white-space:pre-wrap;max-height:420px;overflow:auto;padding:10px;font-size:11px;background:var(--bg2);color:var(--t1);margin:0">— prazno —</pre>
</div>
</div>
</div>
</section>
<!-- ============ PUTNI NALOZI / EXPENSE REPORTS ============ --> <!-- ============ PUTNI NALOZI / EXPENSE REPORTS ============ -->
<section class="panel" id="panel-putni"> <section class="panel" id="panel-putni">
<div class="card"> <div class="card">
<div class="card-h"><div class="card-t">Putni nalozi i ostali troškovi (expense_reports)</div></div> <div class="card-h">
<div class="card-t">Putni nalozi (expense_reports)</div>
<button class="btn gold" onclick="openPutniModal()">+ Novi putni nalog</button>
</div>
<div class="toolbar"> <div class="toolbar">
<label>Tip <select id="pn-type"><option value="">— svi —</option><option value="putni_nalog">Putni nalog</option><option value="expense">Trošak</option></select></label>
<label>Status <select id="pn-status"><option value="">— svi —</option><option value="draft">draft</option><option value="podnesen">podnesen</option><option value="odobren">odobren</option><option value="isplacen">isplacen</option><option value="rejected">rejected</option></select></label>
<label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label> <label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label>
<label>Status <select id="pn-status"><option value="">— svi —</option><option value="draft">draft</option><option value="poslano">poslano</option><option value="odobreno">odobreno</option><option value="odbijeno">odbijeno</option><option value="isplaceno">isplaceno</option></select></label>
<label>Klub ID <input type="number" id="pn-klub" placeholder="ID kluba" style="width:90px"></label>
<label>Tip <select id="pn-type"><option value="">— svi —</option><option value="sluzbeno_putovanje">Službeno putovanje</option><option value="putni_nalog">Putni nalog</option><option value="expense">Trošak</option></select></label>
<button class="btn" onclick="loadExpenseReports()">Osvježi</button> <button class="btn" onclick="loadExpenseReports()">Osvježi</button>
<button id="pn-export-btn" class="export-btn" type="button">Export ▾</button> <button id="pn-export-btn" class="export-btn" type="button">Export ▾</button>
</div> </div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table id="pn-tbl"><thead><tr><th>#</th><th>Tip</th><th>Klub</th><th>Odredište</th><th>Svrha</th><th>Od</th><th>Do</th><th class="num">Km</th><th class="num">Trošak</th><th class="num">Dnevnice</th><th>Status</th></tr></thead><tbody><tr><td colspan="11" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table> <table id="pn-tbl"><thead><tr><th>#</th><th>Br</th><th>Klub</th><th>Destinacija</th><th>Od</th><th>Do</th><th class="num">Km</th><th class="num">Cost total</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div> </div>
<div id="pn-detail" style="display:none;margin-top:14px;border-top:1px solid var(--bd);padding-top:12px"> <div id="pn-detail" style="display:none;margin-top:14px;border-top:1px solid var(--bd);padding-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
@@ -274,16 +332,27 @@ table tbody tr:hover{background:var(--bg3)}
<!-- ============ PAYMENTS ============ --> <!-- ============ PAYMENTS ============ -->
<section class="panel" id="panel-payments"> <section class="panel" id="panel-payments">
<div class="card"> <div class="card">
<div class="card-h"><div class="card-t">Plaćanja / Bank Reconciliation (payments)</div></div> <div class="card-h">
<div class="card-t">Plaćanja / Bank Reconciliation (payments)</div>
<div style="display:flex;gap:6px">
<button class="btn gold" onclick="openPaymentModal()">+ Novo plaćanje</button>
<button class="btn sec" onclick="document.getElementById('py-csv-file').click()">📥 Import CSV</button>
<input type="file" id="py-csv-file" accept=".csv,text/csv" style="display:none" onchange="importPaymentsCSV(this)">
<button class="btn sec" onclick="exportPaymentsSepa()">📤 SEPA XML</button>
</div>
</div>
<div class="toolbar"> <div class="toolbar">
<label>Status <select id="py-status"><option value="">— svi —</option><option value="unmatched">unmatched</option><option value="matched">matched</option><option value="manual">manual</option></select></label>
<label>Način <select id="py-method"><option value="">— svi —</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
<label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label> <label>Godina <input type="number" id="py-godina" placeholder="2026" style="width:90px"></label>
<label>Status <select id="py-status"><option value="">— svi —</option><option value="unmatched">unmatched</option><option value="matched">matched</option><option value="manual">manual</option></select></label>
<label>Način <select id="py-method"><option value="">— svi —</option><option value="iban">iban</option><option value="sepa">sepa</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></label>
<label>Klub ID <input type="number" id="py-klub" placeholder="—" style="width:90px"></label>
<button class="btn" onclick="loadPayments()">Osvježi</button> <button class="btn" onclick="loadPayments()">Osvježi</button>
<button id="py-export-btn" class="export-btn" type="button">Export ▾</button> <button id="py-export-btn" class="export-btn" type="button">Export ▾</button>
</div> </div>
<div id="py-summary" class="kpi-grid"></div>
<div id="py-csv-result"></div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Način</th><th>IBAN OD</th><th>IBAN ZA</th><th>Referenca</th><th>Račun</th><th>Putni nalog</th><th>Match</th></tr></thead><tbody><tr><td colspan="12" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table> <table id="py-tbl"><thead><tr><th>#</th><th>Datum</th><th>Klub</th><th class="num">Iznos</th><th>Valuta</th><th>Metoda</th><th>IBANIBAN</th><th>Reference</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div> </div>
</div> </div>
</section> </section>
@@ -477,6 +546,39 @@ table tbody tr:hover{background:var(--bg3)}
</div> </div>
</div> </div>
<div class="modal-bg" id="m-pn" onclick="if(event.target===this)closeModal('m-pn')">
<div class="modal" style="width:min(820px,96vw)">
<h3 id="m-pn-title">Novi putni nalog</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div class="form-row"><label>Klub ID *</label><input type="number" id="pn-f-klub"></div>
<div class="form-row"><label>User ID</label><input type="number" id="pn-f-user"></div>
<div class="form-row"><label>Član ID</label><input type="number" id="pn-f-clan"></div>
<div class="form-row"><label>Tip</label><select id="pn-f-type"><option value="sluzbeno_putovanje">Službeno putovanje</option><option value="putni_nalog">Putni nalog</option><option value="expense">Trošak</option></select></div>
<div class="form-row"><label>Br. naloga</label><input id="pn-f-no" placeholder="auto / opc"></div>
<div class="form-row"><label>Destinacija *</label><input id="pn-f-dest"></div>
<div class="form-row" style="grid-column:1/3"><label>Svrha *</label><input id="pn-f-purpose"></div>
<div class="form-row"><label>Datum od *</label><input type="date" id="pn-f-from"></div>
<div class="form-row"><label>Datum do *</label><input type="date" id="pn-f-to"></div>
<div class="form-row"><label>Vozilo (tip)</label><input id="pn-f-vtype" placeholder="osobno / službeno"></div>
<div class="form-row"><label>Reg. oznaka</label><input id="pn-f-vplate"></div>
<div class="form-row"><label>Km voženo</label><input type="number" step="0.1" id="pn-f-km" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>€ / km</label><input type="number" step="0.01" id="pn-f-kmrate" value="0.42" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Trošak prijevoz</label><input type="number" step="0.01" id="pn-f-tr" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Trošak smještaj</label><input type="number" step="0.01" id="pn-f-lo" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Trošak hrana</label><input type="number" step="0.01" id="pn-f-me" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Ostali troškovi</label><input type="number" step="0.01" id="pn-f-ot" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Br. dnevnica</label><input type="number" step="0.5" id="pn-f-dnc" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Iznos dnevnice</label><input type="number" step="0.01" id="pn-f-dna" value="30.00" oninput="recalcPutniTotal()"></div>
<div class="form-row" style="grid-column:1/3"><label>Napomena</label><textarea id="pn-f-notes" rows="2"></textarea></div>
</div>
<div class="dnev-balans ok" id="pn-f-total" style="margin-top:10px">Ukupno: 0,00 €</div>
<div class="form-actions">
<button class="btn sec" onclick="closeModal('m-pn')">Odustani</button>
<button class="btn gold" onclick="savePutni()">Spremi</button>
</div>
</div>
</div>
<div class="modal-bg" id="m-pl" onclick="if(event.target===this)closeModal('m-pl')"> <div class="modal-bg" id="m-pl" onclick="if(event.target===this)closeModal('m-pl')">
<div class="modal"> <div class="modal">
<h3>Obračun plaće (HR 2026)</h3> <h3>Obračun plaće (HR 2026)</h3>
@@ -495,6 +597,30 @@ table tbody tr:hover{background:var(--bg3)}
</div> </div>
</div> </div>
<div class="modal-bg" id="m-py" onclick="if(event.target===this)closeModal('m-py')">
<div class="modal" style="width:min(820px,96vw)">
<h3 id="m-py-title">Novo plaćanje</h3>
<div class="form-row"><label>Datum *</label><input type="date" id="py-date"></div>
<div class="form-row"><label>Iznos *</label><input type="number" step="0.01" id="py-amount"></div>
<div class="form-row"><label>Valuta</label><input id="py-currency" value="EUR" maxlength="3"></div>
<div class="form-row"><label>Metoda</label><select id="py-method-in"><option value=""></option><option value="iban">iban</option><option value="sepa">sepa</option><option value="transfer">transfer</option><option value="cash">cash</option><option value="card">card</option></select></div>
<div class="form-row"><label>Klub ID</label><input type="number" id="py-klub-in" placeholder="opcionalno"></div>
<div class="form-row"><label>Račun ID</label><input type="number" id="py-invoice-in" placeholder="opcionalno"></div>
<div class="form-row"><label>Putni nalog ID</label><input type="number" id="py-er-in" placeholder="opcionalno"></div>
<div class="form-row"><label>Članarina ID</label><input type="number" id="py-cl-in" placeholder="opcionalno"></div>
<div class="form-row"><label>IBAN OD</label><input id="py-iban-from" placeholder="HR..."></div>
<div class="form-row"><label>IBAN ZA</label><input id="py-iban-to" placeholder="HR..."></div>
<div class="form-row"><label>Reference</label><input id="py-ref"></div>
<div class="form-row"><label>Opis</label><input id="py-desc"></div>
<div class="form-row"><label>Bank statement #</label><input id="py-bs"></div>
<div class="form-row"><label>Bank txn ID</label><input id="py-btx"></div>
<div class="form-actions">
<button class="btn sec" onclick="closeModal('m-py')">Odustani</button>
<button class="btn gold" onclick="savePayment()">Spremi</button>
</div>
</div>
</div>
<script> <script>
const API = '/api/v2/erp'; const API = '/api/v2/erp';
const AUTH = () => ({ 'Authorization': 'Bearer ' + (localStorage.getItem('jwt') || localStorage.getItem('access_token') || 'admin-pgz-2026') }); const AUTH = () => ({ 'Authorization': 'Bearer ' + (localStorage.getItem('jwt') || localStorage.getItem('access_token') || 'admin-pgz-2026') });
@@ -1057,35 +1183,56 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// ===== PUTNI NALOZI / EXPENSE REPORTS ===== // ===== PUTNI NALOZI / EXPENSE REPORTS =====
function _pnStatusActions(r){
const id = r.id;
const s = r.status || 'draft';
const acts = [];
if (s === 'draft') {
acts.push(`<button class="btn green" title="Pošalji na odobrenje" onclick="event.stopPropagation();putniSetStatus(${id},'poslano')">Pošalji</button>`);
acts.push(`<button class="btn red" title="Odbij" onclick="event.stopPropagation();putniSetStatus(${id},'odbijeno')">Odbij</button>`);
} else if (s === 'poslano') {
acts.push(`<button class="btn green" title="Odobri" onclick="event.stopPropagation();putniSetStatus(${id},'odobreno')">Odobri</button>`);
acts.push(`<button class="btn red" title="Odbij" onclick="event.stopPropagation();putniSetStatus(${id},'odbijeno')">Odbij</button>`);
} else if (s === 'odobreno') {
acts.push(`<button class="btn gold" title="Označi kao isplaćeno" onclick="event.stopPropagation();putniSetStatus(${id},'isplaceno')">Isplati</button>`);
}
acts.push(`<button class="btn sec" title="Uredi" onclick="event.stopPropagation();editPutni(${id})">✎</button>`);
if (s === 'draft') {
acts.push(`<button class="btn red" title="Obriši" onclick="event.stopPropagation();deletePutni(${id})">×</button>`);
}
return acts.join(' ');
}
async function loadExpenseReports(){ async function loadExpenseReports(){
const tbody = document.querySelector('#pn-tbl tbody'); const tbody = document.querySelector('#pn-tbl tbody');
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`; tbody.innerHTML = `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
try { try {
const t = document.getElementById('pn-type').value; const t = document.getElementById('pn-type').value;
const s = document.getElementById('pn-status').value; const s = document.getElementById('pn-status').value;
const g = document.getElementById('pn-godina').value; const g = document.getElementById('pn-godina').value;
const k = document.getElementById('pn-klub').value;
const p = new URLSearchParams(); const p = new URLSearchParams();
if(t) p.set('report_type', t); if(t) p.set('report_type', t);
if(s) p.set('status', s); if(s) p.set('status', s);
if(g) p.set('godina', g); if(g) p.set('godina', g);
const d = await api('/expense-reports?'+p.toString()); if(k) p.set('klub_id', k);
const d = await api('/putni-nalozi?'+p.toString());
tbody.innerHTML = (d.rows||[]).length tbody.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr onclick="expenseDetail(${r.id})" style="cursor:pointer"> ? d.rows.map(r=>`<tr onclick="expenseDetail(${r.id})" style="cursor:pointer">
<td>${r.id}</td> <td>${r.id}</td>
<td>${r.report_type||''}</td> <td>${r.report_no||''}</td>
<td>${r.klub_naziv||r.klub_id||''}</td> <td>${r.klub_naziv||r.klub_id||''}</td>
<td>${r.destination||''}</td> <td>${r.destination||''}</td>
<td>${r.purpose||''}</td>
<td>${r.date_from||''}</td> <td>${r.date_from||''}</td>
<td>${r.date_to||''}</td> <td>${r.date_to||''}</td>
<td class="num">${fmt(r.km_driven)}</td> <td class="num">${fmt(r.km_driven)}</td>
<td class="num">${fmt(r.cost_total)}</td> <td class="num"><b>${fmt(r.cost_total)}</b></td>
<td class="num">${fmt(r.dnevnice_amount)}</td>
<td><span class="badge ${r.status||''}">${r.status||''}</span></td> <td><span class="badge ${r.status||''}">${r.status||''}</span></td>
<td style="white-space:nowrap">${_pnStatusActions(r)}</td>
</tr>`).join('') </tr>`).join('')
: `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Nema putnih naloga.</td></tr>`; : `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Nema putnih naloga.</td></tr>`;
} catch(e) { } catch(e) {
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--red)">Greška: ${e.message}</td></tr>`; tbody.innerHTML = `<tr><td colspan="10" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
} }
} }
@@ -1093,48 +1240,310 @@ async function expenseDetail(id){
try { try {
document.getElementById('pn-detail').style.display='block'; document.getElementById('pn-detail').style.display='block';
document.getElementById('pn-detail-title').textContent = `Vezani računi za putni nalog #${id}`; document.getElementById('pn-detail-title').textContent = `Vezani računi za putni nalog #${id}`;
const d = await api('/putni-nalog-racuni?putni_nalog_id='+id); const d = await api('/putni-nalozi/'+id);
const tb = document.querySelector('#pn-rac-tbl tbody'); const tb = document.querySelector('#pn-rac-tbl tbody');
tb.innerHTML = (d.rows||[]).length const racuni = d.racuni || [];
? d.rows.map(r=>`<tr><td>${r.id}</td><td>${r.invoice_no||('#'+r.invoice_id)}</td><td>${r.vendor_name||''}</td><td class="num">${fmt(r.amount_gross)}</td><td>${r.currency||''}</td><td>${r.kategorija||''}</td><td>${(r.attached_at||'').slice(0,10)}</td></tr>`).join('') tb.innerHTML = racuni.length
? racuni.map(r=>`<tr><td>${r.id}</td><td>${r.invoice_no||('#'+r.invoice_id)}</td><td>${r.vendor_name||''}</td><td class="num">${fmt(r.amount_gross)}</td><td>${r.currency||''}</td><td>${r.kategorija||''}</td><td>${(r.attached_at||'').slice(0,10)}</td></tr>`).join('')
: `<tr><td colspan="7" style="color:var(--t2);text-align:center;padding:10px">Nema vezanih računa.</td></tr>`; : `<tr><td colspan="7" style="color:var(--t2);text-align:center;padding:10px">Nema vezanih računa.</td></tr>`;
} catch(e) { alert('Greška: '+e.message); } } catch(e) { alert('Greška: '+e.message); }
} }
function _pnSetForm(r){
const set = (id,v) => { const el = document.getElementById(id); if(el) el.value = (v==null?'':v); };
set('pn-f-klub', r.klub_id);
set('pn-f-user', r.user_id);
set('pn-f-clan', r.clan_id);
set('pn-f-type', r.report_type || 'sluzbeno_putovanje');
set('pn-f-no', r.report_no);
set('pn-f-dest', r.destination);
set('pn-f-purpose', r.purpose);
set('pn-f-from', (r.date_from||'').toString().slice(0,10));
set('pn-f-to', (r.date_to||'').toString().slice(0,10));
set('pn-f-vtype', r.vehicle_type);
set('pn-f-vplate', r.vehicle_plate);
set('pn-f-km', r.km_driven||0);
set('pn-f-kmrate', r.km_rate||0.42);
set('pn-f-tr', r.cost_transport||0);
set('pn-f-lo', r.cost_lodging||0);
set('pn-f-me', r.cost_meals||0);
set('pn-f-ot', r.cost_other||0);
set('pn-f-dnc', r.dnevnice_count||0);
set('pn-f-dna', r.dnevnice_amount||30.00);
set('pn-f-notes', r.notes);
recalcPutniTotal();
}
function recalcPutniTotal(){
const v = id => parseFloat(document.getElementById(id).value)||0;
const total = v('pn-f-km')*v('pn-f-kmrate') + v('pn-f-tr') + v('pn-f-lo')
+ v('pn-f-me') + v('pn-f-ot') + v('pn-f-dnc')*v('pn-f-dna');
document.getElementById('pn-f-total').textContent = `Ukupno: ${fmt(total)}`;
}
function openPutniModal(){
document.getElementById('m-pn-title').textContent = 'Novi putni nalog';
document.getElementById('m-pn').dataset.id = '';
_pnSetForm({
report_type: 'sluzbeno_putovanje',
date_from: new Date().toISOString().slice(0,10),
date_to: new Date().toISOString().slice(0,10),
km_driven: 0, km_rate: 0.42,
cost_transport: 0, cost_lodging: 0, cost_meals: 0, cost_other: 0,
dnevnice_count: 0, dnevnice_amount: 30.00,
});
openModal('m-pn');
}
async function editPutni(id){
try {
const d = await api('/putni-nalozi/'+id);
document.getElementById('m-pn-title').textContent = `Putni nalog #${id} (${d.head.status})`;
document.getElementById('m-pn').dataset.id = id;
_pnSetForm(d.head);
openModal('m-pn');
} catch(e){ alert('Greška: '+e.message); }
}
async function savePutni(){
const v = id => document.getElementById(id).value;
const num = id => { const x = parseFloat(v(id)); return isNaN(x)?0:x; };
const intOrNull = id => { const x = parseInt(v(id)); return isNaN(x)?null:x; };
const strOrNull = id => { const s = v(id).trim(); return s?s:null; };
const body = {
klub_id: intOrNull('pn-f-klub'),
user_id: intOrNull('pn-f-user'),
clan_id: intOrNull('pn-f-clan'),
report_type: v('pn-f-type') || 'sluzbeno_putovanje',
report_no: strOrNull('pn-f-no'),
destination: v('pn-f-dest').trim(),
purpose: v('pn-f-purpose').trim(),
date_from: v('pn-f-from'),
date_to: v('pn-f-to'),
vehicle_type: strOrNull('pn-f-vtype'),
vehicle_plate: strOrNull('pn-f-vplate'),
km_driven: num('pn-f-km'),
km_rate: num('pn-f-kmrate'),
cost_transport: num('pn-f-tr'),
cost_lodging: num('pn-f-lo'),
cost_meals: num('pn-f-me'),
cost_other: num('pn-f-ot'),
dnevnice_count: num('pn-f-dnc'),
dnevnice_amount: num('pn-f-dna'),
notes: strOrNull('pn-f-notes'),
};
if (!body.klub_id) { alert('Klub ID je obavezan.'); return; }
if (!body.destination) { alert('Destinacija je obavezna.'); return; }
if (!body.purpose) { alert('Svrha je obavezna.'); return; }
if (!body.date_from || !body.date_to) { alert('Datumi su obavezni.'); return; }
const id = document.getElementById('m-pn').dataset.id;
try {
if (id) {
await api('/putni-nalozi/'+id, { method:'PATCH', body: JSON.stringify(body) });
} else {
await api('/putni-nalozi', { method:'POST', body: JSON.stringify(body) });
}
closeModal('m-pn');
loadExpenseReports();
} catch(e){ alert('Greška: '+e.message); }
}
async function putniSetStatus(id, newStatus){
const labels = {poslano:'pošaljete na odobrenje', odobreno:'odobrite', odbijeno:'odbijete', isplaceno:'označite kao isplaćeno'};
if(!confirm(`Sigurno ${labels[newStatus]||'promijenite status'} za putni nalog #${id}?`)) return;
try {
await api('/putni-nalozi/'+id, { method:'PATCH', body: JSON.stringify({status:newStatus}) });
loadExpenseReports();
} catch(e){ alert('Greška: '+e.message); }
}
async function deletePutni(id){
if(!confirm(`Obriši putni nalog #${id}? (samo draft)`)) return;
try {
await api('/putni-nalozi/'+id, { method:'DELETE' });
loadExpenseReports();
} catch(e){ alert('Greška: '+e.message); }
}
// ===== PAYMENTS ===== // ===== PAYMENTS =====
function _pyFilterParams(){
const p = new URLSearchParams();
const s = document.getElementById('py-status').value;
const m = document.getElementById('py-method').value;
const g = document.getElementById('py-godina').value;
const k = document.getElementById('py-klub').value;
if(s) p.set('matched_status', s);
if(m) p.set('payment_method', m);
if(g) p.set('godina', g);
if(k) p.set('klub_id', k);
return p;
}
async function loadPayments(){ async function loadPayments(){
const tbody = document.querySelector('#py-tbl tbody'); const tbody = document.querySelector('#py-tbl tbody');
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`; tbody.innerHTML = `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
const sumDiv = document.getElementById('py-summary');
try { try {
const s = document.getElementById('py-status').value; const p = _pyFilterParams();
const m = document.getElementById('py-method').value; if(!p.has('godina')){
const g = document.getElementById('py-godina').value; // for summary tile we still want "this year" total
const p = new URLSearchParams(); }
if(s) p.set('matched_status', s); const d = await api('/payments?'+p.toString()+'&limit=500');
if(m) p.set('payment_method', m); const rows = d.rows || [];
if(g) p.set('godina', g); const yearNow = new Date().getFullYear();
const d = await api('/payments?'+p.toString()); let totalYear = 0, cMatched = 0, cUnmatched = 0;
tbody.innerHTML = (d.rows||[]).length for(const r of rows){
? d.rows.map(r=>`<tr> const py = r.payment_date ? Number(r.payment_date.slice(0,4)) : null;
if(py === yearNow) totalYear += Number(r.amount||0);
if(r.matched_status === 'matched') cMatched++;
else if(r.matched_status === 'unmatched' || !r.matched_status) cUnmatched++;
}
sumDiv.innerHTML = `
<div class="kpi g"><div class="kpi-l">Ukupno ${yearNow}</div><div class="kpi-v">${fmt(totalYear)} €</div></div>
<div class="kpi r"><div class="kpi-l">Unmatched</div><div class="kpi-v">${cUnmatched}</div></div>
<div class="kpi"><div class="kpi-l">Matched</div><div class="kpi-v">${cMatched}</div></div>
<div class="kpi"><div class="kpi-l">Total rows</div><div class="kpi-v">${rows.length}</div></div>`;
tbody.innerHTML = rows.length
? rows.map(r=>`<tr>
<td>${r.id}</td> <td>${r.id}</td>
<td>${r.payment_date||''}</td> <td>${r.payment_date||''}</td>
<td>${r.klub_naziv||r.klub_id||''}</td> <td>${(r.klub_naziv||r.klub_id||'').toString().replace(/</g,'&lt;')}</td>
<td class="num"><b>${fmt(r.amount)}</b></td> <td class="num"><b>${fmt(r.amount)}</b></td>
<td>${r.currency||''}</td> <td>${r.currency||''}</td>
<td>${r.payment_method||''}</td> <td>${r.payment_method||''}</td>
<td>${r.iban_from||''}</td> <td style="font-family:var(--mono);font-size:10.5px">${(r.iban_from||'')}${(r.iban_to||'—')}</td>
<td>${r.iban_to||''}</td> <td>${(r.reference||'').replace(/</g,'&lt;')}</td>
<td>${r.reference||''}</td>
<td>${r.invoice_id?('#'+r.invoice_id):'—'}</td>
<td>${r.expense_report_id?('#'+r.expense_report_id):'—'}</td>
<td><span class="badge ${r.matched_status||''}">${r.matched_status||''}</span></td> <td><span class="badge ${r.matched_status||''}">${r.matched_status||''}</span></td>
<td style="white-space:nowrap">
<button class="btn sec" style="padding:3px 8px" onclick="event.stopPropagation();viewPayment(${r.id})">👁</button>
${r.matched_status==='matched' ? '' : `<button class="btn green" style="padding:3px 8px" onclick="event.stopPropagation();matchPayment(${r.id})">✓ Match</button>`}
</td>
</tr>`).join('') </tr>`).join('')
: `<tr><td colspan="12" style="color:var(--t2);text-align:center;padding:14px">Nema plaćanja.</td></tr>`; : `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Nema plaćanja.</td></tr>`;
} catch(e) { } catch(e) {
tbody.innerHTML = `<tr><td colspan="12" style="color:var(--red)">Greška: ${e.message}</td></tr>`; sumDiv.innerHTML = '';
tbody.innerHTML = `<tr><td colspan="10" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
} }
} }
function openPaymentModal(){
['py-date','py-amount','py-klub-in','py-invoice-in','py-er-in','py-cl-in',
'py-iban-from','py-iban-to','py-ref','py-desc','py-bs','py-btx']
.forEach(i=>{ const el=document.getElementById(i); if(el) el.value=''; });
document.getElementById('py-currency').value='EUR';
document.getElementById('py-method-in').value='';
// Pre-fill today
document.getElementById('py-date').value = new Date().toISOString().slice(0,10);
openModal('m-py');
}
async function savePayment(){
const v = id => { const el=document.getElementById(id); return el?el.value.trim():''; };
const numOrNull = s => s==='' ? null : Number(s);
const strOrNull = s => s==='' ? null : s;
const body = {
payment_date: v('py-date'),
amount: v('py-amount'),
currency: v('py-currency') || 'EUR',
payment_method: strOrNull(v('py-method-in')),
klub_id: numOrNull(v('py-klub-in')),
invoice_id: numOrNull(v('py-invoice-in')),
expense_report_id: numOrNull(v('py-er-in')),
clanarina_id: numOrNull(v('py-cl-in')),
iban_from: strOrNull(v('py-iban-from')),
iban_to: strOrNull(v('py-iban-to')),
reference: strOrNull(v('py-ref')),
description: strOrNull(v('py-desc')),
bank_statement_no: strOrNull(v('py-bs')),
bank_transaction_id: strOrNull(v('py-btx')),
};
if(!body.payment_date){ alert('Datum je obavezan'); return; }
if(!body.amount || Number(body.amount)<=0){ alert('Iznos mora biti > 0'); return; }
try {
await api('/payments', { method:'POST', body: JSON.stringify(body) });
closeModal('m-py');
loadPayments();
} catch(e) {
alert('Greška: '+e.message);
}
}
async function viewPayment(id){
try {
const r = await api('/payments/'+id);
const lines = [
`ID: ${r.id}`,
`Datum: ${r.payment_date||''}`,
`Klub: ${r.klub_naziv||r.klub_id||'—'}`,
`Iznos: ${fmt(r.amount)} ${r.currency||''}`,
`Metoda: ${r.payment_method||'—'}`,
`IBAN: ${r.iban_from||'—'}${r.iban_to||'—'}`,
`Reference: ${r.reference||'—'}`,
`Opis: ${r.description||'—'}`,
`Status: ${r.matched_status||''}${r.matched_at?(' @ '+r.matched_at):''}`,
r.invoice_no ? `Račun: ${r.invoice_no} (${r.vendor_name||''}, ${fmt(r.invoice_amount)})` : null,
r.report_no ? `Putni nalog: ${r.report_no} (${r.expense_purpose||''})` : null,
r.bank_statement_no ? `Izvod: ${r.bank_statement_no}` : null,
r.bank_transaction_id ? `Bank txn: ${r.bank_transaction_id}` : null,
].filter(Boolean);
alert(lines.join('\n'));
} catch(e) {
alert('Greška: '+e.message);
}
}
async function matchPayment(id){
const inv = prompt('Invoice ID za match (ostavi prazno za skip):', '');
let er = '';
if(!inv) er = prompt('Expense report ID za match (ostavi prazno za skip):', '') || '';
const body = { matched_status: 'matched', matched_by: 'user' };
if(inv && inv.trim()) body.invoice_id = parseInt(inv.trim(),10);
if(er && er.trim()) body.expense_report_id = parseInt(er.trim(),10);
try {
await api('/payments/'+id, { method:'PATCH', body: JSON.stringify(body) });
loadPayments();
} catch(e) {
alert('Greška: '+e.message);
}
}
async function importPaymentsCSV(input){
const f = input.files && input.files[0];
if(!f) return;
const fd = new FormData();
fd.append('file', f);
const out = document.getElementById('py-csv-result');
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-radius:4px;margin:8px 0;color:var(--t2)">Importing ${f.name}…</div>`;
try {
const r = await fetch(API+'/payments/import-csv', {
method: 'POST',
headers: { ...AUTH() },
body: fd,
});
const j = await r.json();
if(!r.ok){
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--red);margin:8px 0;color:var(--red)">Import error: ${j.detail||r.status}</div>`;
} else {
const errLines = (j.errors||[]).slice(0,20).map(e=>`<li>row ${e.row}: ${e.msg}</li>`).join('');
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--green);margin:8px 0">
<b style="color:var(--green)">Inserted: ${j.inserted}</b> · Errors: ${(j.errors||[]).length} · Total: ${j.total_rows||(j.inserted+(j.errors||[]).length)}
${errLines?`<ul style="margin-top:6px;color:var(--amber);font-size:11px">${errLines}</ul>`:''}
</div>`;
loadPayments();
}
} catch(e) {
out.innerHTML = `<div style="padding:8px;background:var(--bg3);border-left:3px solid var(--red);margin:8px 0;color:var(--red)">Greška: ${e.message}</div>`;
} finally {
input.value = '';
}
}
function exportPaymentsSepa(){
const p = _pyFilterParams();
const url = API+'/payments/sepa-export'+(p.toString()?('?'+p.toString()):'');
window.open(url, '_blank');
}
// ===== IZVJEŠTAJI ===== // ===== IZVJEŠTAJI =====
async function loadIzvjestaj(){ async function loadIzvjestaj(){
const tip = document.getElementById('iz-tip').value; const tip = document.getElementById('iz-tip').value;
@@ -1187,6 +1596,80 @@ function exportPdf(report, godina){
window.open(API+'/export/pdf/'+report+'?godina='+godina, '_blank'); window.open(API+'/export/pdf/'+report+'?godina='+godina, '_blank');
} }
// ===== OCR (lightweight /api/ocr) =====
const OCR_API = '/sport/api/ocr';
let _ocrLast = null;
function _ocrSet(id, val){
const el = document.getElementById(id);
if(el) el.textContent = (val === null || val === undefined || val === '') ? '—' : String(val);
}
async function ocrHealth(){
const out = document.getElementById('ocr-health');
if(out) out.textContent = '...checking';
try {
const r = await fetch(OCR_API + '/health');
const j = await r.json();
if(out){
out.textContent = 'tesseract: ' + (j.tesseract_available ? 'OK' : 'NO') +
' · pdf2image: ' + (j.pdf2image_available ? 'OK' : 'NO') +
' · upload_dir: ' + (j.upload_dir || '?');
}
} catch(e){
if(out) out.textContent = 'health err: ' + (e && e.message || e);
}
}
async function ocrUpload(){
const f = document.getElementById('ocr-file').files[0];
const stat = document.getElementById('ocr-status');
if(!f){ if(stat) stat.textContent = 'odaberi datoteku'; return; }
if(stat) stat.textContent = 'uploading…';
const fd = new FormData();
fd.append('file', f);
try {
const r = await fetch(OCR_API + '/upload', { method: 'POST', body: fd });
const j = await r.json();
if(!r.ok){
if(stat) stat.textContent = 'err ' + r.status + ': ' + (j && j.detail || '');
return;
}
_ocrLast = j;
const ex = j.extracted || {};
_ocrSet('ocr-vendor', ex.vendor);
_ocrSet('ocr-oib', ex.oib);
_ocrSet('ocr-invno', ex.invoice_no);
_ocrSet('ocr-date', ex.date);
_ocrSet('ocr-amount', ex.amount);
_ocrSet('ocr-ostatus', j.ocr_status);
_ocrSet('ocr-conf', j.ocr_confidence);
_ocrSet('ocr-file-info', (j.file_name || '?') + ' · ' + (j.file_size||0) + ' B · ' + (j.mime||'?'));
const txt = document.getElementById('ocr-text');
if(txt) txt.textContent = j.ocr_text || '— (prazno / OCR nije izvršen) —';
if(stat) stat.textContent = 'done · id=' + (j.id == null ? 'n/a' : j.id);
} catch(e){
if(stat) stat.textContent = 'err: ' + (e && e.message || e);
}
}
function ocrReset(){
['ocr-vendor','ocr-oib','ocr-invno','ocr-date','ocr-amount','ocr-ostatus','ocr-conf','ocr-file-info'].forEach(id => _ocrSet(id, null));
const txt = document.getElementById('ocr-text');
if(txt) txt.textContent = '— prazno —';
const stat = document.getElementById('ocr-status');
if(stat) stat.textContent = '';
_ocrLast = null;
}
function ocrSaveRacun(){
// TODO: stvarna integracija sa pgz_sport.racuni_ulazni (real save) — wire later
if(!_ocrLast){ alert('Nema OCR podatka. Prvo uploadaj račun.'); return; }
alert('TODO: spremi u racuni_ulazni\nfile_path: ' + (_ocrLast.file_path || '?') +
'\nvendor: ' + ((_ocrLast.extracted||{}).vendor || '?') +
'\namount: ' + ((_ocrLast.extracted||{}).amount || '?'));
}
// Lazy loaders per panel // Lazy loaders per panel
const loaders = { const loaders = {
dnevnik: loadDnevnik, dnevnik: loadDnevnik,
@@ -1200,7 +1683,8 @@ const loaders = {
place: () => { loadZap(); loadPlace(); }, place: () => { loadZap(); loadPlace(); },
proracun: loadProracun, proracun: loadProracun,
izvjestaji: loadIzvjestaj, izvjestaji: loadIzvjestaj,
kontni: loadKontniPlan kontni: loadKontniPlan,
ocr: ocrHealth
}; };
// Switch programmatically (used by deep links: ?tab=uploads / #tab=putni) // Switch programmatically (used by deep links: ?tab=uploads / #tab=putni)
+196
View File
@@ -0,0 +1,196 @@
<!DOCTYPE html>
<!--
objekti.html — Sportski objekti PGŽ (Google Maps + filter)
Author: Damir Radulić | v1.0 | 05.05.2026
-->
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>🏟️ Sportski objekti PGŽ</title>
<link rel="icon" href="/favicon.ico">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font:14px system-ui;background:#06080d;color:#e0e0e0}
header{background:#0a0e15;padding:12px 20px;border-bottom:1px solid #2a2a2e;display:flex;justify-content:space-between;align-items:center}
header h1{font-size:18px;color:#5fb6ff}
header a{color:#888;text-decoration:none;margin-left:14px;font-size:13px}
header a:hover{color:#fff}
.container{display:grid;grid-template-columns:380px 1fr;height:calc(100vh - 50px)}
.sidebar{background:#0c1016;border-right:1px solid #1a1a1e;overflow-y:auto;padding:14px}
.filters{display:flex;flex-direction:column;gap:8px;margin-bottom:14px;padding-bottom:14px;border-bottom:1px solid #1a1a1e}
.filters label{font-size:11px;color:#888;text-transform:uppercase}
.filters select, .filters input{
background:#1a1a1e;border:1px solid #2a2a2e;color:#fff;padding:8px 10px;border-radius:5px;font-size:13px;width:100%
}
.stats{font-size:12px;color:#888;padding:8px 0;border-bottom:1px solid #1a1a1e;margin-bottom:8px}
.stats b{color:#5fb6ff}
.obj-list{display:flex;flex-direction:column;gap:6px}
.obj-item{background:#0c1016;border:1px solid #1a1a1e;border-radius:5px;padding:10px;cursor:pointer;transition:all .15s}
.obj-item:hover{border-color:#5fb6ff;background:#0f1620}
.obj-item.active{border-color:#fbbf24;background:#1a1610}
.obj-name{font-weight:600;color:#fff;margin-bottom:3px;font-size:13px}
.obj-meta{font-size:10px;color:#888;display:flex;gap:6px;flex-wrap:wrap}
.obj-meta span{background:#1a1a1e;padding:1px 6px;border-radius:3px}
#map{flex:1;background:#000}
.leaflet-container{background:#1a1a1e}
.popup-title{font-weight:700;font-size:14px;margin-bottom:4px;color:#000}
.popup-meta{font-size:11px;color:#666;margin-bottom:4px}
.popup-link{display:inline-block;margin-top:6px;padding:4px 8px;background:#1a73e8;color:#fff;text-decoration:none;border-radius:3px;font-size:11px}
</style>
</head>
<body>
<header>
<h1><a href="/" style="color:#5fb6ff;text-decoration:none">🏟️ Sportski objekti PGŽ</a></h1>
<div>
<a href="/">🏠 Home</a>
<a href="/static/sport2.html#dashboard">📊 Dashboard</a>
<a href="/sport/dokumenti">📚 Dokumenti</a>
<a href="/admin/users">👥 Admin</a>
</div>
</header>
<div class="container">
<div class="sidebar">
<div class="filters">
<div>
<label>Tip objekta</label>
<select id="f-tip" onchange="loadObjekti()">
<option value="">Svi tipovi</option>
</select>
</div>
<div>
<label>Grad</label>
<select id="f-grad" onchange="loadObjekti()">
<option value="">Svi gradovi</option>
</select>
</div>
<div>
<label>Sport</label>
<select id="f-sport" onchange="loadObjekti()">
<option value="">Svi sportovi</option>
</select>
</div>
<div>
<label>Pretraga</label>
<input type="search" id="f-q" placeholder="Naziv, adresa…" onkeyup="if(event.key==='Enter') loadObjekti()">
</div>
</div>
<div class="stats" id="stats">Učitavanje…</div>
<div class="obj-list" id="obj-list"></div>
</div>
<div id="map"></div>
</div>
<script>
const map = L.map('map').setView([45.3271, 14.4422], 10); // Rijeka centar
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '© OpenStreetMap'
}).addTo(map);
let markers = L.layerGroup().addTo(map);
const tipIcons = {
'dvorana': '🏟️', 'stadion': '⚽', 'bazen': '🏊', 'kompleks': '🏛️',
'marina': '⛵', 'skijalište': '🎿', 'kuglana': '🎳', 'tenis kompleks': '🎾',
'klizalište': '⛸️', 'strelište': '🎯', 'boćalište': '🥌', 'atletska staza': '🏃',
'centar': '🏟️', 'sanjkalište': '🛷', 'hipodrom': '🐎'
};
async function loadMeta(){
const r = await fetch('/sport/api/v2/sportski-objekti/meta');
const m = await r.json();
const tipSel = document.getElementById('f-tip');
m.tipovi.forEach(t => tipSel.innerHTML += `<option value="${t.tip}">${tipIcons[t.tip]||'•'} ${t.tip} (${t.broj})</option>`);
const gradSel = document.getElementById('f-grad');
m.gradovi.forEach(g => gradSel.innerHTML += `<option value="${g.grad}">${g.grad} (${g.broj})</option>`);
const sportSel = document.getElementById('f-sport');
m.sportovi.forEach(s => sportSel.innerHTML += `<option value="${s.sport}">${s.sport} (${s.broj})</option>`);
}
async function loadObjekti(){
const params = new URLSearchParams();
const tip = document.getElementById('f-tip').value;
const grad = document.getElementById('f-grad').value;
const sport = document.getElementById('f-sport').value;
const q = document.getElementById('f-q').value;
if(tip) params.set('tip', tip);
if(grad) params.set('grad', grad);
if(sport) params.set('sport', sport);
if(q) params.set('q', q);
params.set('limit', '500');
const r = await fetch('/sport/api/v2/sportski-objekti?'+params.toString());
const d = await r.json();
document.getElementById('stats').innerHTML = `<b>${d.count}</b> objekata po filtru`;
// Markers
markers.clearLayers();
const list = document.getElementById('obj-list');
list.innerHTML = '';
const bounds = [];
d.rows.forEach((o, i) => {
if(o.lat && o.lng){
const icon = tipIcons[o.tip] || '📍';
const m = L.marker([o.lat, o.lng], {
title: o.naziv,
icon: L.divIcon({
html: `<div style="background:#1a73e8;color:#fff;padding:2px 5px;border-radius:50%;border:2px solid #fff;box-shadow:0 2px 4px rgba(0,0,0,.5);font-size:14px;width:30px;height:30px;display:flex;align-items:center;justify-content:center">${icon}</div>`,
className: '', iconSize: [30, 30], iconAnchor: [15, 15]
})
});
m.bindPopup(`
<div class="popup-title">${o.naziv}</div>
<div class="popup-meta">
${icon} ${o.tip} · ${o.grad || ''}
${o.kapacitet ? ' · ' + o.kapacitet + ' mjesta' : ''}
</div>
${o.adresa ? '<div class="popup-meta">📍 ' + o.adresa + '</div>' : ''}
${o.upravitelj ? '<div class="popup-meta">👤 ' + o.upravitelj + '</div>' : ''}
${o.izgradeno ? '<div class="popup-meta">🏗 Izgrađeno: ' + o.izgradeno + '</div>' : ''}
${o.sportovi && o.sportovi.length ? '<div class="popup-meta">⚽ ' + o.sportovi.join(', ') + '</div>' : ''}
${o.web ? '<a href="' + o.web + '" target="_blank" class="popup-link">🌐 Web</a>' : ''}
<a href="https://www.google.com/maps?q=${o.lat},${o.lng}" target="_blank" class="popup-link">🗺️ Google Maps</a>
`, {maxWidth: 320});
markers.addLayer(m);
bounds.push([o.lat, o.lng]);
}
list.innerHTML += `
<div class="obj-item" onclick="zoomTo(${o.lat||0}, ${o.lng||0})">
<div class="obj-name">${tipIcons[o.tip]||'•'} ${o.naziv}</div>
<div class="obj-meta">
<span>${o.tip}</span>
${o.grad ? '<span>'+o.grad+'</span>' : ''}
${o.kapacitet ? '<span>'+o.kapacitet+' mj</span>' : ''}
</div>
</div>
`;
});
if(bounds.length > 0){
map.fitBounds(bounds, {padding: [40, 40], maxZoom: 13});
}
}
function zoomTo(lat, lng){
if(lat && lng){
map.setView([lat, lng], 16);
markers.eachLayer(m => {
if(m.getLatLng().lat === lat) m.openPopup();
});
}
}
loadMeta().then(() => loadObjekti());
</script>
</body>
</html>