diff --git a/public/js/modals/refund-modal.js b/public/js/modals/refund-modal.js new file mode 100644 index 0000000..fd58e49 --- /dev/null +++ b/public/js/modals/refund-modal.js @@ -0,0 +1,282 @@ +/** + * refund-modal.js — Record Vendor Refund + * + * Erzeugt einen QBO Deposit gegen die ursprüngliche Expense-Kategorie. + * Für den Fall: Geld kam tatsächlich zurück aufs Bank-/Kreditkartenkonto. + */ + +import '../utils/api.js'; + +let modalEl = null; +let onSavedCb = null; + +let vendors = []; +let expenseAccounts = []; +let paymentAccounts = []; + +let selectedVendorId = null; +let selectedVendorName = ''; +let isSaving = false; + +function todayISO() { return new Date().toISOString().split('T')[0]; } + +function escapeHtml(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); +} +function escapeAttr(s) { + return String(s || '') + .replace(/&/g, '&').replace(/"/g, '"') + .replace(//g, '>'); +} + +export async function openRefundModal({ onSaved } = {}) { + onSavedCb = onSaved || null; + selectedVendorId = null; + selectedVendorName = ''; + isSaving = false; + + try { + [vendors, expenseAccounts, paymentAccounts] = await Promise.all([ + window.API.accounting.getVendors('', 1000), + window.API.accounting.getExpenseAccounts(), + window.API.accounting.getPaymentAccounts() + ]); + for (const [name, data] of [['vendors', vendors], ['expenseAccounts', expenseAccounts], ['paymentAccounts', paymentAccounts]]) { + if (data && data.error) { + alert(`Failed to load ${name}: ${data.error}\n\nTry "Sync from QBO" first.`); + return; + } + } + } catch (err) { + alert('Failed to load refund modal data: ' + err.message); + return; + } + + renderModal(); +} + +function closeModal() { + if (modalEl) { modalEl.remove(); modalEl = null; } +} + +function renderModal() { + closeModal(); + + const html = ` + `; + + document.body.insertAdjacentHTML('beforeend', html); + modalEl = document.getElementById('refund-modal'); +} + +function onVendorInput() { + const inputEl = document.getElementById('ref-vendor-search'); + const dropdownEl = document.getElementById('ref-vendor-dropdown'); + const q = (inputEl.value || '').trim().toLowerCase(); + + if (selectedVendorName && inputEl.value !== selectedVendorName) { + selectedVendorId = null; + selectedVendorName = ''; + document.getElementById('ref-vendor-id').value = ''; + } + + const filtered = q + ? vendors.filter(v => v.displayName.toLowerCase().includes(q)).slice(0, 50) + : vendors.slice(0, 50); + + let html = ''; + if (filtered.length === 0) { + html = `
No vendor found.
`; + } else { + html = filtered.map(v => ` +
+
${escapeHtml(v.displayName)}
+ ${v.email ? `
${escapeHtml(v.email)}
` : ''} +
`).join(''); + } + + dropdownEl.innerHTML = html; + dropdownEl.classList.remove('hidden'); + + if (!dropdownEl._outsideHandlerInstalled) { + dropdownEl._outsideHandlerInstalled = true; + document.addEventListener('click', (e) => { + if (!dropdownEl.contains(e.target) && e.target !== inputEl) { + dropdownEl.classList.add('hidden'); + } + }); + } +} + +function selectVendor(id, name) { + selectedVendorId = id; + selectedVendorName = name; + document.getElementById('ref-vendor-search').value = name; + document.getElementById('ref-vendor-id').value = id; + document.getElementById('ref-vendor-dropdown').classList.add('hidden'); +} + +async function save() { + if (isSaving) return; + hideError(); + + if (!selectedVendorId) return showError('Please select a vendor.'); + + const depositAccountId = document.getElementById('ref-deposit-account').value; + if (!depositAccountId) return showError('Please select the deposit account.'); + + const categoryAccountId = document.getElementById('ref-category').value; + if (!categoryAccountId) return showError('Please select the original expense category.'); + + const txnDate = document.getElementById('ref-date').value; + if (!txnDate) return showError('Please enter a date.'); + + const amount = parseFloat(document.getElementById('ref-amount').value); + if (!isFinite(amount) || amount <= 0) { + return showError('Please enter a positive refund amount.'); + } + + const payload = { + vendorId: selectedVendorId, + depositAccountId, + categoryAccountId, + txnDate, + amount, + refNo: document.getElementById('ref-ref-no').value.trim() || null, + memo: document.getElementById('ref-memo').value.trim() || null + }; + + isSaving = true; + const btn = document.getElementById('ref-save-btn'); + btn.disabled = true; + btn.textContent = 'Saving…'; + + try { + const result = await window.API.accounting.createRefund(payload); + if (result.error) { + showError(result.error); + return; + } + if (typeof onSavedCb === 'function') { + try { onSavedCb(result); } catch (e) { console.warn('onSaved cb threw:', e); } + } + closeModal(); + } catch (err) { + showError(err.message || 'Save failed'); + } finally { + isSaving = false; + if (btn) { btn.disabled = false; btn.textContent = 'Record Refund'; } + } +} + +function showError(msg) { + const el = document.getElementById('ref-error'); + if (el) { el.textContent = msg; el.classList.remove('hidden'); } +} +function hideError() { + const el = document.getElementById('ref-error'); + if (el) el.classList.add('hidden'); +} + +window.refundModal = { + open: openRefundModal, + close: closeModal, + onVendorInput, + selectVendor, + save +}; \ No newline at end of file diff --git a/public/js/utils/api.js b/public/js/utils/api.js index 0eab879..311c479 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -169,6 +169,12 @@ const API = { body: JSON.stringify(data) }).then(r => r.json()), + createRefund: (data) => fetch('/api/accounting/refunds', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }).then(r => r.json()), + updateExpense: (id, data) => fetch(`/api/accounting/expenses/${id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index 2684745..db19678 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -8,6 +8,7 @@ import '../utils/api.js'; import { formatDate } from '../utils/helpers.js'; import { openExpenseModal } from '../modals/expense-modal.js'; +import { openRefundModal } from '../modals/refund-modal.js'; // ──────────────────────────────────────────────────────────────────── // State (modul-lokal) @@ -568,7 +569,11 @@ export function injectExpensesSection() { class="px-3 py-1.5 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700"> Load -
+
+