new filters

This commit is contained in:
2026-06-12 10:44:35 -05:00
parent df5ee9e6bd
commit 8543dd5190
2 changed files with 44 additions and 54 deletions

View File

@@ -6,8 +6,8 @@ let filterCustomer = localStorage.getItem('inv_filterCustomer') || '';
let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid'; let filterStatus = localStorage.getItem('inv_filterStatus') || 'unpaid';
let groupBy = localStorage.getItem('inv_groupBy') || 'none'; let groupBy = localStorage.getItem('inv_groupBy') || 'none';
let filterWorker = localStorage.getItem('inv_filterWorker') || ''; let filterWorker = localStorage.getItem('inv_filterWorker') || '';
let filterParts = localStorage.getItem('inv_filterParts') === 'true'; let filterCategory = localStorage.getItem('inv_filterCategory') || '';
let filterEmptyCost = localStorage.getItem('inv_filterEmptyCost') === 'true'; let filterItemSearch = localStorage.getItem('inv_filterItemSearch') || '';
const OVERDUE_DAYS = 30; const OVERDUE_DAYS = 30;
@@ -198,8 +198,8 @@ function saveSettings() {
localStorage.setItem('inv_groupBy', groupBy); localStorage.setItem('inv_groupBy', groupBy);
localStorage.setItem('inv_filterCustomer', filterCustomer); localStorage.setItem('inv_filterCustomer', filterCustomer);
localStorage.setItem('inv_filterWorker', filterWorker); localStorage.setItem('inv_filterWorker', filterWorker);
localStorage.setItem('inv_filterParts', filterParts); localStorage.setItem('inv_filterCategory', filterCategory);
localStorage.setItem('inv_filterEmptyCost', filterEmptyCost); localStorage.setItem('inv_filterItemSearch', filterItemSearch);
} }
// ============================================================ // ============================================================
@@ -209,10 +209,8 @@ function saveSettings() {
export async function loadInvoices() { export async function loadInvoices() {
try { try {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (filterParts) { if (filterCategory) params.set('category', filterCategory);
params.set('has_parts', 'true'); if (filterItemSearch) params.set('item_search', filterItemSearch);
if (filterEmptyCost) params.set('empty_cost_only', 'true');
}
const qs = params.toString(); const qs = params.toString();
const url = qs ? `/api/invoices?${qs}` : '/api/invoices'; const url = qs ? `/api/invoices?${qs}` : '/api/invoices';
const response = await fetch(url); const response = await fetch(url);
@@ -610,11 +608,20 @@ export function injectToolbar() {
</select> </select>
</div> </div>
<div class="w-px h-8 bg-gray-300"></div> <div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-1 border border-gray-300 rounded-lg p-1 bg-gray-100"> <div class="flex items-center gap-2">
<button id="filter-item-type" onclick="window.invoiceView.toggleParts()" <label class="text-sm font-medium text-gray-700">Category:</label>
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${filterParts ? 'bg-blue-500 text-white' : 'bg-white text-gray-600'}">Cost Items</button> <select id="invoice-filter-category" class="px-3 py-1.5 border border-gray-300 rounded-md text-sm bg-white">
<button id="filter-empty-cost" onclick="window.invoiceView.toggleEmptyCost()" style="${filterParts ? '' : 'display:none'}" <option value="">All</option>
class="px-3 py-1.5 text-xs font-medium rounded-md transition-colors ${filterEmptyCost ? 'bg-blue-500 text-white' : 'bg-white text-gray-600'}">Empty cost only</button> <option value="9" ${filterCategory === '9' ? 'selected' : ''}>Parts</option>
<option value="115" ${filterCategory === '115' ? 'selected' : ''}>Subscription</option>
<option value="5" ${filterCategory === '5' ? 'selected' : ''}>Labor</option>
</select>
</div>
<div class="w-px h-8 bg-gray-300"></div>
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-700">Item:</label>
<input type="text" id="invoice-filter-item-search" placeholder="Search descriptions..." value="${filterItemSearch}"
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-48 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
<div class="w-px h-8 bg-gray-300"></div> <div class="w-px h-8 bg-gray-300"></div>
<div class="ml-auto flex items-center gap-4"> <div class="ml-auto flex items-center gap-4">
@@ -636,6 +643,12 @@ export function injectToolbar() {
populateWorkerFilter(); populateWorkerFilter();
document.getElementById('invoice-filter-worker').addEventListener('change', (e) => { document.getElementById('invoice-filter-worker').addEventListener('change', (e) => {
filterWorker = e.target.value; saveSettings(); renderInvoiceView(); filterWorker = e.target.value; saveSettings(); renderInvoiceView();
});
document.getElementById('invoice-filter-category').addEventListener('change', (e) => {
filterCategory = e.target.value; saveSettings(); loadInvoices();
});
document.getElementById('invoice-filter-item-search').addEventListener('input', (e) => {
filterItemSearch = e.target.value; saveSettings(); loadInvoices();
}); });
} }
@@ -644,37 +657,6 @@ export function injectToolbar() {
// ============================================================ // ============================================================
export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); } export function setStatus(s) { filterStatus = s; saveSettings(); renderInvoiceView(); }
export function toggleParts() {
filterParts = !filterParts;
if (!filterParts) filterEmptyCost = false;
saveSettings();
const itemBtn = document.getElementById('filter-item-type');
const emptyBtn = document.getElementById('filter-empty-cost');
if (itemBtn) {
itemBtn.className = filterParts
? 'px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-blue-500 text-white'
: 'px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600';
}
if (emptyBtn) {
emptyBtn.style.display = filterParts ? '' : 'none';
emptyBtn.className = filterEmptyCost
? 'px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-blue-500 text-white'
: 'px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600';
}
loadInvoices();
}
export function toggleEmptyCost() {
if (!filterParts) return;
filterEmptyCost = !filterEmptyCost;
saveSettings();
const btn = document.getElementById('filter-empty-cost');
if (btn) {
btn.className = filterEmptyCost
? 'px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-blue-500 text-white'
: 'px-3 py-1.5 text-xs font-medium rounded-md transition-colors bg-white text-gray-600';
}
loadInvoices();
}
export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); } export function viewPDF(id) { window.open(`/api/invoices/${id}/pdf`, '_blank'); }
export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); } export function viewHTML(id) { window.open(`/api/invoices/${id}/html`, '_blank'); }
@@ -915,5 +897,5 @@ async function populateWorkerFilter() {
window.invoiceView = { window.invoiceView = {
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove, viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
loadInvoices, renderInvoiceView, setStatus, toggleParts, toggleEmptyCost, checkStripePayment, editSentDates ,_addSentDateRow, _saveSentDates loadInvoices, renderInvoiceView, setStatus, checkStripePayment, editSentDates ,_addSentDateRow, _saveSentDates
}; };

View File

@@ -50,20 +50,28 @@ function buildPaymentLinkHtml(invoice) {
// GET all invoices // GET all invoices
router.get('/', async (req, res) => { router.get('/', async (req, res) => {
try { try {
const { has_parts, empty_cost_only } = req.query; const CATEGORIES = { '5': 'Labor', '9': 'Parts', '115': 'Subscription' };
const { category, item_search } = req.query;
let whereClauses = []; let whereClauses = [];
if (has_parts === 'true') { let params = [];
const costCondition = empty_cost_only === 'true'
? `AND (ii.unit_cost IS NULL OR ii.unit_cost = '')` if (category && CATEGORIES[category]) {
: '';
whereClauses.push(`EXISTS ( whereClauses.push(`EXISTS (
SELECT 1 FROM invoice_items ii SELECT 1 FROM invoice_items ii
WHERE ii.invoice_id = i.id WHERE ii.invoice_id = i.id AND ii.qbo_item_id = $${params.length + 1}
AND ii.qbo_item_id = '9'
${costCondition}
)`); )`);
params.push(category);
} }
if (item_search && item_search.trim()) {
whereClauses.push(`EXISTS (
SELECT 1 FROM invoice_items ii
WHERE ii.invoice_id = i.id AND ii.description ILIKE $${params.length + 1}
)`);
params.push('%' + item_search.trim() + '%');
}
const whereSQL = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : ''; const whereSQL = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : '';
const result = await pool.query(` const result = await pool.query(`
@@ -73,7 +81,7 @@ router.get('/', async (req, res) => {
LEFT JOIN customers c ON i.customer_id = c.id LEFT JOIN customers c ON i.customer_id = c.id
${whereSQL} ${whereSQL}
ORDER BY i.created_at DESC ORDER BY i.created_at DESC
`); `, params);
const rows = result.rows.map(r => ({ const rows = result.rows.map(r => ({
...r, ...r,
amount_paid: parseFloat(r.amount_paid) || 0, amount_paid: parseFloat(r.amount_paid) || 0,