diff --git a/README.md b/README.md index b8c3a64..50cfdde 100644 --- a/README.md +++ b/README.md @@ -38,10 +38,15 @@ ledgerflow_frontend/ ### Invoices - Create invoices linked to POs +- Edit invoices (pre-filled modal with line items, totals recalculated in real-time) +- Delete individual invoices from the detail view, or bulk-select and delete multiple at once +- "Modified After Send" EDI status badge when an invoice is edited after EDI submission +- Paid and EDI-submitted invoices are protected from selection/deletion - Line items with quantity, unit price, and descriptions - Payment terms: Net 15, Net 30, Net 45, Net 60, 90 NET, Due on Receipt - Due date auto-calculates based on selected payment terms - Tax rate from company settings +- PO remaining balance updates in real-time during edit (accounts for current invoice) - Save as draft or queue for EDI submission ### EDI (X12 810) diff --git a/accounting-app.html b/accounting-app.html index 8869e19..5e92bdc 100644 --- a/accounting-app.html +++ b/accounting-app.html @@ -298,6 +298,7 @@ .badge.open { background: rgba(0,102,255,0.15); color: #5b9bff; border: 1px solid rgba(0,102,255,0.2); } .badge.pending { background: rgba(255,211,42,0.12); color: var(--yellow); border: 1px solid rgba(255,211,42,0.2); } .badge.submitted { background: rgba(0,229,160,0.1); color: var(--accent); border: 1px solid rgba(0,229,160,0.2); } + .badge.modified { background: rgba(255,140,50,0.12); color: #ff8c32; border: 1px solid rgba(255,140,50,0.25); } .badge.paid { background: rgba(0,229,160,0.05); color: #4a8; border: 1px solid rgba(0,229,160,0.1); } .badge.overdue { background: rgba(255,71,87,0.12); color: var(--red); border: 1px solid rgba(255,71,87,0.2); } .badge.partial { background: rgba(255,107,53,0.12); color: var(--accent3); border: 1px solid rgba(255,107,53,0.2); } @@ -694,13 +695,16 @@
All Invoices - +
+ + +
- + - +
Invoice #PO ReferenceVendorIssuedDueAmountEDI StatusStatusActions
Invoice #PO ReferenceVendorIssuedDueAmountEDI StatusStatusActions
๐Ÿงพ

No invoices yet. Create one against a PO.

๐Ÿงพ

No invoices yet. Create one against a PO.

