coming together
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user