Task 3: Plaćanja — POST/PATCH + CSV batch import + SEPA XML mock
- routers/erp_full_router.py: POST/PATCH/import-csv/sepa-export - static/erp_full.html: high-end UI s match workflow + SEPA export + summary tiles Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+203
-23
@@ -272,15 +272,26 @@ 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>
|
||||
</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>
|
||||
@@ -492,6 +503,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') });
|
||||
@@ -1099,39 +1134,184 @@ async function expenseDetail(id){
|
||||
}
|
||||
|
||||
// ===== 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;
|
||||
|
||||
Reference in New Issue
Block a user