Files
invoice-system/public/js/modals/expense-modal.js
2026-05-07 11:04:24 -05:00

778 lines
35 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* expense-modal.js — New Expense Modal
*
* Dependencies: window.API.accounting.* (api.js)
*
* Two main modes:
* - Create: openExpenseModal({ onSaved }) — leeres Formular
*
* The modal includes a sub-modal for "Add new vendor" inline.
*
* Action buttons: "Save & Close" and "Save & New".
*/
import '../utils/api.js';
// ────────────────────────────────────────────────────────────────────
// State (modul-lokal)
// ────────────────────────────────────────────────────────────────────
let modalEl = null;
let onSavedCb = null;
let vendors = []; // alle Vendors aus Cache
let expenseAccounts = []; // alle Expense-Categories aus Cache
let paymentAccounts = []; // alle Bank/CreditCard-Accounts
let paymentMethods = []; // alle Payment-Methods (live)
let selectedVendorId = null;
let selectedVendorName = '';
let lineCount = 0;
let isSaving = false;
let selectedFile = null; // File object aus <input type="file">
let attachmentLimits = null; // { maxBytes, maxMb, allowedMimeTypes, allowedExtensions }
// ────────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────────
function todayISO() { return new Date().toISOString().split('T')[0]; }
function escapeHtml(s) {
if (s == null) return '';
return String(s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function escapeAttr(s) {
return String(s || '')
.replace(/&/g, '&amp;').replace(/"/g, '&quot;')
.replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function fmtMoney(n) {
if (n == null || isNaN(n)) return '$0.00';
return n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
}
function onFileSelected(e) {
const f = e.target.files && e.target.files[0];
if (!f) return;
handleFile(f);
}
function onFileDrop(e) {
e.preventDefault();
const zone = document.getElementById('exp-attach-zone');
if (zone) zone.classList.remove('border-blue-500', 'bg-blue-50');
const f = e.dataTransfer.files && e.dataTransfer.files[0];
if (!f) return;
handleFile(f);
}
function handleFile(file) {
// Validate type
const allowed = attachmentLimits?.allowedMimeTypes ||
['image/png','image/jpeg','image/jpg','image/heic','image/heif','application/pdf'];
if (!allowed.includes(file.type)) {
alert(`Unsupported file type: ${file.type || 'unknown'}\nAllowed: PNG, JPG, HEIC, PDF`);
return;
}
const maxBytes = attachmentLimits?.maxBytes || (5 * 1024 * 1024);
if (file.size > maxBytes) {
const maxMb = attachmentLimits?.maxMb || 5;
alert(`File too large: ${(file.size/1024/1024).toFixed(2)} MB\nMax: ${maxMb} MB`);
return;
}
selectedFile = file;
renderFilePreview();
}
function clearFile() {
selectedFile = null;
const input = document.getElementById('exp-attach-input');
if (input) input.value = '';
renderFilePreview();
}
function renderFilePreview() {
const el = document.getElementById('exp-attach-preview');
if (!el) return;
if (!selectedFile) {
el.classList.add('hidden');
el.innerHTML = '';
return;
}
const sizeMb = (selectedFile.size / 1024 / 1024).toFixed(2);
const isImage = selectedFile.type.startsWith('image/') && selectedFile.type !== 'image/heic' && selectedFile.type !== 'image/heif';
let thumbHtml = '';
if (isImage) {
const url = URL.createObjectURL(selectedFile);
thumbHtml = `<img src="${url}" class="h-16 w-16 object-cover rounded border border-gray-200" onload="URL.revokeObjectURL(this.src)">`;
} else if (selectedFile.type === 'application/pdf') {
thumbHtml = `<div class="h-16 w-16 bg-red-50 border border-red-200 rounded flex items-center justify-center text-red-600 font-bold text-xs">PDF</div>`;
} else {
thumbHtml = `<div class="h-16 w-16 bg-gray-100 border border-gray-200 rounded flex items-center justify-center text-gray-500 text-xs">${selectedFile.type.split('/')[1] || 'file'}</div>`;
}
el.classList.remove('hidden');
el.innerHTML = `
<div class="flex items-center gap-3 p-2 bg-gray-50 border border-gray-200 rounded">
${thumbHtml}
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">${escapeHtml(selectedFile.name)}</p>
<p class="text-xs text-gray-500">${sizeMb} MB · ${selectedFile.type || 'unknown'}</p>
</div>
<button type="button" onclick="window.expenseModal.clearFile()"
class="text-red-500 hover:text-red-700 text-sm px-2">Remove</button>
</div>`;
}
// ────────────────────────────────────────────────────────────────────
// Public Entry
// ────────────────────────────────────────────────────────────────────
export async function openExpenseModal({ onSaved } = {}) {
onSavedCb = onSaved || null;
selectedVendorId = null;
selectedVendorName = '';
lineCount = 0;
isSaving = false;
// Lade Stammdaten parallel (alles aus dem Cache → schnell)
try {
[vendors, expenseAccounts, paymentAccounts, paymentMethods, attachmentLimits] = await Promise.all([
window.API.accounting.getVendors('', 1000),
window.API.accounting.getExpenseAccounts(),
window.API.accounting.getPaymentAccounts(),
window.API.accounting.getPaymentMethods(),
window.API.accounting.getAttachmentLimits()
]);
// Errors aus dem Backend abfangen
for (const [name, data] of [['vendors', vendors], ['expenseAccounts', expenseAccounts], ['paymentAccounts', paymentAccounts], ['paymentMethods', paymentMethods]]) {
if (data && data.error) {
alert(`Failed to load ${name}: ${data.error}\n\nTry "Sync from QBO" first.`);
return;
}
}
} catch (err) {
alert('Failed to load expense modal data: ' + err.message);
return;
}
renderModal();
}
function closeModal() {
if (modalEl) {
modalEl.remove();
modalEl = null;
}
}
function resetForm() {
selectedVendorId = null;
selectedVendorName = '';
document.getElementById('exp-vendor-search').value = '';
document.getElementById('exp-vendor-id').value = '';
document.getElementById('exp-ref-no').value = '';
document.getElementById('exp-memo').value = '';
document.getElementById('exp-date').value = todayISO();
clearFile();
document.getElementById('exp-lines-tbody').innerHTML = '';
lineCount = 0;
addLine();
updateTotal();
}
// ────────────────────────────────────────────────────────────────────
// Render
// ────────────────────────────────────────────────────────────────────
function renderModal() {
closeModal(); // safety
const html = `
<div id="expense-modal" class="modal fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full flex items-start justify-center z-50">
<div class="relative mx-auto p-6 border w-full max-w-5xl shadow-lg rounded-lg bg-white my-8">
<div class="flex justify-between items-center mb-5">
<h3 class="text-2xl font-bold text-gray-900">📝 New Expense</h3>
<button onclick="window.expenseModal.close()" class="text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- Header Row -->
<div class="grid grid-cols-3 gap-4 mb-4">
<div class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Payee (Vendor) *</label>
<input type="text" id="exp-vendor-search" autocomplete="off"
oninput="window.expenseModal.onVendorInput()"
onfocus="window.expenseModal.onVendorInput()"
placeholder="Type to search…"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
<input type="hidden" id="exp-vendor-id">
<div id="exp-vendor-dropdown"
class="hidden absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-72 overflow-auto"></div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Account *</label>
<select id="exp-payment-account" class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">— Select —</option>
${paymentAccounts.map(a => `
<option value="${a.id}" data-type="${escapeAttr(a.accountType)}">
${escapeHtml(a.name)} (${escapeHtml(a.accountType)})
</option>`).join('')}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Date *</label>
<input type="date" id="exp-date" value="${todayISO()}"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
</div>
<div class="grid grid-cols-3 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Payment Method</label>
<select id="exp-payment-method" class="w-full px-3 py-2 border border-gray-300 rounded-md">
<option value="">— None —</option>
${paymentMethods.map(m => `<option value="${m.id}">${escapeHtml(m.name)}</option>`).join('')}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Ref No.</label>
<input type="text" id="exp-ref-no" maxlength="21"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="e.g. invoice number, check no.">
</div>
<div></div>
</div>
<!-- Line Items -->
<div class="mb-4 border border-gray-200 rounded-lg overflow-hidden">
<div class="bg-gray-50 px-3 py-2 border-b text-sm font-medium text-gray-700 flex items-center justify-between">
<span>Category Lines</span>
<div class="flex items-center gap-2">
<button type="button" onclick="window.expenseModal.addLine()"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white rounded text-xs font-medium">
+ Add Line
</button>
<button type="button" onclick="window.expenseModal.clearLines()"
class="px-3 py-1 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded text-xs">
Clear
</button>
</div>
</div>
<table class="min-w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-1.5 text-left font-medium text-gray-700">#</th>
<th class="px-3 py-1.5 text-left font-medium text-gray-700">Category *</th>
<th class="px-3 py-1.5 text-left font-medium text-gray-700">Description</th>
<th class="px-3 py-1.5 text-right font-medium text-gray-700">Amount *</th>
<th class="px-3 py-1.5"></th>
</tr>
</thead>
<tbody id="exp-lines-tbody"></tbody>
<tfoot class="bg-gray-50 border-t">
<tr>
<td colspan="3" class="px-3 py-2 text-right font-medium text-gray-700">Total:</td>
<td class="px-3 py-2 text-right font-bold text-gray-900" id="exp-total">$0.00</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<!-- Attachment -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">
Attachment <span class="text-gray-400 text-xs font-normal">(optional — receipt screenshot or PDF)</span>
</label>
<div id="exp-attach-zone"
class="border-2 border-dashed border-gray-300 rounded-md p-4 text-center cursor-pointer hover:border-blue-400 hover:bg-blue-50 transition"
onclick="document.getElementById('exp-attach-input').click()"
ondragover="event.preventDefault(); this.classList.add('border-blue-500','bg-blue-50');"
ondragleave="this.classList.remove('border-blue-500','bg-blue-50');"
ondrop="window.expenseModal.onFileDrop(event)">
<p class="text-sm text-gray-500">
📎 Drop file here or click to select
</p>
<p class="text-xs text-gray-400 mt-1">
${attachmentLimits ? `PNG, JPG, HEIC, PDF · max ${attachmentLimits.maxMb} MB` : 'Loading limits…'}
</p>
</div>
<input type="file" id="exp-attach-input" class="hidden"
accept=".png,.jpg,.jpeg,.heic,.heif,.pdf,image/png,image/jpeg,image/heic,application/pdf"
onchange="window.expenseModal.onFileSelected(event)">
<div id="exp-attach-preview" class="hidden mt-2"></div>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Memo</label>
<textarea id="exp-memo" rows="2"
class="w-full px-3 py-2 border border-gray-300 rounded-md"
placeholder="Internal note (visible in QBO)"></textarea>
</div>
<div id="exp-error" class="hidden mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700"></div>
<div class="flex justify-end gap-3">
<button type="button" onclick="window.expenseModal.close()"
class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">
Cancel
</button>
<button type="button" id="exp-save-new-btn" onclick="window.expenseModal.save('new')"
class="px-5 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md font-medium">
Save &amp; New
</button>
<button type="button" id="exp-save-close-btn" onclick="window.expenseModal.save('close')"
class="px-5 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md font-medium">
Save &amp; Close
</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
modalEl = document.getElementById('expense-modal');
// Initial: eine leere Linie
addLine();
updateTotal();
}
// ────────────────────────────────────────────────────────────────────
// Vendor Search
// ────────────────────────────────────────────────────────────────────
function onVendorInput() {
const inputEl = document.getElementById('exp-vendor-search');
const dropdownEl = document.getElementById('exp-vendor-dropdown');
const q = (inputEl.value || '').trim().toLowerCase();
// Wenn Suche != aktuell ausgewählter Vendor → Selection invalidieren
if (selectedVendorName && inputEl.value !== selectedVendorName) {
selectedVendorId = null;
selectedVendorName = '';
document.getElementById('exp-vendor-id').value = '';
}
// Filter Vendors clientseitig (wir haben sie alle im Cache)
const filtered = q
? vendors.filter(v => v.displayName.toLowerCase().includes(q)).slice(0, 50)
: vendors.slice(0, 50);
let html = '';
if (q && !filtered.some(v => v.displayName.toLowerCase() === q)) {
// "Add new" Option oben
html += `
<div class="px-3 py-2 cursor-pointer bg-blue-50 hover:bg-blue-100 border-b border-blue-200 text-blue-700 font-medium"
onclick="window.expenseModal.openAddVendor('${escapeAttr(inputEl.value)}')">
+ Add new vendor: <strong>${escapeHtml(inputEl.value)}</strong>
</div>`;
}
if (filtered.length === 0 && !q) {
html += `<div class="px-3 py-2 text-gray-500 text-sm">Type to search…</div>`;
} else {
html += filtered.map(v => `
<div class="px-3 py-2 cursor-pointer hover:bg-gray-100 text-sm"
onclick="window.expenseModal.selectVendor('${v.id}', '${escapeAttr(v.displayName)}')">
<div class="font-medium text-gray-900">${escapeHtml(v.displayName)}</div>
${v.email ? `<div class="text-xs text-gray-500">${escapeHtml(v.email)}</div>` : ''}
</div>`).join('');
}
dropdownEl.innerHTML = html;
dropdownEl.classList.remove('hidden');
// Auto-close on outside click
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('exp-vendor-search').value = name;
document.getElementById('exp-vendor-id').value = id;
document.getElementById('exp-vendor-dropdown').classList.add('hidden');
}
// ────────────────────────────────────────────────────────────────────
// Add New Vendor (sub-Modal)
// ────────────────────────────────────────────────────────────────────
function openAddVendor(prefilledName = '') {
document.getElementById('exp-vendor-dropdown').classList.add('hidden');
const html = `
<div id="vendor-add-modal" class="fixed inset-0 bg-gray-700 bg-opacity-60 z-[60] flex items-start justify-center overflow-y-auto">
<div class="relative mx-auto p-6 border w-full max-w-2xl shadow-lg rounded-lg bg-white my-8">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-gray-900">+ New Vendor</h3>
<button onclick="window.expenseModal.closeAddVendor()" class="text-gray-400 hover:text-gray-600">
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<div class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Name *</label>
<input type="text" id="ven-name" value="${escapeAttr(prefilledName)}"
class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input type="email" id="ven-email" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Phone</label>
<input type="tel" id="ven-phone" class="w-full px-3 py-2 border border-gray-300 rounded-md">
</div>
</div>
<div class="border-t pt-3 mt-3">
<p class="text-xs font-medium text-gray-500 uppercase mb-2">Address (optional)</p>
<div class="space-y-2">
<input type="text" id="ven-line1" placeholder="Street address 1" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<input type="text" id="ven-line2" placeholder="Street address 2" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<div class="grid grid-cols-3 gap-2">
<input type="text" id="ven-city" placeholder="City" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<input type="text" id="ven-state" placeholder="State" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<input type="text" id="ven-zip" placeholder="ZIP" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
<input type="text" id="ven-country" placeholder="Country (optional)" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea id="ven-notes" rows="2" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"></textarea>
</div>
<div id="ven-error" class="hidden p-2 bg-red-50 border border-red-200 rounded text-sm text-red-700"></div>
</div>
<div class="flex justify-end gap-3 mt-5">
<button onclick="window.expenseModal.closeAddVendor()"
class="px-5 py-2 bg-gray-200 hover:bg-gray-300 text-gray-700 rounded-md">Cancel</button>
<button id="ven-save-btn" onclick="window.expenseModal.saveAddVendor()"
class="px-5 py-2 bg-green-600 hover:bg-green-700 text-white rounded-md font-medium">
Save Vendor
</button>
</div>
</div>
</div>`;
document.body.insertAdjacentHTML('beforeend', html);
setTimeout(() => document.getElementById('ven-name').focus(), 50);
}
function closeAddVendor() {
const m = document.getElementById('vendor-add-modal');
if (m) m.remove();
}
async function saveAddVendor() {
const name = document.getElementById('ven-name').value.trim();
if (!name) {
showVenError('Name is required');
document.getElementById('ven-name').focus();
return;
}
const data = {
name,
email: document.getElementById('ven-email').value.trim() || null,
phone: document.getElementById('ven-phone').value.trim() || null,
notes: document.getElementById('ven-notes').value.trim() || null,
address: {
line1: document.getElementById('ven-line1').value.trim() || null,
line2: document.getElementById('ven-line2').value.trim() || null,
city: document.getElementById('ven-city').value.trim() || null,
state: document.getElementById('ven-state').value.trim() || null,
zip: document.getElementById('ven-zip').value.trim() || null,
country: document.getElementById('ven-country').value.trim() || null
}
};
const btn = document.getElementById('ven-save-btn');
btn.disabled = true;
btn.textContent = 'Saving…';
try {
const result = await window.API.accounting.createVendor(data);
if (result.error) {
showVenError(result.error);
btn.disabled = false;
btn.textContent = 'Save Vendor';
return;
}
// Vendor in lokale Liste aufnehmen, sofort selektieren
if (!result.existed) {
vendors.push({
id: result.id,
displayName: result.displayName,
companyName: result.displayName,
email: result.email,
phone: result.phone,
active: true
});
// Liste alphabetisch halten
vendors.sort((a, b) => a.displayName.localeCompare(b.displayName));
}
selectVendor(result.id, result.displayName);
closeAddVendor();
} catch (err) {
showVenError(err.message || 'Failed to save vendor');
btn.disabled = false;
btn.textContent = 'Save Vendor';
}
}
function showVenError(msg) {
const el = document.getElementById('ven-error');
el.textContent = msg;
el.classList.remove('hidden');
}
// ────────────────────────────────────────────────────────────────────
// Lines
// ────────────────────────────────────────────────────────────────────
function addLine() {
lineCount++;
const tbody = document.getElementById('exp-lines-tbody');
const tr = document.createElement('tr');
tr.className = 'border-t';
tr.dataset.lineIndex = lineCount;
tr.innerHTML = `
<td class="px-3 py-1.5 text-gray-500 text-xs">${lineCount}</td>
<td class="px-3 py-1.5">
<select class="exp-line-account w-full px-2 py-1 border border-gray-300 rounded-md text-sm">
<option value="">— Select category —</option>
${expenseAccounts.map(a => `
<option value="${a.id}" title="${escapeAttr(a.fullyQualifiedName || a.name)}">
${escapeHtml(a.fullyQualifiedName || a.name)}
</option>`).join('')}
</select>
</td>
<td class="px-3 py-1.5">
<input type="text" class="exp-line-desc w-full px-2 py-1 border border-gray-300 rounded-md text-sm" placeholder="(optional)">
</td>
<td class="px-3 py-1.5">
<input type="number" step="0.01" min="0" class="exp-line-amount w-32 px-2 py-1 border border-gray-300 rounded-md text-sm text-right"
oninput="window.expenseModal.updateTotal()">
</td>
<td class="px-3 py-1.5 text-center">
<button type="button" onclick="window.expenseModal.removeLine(this)"
class="text-red-500 hover:text-red-700 text-lg leading-none">×</button>
</td>`;
tbody.appendChild(tr);
}
function removeLine(btn) {
const tr = btn.closest('tr');
if (tr) {
tr.remove();
// Re-number visible lines
const rows = document.querySelectorAll('#exp-lines-tbody tr');
rows.forEach((r, i) => { r.firstElementChild.textContent = i + 1; });
updateTotal();
}
}
function clearLines() {
document.getElementById('exp-lines-tbody').innerHTML = '';
lineCount = 0;
addLine();
updateTotal();
}
function getLines() {
const rows = document.querySelectorAll('#exp-lines-tbody tr');
return Array.from(rows).map(r => ({
accountId: r.querySelector('.exp-line-account').value,
description: r.querySelector('.exp-line-desc').value.trim() || null,
amount: parseFloat(r.querySelector('.exp-line-amount').value)
}));
}
function updateTotal() {
const total = getLines()
.filter(l => isFinite(l.amount))
.reduce((sum, l) => sum + l.amount, 0);
document.getElementById('exp-total').textContent = fmtMoney(total);
}
// ────────────────────────────────────────────────────────────────────
// Save
// ────────────────────────────────────────────────────────────────────
async function save(mode) {
if (isSaving) return;
hideError();
// Validate
if (!selectedVendorId) {
return showError('Please select or add a vendor.');
}
const paymentAccountId = document.getElementById('exp-payment-account').value;
if (!paymentAccountId) {
return showError('Please select a payment account.');
}
const txnDate = document.getElementById('exp-date').value;
if (!txnDate) {
return showError('Please enter a date.');
}
const lines = getLines().filter(l => l.accountId && isFinite(l.amount) && l.amount > 0);
if (lines.length === 0) {
return showError('At least one line with category and positive amount is required.');
}
// Auch ungültige Lines flaggen
const invalidLines = getLines().filter(l =>
(l.accountId && (!isFinite(l.amount) || l.amount <= 0)) ||
(!l.accountId && isFinite(l.amount) && l.amount > 0)
);
if (invalidLines.length > 0) {
return showError('Some lines have a category but no amount, or vice versa. Please complete or remove them.');
}
const payload = {
vendorId: selectedVendorId,
paymentAccountId,
txnDate,
paymentMethodId: document.getElementById('exp-payment-method').value || null,
refNo: document.getElementById('exp-ref-no').value.trim() || null,
memo: document.getElementById('exp-memo').value.trim() || null,
lines
};
isSaving = true;
setButtonsDisabled(true);
try {
const result = await window.API.accounting.createExpense(payload);
if (result.error) {
showError(result.error);
return;
}
// Attachment hochladen, falls vorhanden
if (selectedFile && result.id) {
try {
setButtonsDisabled(true, 'Uploading attachment…');
const attachResult = await window.API.accounting.attachToExpense(
result.id,
selectedFile
);
if (attachResult.error) {
// Expense ist schon erstellt — Fehler nur anzeigen, nicht blockieren
alert(`⚠️ Expense saved (Purchase #${result.id}) but attachment failed:\n${attachResult.error}\n\nYou can attach the file directly in QBO.`);
} else {
console.log(`📎 Attachment uploaded: ${attachResult.id}`);
}
} catch (attachErr) {
alert(`⚠️ Expense saved (Purchase #${result.id}) but attachment failed:\n${attachErr.message}\n\nYou can attach the file directly in QBO.`);
}
}
if (typeof onSavedCb === 'function') {
try { onSavedCb(result); } catch (e) { console.warn('onSaved cb threw:', e); }
}
if (mode === 'close') {
closeModal();
} else if (mode === 'new') {
// Form leeren, Modal offen lassen
resetForm();
// kleiner Toast
showToast(`✅ Saved Purchase #${result.id}${fmtMoney(result.totalAmt)}`);
}
} catch (err) {
showError(err.message || 'Save failed');
} finally {
isSaving = false;
setButtonsDisabled(false);
}
}
function setButtonsDisabled(disabled, customText) {
const a = document.getElementById('exp-save-new-btn');
const b = document.getElementById('exp-save-close-btn');
if (a) a.disabled = disabled;
if (b) b.disabled = disabled;
const text = customText || (disabled ? 'Saving…' : null);
if (a) a.textContent = text || 'Save & New';
if (b) b.textContent = text || 'Save & Close';
}
function showError(msg) {
const el = document.getElementById('exp-error');
if (el) {
el.textContent = msg;
el.classList.remove('hidden');
}
}
function hideError() {
const el = document.getElementById('exp-error');
if (el) el.classList.add('hidden');
}
function showToast(msg) {
const id = 'exp-toast-' + Date.now();
const div = document.createElement('div');
div.id = id;
div.className = 'fixed bottom-6 right-6 bg-green-600 text-white px-4 py-2 rounded-md shadow-lg z-[70] text-sm';
div.textContent = msg;
document.body.appendChild(div);
setTimeout(() => div.remove(), 3000);
}
// ────────────────────────────────────────────────────────────────────
// Expose
// ────────────────────────────────────────────────────────────────────
window.expenseModal = {
open: openExpenseModal,
close: closeModal,
onVendorInput,
selectVendor,
openAddVendor,
closeAddVendor,
saveAddVendor,
addLine,
removeLine,
clearLines,
updateTotal,
save,
onFileSelected,
onFileDrop,
clearFile
};