coming together

This commit is contained in:
HotSwapp
2025-08-13 18:53:35 -05:00
parent acc5155bf7
commit 5111079149
51 changed files with 14457 additions and 588 deletions

View File

@@ -3,7 +3,7 @@
{% block title %}Customers (Rolodex) - Delphi Database{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-3">
@@ -27,6 +27,12 @@
<!-- Search and Filter Panel -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-filter"></i>
<span>Search & Filters</span>
</h5>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div class="lg:col-span-2">
@@ -42,16 +48,20 @@
</div>
</div>
<div>
<label for="groupFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Group Filter</label>
<select id="groupFilter" class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200">
<option value="">All Groups</option>
</select>
<label for="groupFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Groups</label>
<select id="groupFilter" multiple class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200"></select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Select one or more groups</p>
</div>
<div>
<label for="stateFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">State Filter</label>
<select id="stateFilter" class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200">
<option value="">All States</option>
</select>
<label for="stateFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">States</label>
<select id="stateFilter" multiple class="w-full px-3 py-3 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-neutral-900 dark:text-neutral-100 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200"></select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">Select one or more states</p>
</div>
</div>
<div class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<div class="flex items-center justify-between">
<div id="activeFilterChips" class="flex flex-wrap gap-2"></div>
<button id="clearAllFiltersBtn" class="hidden px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors">Clear all</button>
</div>
</div>
<div class="mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-700">
@@ -73,17 +83,72 @@
<!-- Customer List -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft overflow-hidden">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-users"></i>
<span>Customer List</span>
</h5>
<div class="flex items-center gap-3">
<label for="pageSizeSelect" class="text-xs text-neutral-600 dark:text-neutral-300">Page size</label>
<select id="pageSizeSelect" class="px-2 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors">
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<option value="200">200</option>
</select>
<button id="toggleCompactMode" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Toggle compact mode">
Compact: Off
</button>
<button id="copyViewLinkBtn" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Copy link to this view">
<i class="fa-solid fa-link mr-1"></i>
Copy link
</button>
<button id="exportCsvBtn" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Export current view to CSV">
<i class="fa-solid fa-file-csv mr-1"></i>
Export CSV
</button>
<span id="exportPreview" class="text-xs text-neutral-600 dark:text-neutral-300" title="Export preview: current page vs all matches"></span>
<div class="relative inline-block" id="exportColumnsWrapper">
<button id="selectColumnsBtn" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Select CSV columns">
<i class="fa-solid fa-table-columns mr-1"></i>
Columns
</button>
<div id="columnsPopover" class="hidden absolute right-0 mt-2 w-64 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-3 z-20">
<div class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase mb-2">Export Columns</div>
<div class="space-y-2 text-sm">
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="id" checked> <span>Customer ID</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="name" checked> <span>Name</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="group" checked> <span>Group</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="city" checked> <span>City</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="state" checked> <span>State</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="phone" checked> <span>Primary Phone</span></label>
<label class="flex items-center gap-2"><input type="checkbox" class="export-col" value="email" checked> <span>Email</span></label>
</div>
<div class="mt-3 border-t border-neutral-200 dark:border-neutral-700 pt-3">
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" id="exportAllToggle">
<span>Export all matches (ignore pagination)</span>
</label>
</div>
<div class="mt-3 flex items-center justify-between">
<button id="columnsSelectAll" class="text-xs text-blue-600 dark:text-blue-400 hover:underline">Select all</button>
<button id="columnsClearAll" class="text-xs text-neutral-600 dark:text-neutral-300 hover:underline">Clear</button>
</div>
</div>
</div>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="customersTable">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<thead class="bg-neutral-50 dark:bg-neutral-800/60">
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Customer</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Name</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Group</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Location</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Phone</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Email</th>
<th class="px-4 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
<th id="thCustomer" data-sort="text" data-sort-field="id" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Customer</th>
<th id="thName" data-sort="text" data-sort-field="name" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Name</th>
<th data-sort="text" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 select-none">Group</th>
<th id="thCity" data-sort="text" data-sort-field="city" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Location</th>
<th data-sort="text" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 select-none">Phone</th>
<th id="thEmail" data-sort="text" data-sort-field="email" class="sticky top-0 z-10 px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60 cursor-pointer select-none">Email</th>
<th class="sticky top-0 z-10 px-6 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider bg-neutral-50 dark:bg-neutral-800/60">Actions</th>
</tr>
</thead>
<tbody id="customersTableBody" class="divide-y divide-neutral-200 dark:divide-neutral-700">
@@ -290,10 +355,50 @@ document.addEventListener('DOMContentLoaded', function() {
return;
}
if (window.initializeCustomerListState) { window.initializeCustomerListState(); }
// Initialize page size from storage before first load
try {
const savedSize = parseInt(localStorage.getItem('customers.pageSize') || '50', 10);
window.customerPageSize = [25, 50, 100, 200].includes(savedSize) ? savedSize : 50;
} catch (_) {
window.customerPageSize = 50;
}
// Load saved sort state
try {
window.currentSortBy = localStorage.getItem('customers.sortBy') || 'id';
window.currentSortDir = localStorage.getItem('customers.sortDir') || 'asc';
} catch (_) {
window.currentSortBy = 'id';
window.currentSortDir = 'asc';
}
updateSortIndicators();
// Load saved filters
try {
const savedGroups = localStorage.getItem('customers.filterGroups');
const savedStates = localStorage.getItem('customers.filterStates');
const singleGroup = localStorage.getItem('customers.filterGroup') || '';
const singleState = localStorage.getItem('customers.filterState') || '';
window.currentGroupFilters = savedGroups ? JSON.parse(savedGroups) : (singleGroup ? [singleGroup] : []);
window.currentStateFilters = savedStates ? JSON.parse(savedStates) : (singleState ? [singleState] : []);
if (!Array.isArray(window.currentGroupFilters)) window.currentGroupFilters = [];
if (!Array.isArray(window.currentStateFilters)) window.currentStateFilters = [];
} catch (_) {
window.currentGroupFilters = [];
window.currentStateFilters = [];
}
renderActiveFilterChips();
loadCustomers();
loadGroups();
loadStates();
setupEventListeners();
try { if (window.initializeDataTable) { window.initializeDataTable('customersTable'); } } catch (_) {}
const compactBtn = document.getElementById('toggleCompactMode');
if (compactBtn && window.toggleCompactMode) {
compactBtn.addEventListener('click', window.toggleCompactMode);
}
// Initialize page size selector value
const sizeSel = document.getElementById('pageSizeSelect');
if (sizeSel) { sizeSel.value = String(window.customerPageSize); }
});
function setupEventListeners() {
@@ -354,6 +459,196 @@ function setupEventListeners() {
if (customerIdInput) {
customerIdInput.addEventListener('blur', validateCustomerId);
}
// Page size selector
const pageSizeSelect = document.getElementById('pageSizeSelect');
if (pageSizeSelect) {
pageSizeSelect.addEventListener('change', function() {
const newSize = parseInt(this.value, 10);
if ([25, 50, 100, 200].includes(newSize)) {
try { localStorage.setItem('customers.pageSize', String(newSize)); } catch (_) {}
window.customerPageSize = newSize;
currentPage = 0;
loadCustomers(currentPage, currentSearch);
}
});
}
// Copy view link button
const copyBtn = document.getElementById('copyViewLinkBtn');
if (copyBtn) {
copyBtn.addEventListener('click', async function() {
const url = typeof buildViewUrl === 'function' ? buildViewUrl() : window.location.href;
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(url);
if (window.alerts && window.alerts.show) { window.alerts.show('Link copied', 'success'); }
} else {
throw new Error('Clipboard API not available');
}
} catch (e) {
prompt('Copy this link:', url);
}
});
}
// Export CSV button
const exportBtn = document.getElementById('exportCsvBtn');
if (exportBtn) {
exportBtn.addEventListener('click', function() {
// Build URL for export endpoint using current state
const u = new URL(window.location.origin + '/api/customers/export');
const p = u.searchParams;
p.set('skip', String(currentPage * (window.customerPageSize || 50)));
p.set('limit', String(window.customerPageSize || 50));
const q = (document.getElementById('searchInput')?.value || '').trim();
if (q) p.set('search', q);
const by = window.currentSortBy || 'id';
const dir = window.currentSortDir || 'asc';
p.set('sort_by', by);
p.set('sort_dir', dir);
(Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : []).forEach(v => p.append('groups', v));
(Array.isArray(window.currentStateFilters) ? window.currentStateFilters : []).forEach(v => p.append('states', v));
// Selected columns
const cols = Array.from(document.querySelectorAll('#columnsPopover .export-col'))
.filter(cb => cb.checked)
.map(cb => cb.value);
cols.forEach(f => p.append('fields', f));
// Export all toggle
const exportAll = document.getElementById('exportAllToggle');
const shouldExportAll = exportAll && exportAll.checked;
if (shouldExportAll) p.set('export_all', '1'); else p.delete('export_all');
// Trigger download
window.location.href = u.toString();
});
}
// Columns popover
const selectColumnsBtn = document.getElementById('selectColumnsBtn');
const columnsPopover = document.getElementById('columnsPopover');
if (selectColumnsBtn && columnsPopover) {
selectColumnsBtn.addEventListener('click', function(e) {
e.stopPropagation();
columnsPopover.classList.toggle('hidden');
});
document.addEventListener('click', function() {
columnsPopover.classList.add('hidden');
});
columnsPopover.addEventListener('click', function(e) { e.stopPropagation(); });
const selAll = document.getElementById('columnsSelectAll');
const clrAll = document.getElementById('columnsClearAll');
if (selAll) selAll.addEventListener('click', function(e) {
e.preventDefault();
Array.from(columnsPopover.querySelectorAll('.export-col')).forEach(cb => cb.checked = true);
});
if (clrAll) clrAll.addEventListener('click', function(e) {
e.preventDefault();
Array.from(columnsPopover.querySelectorAll('.export-col')).forEach(cb => cb.checked = false);
});
// Persist selection
try {
const saved = JSON.parse(localStorage.getItem('customers.exportFields') || '[]');
if (Array.isArray(saved) && saved.length) {
const set = new Set(saved);
Array.from(columnsPopover.querySelectorAll('.export-col')).forEach(cb => {
cb.checked = set.has(cb.value);
});
}
} catch (_) {}
// Persist export all toggle
try {
const savedAll = localStorage.getItem('customers.exportAll') === '1';
const toggle = document.getElementById('exportAllToggle');
if (toggle) {
toggle.checked = savedAll;
try { updateExportPreview(window.lastCustomersTotal || 0, window.lastCustomersPageCount || 0); } catch (_) {}
}
} catch (_) {}
columnsPopover.querySelectorAll('.export-col').forEach(cb => {
cb.addEventListener('change', function() {
const cols = Array.from(columnsPopover.querySelectorAll('.export-col'))
.filter(x => x.checked).map(x => x.value);
try { localStorage.setItem('customers.exportFields', JSON.stringify(cols)); } catch (_) {}
});
});
const exportAllToggle = document.getElementById('exportAllToggle');
if (exportAllToggle) {
exportAllToggle.addEventListener('change', function() {
try { localStorage.setItem('customers.exportAll', this.checked ? '1' : '0'); } catch (_) {}
try { updateExportPreview(window.lastCustomersTotal || 0, window.lastCustomersPageCount || 0); } catch (_) {}
});
}
}
// Sort header clicks
const thCustomer = document.getElementById('thCustomer');
const thName = document.getElementById('thName');
const thCity = document.getElementById('thCity');
const thEmail = document.getElementById('thEmail');
const addSortHandler = (el, field) => {
if (!el) return;
el.addEventListener('click', () => {
const prevField = window.currentSortBy || 'id';
const prevDir = window.currentSortDir || 'asc';
if (prevField === field) {
window.currentSortDir = prevDir === 'asc' ? 'desc' : 'asc';
} else {
window.currentSortBy = field;
window.currentSortDir = 'asc';
}
try {
localStorage.setItem('customers.sortBy', window.currentSortBy);
localStorage.setItem('customers.sortDir', window.currentSortDir);
} catch (_) {}
updateSortIndicators();
currentPage = 0;
loadCustomers(currentPage, currentSearch);
});
};
addSortHandler(thCustomer, 'id');
addSortHandler(thName, 'name');
addSortHandler(thCity, 'city');
addSortHandler(thEmail, 'email');
// Filter changes (multi-select)
const groupSel = document.getElementById('groupFilter');
const stateSel = document.getElementById('stateFilter');
if (groupSel) {
groupSel.addEventListener('change', function() {
const values = Array.from(this.selectedOptions).map(o => o.value).filter(Boolean);
window.currentGroupFilters = values;
try { localStorage.setItem('customers.filterGroups', JSON.stringify(values)); } catch (_) {}
currentPage = 0;
loadCustomers(currentPage, currentSearch);
renderActiveFilterChips();
});
}
if (stateSel) {
stateSel.addEventListener('change', function() {
const values = Array.from(this.selectedOptions).map(o => o.value).filter(Boolean);
window.currentStateFilters = values;
try { localStorage.setItem('customers.filterStates', JSON.stringify(values)); } catch (_) {}
currentPage = 0;
loadCustomers(currentPage, currentSearch);
renderActiveFilterChips();
});
}
const clearBtn = document.getElementById('clearAllFiltersBtn');
if (clearBtn) {
clearBtn.addEventListener('click', function() {
window.currentGroupFilters = [];
window.currentStateFilters = [];
try { localStorage.setItem('customers.filterGroups', JSON.stringify([])); } catch (_) {}
try { localStorage.setItem('customers.filterStates', JSON.stringify([])); } catch (_) {}
const gSel = document.getElementById('groupFilter');
const sSel = document.getElementById('stateFilter');
if (gSel) Array.from(gSel.options).forEach(o => o.selected = false);
if (sSel) Array.from(sSel.options).forEach(o => o.selected = false);
currentPage = 0;
renderActiveFilterChips();
loadCustomers(currentPage, currentSearch);
});
}
}
// Modal functions
@@ -368,18 +663,31 @@ async function loadCustomers(page = 0, search = '') {
setSearchLoading(true);
const params = new URLSearchParams({
skip: page * 50,
limit: 50
skip: String(page * (window.customerPageSize || 50)),
limit: String(window.customerPageSize || 50)
});
if (search) params.append('search', search);
// Sorting
const sortBy = window.currentSortBy || 'id';
const sortDir = window.currentSortDir || 'asc';
params.append('sort_by', sortBy);
params.append('sort_dir', sortDir);
// Filters (multi)
const grpArr = Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : [];
const stArr = Array.isArray(window.currentStateFilters) ? window.currentStateFilters : [];
grpArr.forEach(v => params.append('groups', v));
stArr.forEach(v => params.append('states', v));
params.append('include_total', '1');
const response = await window.http.wrappedFetch(`/api/customers/?${params}`);
if (!response.ok) throw new Error('Failed to load customers');
const customers = await response.json();
displayCustomers(customers);
const data = await response.json();
displayCustomers(data.items);
renderPagination(data.total, data.items.length);
try { updateExportPreview(data.total, data.items.length); } catch (_) {}
} catch (error) {
console.error('Error loading customers:', error);
@@ -403,6 +711,29 @@ function performSearch() {
currentSearch = document.getElementById('searchInput').value.trim();
currentPage = 0;
loadCustomers(currentPage, currentSearch);
if (typeof syncUrlToState === 'function') { try { syncUrlToState(); } catch (_) {} }
}
function renderPagination(totalCount, returnedCount) {
const container = document.getElementById('pagination');
if (!container) return;
const size = window.customerPageSize || 50;
const isFirst = currentPage === 0;
const totalPages = Math.max(1, Math.ceil(totalCount / size));
const isLast = currentPage + 1 >= totalPages || returnedCount < size;
container.innerHTML = `
<button id="prevPageBtn" class="px-3 py-1.5 text-sm rounded-lg ${isFirst ? 'bg-neutral-100 text-neutral-400 cursor-not-allowed' : 'bg-white dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-600'} border border-neutral-200 dark:border-neutral-600" ${isFirst ? 'disabled' : ''}>
<i class="fa-solid fa-chevron-left mr-1"></i> Prev
</button>
<span class="px-3 py-1.5 text-sm text-neutral-700 dark:text-neutral-300">Page ${currentPage + 1} of ${totalPages} • ${totalCount.toLocaleString()} results</span>
<button id="nextPageBtn" class="px-3 py-1.5 text-sm rounded-lg ${isLast ? 'bg-neutral-100 text-neutral-400 cursor-not-allowed' : 'bg-white dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-100 dark:hover:bg-neutral-600'} border border-neutral-200 dark:border-neutral-600" ${isLast ? 'disabled' : ''}>
Next <i class="fa-solid fa-chevron-right ml-1"></i>
</button>
`;
const prevBtn = document.getElementById('prevPageBtn');
const nextBtn = document.getElementById('nextPageBtn');
if (prevBtn && !isFirst) prevBtn.addEventListener('click', () => { currentPage = Math.max(0, currentPage - 1); loadCustomers(currentPage, currentSearch); if (typeof syncUrlToState === 'function') { try { syncUrlToState(); } catch (_) {} } });
if (nextBtn && !isLast) nextBtn.addEventListener('click', () => { currentPage = currentPage + 1; loadCustomers(currentPage, currentSearch); if (typeof syncUrlToState === 'function') { try { syncUrlToState(); } catch (_) {} } });
}
async function performPhoneSearch() {
@@ -436,6 +767,20 @@ function displayPhoneSearchResults(results) {
emptyState.classList.add('hidden');
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
? window.highlightUtils.buildTokens((currentSearch || '').trim())
: [];
function highlightText(text) {
if (!text) return '';
if (!window.highlightUtils || typeof window.highlightUtils.highlight !== 'function' || tokens.length === 0) {
return escapeHtml(String(text));
}
const strongHtml = window.highlightUtils.highlight(String(text), tokens);
return strongHtml
.replace(/<strong>/g, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">')
.replace(/<\/strong>/g, '</mark>');
}
results.forEach(result => {
const row = document.createElement('tr');
row.className = 'group border-b border-neutral-100 dark:border-neutral-700/50 hover:bg-gradient-to-r hover:from-blue-50/30 hover:to-indigo-50/30 dark:hover:from-blue-900/10 dark:hover:to-indigo-900/10 transition-all duration-200 cursor-pointer';
@@ -455,12 +800,15 @@ function displayPhoneSearchResults(results) {
</td>
<td class=\"px-4 py-4 customer-cell\">
<div class=\"text-sm font-medium text-neutral-900 dark:text-neutral-100\" title=\"${result.customer.city}, ${result.customer.state}\">
${result.customer.city}, ${result.customer.state}
${highlightText(`${result.customer.city}, ${result.customer.state}`)}
</div>
</td>
<td class=\"px-4 py-4 customer-cell\">
<div class=\"text-sm font-mono font-medium text-neutral-900 dark:text-neutral-100\" title=\"${result.location}: ${result.phone}\">
<div class="font-semibold text-warning-600 dark:text-warning-400">${result.location}: ${result.phone}</div>
<div class="font-semibold text-warning-600 dark:text-warning-400">
<span class="mr-1">${result.location}:</span>
<span>${escapeHtml(result.phone)}</span>
</div>
</div>
</td>
<td class=\"px-4 py-4 customer-cell\">
@@ -517,6 +865,15 @@ async function loadGroups() {
option.textContent = g.group;
select.appendChild(option);
});
// Apply saved selections
try {
const savedStr = localStorage.getItem('customers.filterGroups');
const savedLegacy = localStorage.getItem('customers.filterGroup') || '';
const saved = savedStr ? JSON.parse(savedStr) : (savedLegacy ? [savedLegacy] : []);
if (Array.isArray(saved)) {
Array.from(select.options).forEach(o => { o.selected = saved.includes(o.value); });
}
} catch (_) {}
}
} catch (error) {
console.error('Error loading groups:', error);
@@ -536,12 +893,69 @@ async function loadStates() {
option.textContent = s.state;
select.appendChild(option);
});
// Apply saved selections
try {
const savedStr = localStorage.getItem('customers.filterStates');
const savedLegacy = localStorage.getItem('customers.filterState') || '';
const saved = savedStr ? JSON.parse(savedStr) : (savedLegacy ? [savedLegacy] : []);
if (Array.isArray(saved)) {
Array.from(select.options).forEach(o => { o.selected = saved.includes(o.value); });
}
} catch (_) {}
}
} catch (error) {
console.error('Error loading states:', error);
}
}
function renderActiveFilterChips() {
const container = document.getElementById('activeFilterChips');
const clearBtn = document.getElementById('clearAllFiltersBtn');
if (!container) return;
const groups = Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : [];
const states = Array.isArray(window.currentStateFilters) ? window.currentStateFilters : [];
const chips = [];
groups.forEach(g => chips.push({ type: 'group', label: g }));
states.forEach(s => chips.push({ type: 'state', label: s }));
if (chips.length === 0) {
container.innerHTML = '';
if (clearBtn) clearBtn.classList.add('hidden');
return;
}
container.innerHTML = chips.map((c, idx) => `
<span class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-200 border border-blue-200 dark:border-blue-700" data-type="${c.type}" data-value="${c.label}">
${c.type === 'group' ? 'Group' : 'State'}: ${c.label}
<button type="button" class="chip-remove ml-1 text-blue-700 dark:text-blue-300 hover:text-blue-900" aria-label="Remove">
<i class="fa-solid fa-xmark"></i>
</button>
</span>
`).join('');
if (clearBtn) clearBtn.classList.remove('hidden');
// Wire remove events
Array.from(container.querySelectorAll('.chip-remove')).forEach(btn => {
btn.addEventListener('click', (e) => {
const chip = e.currentTarget.closest('span');
if (!chip) return;
const type = chip.getAttribute('data-type');
const value = chip.getAttribute('data-value');
if (type === 'group') {
window.currentGroupFilters = (window.currentGroupFilters || []).filter(v => v !== value);
try { localStorage.setItem('customers.filterGroups', JSON.stringify(window.currentGroupFilters)); } catch (_) {}
const sel = document.getElementById('groupFilter');
if (sel) Array.from(sel.options).forEach(o => { if (o.value === value) o.selected = false; });
} else if (type === 'state') {
window.currentStateFilters = (window.currentStateFilters || []).filter(v => v !== value);
try { localStorage.setItem('customers.filterStates', JSON.stringify(window.currentStateFilters)); } catch (_) {}
const sel = document.getElementById('stateFilter');
if (sel) Array.from(sel.options).forEach(o => { if (o.value === value) o.selected = false; });
}
currentPage = 0;
renderActiveFilterChips();
loadCustomers(currentPage, currentSearch);
});
});
}
async function showStats() {
try {
const response = await window.http.wrappedFetch('/api/customers/stats');
@@ -597,4 +1011,112 @@ function displayStats(stats) {
// Functions are now implemented in the external customers-tailwind.js file
</script>
<script>
// Build sharable URL reflecting current state
function buildViewUrl() {
const u = new URL(window.location.href);
const p = u.searchParams;
p.set('skip', String(currentPage * (window.customerPageSize || 50)));
p.set('limit', String(window.customerPageSize || 50));
const q = (document.getElementById('searchInput')?.value || '').trim();
if (q) p.set('search', q); else p.delete('search');
const by = window.currentSortBy || 'id';
const dir = window.currentSortDir || 'asc';
p.set('sort_by', by);
p.set('sort_dir', dir);
// Filters
p.delete('groups'); p.delete('states');
(Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : []).forEach(v => p.append('groups', v));
(Array.isArray(window.currentStateFilters) ? window.currentStateFilters : []).forEach(v => p.append('states', v));
u.search = p.toString();
return u.toString();
}
function syncUrlToState() {
const url = buildViewUrl();
window.history.replaceState(null, '', url);
}
// On load, hydrate state from URL if present
function hydrateStateFromUrl() {
const p = new URLSearchParams(window.location.search);
const skip = parseInt(p.get('skip') || '0', 10);
const limit = parseInt(p.get('limit') || String(window.customerPageSize || 50), 10);
if ([25,50,100,200].includes(limit)) {
window.customerPageSize = limit;
try { localStorage.setItem('customers.pageSize', String(limit)); } catch (_) {}
const sizeSel = document.getElementById('pageSizeSelect');
if (sizeSel) sizeSel.value = String(limit);
}
currentPage = Math.max(0, Math.floor(skip / (window.customerPageSize || 50)));
const search = p.get('search') || '';
if (search) {
const input = document.getElementById('searchInput');
if (input) input.value = search;
currentSearch = search;
}
const by = p.get('sort_by');
const dir = p.get('sort_dir');
if (by) window.currentSortBy = by;
if (dir === 'asc' || dir === 'desc') window.currentSortDir = dir;
try {
const urlGroups = p.getAll('groups');
const urlStates = p.getAll('states');
if (urlGroups && urlGroups.length) window.currentGroupFilters = urlGroups;
if (urlStates && urlStates.length) window.currentStateFilters = urlStates;
} catch (_) {}
}
// Call hydrator before first loadCustomers
try { hydrateStateFromUrl(); } catch (_) {}
// After each successful load, reflect state in URL
const __origLoadCustomers = loadCustomers;
loadCustomers = async function(page = 0, search = '') {
await __origLoadCustomers(page, search);
try { syncUrlToState(); } catch (_) {}
}
// Sort indicator rendering
function updateSortIndicators() {
const by = window.currentSortBy || 'id';
const dir = (window.currentSortDir || 'asc') === 'desc' ? 'desc' : 'asc';
const arrow = dir === 'asc' ? '▲' : '▼';
// Reset labels
const labelMap = {
thCustomer: 'Customer',
thName: 'Name',
thCity: 'Location',
thEmail: 'Email'
};
Object.keys(labelMap).forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = labelMap[id];
});
// Apply arrow
const idByField = { id: 'thCustomer', name: 'thName', city: 'thCity', email: 'thEmail' };
const activeId = idByField[by] || 'thCustomer';
const activeEl = document.getElementById(activeId);
if (activeEl) {
activeEl.textContent = `${activeEl.textContent} ${arrow}`;
}
}
// Export preview updater
function updateExportPreview(totalCount, pageCount) {
window.lastCustomersTotal = Number.isFinite(totalCount) ? totalCount : 0;
window.lastCustomersPageCount = Number.isFinite(pageCount) ? pageCount : 0;
const el = document.getElementById('exportPreview');
if (!el) return;
const toggle = document.getElementById('exportAllToggle');
const isAll = !!(toggle && toggle.checked);
const pageClsActive = isAll ? 'text-neutral-600 dark:text-neutral-300' : 'font-semibold text-primary-700 dark:text-primary-300';
const allClsActive = isAll ? 'font-semibold text-primary-700 dark:text-primary-300' : 'text-neutral-600 dark:text-neutral-300';
const pageStr = (window.lastCustomersPageCount || 0).toLocaleString();
const allStr = (window.lastCustomersTotal || 0).toLocaleString();
el.innerHTML = `Export: <span class="${pageClsActive}">${pageStr}</span> page • <span class="${allClsActive}">${allStr}</span> all`;
}
</script>
{% endblock %}