822 lines
37 KiB
JavaScript
822 lines
37 KiB
JavaScript
/**
|
||
* 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 }
|
||
let editingExpenseId = null; // null = Create-Modus, sonst = Purchase-ID im Edit-Modus
|
||
// ────────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ────────────────────────────────────────────────────────────────────
|
||
|
||
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, '"').replace(/'/g, ''');
|
||
}
|
||
|
||
function escapeAttr(s) {
|
||
return String(s || '')
|
||
.replace(/&/g, '&').replace(/"/g, '"')
|
||
.replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
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, expense = null } = {}) {
|
||
onSavedCb = onSaved || null;
|
||
selectedVendorId = null;
|
||
selectedVendorName = '';
|
||
lineCount = 0;
|
||
isSaving = false;
|
||
editingExpenseId = expense ? expense.id : null;
|
||
|
||
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()
|
||
]);
|
||
|
||
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();
|
||
|
||
// Im Edit-Modus: Formular mit bestehenden Daten befüllen
|
||
if (expense) {
|
||
prefillForm(expense);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
function prefillForm(expense) {
|
||
// Vendor
|
||
if (expense.vendorId) {
|
||
selectVendor(expense.vendorId, expense.vendorName || expense.vendorId);
|
||
}
|
||
// Payment Account
|
||
if (expense.accountId) {
|
||
const pa = document.getElementById('exp-payment-account');
|
||
if (pa) pa.value = expense.accountId;
|
||
}
|
||
// Datum
|
||
if (expense.txnDate) {
|
||
document.getElementById('exp-date').value = expense.txnDate;
|
||
}
|
||
// Payment Method
|
||
if (expense.paymentMethodId) {
|
||
const pm = document.getElementById('exp-payment-method');
|
||
if (pm) pm.value = expense.paymentMethodId;
|
||
}
|
||
// Ref / Memo
|
||
if (expense.refNo) document.getElementById('exp-ref-no').value = expense.refNo;
|
||
if (expense.memo) document.getElementById('exp-memo').value = expense.memo;
|
||
|
||
// Lines — bestehende Zeilen ersetzen
|
||
document.getElementById('exp-lines-tbody').innerHTML = '';
|
||
lineCount = 0;
|
||
const lines = expense.lines && expense.lines.length ? expense.lines : [{}];
|
||
lines.forEach(l => {
|
||
addLine();
|
||
const rows = document.querySelectorAll('#exp-lines-tbody tr');
|
||
const tr = rows[rows.length - 1];
|
||
if (l.accountId) tr.querySelector('.exp-line-account').value = l.accountId;
|
||
if (l.description) tr.querySelector('.exp-line-desc').value = l.description;
|
||
if (l.amount != null) tr.querySelector('.exp-line-amount').value = l.amount;
|
||
});
|
||
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">${editingExpenseId ? '✏️ Edit Expense' : '📝 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>
|
||
${editingExpenseId ? '' : `
|
||
<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 & 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">
|
||
${editingExpenseId ? 'Save Changes' : 'Save & 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 = editingExpenseId
|
||
? await window.API.accounting.updateExpense(editingExpenseId, payload)
|
||
: 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 || (editingExpenseId ? 'Save Changes' : '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
|
||
};
|