Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae9c4e2bfd | |||
| 6e5ada8517 | |||
| 47df057270 | |||
| 7625e59173 | |||
| c4640ca3af | |||
| 38383d07c5 | |||
| 55a27fb315 | |||
| efa15d0086 | |||
| f488623920 |
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
@@ -1716,6 +1716,13 @@ try:
|
||||
except Exception as 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:
|
||||
from erp.putni_nalozi import router as 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:
|
||||
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("/")
|
||||
def root(request: Request):
|
||||
host = request.headers.get("host", "")
|
||||
|
||||
+660
-11
@@ -1,6 +1,6 @@
|
||||
#!/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
|
||||
# Lokacija: /opt/pgz-sport/routers/erp_full_router.py
|
||||
# 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,
|
||||
# invoice_uploads (OCR), expense_reports (Putni nalozi), payments.
|
||||
# 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/*
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from io import BytesIO, StringIO
|
||||
from pathlib import Path
|
||||
from typing import Optional, List
|
||||
from xml.etree import ElementTree as ET
|
||||
from xml.sax.saxutils import escape as xml_escape
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
from fastapi import APIRouter, HTTPException, Query, Body, UploadFile, File, Depends, Header, Form
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from fastapi.responses import StreamingResponse, Response
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
# ── Upload destination (relative to web root /uploads/...) ──────────
|
||||
UPLOAD_BASE = Path("/opt/pgz-sport/uploads")
|
||||
@@ -1223,12 +1231,7 @@ async def invoice_uploads_create(
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
# 12) PUTNI NALOZI / EXPENSE REPORTS
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
@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):
|
||||
def _expense_reports_list_impl(klub_id, status, report_type, godina, limit):
|
||||
where = ["1=1"]
|
||||
params: list = []
|
||||
if klub_id:
|
||||
@@ -1252,6 +1255,248 @@ def expense_reports_list(klub_id: Optional[int] = None,
|
||||
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")
|
||||
def putni_nalog_racuni_list(putni_nalog_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}
|
||||
|
||||
|
||||
# ── 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
|
||||
# ═══════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -506,6 +506,7 @@ const NAV_BY_ROLE = {
|
||||
{id:'dashboard', ic:'\u{1F4CA}', label:'Dashboard'},
|
||||
{id:'korisnici', ic:'\u{1F465}', label:'Korisnici', href:'/admin/users'},
|
||||
{id:'savezi', ic:'\u{1F3C5}', label:'Savezi'},
|
||||
{id:'objekti', ic:'\u{1F3DF}', label:'Sportski objekti', href:'/objekti'},
|
||||
{id:'klubovi', ic:'⬢', label:'Klubovi'},
|
||||
{id:'sportasi', ic:'\u{1F464}', label:'Sportaši'},
|
||||
{id:'financije', ic:'€', label:'Financije'},
|
||||
|
||||
@@ -623,6 +623,33 @@ footer { height:36px; background:var(--bg2); border-top:1px solid var(--rim);
|
||||
</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>
|
||||
|
||||
<script>
|
||||
@@ -2077,6 +2104,59 @@ document.getElementById('modal').addEventListener('click', e => {
|
||||
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 ──────
|
||||
loadMe();
|
||||
ensureMe();
|
||||
|
||||
+523
-39
@@ -90,6 +90,11 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
.badge.knjizen{background:var(--green);color:var(--bg0)}
|
||||
.badge.placen{background:var(--pgz-gold);color:var(--bg0)}
|
||||
.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 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="racuni">🧾 Računi</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="payments">💰 Plaćanja</button>
|
||||
<button class="tab" data-panel="pdv">% PDV</button>
|
||||
@@ -245,19 +251,71 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
</div>
|
||||
</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 ============ -->
|
||||
<section class="panel" id="panel-putni">
|
||||
<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">
|
||||
<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>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 id="pn-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||
</div>
|
||||
<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 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">
|
||||
@@ -274,16 +332,27 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
<!-- ============ PAYMENTS ============ -->
|
||||
<section class="panel" id="panel-payments">
|
||||
<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">
|
||||
<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>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 id="py-export-btn" class="export-btn" type="button">Export ▾</button>
|
||||
</div>
|
||||
<div id="py-summary" class="kpi-grid"></div>
|
||||
<div id="py-csv-result"></div>
|
||||
<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>IBAN→IBAN</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>
|
||||
</section>
|
||||
@@ -477,6 +546,39 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
</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">
|
||||
<h3>Obračun plaće (HR 2026)</h3>
|
||||
@@ -495,6 +597,30 @@ table tbody tr:hover{background:var(--bg3)}
|
||||
</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>
|
||||
const API = '/api/v2/erp';
|
||||
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 =====
|
||||
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(){
|
||||
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 {
|
||||
const t = document.getElementById('pn-type').value;
|
||||
const s = document.getElementById('pn-status').value;
|
||||
const g = document.getElementById('pn-godina').value;
|
||||
const k = document.getElementById('pn-klub').value;
|
||||
const p = new URLSearchParams();
|
||||
if(t) p.set('report_type', t);
|
||||
if(s) p.set('status', s);
|
||||
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
|
||||
? d.rows.map(r=>`<tr onclick="expenseDetail(${r.id})" style="cursor:pointer">
|
||||
<td>${r.id}</td>
|
||||
<td>${r.report_type||''}</td>
|
||||
<td>${r.report_no||'—'}</td>
|
||||
<td>${r.klub_naziv||r.klub_id||''}</td>
|
||||
<td>${r.destination||''}</td>
|
||||
<td>${r.purpose||''}</td>
|
||||
<td>${r.date_from||''}</td>
|
||||
<td>${r.date_to||''}</td>
|
||||
<td class="num">${fmt(r.km_driven)}</td>
|
||||
<td class="num">${fmt(r.cost_total)}</td>
|
||||
<td class="num">${fmt(r.dnevnice_amount)}</td>
|
||||
<td class="num"><b>${fmt(r.cost_total)}</b></td>
|
||||
<td><span class="badge ${r.status||''}">${r.status||''}</span></td>
|
||||
<td style="white-space:nowrap">${_pnStatusActions(r)}</td>
|
||||
</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) {
|
||||
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 {
|
||||
document.getElementById('pn-detail').style.display='block';
|
||||
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');
|
||||
tb.innerHTML = (d.rows||[]).length
|
||||
? 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('')
|
||||
const racuni = d.racuni || [];
|
||||
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>`;
|
||||
} 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 =====
|
||||
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(){
|
||||
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 {
|
||||
const s = document.getElementById('py-status').value;
|
||||
const m = document.getElementById('py-method').value;
|
||||
const g = document.getElementById('py-godina').value;
|
||||
const p = new URLSearchParams();
|
||||
if(s) p.set('matched_status', s);
|
||||
if(m) p.set('payment_method', m);
|
||||
if(g) p.set('godina', g);
|
||||
const d = await api('/payments?'+p.toString());
|
||||
tbody.innerHTML = (d.rows||[]).length
|
||||
? d.rows.map(r=>`<tr>
|
||||
const p = _pyFilterParams();
|
||||
if(!p.has('godina')){
|
||||
// for summary tile we still want "this year" total
|
||||
}
|
||||
const d = await api('/payments?'+p.toString()+'&limit=500');
|
||||
const rows = d.rows || [];
|
||||
const yearNow = new Date().getFullYear();
|
||||
let totalYear = 0, cMatched = 0, cUnmatched = 0;
|
||||
for(const r of rows){
|
||||
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.payment_date||''}</td>
|
||||
<td>${r.klub_naziv||r.klub_id||''}</td>
|
||||
<td>${(r.klub_naziv||r.klub_id||'').toString().replace(/</g,'<')}</td>
|
||||
<td class="num"><b>${fmt(r.amount)}</b></td>
|
||||
<td>${r.currency||''}</td>
|
||||
<td>${r.payment_method||''}</td>
|
||||
<td>${r.iban_from||''}</td>
|
||||
<td>${r.iban_to||''}</td>
|
||||
<td>${r.reference||''}</td>
|
||||
<td>${r.invoice_id?('#'+r.invoice_id):'—'}</td>
|
||||
<td>${r.expense_report_id?('#'+r.expense_report_id):'—'}</td>
|
||||
<td style="font-family:var(--mono);font-size:10.5px">${(r.iban_from||'—')} → ${(r.iban_to||'—')}</td>
|
||||
<td>${(r.reference||'').replace(/</g,'<')}</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><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) {
|
||||
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 =====
|
||||
async function loadIzvjestaj(){
|
||||
const tip = document.getElementById('iz-tip').value;
|
||||
@@ -1187,6 +1596,80 @@ function exportPdf(report, godina){
|
||||
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
|
||||
const loaders = {
|
||||
dnevnik: loadDnevnik,
|
||||
@@ -1200,7 +1683,8 @@ const loaders = {
|
||||
place: () => { loadZap(); loadPlace(); },
|
||||
proracun: loadProracun,
|
||||
izvjestaji: loadIzvjestaj,
|
||||
kontni: loadKontniPlan
|
||||
kontni: loadKontniPlan,
|
||||
ocr: ocrHealth
|
||||
};
|
||||
|
||||
// Switch programmatically (used by deep links: ?tab=uploads / #tab=putni)
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user