Add invoice edit, delete, and bulk delete UI #1
2 changed files with 269 additions and 11 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
<div class="card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">All Invoices</span>
|
||||
<input class="form-input" style="width:200px" placeholder="🔍 Search..." oninput="filterTable('inv-table', this.value)">
|
||||
<div style="display:flex;align-items:center;gap:8px">
|
||||
<button class="btn" id="inv-bulk-delete-btn" style="display:none;padding:4px 12px;font-size:11px;color:var(--red);border-color:var(--red)" onclick="confirmBulkDelete()">Delete Selected (<span id="inv-selected-count">0</span>)</button>
|
||||
<input class="form-input" style="width:200px" placeholder="🔍 Search..." oninput="filterTable('inv-table', this.value)">
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table id="inv-table">
|
||||
<thead><tr><th>Invoice #</th><th>PO Reference</th><th>Vendor</th><th>Issued</th><th>Due</th><th>Amount</th><th>EDI Status</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<thead><tr><th style="width:32px"><input type="checkbox" id="inv-select-all" onchange="toggleAllInvoices(this.checked)"></th><th>Invoice #</th><th>PO Reference</th><th>Vendor</th><th>Issued</th><th>Due</th><th>Amount</th><th>EDI Status</th><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody id="inv-table-body">
|
||||
<tr><td colspan="9"><div class="empty-state"><div class="es-icon">🧾</div><p>No invoices yet. Create one against a PO.</p></div></td></tr>
|
||||
<tr><td colspan="10"><div class="empty-state"><div class="es-icon">🧾</div><p>No invoices yet. Create one against a PO.</p></div></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
@ -1141,11 +1145,11 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Invoice Modal -->
|
||||
<!-- New / Edit Invoice Modal -->
|
||||
<div class="modal-overlay" id="newInvoiceModal">
|
||||
<div class="modal" style="width:660px">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Create Invoice</span>
|
||||
<span class="modal-title" id="inv-modal-title">Create Invoice</span>
|
||||
<button class="modal-close" onclick="closeModal('newInvoiceModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
|
|
@ -1208,7 +1212,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer" id="inv-modal-footer">
|
||||
<button class="btn" onclick="closeModal('newInvoiceModal')">Cancel</button>
|
||||
<button class="btn" onclick="saveInvoice('draft')">Save Draft</button>
|
||||
<button class="btn primary" onclick="saveInvoice('pending')">Create & Queue for EDI</button>
|
||||
|
|
@ -1226,12 +1230,32 @@
|
|||
<div class="modal-body" id="view-inv-body"></div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" onclick="closeModal('viewInvoiceModal')">Close</button>
|
||||
<button class="btn" id="view-inv-delete-btn" style="color:var(--red)">Delete</button>
|
||||
<button class="btn" id="view-inv-edit-btn">Edit</button>
|
||||
<button class="btn" id="view-inv-edi-btn">⚡ Submit via EDI</button>
|
||||
<button class="btn primary" id="view-inv-paid-btn">✓ Mark as Paid</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bulk Delete Confirmation Modal -->
|
||||
<div class="modal-overlay" id="bulkDeleteModal">
|
||||
<div class="modal" style="width:440px">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Confirm Deletion</span>
|
||||
<button class="modal-close" onclick="closeModal('bulkDeleteModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color:var(--text);margin-bottom:12px">Are you sure you want to delete the following <strong id="bulk-del-count">0</strong> invoice(s)? This cannot be undone.</p>
|
||||
<div id="bulk-del-list" style="max-height:200px;overflow-y:auto;background:var(--surface2);border-radius:6px;padding:8px 12px;font-size:12px;color:var(--text2)"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn" onclick="closeModal('bulkDeleteModal')">Cancel</button>
|
||||
<button class="btn" style="background:var(--red);color:#fff;border-color:var(--red)" onclick="executeBulkDelete()">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toast -->
|
||||
<div id="toast"><span class="t-icon" id="toast-icon">✓</span><span id="toast-msg"></span></div>
|
||||
|
||||
|
|
@ -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 = `
|
||||
<button class="btn" onclick="closeModal('newInvoiceModal')">Cancel</button>
|
||||
<button class="btn" onclick="saveInvoice('draft')">Save Draft</button>
|
||||
<button class="btn primary" onclick="saveInvoice('pending')">Create & Queue for EDI</button>
|
||||
`;
|
||||
|
||||
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 = '<tr><td colspan="9"><div class="empty-state"><div class="es-icon">🧾</div><p>No invoices yet</p></div></td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="10"><div class="empty-state"><div class="es-icon">🧾</div><p>No invoices yet</p></div></td></tr>';
|
||||
return;
|
||||
}
|
||||
const canSelect = inv => inv.status !== 'paid' && inv.ediStatus !== 'submitted';
|
||||
tbody.innerHTML = state.invoices.map(inv => `
|
||||
<tr onclick="viewInvoice('${inv.id}')">
|
||||
<td onclick="event.stopPropagation()">
|
||||
${canSelect(inv) ? `<input type="checkbox" class="inv-select-cb" data-id="${inv.id}" onchange="updateBulkDeleteBtn()">` : ''}
|
||||
</td>
|
||||
<td class="mono">${inv.number}</td>
|
||||
<td class="mono">${inv.poNumber || '—'}</td>
|
||||
<td class="bold">${inv.vendor}</td>
|
||||
|
|
@ -1900,6 +1947,7 @@ function refreshInvoiceTable() {
|
|||
<td><span class="badge ${ediStatusClass(inv.ediStatus)}">${ediStatusLabel(inv.ediStatus)}</span></td>
|
||||
<td><span class="badge ${inv.status}"><span class="badge-dot"></span>${inv.status}</span></td>
|
||||
<td onclick="event.stopPropagation()">
|
||||
${inv.status !== 'paid' ? `<button class="btn" style="padding:3px 9px;font-size:11px;margin-right:4px" onclick="editInvoice('${inv.id}')">Edit</button>` : ''}
|
||||
<button class="btn" style="padding:3px 9px;font-size:11px;margin-right:4px" onclick="submitInvoiceEDI('${inv.id}')">⚡ EDI</button>
|
||||
${inv.status !== 'paid' ? `<button class="btn" style="padding:3px 9px;font-size:11px" onclick="markPaid('${inv.id}')">✓ Paid</button>` : ''}
|
||||
</td>
|
||||
|
|
@ -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 = `
|
||||
<button class="btn" onclick="closeModal('newInvoiceModal'); editingInvoiceId = null;">Cancel</button>
|
||||
<button class="btn primary" onclick="saveInvoiceEdit()">Save Changes</button>
|
||||
`;
|
||||
|
||||
// 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 = '<option value="">— Select PO (optional) —</option>';
|
||||
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 = `
|
||||
<td><input placeholder="Description" oninput="calcTotals()" value="${li.description}"></td>
|
||||
<td style="width:70px"><input type="number" value="${li.qty}" min="0" oninput="calcTotals()" style="width:60px"></td>
|
||||
<td style="width:100px"><input type="number" value="${li.unitPrice}" min="0" step="0.01" oninput="calcTotals()" style="width:90px"></td>
|
||||
<td style="width:90px;color:var(--text);padding:6px 8px" class="li-total">${fmt(li.total)}</td>
|
||||
<td><button class="li-del" onclick="removeLineItem('${rowId}')">×</button></td>`;
|
||||
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 =>
|
||||
`<div style="padding:4px 0;border-bottom:1px solid var(--border)"><strong>${inv.number}</strong> — ${inv.vendor} (${fmt(inv.total)})</div>`
|
||||
).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();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue