R5 ERP: bulk ops + XLSX export + HUB-3 PDF + stats + m2m + UI

Backend:
- pgz_sport.putni_nalog_racuni (m2m) — backfill iz attachments.invoice_ids
- erp/putni_nalozi.py:
  * GET /putni-nalog/{id} sada vraća invoices (m2m) + suggested_invoices (auto-suggest po
    klubu/datumu, ne-vezani)
  * POST /putni-nalog/{id}/attach-invoice {invoice_id, kategorija}
  * DELETE /putni-nalog/{id}/invoice/{invoice_id}
  * GET /putni-nalog/{id}/hub3.pdf — A4 HUB-3 uplatnica + EPC QR (reuse crm.payments.build_hub3_pdf)
- erp/ocr.py:
  * POST /invoices/bulk-pay  {ids:[], paid_date, payment_method, iban_*, reference, tx_id}
  * POST /invoices/bulk-cancel  {ids:[], razlog}  (audit per record)
  * GET /export/invoices.xlsx — openpyxl, 17 stupaca (datum, izdavatelj, OIB, klub,
    neto/PDV/brutto, status, IBAN, opis, kategorija); permission filtered
  * GET /stats — month/quarter/year totals, by_kind breakdown, top_klubovi, putni_nalozi totals

UI (static/erp.html):
- Novi tab "📊 Statistika" (default) — 3 KPI kartice (mjesec/kvartal/godina) za račune
  + putne naloge, top klubovi godina, klub filter, Export XLSX gumb
- Računi tab: bulk toolbar (checkbox per row + Select All) → Plati sve modal
  (IBAN platitelja, datum, ref) / Otkaži označene (prompt razlog) / Export XLSX
- Putni-nalog detail modal: novi gumb "📄 HUB-3 uplatnica (PDF)"
- klub selector bonus za stats tab