@@ -1141,11 +1145,11 @@
- + - + + +
โœ“
@@ -1342,7 +1366,7 @@ function navigate(view, el) { // ============================================================ function openModal(id) { document.getElementById(id).classList.add('open'); - if (id === 'newInvoiceModal') initNewInvoiceModal(); + if (id === 'newInvoiceModal' && !editingInvoiceId) initNewInvoiceModal(); if (id === 'importPOModal') { document.getElementById('imp-region').textContent = state.config.dynamo.region || 'โ€”'; document.getElementById('imp-table').textContent = state.config.dynamo.table || 'โ€”'; @@ -1747,9 +1771,23 @@ function updateDueDate() { } function initNewInvoiceModal() { + editingInvoiceId = null; + + // Reset modal to create mode + document.getElementById('inv-modal-title').textContent = 'Create Invoice'; + document.getElementById('inv-modal-footer').innerHTML = ` + + + + `; + + document.getElementById('inv-number').disabled = false; + document.getElementById('inv-po-ref').disabled = false; + const prefix = state.config.company.invPrefix || 'INV-'; document.getElementById('inv-number').value = prefix + String(state.invCounter).padStart(4, '0'); document.getElementById('inv-issue').value = today(); + document.getElementById('inv-notes').value = ''; updateDueDate(); document.getElementById('inv-tax-rate-label').textContent = state.config.company.taxRate || 0; @@ -1821,7 +1859,10 @@ function calcTotals() { if (poId) { const po = state.pos.find(p => p.id === poId); if (po) { - const poRemaining = po.amount - (po.billed || 0) - total; + // When editing, the PO's billed amount already includes this invoice โ€” add it back + const editingInv = editingInvoiceId ? state.invoices.find(i => i.id === editingInvoiceId) : null; + const editingInvTotal = editingInv ? parseFloat(editingInv.total || 0) : 0; + const poRemaining = parseFloat(po.amount || 0) - parseFloat(po.billed || 0) + editingInvTotal - total; remainEl.textContent = fmt(poRemaining); remainEl.style.color = poRemaining < 0 ? 'var(--red, #e74c3c)' : 'var(--accent)'; } @@ -1885,12 +1926,18 @@ async function saveInvoice(status) { function refreshInvoiceTable() { const tbody = document.getElementById('inv-table-body'); document.getElementById('inv-count').textContent = state.invoices.length; + document.getElementById('inv-select-all').checked = false; + document.getElementById('inv-bulk-delete-btn').style.display = 'none'; if (!state.invoices.length) { - tbody.innerHTML = '
๐Ÿงพ

No invoices yet

'; + tbody.innerHTML = '
๐Ÿงพ

No invoices yet

'; return; } + const canSelect = inv => inv.status !== 'paid' && inv.ediStatus !== 'submitted'; tbody.innerHTML = state.invoices.map(inv => ` + + ${canSelect(inv) ? `` : ''} + ${inv.number} ${inv.poNumber || 'โ€”'} ${inv.vendor} @@ -1900,6 +1947,7 @@ function refreshInvoiceTable() { ${ediStatusLabel(inv.ediStatus)} ${inv.status} + ${inv.status !== 'paid' ? `` : ''} ${inv.status !== 'paid' ? `` : ''} @@ -1932,9 +1980,214 @@ function viewInvoice(id) { `; document.getElementById('view-inv-edi-btn').onclick = () => { closeModal('viewInvoiceModal'); submitInvoiceEDI(id); }; document.getElementById('view-inv-paid-btn').onclick = () => { markPaid(id); closeModal('viewInvoiceModal'); }; + + // Edit button โ€” hidden for paid invoices + const editBtn = document.getElementById('view-inv-edit-btn'); + editBtn.style.display = inv.status === 'paid' ? 'none' : ''; + editBtn.onclick = () => { closeModal('viewInvoiceModal'); editInvoice(id); }; + + // Delete button โ€” hidden for paid invoices + const deleteBtn = document.getElementById('view-inv-delete-btn'); + deleteBtn.style.display = inv.status === 'paid' ? 'none' : ''; + deleteBtn.onclick = () => { deleteInvoice(id); }; + openModal('viewInvoiceModal'); } +let editingInvoiceId = null; + +function editInvoice(id) { + const inv = state.invoices.find(i => i.id === id); + if (!inv) return; + + editingInvoiceId = id; + + // Set modal to edit mode + document.getElementById('inv-modal-title').textContent = `Edit Invoice ${inv.number}`; + document.getElementById('inv-modal-footer').innerHTML = ` + + + `; + + // Populate fields + document.getElementById('inv-number').value = inv.number; + document.getElementById('inv-number').disabled = true; + document.getElementById('inv-vendor').value = inv.vendor; + document.getElementById('inv-issue').value = inv.issueDate || ''; + document.getElementById('inv-due').value = inv.dueDate || ''; + document.getElementById('inv-terms').value = inv.terms || 'Net 30'; + document.getElementById('inv-notes').value = inv.notes || ''; + document.getElementById('inv-tax-rate-label').textContent = state.config.company.taxRate || 0; + + // Populate PO ref + const sel = document.getElementById('inv-po-ref'); + sel.innerHTML = ''; + state.pos.filter(p => p.status !== 'closed' && p.status !== 'cancelled').forEach(po => { + const opt = document.createElement('option'); + opt.value = po.id; + opt.textContent = `${po.poNumber} โ€” ${po.vendor} (${fmt(po.amount - (po.billed||0))} remaining)`; + sel.appendChild(opt); + }); + sel.value = inv.poId || ''; + sel.disabled = true; // PO linkage shouldn't change on edit + + // Populate line items + document.getElementById('line-items-body').innerHTML = ''; + inv.lineItems.forEach(li => { + lineItemCounter++; + const rowId = 'li-' + lineItemCounter; + const row = document.createElement('tr'); + row.id = rowId; + row.innerHTML = ` + + + + ${fmt(li.total)} + `; + document.getElementById('line-items-body').appendChild(row); + }); + + if (inv.poId) { + document.getElementById('po-remaining-row').style.display = ''; + } else { + document.getElementById('po-remaining-row').style.display = 'none'; + } + + calcTotals(); + openModal('newInvoiceModal'); +} + +async function saveInvoiceEdit() { + if (!editingInvoiceId) return; + + const vendor = document.getElementById('inv-vendor').value.trim(); + if (!vendor) { toast('Vendor is required', 'warn'); return; } + + const body = { + vendor, + issueDate: document.getElementById('inv-issue').value, + dueDate: document.getElementById('inv-due').value, + terms: document.getElementById('inv-terms').value, + lineItems: getLineItems(), + taxRate: state.config.company.taxRate || 0, + notes: document.getElementById('inv-notes').value, + }; + + const data = await api(`/invoices/${editingInvoiceId}`, { method: 'PUT', body }); + if (!data) return; + + // Update local state + const idx = state.invoices.findIndex(i => i.id === editingInvoiceId); + if (idx !== -1) state.invoices[idx] = data; + save(); + + // Refresh POs if linked + if (data.poId) await loadPOs(); + + closeModal('newInvoiceModal'); + editingInvoiceId = null; + document.getElementById('inv-number').disabled = false; + document.getElementById('inv-po-ref').disabled = false; + + refreshInvoiceTable(); + refreshPOTable(); + refreshDashboard(); + addActivity(`Invoice ${data.number} updated`, 'blue'); + toast(`Invoice ${data.number} updated`, 'ok'); +} + +async function deleteInvoice(id) { + const inv = state.invoices.find(i => i.id === id); + if (!inv) return; + + if (!confirm(`Delete invoice ${inv.number}? This cannot be undone.`)) return; + + const res = await fetch(`${API_URL}/invoices/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (!res.ok && res.status !== 204) { + const err = await res.json().catch(() => ({})); + toast(err.message || err.error || 'Failed to delete invoice', 'err'); + return; + } + + state.invoices = state.invoices.filter(i => i.id !== id); + save(); + + if (inv.poId) await loadPOs(); + + closeModal('viewInvoiceModal'); + refreshInvoiceTable(); + refreshPOTable(); + refreshDashboard(); + addActivity(`Invoice ${inv.number} deleted`, 'red'); + toast(`Invoice ${inv.number} deleted`, 'ok'); +} + +function toggleAllInvoices(checked) { + document.querySelectorAll('.inv-select-cb').forEach(cb => { cb.checked = checked; }); + updateBulkDeleteBtn(); +} + +function updateBulkDeleteBtn() { + const selected = document.querySelectorAll('.inv-select-cb:checked'); + const btn = document.getElementById('inv-bulk-delete-btn'); + document.getElementById('inv-selected-count').textContent = selected.length; + btn.style.display = selected.length > 0 ? '' : 'none'; + + // Update select-all checkbox state + const all = document.querySelectorAll('.inv-select-cb'); + document.getElementById('inv-select-all').checked = all.length > 0 && selected.length === all.length; +} + +function confirmBulkDelete() { + const selectedIds = Array.from(document.querySelectorAll('.inv-select-cb:checked')).map(cb => cb.dataset.id); + if (!selectedIds.length) return; + + const invoices = selectedIds.map(id => state.invoices.find(i => i.id === id)).filter(Boolean); + document.getElementById('bulk-del-count').textContent = invoices.length; + document.getElementById('bulk-del-list').innerHTML = invoices.map(inv => + `
${inv.number} โ€” ${inv.vendor} (${fmt(inv.total)})
` + ).join(''); + + openModal('bulkDeleteModal'); +} + +async function executeBulkDelete() { + const selectedIds = Array.from(document.querySelectorAll('.inv-select-cb:checked')).map(cb => cb.dataset.id); + if (!selectedIds.length) return; + + closeModal('bulkDeleteModal'); + let deleted = 0; + let hadPO = false; + + for (const id of selectedIds) { + const inv = state.invoices.find(i => i.id === id); + if (!inv) continue; + + const res = await fetch(`${API_URL}/invoices/${id}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${getToken()}` }, + }); + if (res.ok || res.status === 204) { + if (inv.poId) hadPO = true; + state.invoices = state.invoices.filter(i => i.id !== id); + deleted++; + } + } + + if (deleted) { + save(); + if (hadPO) await loadPOs(); + refreshInvoiceTable(); + refreshPOTable(); + refreshDashboard(); + addActivity(`Deleted ${deleted} invoice(s)`, 'red'); + toast(`Deleted ${deleted} invoice(s)`, 'ok'); + } +} + async function markPaid(id) { const inv = state.invoices.find(i => i.id === id); if (!inv) return; @@ -2186,8 +2439,8 @@ function fmt(n) { } function today() { return new Date().toISOString().split('T')[0]; } function genId() { return Math.random().toString(36).slice(2, 10); } -function ediStatusClass(s) { return s === 'submitted' ? 'submitted' : s === 'processing' ? 'pending' : 'open'; } -function ediStatusLabel(s) { return s === 'submitted' ? 'Submitted' : s === 'processing' ? 'Processing' : 'Not Sent'; } +function ediStatusClass(s) { return s === 'submitted' ? 'submitted' : s === 'modified_after_send' ? 'modified' : s === 'processing' ? 'pending' : 'open'; } +function ediStatusLabel(s) { return s === 'submitted' ? 'Submitted' : s === 'modified_after_send' ? 'Modified After Send' : s === 'processing' ? 'Processing' : 'Not Sent'; } function filterTable(tableId, query) { const q = query.toLowerCase();