Task 2: Putni nalozi — full CRUD + status workflow

- routers/erp_full_router.py: GET/POST/PATCH/DELETE /api/v2/erp/putni-nalozi
  - status workflow: draft → poslano → odobreno/odbijeno → isplaceno
  - cost_total auto-calc, approved_at/paid_at on transitions
  - alias under /expense-reports/* preserved
- static/erp_full.html: novi UI lista + modal + status buttons

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Damir Radulić
2026-05-05 18:28:53 +02:00
parent 8127e2ef22
commit efa15d0086
2 changed files with 443 additions and 22 deletions
+248 -7
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# Fajl: routers/erp_full_router.py | v1.1.0 | 05.05.2026 # Fajl: routers/erp_full_router.py | v1.2.0 | 05.05.2026
# Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one # Autor: Damir Radulić <dradulic@outlook.com> / damir@rinet.one
# Lokacija: /opt/pgz-sport/routers/erp_full_router.py # Lokacija: /opt/pgz-sport/routers/erp_full_router.py
# Svrha: FULL ERP (SAP-Lite) — kontni plan, dnevnik, glavna knjiga, # Svrha: FULL ERP (SAP-Lite) — kontni plan, dnevnik, glavna knjiga,
@@ -8,6 +8,10 @@
# PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export, # PDV, plaće, izvještaji (Bilanca/PnL/Cashflow), PDF/XLSX export,
# invoice_uploads (OCR), expense_reports (Putni nalozi), payments. # invoice_uploads (OCR), expense_reports (Putni nalozi), payments.
# v1.1.0 (2026-05-05): + POST /invoice-uploads multipart upload (Agent E). # v1.1.0 (2026-05-05): + POST /invoice-uploads multipart upload (Agent E).
# v1.2.0 (2026-05-05): + Full CRUD za Putni nalozi (/putni-nalozi + alias
# /expense-reports): GET/POST/PATCH/DELETE + status workflow
# (draft → poslano → odobreno/odbijeno → isplaceno) + auto cost_total
# + approved_at/paid_at na prijelazima (Agent 2).
# Mount: /api/v2/erp/* # Mount: /api/v2/erp/*
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
from __future__ import annotations from __future__ import annotations
@@ -1223,12 +1227,7 @@ async def invoice_uploads_create(
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
# 12) PUTNI NALOZI / EXPENSE REPORTS # 12) PUTNI NALOZI / EXPENSE REPORTS
# ═══════════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════════
@router.get("/expense-reports") def _expense_reports_list_impl(klub_id, status, report_type, godina, limit):
def expense_reports_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
where = ["1=1"] where = ["1=1"]
params: list = [] params: list = []
if klub_id: if klub_id:
@@ -1252,6 +1251,248 @@ def expense_reports_list(klub_id: Optional[int] = None,
return {"count": len(rows), "rows": rows} return {"count": len(rows), "rows": rows}
@router.get("/expense-reports")
def expense_reports_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
return _expense_reports_list_impl(klub_id, status, report_type, godina, limit)
@router.get("/putni-nalozi")
def putni_nalozi_list(klub_id: Optional[int] = None,
status: Optional[str] = None,
report_type: Optional[str] = None,
godina: Optional[int] = None,
limit: int = 200):
return _expense_reports_list_impl(klub_id, status, report_type, godina, limit)
# ── Putni nalog single + CRUD ──────────────────────────────────────
class PutniNalogIn(BaseModel):
klub_id: int
user_id: Optional[int] = None
clan_id: Optional[int] = None
report_type: str = "sluzbeno_putovanje"
report_no: Optional[str] = None
destination: str
purpose: str
date_from: date
date_to: date
vehicle_type: Optional[str] = None
vehicle_plate: Optional[str] = None
km_driven: float = 0
km_rate: float = 0.42
cost_transport: float = 0
cost_lodging: float = 0
cost_meals: float = 0
cost_other: float = 0
dnevnice_count: float = 0
dnevnice_amount: float = 30.00
notes: Optional[str] = None
class PutniNalogPatch(BaseModel):
klub_id: Optional[int] = None
user_id: Optional[int] = None
clan_id: Optional[int] = None
report_type: Optional[str] = None
report_no: Optional[str] = None
destination: Optional[str] = None
purpose: Optional[str] = None
date_from: Optional[date] = None
date_to: Optional[date] = None
vehicle_type: Optional[str] = None
vehicle_plate: Optional[str] = None
km_driven: Optional[float] = None
km_rate: Optional[float] = None
cost_transport: Optional[float] = None
cost_lodging: Optional[float] = None
cost_meals: Optional[float] = None
cost_other: Optional[float] = None
dnevnice_count: Optional[float] = None
dnevnice_amount: Optional[float] = None
notes: Optional[str] = None
status: Optional[str] = None
# Allowed status transitions
_PN_TRANSITIONS = {
"draft": {"poslano", "odbijeno"},
"poslano": {"odobreno", "odbijeno"},
"odobreno": {"isplaceno"},
"isplaceno": set(),
"odbijeno": set(),
}
def _pn_calc_total(km_driven, km_rate, c_tr, c_lo, c_me, c_ot, dn_c, dn_a):
return (
_f(km_driven) * _f(km_rate)
+ _f(c_tr) + _f(c_lo) + _f(c_me) + _f(c_ot)
+ _f(dn_c) * _f(dn_a)
)
def _pn_get_one(pid: int):
head = db_one(
"SELECT er.*, k.naziv AS klub_naziv "
"FROM pgz_sport.expense_reports er "
"LEFT JOIN pgz_sport.klubovi k ON k.id=er.klub_id "
"WHERE er.id=%s", (pid,))
if not head:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
racuni = db_query(
"SELECT pnr.id, pnr.invoice_id, pnr.kategorija, pnr.napomena, pnr.attached_at, "
"i.invoice_no, i.vendor_name, i.amount_gross, i.currency "
"FROM pgz_sport.putni_nalog_racuni pnr "
"LEFT JOIN pgz_sport.invoices i ON i.id=pnr.invoice_id "
"WHERE pnr.putni_nalog_id=%s ORDER BY pnr.id DESC", (pid,))
return {"head": head, "racuni": racuni}
def _pn_create(body: PutniNalogIn):
cost_total = _pn_calc_total(
body.km_driven, body.km_rate,
body.cost_transport, body.cost_lodging, body.cost_meals, body.cost_other,
body.dnevnice_count, body.dnevnice_amount)
rid = db_exec(
"INSERT INTO pgz_sport.expense_reports "
"(klub_id, user_id, clan_id, report_type, report_no, destination, purpose, "
" date_from, date_to, vehicle_type, vehicle_plate, km_driven, km_rate, "
" cost_transport, cost_lodging, cost_meals, cost_other, cost_total, "
" dnevnice_count, dnevnice_amount, status, notes, created_at, updated_at) "
"VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'draft',%s, now(), now()) "
"RETURNING id",
(body.klub_id, body.user_id, body.clan_id, body.report_type, body.report_no,
body.destination, body.purpose, body.date_from, body.date_to,
body.vehicle_type, body.vehicle_plate,
_f(body.km_driven), _f(body.km_rate),
_f(body.cost_transport), _f(body.cost_lodging),
_f(body.cost_meals), _f(body.cost_other), cost_total,
_f(body.dnevnice_count), _f(body.dnevnice_amount),
body.notes),
returning=True)
row = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (rid,))
return {"ok": True, "id": rid, "row": row}
def _pn_patch(pid: int, body: PutniNalogPatch):
current = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
if not current:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
data = body.dict(exclude_unset=True)
# Status workflow validation
new_status = data.get("status")
if new_status is not None and new_status != current["status"]:
allowed = _PN_TRANSITIONS.get(current["status"], set())
if new_status not in allowed:
raise HTTPException(
400,
f"Nedozvoljen prijelaz statusa: {current['status']}{new_status}. "
f"Dozvoljeni: {sorted(allowed) or '(nijedan)'}")
sets = []
params: list = []
cost_fields = {"km_driven", "km_rate", "cost_transport", "cost_lodging",
"cost_meals", "cost_other", "dnevnice_count", "dnevnice_amount"}
cost_changed = bool(cost_fields.intersection(data.keys()))
for col in ("klub_id", "user_id", "clan_id", "report_type", "report_no",
"destination", "purpose", "date_from", "date_to",
"vehicle_type", "vehicle_plate", "km_driven", "km_rate",
"cost_transport", "cost_lodging", "cost_meals", "cost_other",
"dnevnice_count", "dnevnice_amount", "notes", "status"):
if col in data:
sets.append(f"{col}=%s")
params.append(data[col])
if cost_changed:
# Recompute using merged values
merged = dict(current)
merged.update(data)
new_total = _pn_calc_total(
merged["km_driven"], merged["km_rate"],
merged["cost_transport"], merged["cost_lodging"],
merged["cost_meals"], merged["cost_other"],
merged["dnevnice_count"], merged["dnevnice_amount"])
sets.append("cost_total=%s")
params.append(new_total)
if new_status == "odobreno":
sets.append("approved_at=now()")
if new_status == "isplaceno":
sets.append("paid_at=now()")
sets.append("updated_at=now()")
if not sets:
return {"ok": True, "no_changes": True, "row": current}
params.append(pid)
db_exec(f"UPDATE pgz_sport.expense_reports SET {', '.join(sets)} WHERE id=%s",
tuple(params))
row = db_one("SELECT * FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
return {"ok": True, "id": pid, "row": row}
def _pn_delete(pid: int):
cur = db_one("SELECT status FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
if not cur:
raise HTTPException(404, f"Putni nalog #{pid} ne postoji")
if cur["status"] != "draft":
raise HTTPException(
400,
f"Brisanje dopušteno samo za status='draft' (trenutni: {cur['status']})")
db_exec("DELETE FROM pgz_sport.putni_nalog_racuni WHERE putni_nalog_id=%s", (pid,))
db_exec("DELETE FROM pgz_sport.expense_reports WHERE id=%s", (pid,))
return {"ok": True, "deleted": pid}
# ── Routes (both /putni-nalozi and /expense-reports prefixes) ─────
@router.get("/putni-nalozi/{pid}")
def putni_nalog_get(pid: int):
return _pn_get_one(pid)
@router.get("/expense-reports/{pid}")
def expense_report_get(pid: int):
return _pn_get_one(pid)
@router.post("/putni-nalozi")
def putni_nalog_create(body: PutniNalogIn):
return _pn_create(body)
@router.post("/expense-reports")
def expense_report_create(body: PutniNalogIn):
return _pn_create(body)
@router.patch("/putni-nalozi/{pid}")
def putni_nalog_patch(pid: int, body: PutniNalogPatch):
return _pn_patch(pid, body)
@router.patch("/expense-reports/{pid}")
def expense_report_patch(pid: int, body: PutniNalogPatch):
return _pn_patch(pid, body)
@router.delete("/putni-nalozi/{pid}")
def putni_nalog_delete(pid: int):
return _pn_delete(pid)
@router.delete("/expense-reports/{pid}")
def expense_report_delete(pid: int):
return _pn_delete(pid)
@router.get("/putni-nalog-racuni") @router.get("/putni-nalog-racuni")
def putni_nalog_racuni_list(putni_nalog_id: Optional[int] = None, def putni_nalog_racuni_list(putni_nalog_id: Optional[int] = None,
invoice_id: Optional[int] = None, invoice_id: Optional[int] = None,
+195 -15
View File
@@ -90,6 +90,11 @@ table tbody tr:hover{background:var(--bg3)}
.badge.knjizen{background:var(--green);color:var(--bg0)} .badge.knjizen{background:var(--green);color:var(--bg0)}
.badge.placen{background:var(--pgz-gold);color:var(--bg0)} .badge.placen{background:var(--pgz-gold);color:var(--bg0)}
.badge.otkazan{background:var(--red);color:#fff} .badge.otkazan{background:var(--red);color:#fff}
.badge.draft{background:var(--bg4);color:var(--t1)}
.badge.poslano{background:var(--cyan);color:var(--bg0)}
.badge.odobreno{background:var(--green);color:var(--bg0)}
.badge.odbijeno{background:var(--red);color:#fff}
.badge.isplaceno{background:var(--pgz-gold);color:var(--bg0)}
.dnev-line-row{display:grid;grid-template-columns:140px 1fr 100px 100px 1fr 30px;gap:6px;margin-bottom:6px;align-items:center} .dnev-line-row{display:grid;grid-template-columns:140px 1fr 100px 100px 1fr 30px;gap:6px;margin-bottom:6px;align-items:center}
.dnev-line-row input,.dnev-line-row select{background:var(--bg2);border:1px solid var(--rim);border-radius:4px;padding:5px 8px;color:var(--t1);font-size:12px;width:100%} .dnev-line-row input,.dnev-line-row select{background:var(--bg2);border:1px solid var(--rim);border-radius:4px;padding:5px 8px;color:var(--t1);font-size:12px;width:100%}
@@ -247,15 +252,19 @@ table tbody tr:hover{background:var(--bg3)}
<!-- ============ PUTNI NALOZI / EXPENSE REPORTS ============ --> <!-- ============ PUTNI NALOZI / EXPENSE REPORTS ============ -->
<section class="panel" id="panel-putni"> <section class="panel" id="panel-putni">
<div class="card"> <div class="card">
<div class="card-h"><div class="card-t">Putni nalozi i ostali troškovi (expense_reports)</div></div> <div class="card-h">
<div class="card-t">Putni nalozi (expense_reports)</div>
<button class="btn gold" onclick="openPutniModal()">+ Novi putni nalog</button>
</div>
<div class="toolbar"> <div class="toolbar">
<label>Tip <select id="pn-type"><option value="">— svi —</option><option value="putni_nalog">Putni nalog</option><option value="expense">Trošak</option></select></label>
<label>Status <select id="pn-status"><option value="">— svi —</option><option value="draft">draft</option><option value="podnesen">podnesen</option><option value="odobren">odobren</option><option value="isplacen">isplacen</option><option value="rejected">rejected</option></select></label>
<label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label> <label>Godina <input type="number" id="pn-godina" placeholder="2026" style="width:90px"></label>
<label>Status <select id="pn-status"><option value="">— svi —</option><option value="draft">draft</option><option value="poslano">poslano</option><option value="odobreno">odobreno</option><option value="odbijeno">odbijeno</option><option value="isplaceno">isplaceno</option></select></label>
<label>Klub ID <input type="number" id="pn-klub" placeholder="ID kluba" style="width:90px"></label>
<label>Tip <select id="pn-type"><option value="">— svi —</option><option value="sluzbeno_putovanje">Službeno putovanje</option><option value="putni_nalog">Putni nalog</option><option value="expense">Trošak</option></select></label>
<button class="btn" onclick="loadExpenseReports()">Osvježi</button> <button class="btn" onclick="loadExpenseReports()">Osvježi</button>
</div> </div>
<div class="tbl-wrap"> <div class="tbl-wrap">
<table id="pn-tbl"><thead><tr><th>#</th><th>Tip</th><th>Klub</th><th>Odredište</th><th>Svrha</th><th>Od</th><th>Do</th><th class="num">Km</th><th class="num">Trošak</th><th class="num">Dnevnice</th><th>Status</th></tr></thead><tbody><tr><td colspan="11" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table> <table id="pn-tbl"><thead><tr><th>#</th><th>Br</th><th>Klub</th><th>Destinacija</th><th>Od</th><th>Do</th><th class="num">Km</th><th class="num">Cost total</th><th>Status</th><th>Akcije</th></tr></thead><tbody><tr><td colspan="10" style="color:var(--t2);text-align:center;padding:18px">Klikni "Osvježi"…</td></tr></tbody></table>
</div> </div>
<div id="pn-detail" style="display:none;margin-top:14px;border-top:1px solid var(--bd);padding-top:12px"> <div id="pn-detail" style="display:none;margin-top:14px;border-top:1px solid var(--bd);padding-top:12px">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px"> <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
@@ -474,6 +483,39 @@ table tbody tr:hover{background:var(--bg3)}
</div> </div>
</div> </div>
<div class="modal-bg" id="m-pn" onclick="if(event.target===this)closeModal('m-pn')">
<div class="modal" style="width:min(820px,96vw)">
<h3 id="m-pn-title">Novi putni nalog</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div class="form-row"><label>Klub ID *</label><input type="number" id="pn-f-klub"></div>
<div class="form-row"><label>User ID</label><input type="number" id="pn-f-user"></div>
<div class="form-row"><label>Član ID</label><input type="number" id="pn-f-clan"></div>
<div class="form-row"><label>Tip</label><select id="pn-f-type"><option value="sluzbeno_putovanje">Službeno putovanje</option><option value="putni_nalog">Putni nalog</option><option value="expense">Trošak</option></select></div>
<div class="form-row"><label>Br. naloga</label><input id="pn-f-no" placeholder="auto / opc"></div>
<div class="form-row"><label>Destinacija *</label><input id="pn-f-dest"></div>
<div class="form-row" style="grid-column:1/3"><label>Svrha *</label><input id="pn-f-purpose"></div>
<div class="form-row"><label>Datum od *</label><input type="date" id="pn-f-from"></div>
<div class="form-row"><label>Datum do *</label><input type="date" id="pn-f-to"></div>
<div class="form-row"><label>Vozilo (tip)</label><input id="pn-f-vtype" placeholder="osobno / službeno"></div>
<div class="form-row"><label>Reg. oznaka</label><input id="pn-f-vplate"></div>
<div class="form-row"><label>Km voženo</label><input type="number" step="0.1" id="pn-f-km" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>€ / km</label><input type="number" step="0.01" id="pn-f-kmrate" value="0.42" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Trošak prijevoz</label><input type="number" step="0.01" id="pn-f-tr" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Trošak smještaj</label><input type="number" step="0.01" id="pn-f-lo" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Trošak hrana</label><input type="number" step="0.01" id="pn-f-me" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Ostali troškovi</label><input type="number" step="0.01" id="pn-f-ot" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Br. dnevnica</label><input type="number" step="0.5" id="pn-f-dnc" value="0" oninput="recalcPutniTotal()"></div>
<div class="form-row"><label>Iznos dnevnice</label><input type="number" step="0.01" id="pn-f-dna" value="30.00" oninput="recalcPutniTotal()"></div>
<div class="form-row" style="grid-column:1/3"><label>Napomena</label><textarea id="pn-f-notes" rows="2"></textarea></div>
</div>
<div class="dnev-balans ok" id="pn-f-total" style="margin-top:10px">Ukupno: 0,00 €</div>
<div class="form-actions">
<button class="btn sec" onclick="closeModal('m-pn')">Odustani</button>
<button class="btn gold" onclick="savePutni()">Spremi</button>
</div>
</div>
</div>
<div class="modal-bg" id="m-pl" onclick="if(event.target===this)closeModal('m-pl')"> <div class="modal-bg" id="m-pl" onclick="if(event.target===this)closeModal('m-pl')">
<div class="modal"> <div class="modal">
<h3>Obračun plaće (HR 2026)</h3> <h3>Obračun plaće (HR 2026)</h3>
@@ -1054,35 +1096,56 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
// ===== PUTNI NALOZI / EXPENSE REPORTS ===== // ===== PUTNI NALOZI / EXPENSE REPORTS =====
function _pnStatusActions(r){
const id = r.id;
const s = r.status || 'draft';
const acts = [];
if (s === 'draft') {
acts.push(`<button class="btn green" title="Pošalji na odobrenje" onclick="event.stopPropagation();putniSetStatus(${id},'poslano')">Pošalji</button>`);
acts.push(`<button class="btn red" title="Odbij" onclick="event.stopPropagation();putniSetStatus(${id},'odbijeno')">Odbij</button>`);
} else if (s === 'poslano') {
acts.push(`<button class="btn green" title="Odobri" onclick="event.stopPropagation();putniSetStatus(${id},'odobreno')">Odobri</button>`);
acts.push(`<button class="btn red" title="Odbij" onclick="event.stopPropagation();putniSetStatus(${id},'odbijeno')">Odbij</button>`);
} else if (s === 'odobreno') {
acts.push(`<button class="btn gold" title="Označi kao isplaćeno" onclick="event.stopPropagation();putniSetStatus(${id},'isplaceno')">Isplati</button>`);
}
acts.push(`<button class="btn sec" title="Uredi" onclick="event.stopPropagation();editPutni(${id})">✎</button>`);
if (s === 'draft') {
acts.push(`<button class="btn red" title="Obriši" onclick="event.stopPropagation();deletePutni(${id})">×</button>`);
}
return acts.join(' ');
}
async function loadExpenseReports(){ async function loadExpenseReports(){
const tbody = document.querySelector('#pn-tbl tbody'); const tbody = document.querySelector('#pn-tbl tbody');
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`; tbody.innerHTML = `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Učitavam…</td></tr>`;
try { try {
const t = document.getElementById('pn-type').value; const t = document.getElementById('pn-type').value;
const s = document.getElementById('pn-status').value; const s = document.getElementById('pn-status').value;
const g = document.getElementById('pn-godina').value; const g = document.getElementById('pn-godina').value;
const k = document.getElementById('pn-klub').value;
const p = new URLSearchParams(); const p = new URLSearchParams();
if(t) p.set('report_type', t); if(t) p.set('report_type', t);
if(s) p.set('status', s); if(s) p.set('status', s);
if(g) p.set('godina', g); if(g) p.set('godina', g);
const d = await api('/expense-reports?'+p.toString()); if(k) p.set('klub_id', k);
const d = await api('/putni-nalozi?'+p.toString());
tbody.innerHTML = (d.rows||[]).length tbody.innerHTML = (d.rows||[]).length
? d.rows.map(r=>`<tr onclick="expenseDetail(${r.id})" style="cursor:pointer"> ? d.rows.map(r=>`<tr onclick="expenseDetail(${r.id})" style="cursor:pointer">
<td>${r.id}</td> <td>${r.id}</td>
<td>${r.report_type||''}</td> <td>${r.report_no||''}</td>
<td>${r.klub_naziv||r.klub_id||''}</td> <td>${r.klub_naziv||r.klub_id||''}</td>
<td>${r.destination||''}</td> <td>${r.destination||''}</td>
<td>${r.purpose||''}</td>
<td>${r.date_from||''}</td> <td>${r.date_from||''}</td>
<td>${r.date_to||''}</td> <td>${r.date_to||''}</td>
<td class="num">${fmt(r.km_driven)}</td> <td class="num">${fmt(r.km_driven)}</td>
<td class="num">${fmt(r.cost_total)}</td> <td class="num"><b>${fmt(r.cost_total)}</b></td>
<td class="num">${fmt(r.dnevnice_amount)}</td>
<td><span class="badge ${r.status||''}">${r.status||''}</span></td> <td><span class="badge ${r.status||''}">${r.status||''}</span></td>
<td style="white-space:nowrap">${_pnStatusActions(r)}</td>
</tr>`).join('') </tr>`).join('')
: `<tr><td colspan="11" style="color:var(--t2);text-align:center;padding:14px">Nema putnih naloga.</td></tr>`; : `<tr><td colspan="10" style="color:var(--t2);text-align:center;padding:14px">Nema putnih naloga.</td></tr>`;
} catch(e) { } catch(e) {
tbody.innerHTML = `<tr><td colspan="11" style="color:var(--red)">Greška: ${e.message}</td></tr>`; tbody.innerHTML = `<tr><td colspan="10" style="color:var(--red)">Greška: ${e.message}</td></tr>`;
} }
} }
@@ -1090,14 +1153,131 @@ async function expenseDetail(id){
try { try {
document.getElementById('pn-detail').style.display='block'; document.getElementById('pn-detail').style.display='block';
document.getElementById('pn-detail-title').textContent = `Vezani računi za putni nalog #${id}`; document.getElementById('pn-detail-title').textContent = `Vezani računi za putni nalog #${id}`;
const d = await api('/putni-nalog-racuni?putni_nalog_id='+id); const d = await api('/putni-nalozi/'+id);
const tb = document.querySelector('#pn-rac-tbl tbody'); const tb = document.querySelector('#pn-rac-tbl tbody');
tb.innerHTML = (d.rows||[]).length const racuni = d.racuni || [];
? d.rows.map(r=>`<tr><td>${r.id}</td><td>${r.invoice_no||('#'+r.invoice_id)}</td><td>${r.vendor_name||''}</td><td class="num">${fmt(r.amount_gross)}</td><td>${r.currency||''}</td><td>${r.kategorija||''}</td><td>${(r.attached_at||'').slice(0,10)}</td></tr>`).join('') tb.innerHTML = racuni.length
? racuni.map(r=>`<tr><td>${r.id}</td><td>${r.invoice_no||('#'+r.invoice_id)}</td><td>${r.vendor_name||''}</td><td class="num">${fmt(r.amount_gross)}</td><td>${r.currency||''}</td><td>${r.kategorija||''}</td><td>${(r.attached_at||'').slice(0,10)}</td></tr>`).join('')
: `<tr><td colspan="7" style="color:var(--t2);text-align:center;padding:10px">Nema vezanih računa.</td></tr>`; : `<tr><td colspan="7" style="color:var(--t2);text-align:center;padding:10px">Nema vezanih računa.</td></tr>`;
} catch(e) { alert('Greška: '+e.message); } } catch(e) { alert('Greška: '+e.message); }
} }
function _pnSetForm(r){
const set = (id,v) => { const el = document.getElementById(id); if(el) el.value = (v==null?'':v); };
set('pn-f-klub', r.klub_id);
set('pn-f-user', r.user_id);
set('pn-f-clan', r.clan_id);
set('pn-f-type', r.report_type || 'sluzbeno_putovanje');
set('pn-f-no', r.report_no);
set('pn-f-dest', r.destination);
set('pn-f-purpose', r.purpose);
set('pn-f-from', (r.date_from||'').toString().slice(0,10));
set('pn-f-to', (r.date_to||'').toString().slice(0,10));
set('pn-f-vtype', r.vehicle_type);
set('pn-f-vplate', r.vehicle_plate);
set('pn-f-km', r.km_driven||0);
set('pn-f-kmrate', r.km_rate||0.42);
set('pn-f-tr', r.cost_transport||0);
set('pn-f-lo', r.cost_lodging||0);
set('pn-f-me', r.cost_meals||0);
set('pn-f-ot', r.cost_other||0);
set('pn-f-dnc', r.dnevnice_count||0);
set('pn-f-dna', r.dnevnice_amount||30.00);
set('pn-f-notes', r.notes);
recalcPutniTotal();
}
function recalcPutniTotal(){
const v = id => parseFloat(document.getElementById(id).value)||0;
const total = v('pn-f-km')*v('pn-f-kmrate') + v('pn-f-tr') + v('pn-f-lo')
+ v('pn-f-me') + v('pn-f-ot') + v('pn-f-dnc')*v('pn-f-dna');
document.getElementById('pn-f-total').textContent = `Ukupno: ${fmt(total)}`;
}
function openPutniModal(){
document.getElementById('m-pn-title').textContent = 'Novi putni nalog';
document.getElementById('m-pn').dataset.id = '';
_pnSetForm({
report_type: 'sluzbeno_putovanje',
date_from: new Date().toISOString().slice(0,10),
date_to: new Date().toISOString().slice(0,10),
km_driven: 0, km_rate: 0.42,
cost_transport: 0, cost_lodging: 0, cost_meals: 0, cost_other: 0,
dnevnice_count: 0, dnevnice_amount: 30.00,
});
openModal('m-pn');
}
async function editPutni(id){
try {
const d = await api('/putni-nalozi/'+id);
document.getElementById('m-pn-title').textContent = `Putni nalog #${id} (${d.head.status})`;
document.getElementById('m-pn').dataset.id = id;
_pnSetForm(d.head);
openModal('m-pn');
} catch(e){ alert('Greška: '+e.message); }
}
async function savePutni(){
const v = id => document.getElementById(id).value;
const num = id => { const x = parseFloat(v(id)); return isNaN(x)?0:x; };
const intOrNull = id => { const x = parseInt(v(id)); return isNaN(x)?null:x; };
const strOrNull = id => { const s = v(id).trim(); return s?s:null; };
const body = {
klub_id: intOrNull('pn-f-klub'),
user_id: intOrNull('pn-f-user'),
clan_id: intOrNull('pn-f-clan'),
report_type: v('pn-f-type') || 'sluzbeno_putovanje',
report_no: strOrNull('pn-f-no'),
destination: v('pn-f-dest').trim(),
purpose: v('pn-f-purpose').trim(),
date_from: v('pn-f-from'),
date_to: v('pn-f-to'),
vehicle_type: strOrNull('pn-f-vtype'),
vehicle_plate: strOrNull('pn-f-vplate'),
km_driven: num('pn-f-km'),
km_rate: num('pn-f-kmrate'),
cost_transport: num('pn-f-tr'),
cost_lodging: num('pn-f-lo'),
cost_meals: num('pn-f-me'),
cost_other: num('pn-f-ot'),
dnevnice_count: num('pn-f-dnc'),
dnevnice_amount: num('pn-f-dna'),
notes: strOrNull('pn-f-notes'),
};
if (!body.klub_id) { alert('Klub ID je obavezan.'); return; }
if (!body.destination) { alert('Destinacija je obavezna.'); return; }
if (!body.purpose) { alert('Svrha je obavezna.'); return; }
if (!body.date_from || !body.date_to) { alert('Datumi su obavezni.'); return; }
const id = document.getElementById('m-pn').dataset.id;
try {
if (id) {
await api('/putni-nalozi/'+id, { method:'PATCH', body: JSON.stringify(body) });
} else {
await api('/putni-nalozi', { method:'POST', body: JSON.stringify(body) });
}
closeModal('m-pn');
loadExpenseReports();
} catch(e){ alert('Greška: '+e.message); }
}
async function putniSetStatus(id, newStatus){
const labels = {poslano:'pošaljete na odobrenje', odobreno:'odobrite', odbijeno:'odbijete', isplaceno:'označite kao isplaćeno'};
if(!confirm(`Sigurno ${labels[newStatus]||'promijenite status'} za putni nalog #${id}?`)) return;
try {
await api('/putni-nalozi/'+id, { method:'PATCH', body: JSON.stringify({status:newStatus}) });
loadExpenseReports();
} catch(e){ alert('Greška: '+e.message); }
}
async function deletePutni(id){
if(!confirm(`Obriši putni nalog #${id}? (samo draft)`)) return;
try {
await api('/putni-nalozi/'+id, { method:'DELETE' });
loadExpenseReports();
} catch(e){ alert('Greška: '+e.message); }
}
// ===== PAYMENTS ===== // ===== PAYMENTS =====
async function loadPayments(){ async function loadPayments(){
const tbody = document.querySelector('#py-tbl tbody'); const tbody = document.querySelector('#py-tbl tbody');