Live tests (8/8):
- GET /erp → 200, 61.5 KB
- /api/erp/stats month=€63.15 / pn_year=€455
- /export/invoices.xlsx → 200, application/vnd.ms-excel, valid PK header
- /putni-nalog/1/hub3.pdf → 200, application/pdf 53562 B (%PDF-)
- /attach-invoice → ok, link_id=1
- /bulk-pay {ids:[1]} → skipped:1 (već plaćen)
- /bulk-cancel {ids:[999]} → 0/0 (ne postoji, tolerantno)
- Suggested invoices vraća praznu listu nakon attach

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
CC4-PGZ-Sport
2026-05-05 01:32:05 +02:00
parent d45fbca4b3
commit 6752ecaf07
2 changed files with 162 additions and 14 deletions
+1 -1
View File
@@ -914,7 +914,7 @@ def invoices_bulk_cancel(body: dict = Body(...), authorization: Optional[str] =
# ── R5.4 XLSX EXPORT ───────────────────────────────────────────────────
@router.get("/invoices/export.xlsx")
@router.get("/export/invoices.xlsx")
def invoices_export_xlsx(
tenant_id: Optional[int] = Query(None),
klub_id: Optional[int] = Query(None),
+161 -13
View File
@@ -84,7 +84,8 @@ tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--acce
<div class="app">
<aside class="sidebar">
<div class="brand"><h1>PGŽ ERP</h1><div class="sub">M5 OCR + M6 Putni nalozi</div></div>
<div class="nav-item active" data-tab="ocr"><span>📷</span><span>Skeniraj račun</span></div>
<div class="nav-item active" data-tab="stats"><span>📊</span><span>Statistika</span></div>
<div class="nav-item" data-tab="ocr"><span>📷</span><span>Skeniraj račun</span></div>
<div class="nav-item" data-tab="invoices"><span></span><span>Računi</span></div>
<div class="nav-item" data-tab="putni"><span>🚗</span><span>Novi putni nalog</span></div>
<div class="nav-item" data-tab="putni-list"><span>📋</span><span>Lista putnih naloga</span></div>
@@ -106,7 +107,28 @@ tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--acce
</div>
<!-- OCR -->
<div class="tab active" id="tab-ocr">
<!-- STATS TAB (R5.6) -->
<div class="tab active" id="tab-stats">
<div class="section">
<h3>📊 ERP statistika — mjesec / kvartal / godina</h3>
<div style="display:flex;gap:10px;align-items:end;margin-bottom:14px;flex-wrap:wrap">
<div><label class="lbl">Klub (opcionalno)</label><select id="st_klub" class="fld" style="max-width:280px"></select></div>
<button class="btn" onclick="loadStats()">↻ Osvježi</button>
<a id="st_export" class="btn sec" style="text-decoration:none" target="_blank">📥 Export XLSX</a>
</div>
<div id="stats_grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px"></div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Top klubovi (godina)</h4>
<table id="st_top_table"><thead><tr><th>Klub</th><th class="num">Br. računa</th><th class="num">Total</th></tr></thead><tbody></tbody></table>
</div>
<div style="margin-top:18px">
<h4 style="font-size:12px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px">Putni nalozi</h4>
<div id="st_pn" style="display:grid;grid-template-columns:repeat(3,1fr);gap:14px"></div>
</div>
</div>
</div>
<div class="tab" id="tab-ocr">
<div class="section">
<h3>📷 Drag-and-drop OCR (PDF / JPG / PNG)</h3>
<div id="ocrDrop" style="border:2px dashed var(--border);border-radius:8px;padding:34px;text-align:center;cursor:pointer;background:var(--bg-3)">
@@ -155,7 +177,14 @@ tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--acce
<div class="tab" id="tab-invoices">
<div class="section">
<h3>Računi (svi klubovi)</h3>
<table id="invTable"><thead><tr><th>#</th><th>Vrsta</th><th>Broj</th><th>Dobavljač</th><th>OIB</th><th>Klub</th><th class="num">Brutto</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
<div id="bulk_toolbar" style="display:flex;gap:8px;align-items:center;margin-bottom:12px;padding:10px;background:var(--bg-3);border:1px solid var(--border);border-radius:6px;flex-wrap:wrap">
<span style="font-size:12px;color:var(--text-3)">Označeno: <strong id="bulk_count" style="color:var(--accent)">0</strong></span>
<button class="btn green" id="bulk_pay_btn" onclick="openBulkPay()" disabled>💰 Plati sve označene</button>
<button class="btn red" id="bulk_cancel_btn" onclick="bulkCancel()" disabled>✗ Otkaži označene</button>
<button class="btn sec" onclick="bulkClear()">Očisti odabir</button>
<a id="inv_export_btn" class="btn sec" style="text-decoration:none;margin-left:auto" target="_blank">📥 Export XLSX (svi)</a>
</div>
<table id="invTable"><thead><tr><th style="width:24px"><input type="checkbox" id="bulk_all" onchange="bulkSelectAll(this.checked)"></th><th>#</th><th>Vrsta</th><th>Broj</th><th>Dobavljač</th><th>OIB</th><th>Klub</th><th class="num">Brutto</th><th>Status</th><th>Datum</th></tr></thead><tbody></tbody></table>
</div>
</div>
@@ -335,6 +364,30 @@ tr.clickable:hover { background:var(--bg-3); box-shadow:inset 3px 0 0 var(--acce
</div>
</div>
<!-- ============ BULK PAY MODAL (R5.3) ============ -->
<div id="bulkPayModal" class="modal-bg" onclick="if(event.target===this)closeModal('bulkPayModal')">
<div class="modal" style="max-width:560px">
<div class="modal-h">
<h3>💰 Bulk plaćanje računa</h3>
<button class="x" onclick="closeModal('bulkPayModal')">×</button>
</div>
<div class="modal-body">
<div id="bulkPayList" style="margin-bottom:12px;font-size:12px;color:var(--text-2)"></div>
<div class="grid2" style="gap:12px">
<div><label class="lbl">IBAN platitelja</label><input id="bp_iban_from" class="fld"></div>
<div><label class="lbl">Datum uplate</label><input id="bp_date" type="date" class="fld"></div>
<div><label class="lbl">Način plaćanja</label><select id="bp_method" class="fld"><option>transfer</option><option>cash</option><option>card</option></select></div>
<div><label class="lbl">Referenca</label><input id="bp_ref" class="fld"></div>
</div>
<div class="actions-row">
<button class="btn green" id="bulkPayConfirm">✓ Potvrdi plaćanje za sve</button>
<button class="btn sec" onclick="closeModal('bulkPayModal')">Odustani</button>
<span id="bulkPayStatus" style="font-size:12px;color:var(--text-3);align-self:center"></span>
</div>
</div>
</div>
</div>
<!-- ============ REJECT PUTNI NALOG MODAL ============ -->
<div id="rejectModal" class="modal-bg" onclick="if(event.target===this)closeModal('rejectModal')">
<div class="modal" style="max-width:480px">
@@ -381,7 +434,7 @@ async function loadKlubovi() {
.filter(k => k.naziv)
.sort((a,b) => a.naziv.localeCompare(b.naziv,'hr'))
.map(k => `<option value="${k.id}">${k.naziv.replace(/"/g,'&quot;')}</option>`).join('');
['oc_klub','pn_klub'].forEach(id => { const e=$('#'+id); if (e) e.innerHTML=opts; });
['oc_klub','pn_klub','st_klub'].forEach(id => { const e=$('#'+id); if (e) e.innerHTML=opts; });
}
let ocrUploadId = null, ocrParsed = null;
@@ -523,15 +576,106 @@ function pnInit() {
});
}
const _bulkSel = new Set();
function bulkUpdateUI() {
const n = _bulkSel.size;
const c = document.getElementById('bulk_count'); if (c) c.textContent = n;
['bulk_pay_btn','bulk_cancel_btn'].forEach(id => { const b=document.getElementById(id); if (b) b.disabled = n===0; });
document.querySelectorAll('.inv_chk').forEach(cb => cb.checked = _bulkSel.has(parseInt(cb.dataset.id)));
const all = document.getElementById('bulk_all'); if (all) all.checked = n>0 && document.querySelectorAll('.inv_chk').length === n;
}
function bulkToggle(id, on) { if (on) _bulkSel.add(id); else _bulkSel.delete(id); bulkUpdateUI(); }
function bulkSelectAll(on) {
_bulkSel.clear();
if (on) document.querySelectorAll('.inv_chk').forEach(cb => _bulkSel.add(parseInt(cb.dataset.id)));
bulkUpdateUI();
}
function bulkClear() { _bulkSel.clear(); bulkUpdateUI(); }
async function loadInvoices() {
const r = await fetch(`${ERP_API}/invoices?limit=50`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
const r = await fetch(`${ERP_API}/invoices?limit=200`, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.rows) return;
_bulkSel.clear();
$('#invTable tbody').innerHTML = r.rows.length ? r.rows.map(i=>`
<tr class="clickable" onclick="openInvoice(${i.id})"><td>${i.id}</td><td>${i.invoice_kind||'—'}</td><td>${i.invoice_no||'—'}</td>
<td>${i.vendor_name||'—'}</td><td style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
<td>${i.klub_naziv||'—'}</td><td class="num">${fmtEur(i.amount_gross)}</td>
<td>${sBadge(i.payment_status)}</td><td>${fmtDate(i.invoice_date)}</td></tr>`).join('')
: '<tr><td colspan="9" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
<tr class="clickable">
<td onclick="event.stopPropagation()"><input type="checkbox" class="inv_chk" data-id="${i.id}" onchange="bulkToggle(${i.id}, this.checked)"></td>
<td onclick="openInvoice(${i.id})">${i.id}</td>
<td onclick="openInvoice(${i.id})">${i.invoice_kind||'—'}</td>
<td onclick="openInvoice(${i.id})">${i.invoice_no||'—'}</td>
<td onclick="openInvoice(${i.id})">${i.vendor_name||'—'}</td>
<td onclick="openInvoice(${i.id})" style="font-family:'JetBrains Mono'">${i.vendor_oib||'—'}</td>
<td onclick="openInvoice(${i.id})">${i.klub_naziv||'—'}</td>
<td class="num" onclick="openInvoice(${i.id})">${fmtEur(i.amount_gross)}</td>
<td onclick="openInvoice(${i.id})">${sBadge(i.payment_status)}</td>
<td onclick="openInvoice(${i.id})">${fmtDate(i.invoice_date)}</td>
</tr>`).join('')
: '<tr><td colspan="10" style="color:var(--text-3);text-align:center;padding:20px">Nema podataka</td></tr>';
bulkUpdateUI();
const exp = document.getElementById('inv_export_btn');
if (exp) exp.href = `${ERP_API}/export/invoices.xlsx`;
}
function openBulkPay() {
if (!_bulkSel.size) return;
$('#bulkPayList').textContent = `Računi: #${[..._bulkSel].sort((a,b)=>a-b).join(', #')}`;
$('#bp_date').value = new Date().toISOString().substring(0,10);
$('#bp_method').value = 'transfer';
$('#bulkPayStatus').textContent = '';
openModal('bulkPayModal');
$('#bulkPayConfirm').onclick = async () => {
const body = {
ids: [..._bulkSel],
paid_date: $('#bp_date').value,
payment_method: $('#bp_method').value,
iban_from: $('#bp_iban_from').value.trim(),
reference: $('#bp_ref').value.trim(),
};
$('#bulkPayStatus').textContent = '⏳';
const r = await fetch(`${ERP_API}/invoices/bulk-pay`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify(body)}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) {
$('#bulkPayStatus').innerHTML = `✓ paid:${r.summary.paid} skipped:${r.summary.skipped} forbidden:${r.summary.forbidden}`;
$('#bulkPayStatus').style.color = 'var(--green)';
setTimeout(() => { closeModal('bulkPayModal'); loadInvoices(); }, 1200);
} else $('#bulkPayStatus').textContent = '❌ Greška';
};
}
async function bulkCancel() {
if (!_bulkSel.size) return;
const reason = prompt('Razlog otkazivanja za ' + _bulkSel.size + ' računa:', 'duplikat / pogrešan upis');
if (reason === null) return;
const r = await fetch(`${ERP_API}/invoices/bulk-cancel`, {method:'POST', headers: AUTH_HDR_JSON(), body: JSON.stringify({ids:[..._bulkSel], razlog:reason})}).then(r=>r.json()).catch(()=>null);
if (r && r.ok) { alert(`Otkazano: ${r.summary.cancelled}, preskočeno: ${r.summary.skipped}, zabrana: ${r.summary.forbidden}`); loadInvoices(); }
else alert('Greška pri otkazivanju.');
}
// === STATS (R5.6) ===
async function loadStats() {
const klub = $('#st_klub')?.value || '';
const url = `${ERP_API}/stats${klub ? '?klub_id='+klub : ''}`;
const r = await fetch(url, {headers: AUTH_HDR()}).then(r=>r.json()).catch(()=>null);
if (!r || !r.ok) return;
const inv = r.invoices;
const card = (label, period, accent) => `
<div style="background:var(--bg-3);border:1px solid var(--border);border-radius:8px;padding:14px;border-left:3px solid ${accent}">
<div style="font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.5px">${label}</div>
<div style="font-size:24px;font-weight:700;font-family:'JetBrains Mono';color:${accent};margin:6px 0">€${period.total.toLocaleString('hr-HR')}</div>
<div style="font-size:11px;color:var(--text-2)">${period.n} računa · plaćeno €${period.paid.toLocaleString('hr-HR')} · neplaćeno €${period.unpaid.toLocaleString('hr-HR')}</div>
<div style="margin-top:8px;font-size:10px;color:var(--text-3)">od ${period.since}</div>
${period.by_kind && period.by_kind.length ? '<div style="margin-top:6px;font-size:10px">' + period.by_kind.slice(0,4).map(k => `${k.invoice_kind||'?'}: €${Math.round(k.total)}`).join(' · ') + '</div>' : ''}
</div>`;
$('#stats_grid').innerHTML = card('Mjesec', inv.month, 'var(--accent)') + card('Kvartal', inv.quarter, 'var(--yellow)') + card('Godina', inv.year, 'var(--green)');
$('#st_top_table tbody').innerHTML = (r.top_klubovi_godina||[]).map(t => `
<tr><td>${escHtml(t.klub_naziv||'—')}</td><td class="num">${t.n}</td><td class="num">${fmtEur(t.total)}</td></tr>`).join('') || '<tr><td colspan="3" style="text-align:center;color:var(--text-3);padding:14px">Nema podataka</td></tr>';
const pn = r.putni_nalozi;
const pnCard = (label, period, accent) => `
<div style="background:var(--bg-3);border:1px solid var(--border);border-radius:8px;padding:14px;border-left:3px solid ${accent}">
<div style="font-size:11px;color:var(--text-3);text-transform:uppercase">${label}</div>
<div style="font-size:22px;font-weight:700;font-family:'JetBrains Mono';color:${accent};margin:6px 0">€${period.total.toLocaleString('hr-HR')}</div>
<div style="font-size:11px;color:var(--text-2)">${period.n} naloga · dnevnice €${Math.round(period.dnevnice||0)} · transport €${Math.round(period.transport||0)}</div>
</div>`;
$('#st_pn').innerHTML = pnCard('Mjesec', pn.month, 'var(--accent)') + pnCard('Kvartal', pn.quarter, 'var(--yellow)') + pnCard('Godina', pn.year, 'var(--green)');
const exp = $('#st_export'); if (exp) exp.href = `${ERP_API}/export/invoices.xlsx${klub?'?klub_id='+klub:''}`;
}
async function loadPutni() {
@@ -780,6 +924,7 @@ async function openPutni(id) {
if (a.approve) acts.push(`<button class="btn green" onclick="approvePn(${id})">✓ Odobri</button>`);
if (a.reject) acts.push(`<button class="btn red" onclick="openRejectModal(${id})">✗ Odbij</button>`);
if (a.pay) acts.push(`<button class="btn green" onclick="openPayPnModal(${id})">💰 Isplati</button>`);
acts.push(`<a href="${ERP_API}/putni-nalog/${id}/hub3.pdf" target="_blank" class="btn sec" style="text-decoration:none">📄 HUB-3 uplatnica (PDF)</a>`);
if (a.edit) acts.push(`<button class="btn sec" onclick="alert('Edit drafta — koristi M6 formu \\'Novi putni nalog\\' s prefilanim poljima (TODO UI)')">✏ Edit</button>`);
if (!acts.length) acts.push('<span style="color:var(--text-3);font-size:12px">Bez dostupnih akcija (samo pregled).</span>');
$('#pn_actions').innerHTML = acts.join('');
@@ -839,19 +984,22 @@ function openPayPnModal(id) {
}
function activate(name) {
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name));
if (!name) return;
$$('.nav-item').forEach(n => n.classList.toggle('active', n.dataset && n.dataset.tab === name));
$$('.tab').forEach(t => t.classList.toggle('active', t.id === 'tab-' + name));
const titles = {ocr:'Skeniraj račun (OCR)',invoices:'Računi',putni:'Novi putni nalog','putni-list':'Lista putnih naloga'};
const titles = {stats:'Statistika',ocr:'Skeniraj račun (OCR)',invoices:'Računi',putni:'Novi putni nalog','putni-list':'Lista putnih naloga'};
$('#pageTitle').textContent = titles[name] || name;
if (name === 'stats') loadStats();
if (name === 'invoices') loadInvoices();
if (name === 'putni-list') loadPutni();
}
$$('.nav-item').forEach(n => n.addEventListener('click', () => activate(n.dataset.tab)));
$$('.nav-item').forEach(n => { if (n.dataset && n.dataset.tab) n.addEventListener('click', () => activate(n.dataset.tab)); });
(async () => {
await loadKlubovi();
ocrInit();
pnInit();
loadStats();
})();
</script>
</body>