1286 lines
77 KiB
HTML
1286 lines
77 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Customers (Rolodex) - Delphi Database{% endblock %}
|
|
|
|
{% block content %}
|
|
<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">
|
|
<div class="flex items-center justify-center w-10 h-10 bg-primary-100 dark:bg-primary-900/30 text-primary-600 dark:text-primary-400 rounded-xl">
|
|
<i class="fa-solid fa-address-book text-lg"></i>
|
|
</div>
|
|
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Customers (Rolodex)</h1>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
<button id="addCustomerBtn" class="flex items-center gap-2 px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200">
|
|
<i class="fa-solid fa-circle-plus"></i>
|
|
<span>New Customer</span>
|
|
<kbd class="hidden sm:inline-block ml-2 px-1.5 py-0.5 bg-success-700 rounded text-xs">Ctrl+N</kbd>
|
|
</button>
|
|
<button id="statsBtn" class="flex items-center gap-2 px-4 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg transition-colors duration-200">
|
|
<i class="fa-solid fa-chart-line"></i>
|
|
<span>Statistics</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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">
|
|
<label for="searchInput" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Search Customers</label>
|
|
<div class="relative">
|
|
<input type="text" id="searchInput" class="w-full pl-10 pr-4 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 placeholder-neutral-400 dark:placeholder-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" placeholder="Start typing to search customers...">
|
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<i class="fa-solid fa-magnifying-glass text-neutral-400 dark:text-neutral-500"></i>
|
|
</div>
|
|
<button id="searchBtn" class="absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-400 hover:text-primary-600 dark:text-neutral-500 dark:hover:text-primary-400 transition-colors">
|
|
<i id="searchIcon" class="fa-solid fa-magnifying-glass text-lg"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<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">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">
|
|
<div class="max-w-md">
|
|
<label for="phoneSearch" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Phone Search</label>
|
|
<div class="relative">
|
|
<input type="text" id="phoneSearch" class="w-full pl-10 pr-4 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 placeholder-neutral-400 dark:placeholder-neutral-500 focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-all duration-200" placeholder="Type phone number to search...">
|
|
<div class="absolute inset-y-0 left-0 flex items-center pl-3">
|
|
<i class="fa-solid fa-phone text-neutral-400 dark:text-neutral-500"></i>
|
|
</div>
|
|
<button id="phoneSearchBtn" class="absolute inset-y-0 right-0 flex items-center pr-3 text-neutral-400 hover:text-primary-600 dark:text-neutral-500 dark:hover:text-primary-400 transition-colors">
|
|
<i class="fa-solid fa-arrow-right text-lg"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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 class="relative inline-block" id="phoneDirWrapper">
|
|
<div class="flex items-center gap-1">
|
|
<button id="phoneDirBtn" 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="Generate printable phone directory">
|
|
<i class="fa-solid fa-address-book mr-1"></i>
|
|
Phone Directory
|
|
</button>
|
|
<div class="relative group">
|
|
<button type="button" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors" title="Phone Directory Help">
|
|
<i class="fa-solid fa-circle-question text-sm"></i>
|
|
</button>
|
|
<div class="hidden group-hover:block absolute right-0 mt-2 w-80 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-3 z-30">
|
|
<div class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase mb-2">Phone Directory Help</div>
|
|
<div class="space-y-2 text-xs text-neutral-600 dark:text-neutral-300">
|
|
<p><strong>Grouping Options:</strong></p>
|
|
<ul class="list-disc list-inside space-y-1 ml-2">
|
|
<li><strong>None:</strong> Alphabetical by last name, no sections</li>
|
|
<li><strong>By Letter:</strong> A-Z sections based on first letter of last name</li>
|
|
<li><strong>By Group:</strong> Sections by customer group (Client, Attorney, etc.)</li>
|
|
<li><strong>Group + Letter:</strong> Group sections, then letter subsections within each</li>
|
|
</ul>
|
|
<p class="mt-2"><strong>Letter Buckets:</strong> Names starting with numbers or symbols go into the "#" bucket.</p>
|
|
<p class="mt-2"><strong>Page Breaks:</strong> HTML format can insert page breaks between top-level groups for printing.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="phoneDirPopover" class="hidden absolute right-0 mt-2 w-80 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">Phone Directory</div>
|
|
<div class="grid grid-cols-2 gap-3 text-sm">
|
|
<div class="col-span-2">
|
|
<label class="block text-xs text-neutral-600 dark:text-neutral-300 mb-1" for="phoneDirMode">Mode</label>
|
|
<select id="phoneDirMode" class="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800">
|
|
<option value="numbers">Numbers</option>
|
|
<option value="addresses">Addresses</option>
|
|
<option value="full">Full</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-neutral-600 dark:text-neutral-300 mb-1" for="phoneDirFormat">Format</label>
|
|
<select id="phoneDirFormat" class="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800">
|
|
<option value="html">HTML</option>
|
|
<option value="csv">CSV</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs text-neutral-600 dark:text-neutral-300 mb-1" for="phoneDirGrouping">Grouping</label>
|
|
<select id="phoneDirGrouping" class="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800">
|
|
<option value="none">None</option>
|
|
<option value="letter">By Letter</option>
|
|
<option value="group">By Group</option>
|
|
<option value="group_letter">Group + Letter</option>
|
|
</select>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<label class="inline-flex items-center gap-2 text-xs">
|
|
<input type="checkbox" id="phoneDirPageBreak">
|
|
<span>Page break per top-level group (HTML only)</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">Uses current group filters and sort.</div>
|
|
<div class="mt-3 flex items-center justify-end gap-2">
|
|
<button id="downloadPhoneDirBtn" class="px-3 py-1.5 text-sm rounded-lg bg-primary-600 text-white hover:bg-primary-700 transition-colors">
|
|
<i class="fa-solid fa-download mr-1"></i>
|
|
Download
|
|
</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/60">
|
|
<tr class="border-b border-neutral-200 dark:border-neutral-700">
|
|
<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">
|
|
<!-- Customer rows will be populated here -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div id="emptyState" class="hidden flex flex-col items-center justify-center py-12 text-center">
|
|
<div class="w-16 h-16 bg-neutral-100 dark:bg-neutral-800 rounded-full flex items-center justify-center mb-4">
|
|
<i class="fa-solid fa-users text-2xl text-neutral-400 dark:text-neutral-500"></i>
|
|
</div>
|
|
<h3 class="text-lg font-medium text-neutral-900 dark:text-neutral-100 mb-2">No customers found</h3>
|
|
<p class="text-neutral-500 dark:text-neutral-400 mb-4">Get started by adding your first customer.</p>
|
|
<button onclick="showAddCustomerModal()" class="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors duration-200">
|
|
<i class="fa-solid fa-circle-plus"></i>
|
|
<span>Add Customer</span>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div class="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50">
|
|
<nav aria-label="Customer pagination">
|
|
<div id="pagination" class="flex items-center justify-center space-x-1">
|
|
<!-- Pagination will be populated here -->
|
|
</div>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Customer Modal -->
|
|
<div id="customerModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-6xl w-full max-h-screen overflow-hidden">
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
|
|
<h2 id="customerModalLabel" class="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Customer Details</h2>
|
|
<button onclick="closeCustomerModal()" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors">
|
|
<i class="fa-solid fa-xmark text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<div class="px-6 py-4 max-h-96 overflow-y-auto scrollbar-thin">
|
|
<form id="customerForm">
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
<!-- Personal Information -->
|
|
<div class="space-y-4">
|
|
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">Personal Information</h3>
|
|
<div>
|
|
<label for="customerId" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Customer ID <span class="text-danger-600">*</span></label>
|
|
<input type="text" id="customerId" required class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="last" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Last Name/Company <span class="text-danger-600">*</span></label>
|
|
<input type="text" id="last" required class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label for="first" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">First Name</label>
|
|
<input type="text" id="first" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="middle" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Middle Name</label>
|
|
<input type="text" id="middle" class="w-full px-3 py-2 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">
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label for="prefix" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Prefix</label>
|
|
<input type="text" id="prefix" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="suffix" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Suffix</label>
|
|
<input type="text" id="suffix" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="title" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Title</label>
|
|
<input type="text" id="title" class="w-full px-3 py-2 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">
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label for="group" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Group</label>
|
|
<input type="text" id="group" list="groupList" class="w-full px-3 py-2 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">
|
|
<datalist id="groupList"></datalist>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Address Information -->
|
|
<div class="space-y-4">
|
|
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">Address</h3>
|
|
<div>
|
|
<label for="a1" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Address Line 1 / Firm</label>
|
|
<input type="text" id="a1" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="a2" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Address Line 2</label>
|
|
<input type="text" id="a2" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="a3" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Address Line 3</label>
|
|
<input type="text" id="a3" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div class="grid grid-cols-3 gap-4">
|
|
<div class="col-span-2">
|
|
<label for="city" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">City</label>
|
|
<input type="text" id="city" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="abrev" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">State</label>
|
|
<input type="text" id="abrev" list="stateList" class="w-full px-3 py-2 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">
|
|
<datalist id="stateList"></datalist>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label for="zip" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Zip Code</label>
|
|
<input type="text" id="zip" class="w-full px-3 py-2 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">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Contact Information -->
|
|
<div class="mb-6">
|
|
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">Contact Information</h3>
|
|
<div>
|
|
<label for="email" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Email</label>
|
|
<input type="email" id="email" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div id="phoneList" class="space-y-4 mt-4"></div>
|
|
<button type="button" id="addPhoneBtn" class="mt-2 flex items-center gap-2 px-3 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200 text-sm">
|
|
<i class="fa-solid fa-circle-plus"></i>
|
|
Add Phone
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Additional Information -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
<div class="space-y-4">
|
|
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100">Additional Details</h3>
|
|
<div>
|
|
<label for="dob" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Date of Birth</label>
|
|
<input type="date" id="dob" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="ss_number" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Social Security Number</label>
|
|
<input type="text" id="ss_number" class="w-full px-3 py-2 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">
|
|
</div>
|
|
<div>
|
|
<label for="legal_status" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Legal Status</label>
|
|
<input type="text" id="legal_status" class="w-full px-3 py-2 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">
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label for="memo" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">Notes</label>
|
|
<textarea id="memo" rows="6" class="w-full px-3 py-2 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"></textarea>
|
|
</div>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50">
|
|
<button onclick="closeCustomerModal()" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors duration-200">
|
|
Cancel
|
|
</button>
|
|
<button id="deleteCustomerBtn" class="hidden px-4 py-2 bg-danger-600 text-white hover:bg-danger-700 rounded-lg transition-colors duration-200">
|
|
<i class="fa-solid fa-trash"></i>
|
|
<span>Delete</span>
|
|
</button>
|
|
<button id="saveCustomerBtn" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors duration-200">
|
|
<i class="fa-regular fa-circle-check"></i>
|
|
<span>Save</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics Modal -->
|
|
<div id="statsModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-2xl w-full">
|
|
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
|
|
<h2 class="text-xl font-semibold text-neutral-900 dark:text-neutral-100">Customer Database Statistics</h2>
|
|
<button onclick="closeStatsModal()" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors">
|
|
<i class="fa-solid fa-xmark text-xl"></i>
|
|
</button>
|
|
</div>
|
|
<div id="statsContent" class="px-6 py-4">
|
|
<!-- Statistics will be loaded here -->
|
|
</div>
|
|
<div class="flex items-center justify-end gap-3 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50">
|
|
<button onclick="closeStatsModal()" class="px-4 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg transition-colors duration-200">
|
|
Close
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="/static/js/customers-tailwind.js?v=12"></script>
|
|
<script src="/static/js/customers-modern.js?v=1"></script>
|
|
|
|
<script>
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', function() {
|
|
// Check authentication first
|
|
const token = localStorage.getItem('auth_token');
|
|
if (!token) {
|
|
window.location.href = '/login';
|
|
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);
|
|
}
|
|
// Support phone directory preconfiguration via URL
|
|
try {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const wantsPhoneDir = params.has('phone_dir') && params.get('phone_dir') !== '0' && params.get('phone_dir') !== 'false';
|
|
const modeParam = params.get('mode');
|
|
const formatParam = params.get('format');
|
|
const groupingParam = params.get('grouping');
|
|
const pageBreakParam = params.get('page_break');
|
|
const namePrefixParam = params.get('name_prefix');
|
|
|
|
// If any params provided, set UI defaults
|
|
setTimeout(() => {
|
|
const fmt = document.getElementById('phoneDirFormat');
|
|
const grp = document.getElementById('phoneDirGrouping');
|
|
const pb = document.getElementById('phoneDirPageBreak');
|
|
const md = document.getElementById('phoneDirMode');
|
|
if (formatParam && fmt) fmt.value = formatParam;
|
|
if (groupingParam && grp) grp.value = groupingParam;
|
|
if (pageBreakParam != null && pb) pb.checked = (pageBreakParam === '1' || pageBreakParam === 'true');
|
|
if (modeParam && md) md.value = modeParam;
|
|
// If name_prefix provided and single char, also bind search field so our downloader logic includes it
|
|
if (typeof namePrefixParam === 'string' && namePrefixParam.length === 1) {
|
|
const s = document.getElementById('searchInput');
|
|
if (s) s.value = namePrefixParam;
|
|
}
|
|
}, 25);
|
|
|
|
// Auto-open popover from hash or when phone_dir=1
|
|
if (window.location.hash === '#phone-dir' || wantsPhoneDir) {
|
|
setTimeout(() => {
|
|
const btn = document.getElementById('phoneDirBtn');
|
|
if (btn) btn.click();
|
|
// If routing from hash without explicit params, set sensible defaults
|
|
if (!formatParam || !groupingParam) {
|
|
const fmt = document.getElementById('phoneDirFormat');
|
|
const grp = document.getElementById('phoneDirGrouping');
|
|
const pb = document.getElementById('phoneDirPageBreak');
|
|
if (fmt && !formatParam) fmt.value = 'html';
|
|
if (grp && !groupingParam) grp.value = 'letter';
|
|
if (pb && !pageBreakParam) pb.checked = true;
|
|
}
|
|
// Auto-trigger download when phone_dir=1
|
|
if (wantsPhoneDir) {
|
|
const dl = document.getElementById('downloadPhoneDirBtn');
|
|
if (dl) dl.click();
|
|
}
|
|
}, 60);
|
|
}
|
|
} catch (_) {}
|
|
|
|
// Initialize page size selector value
|
|
const sizeSel = document.getElementById('pageSizeSelect');
|
|
if (sizeSel) { sizeSel.value = String(window.customerPageSize); }
|
|
});
|
|
|
|
function setupEventListeners() {
|
|
// Real-time search functionality with debouncing
|
|
let searchTimeout;
|
|
const searchInput = document.getElementById('searchInput');
|
|
|
|
searchInput.addEventListener('input', function(e) {
|
|
clearTimeout(searchTimeout);
|
|
searchTimeout = setTimeout(() => {
|
|
performSearch();
|
|
}, 300); // Wait 300ms after user stops typing
|
|
});
|
|
|
|
// Keep existing functionality for search button and Enter key
|
|
document.getElementById('searchBtn').addEventListener('click', performSearch);
|
|
searchInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
clearTimeout(searchTimeout);
|
|
performSearch();
|
|
}
|
|
});
|
|
|
|
// Real-time phone search functionality with debouncing
|
|
let phoneSearchTimeout;
|
|
const phoneSearchInput = document.getElementById('phoneSearch');
|
|
|
|
phoneSearchInput.addEventListener('input', function(e) {
|
|
clearTimeout(phoneSearchTimeout);
|
|
phoneSearchTimeout = setTimeout(() => {
|
|
if (phoneSearchInput.value.trim()) {
|
|
performPhoneSearch();
|
|
} else {
|
|
// If phone search is cleared, go back to regular customer list
|
|
loadCustomers();
|
|
}
|
|
}, 300); // Wait 300ms after user stops typing
|
|
});
|
|
|
|
// Keep existing functionality for phone search button and Enter key
|
|
document.getElementById('phoneSearchBtn').addEventListener('click', performPhoneSearch);
|
|
phoneSearchInput.addEventListener('keypress', function(e) {
|
|
if (e.key === 'Enter') {
|
|
clearTimeout(phoneSearchTimeout);
|
|
performPhoneSearch();
|
|
}
|
|
});
|
|
|
|
// Modal buttons
|
|
document.getElementById('addCustomerBtn').addEventListener('click', showAddCustomerModal);
|
|
document.getElementById('saveCustomerBtn').addEventListener('click', saveCustomer);
|
|
document.getElementById('deleteCustomerBtn').addEventListener('click', deleteCustomer);
|
|
document.getElementById('statsBtn').addEventListener('click', showStats);
|
|
document.getElementById('addPhoneBtn').addEventListener('click', addPhoneNumber);
|
|
|
|
// Form validation
|
|
const customerIdInput = document.getElementById('customerId');
|
|
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');
|
|
});
|
|
// Phone directory popover toggle
|
|
const phoneDirBtn = document.getElementById('phoneDirBtn');
|
|
const phoneDirPopover = document.getElementById('phoneDirPopover');
|
|
if (phoneDirBtn && phoneDirPopover) {
|
|
phoneDirBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
phoneDirPopover.classList.toggle('hidden');
|
|
});
|
|
// Download action
|
|
const downloadBtn = document.getElementById('downloadPhoneDirBtn');
|
|
if (downloadBtn) {
|
|
downloadBtn.addEventListener('click', function(e) {
|
|
e.stopPropagation();
|
|
const mode = (document.getElementById('phoneDirMode')?.value || 'numbers');
|
|
const format = (document.getElementById('phoneDirFormat')?.value || 'html');
|
|
const grouping = (document.getElementById('phoneDirGrouping')?.value || 'letter');
|
|
const pageBreak = !!(document.getElementById('phoneDirPageBreak')?.checked);
|
|
const u = new URL(window.location.origin + '/api/customers/phone-book');
|
|
const p = u.searchParams;
|
|
p.set('mode', mode);
|
|
p.set('format', format);
|
|
p.set('grouping', grouping);
|
|
if (pageBreak) p.set('page_break', '1');
|
|
// Include filters and sort
|
|
const by = window.currentSortBy || 'name';
|
|
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));
|
|
// Optional name prefix: if user typed single letter quickly, offer faster slicing
|
|
const q = (document.getElementById('searchInput')?.value || '').trim();
|
|
if (q && q.length === 1) {
|
|
p.set('name_prefix', q);
|
|
}
|
|
// Trigger download
|
|
window.location.href = u.toString();
|
|
phoneDirPopover.classList.add('hidden');
|
|
});
|
|
}
|
|
// Clicking outside closes both popovers
|
|
document.addEventListener('click', function() {
|
|
phoneDirPopover.classList.add('hidden');
|
|
});
|
|
phoneDirPopover.addEventListener('click', function(e) { e.stopPropagation(); });
|
|
}
|
|
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
|
|
function closeStatsModal() {
|
|
document.getElementById('statsModal').classList.add('hidden');
|
|
}
|
|
|
|
// Load customers with enhanced formatting
|
|
async function loadCustomers(page = 0, search = '') {
|
|
try {
|
|
// Show loading state
|
|
setSearchLoading(true);
|
|
|
|
const params = new URLSearchParams({
|
|
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 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);
|
|
showAlert('Error loading customers: ' + error.message, 'danger');
|
|
} finally {
|
|
// Hide loading state
|
|
setSearchLoading(false);
|
|
}
|
|
}
|
|
|
|
function setSearchLoading(isLoading) {
|
|
const searchIcon = document.getElementById('searchIcon');
|
|
if (isLoading) {
|
|
searchIcon.className = 'fa-solid fa-spinner fa-spin text-lg';
|
|
} else {
|
|
searchIcon.className = 'fa-solid fa-magnifying-glass text-lg';
|
|
}
|
|
}
|
|
|
|
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() {
|
|
const phone = document.getElementById('phoneSearch').value;
|
|
if (!phone) return;
|
|
|
|
try {
|
|
const response = await window.http.wrappedFetch(`/api/customers/search/phone?phone=${encodeURIComponent(phone)}`);
|
|
|
|
if (!response.ok) throw new Error('Phone search failed');
|
|
|
|
const results = await response.json();
|
|
displayPhoneSearchResults(results);
|
|
|
|
} catch (error) {
|
|
console.error('Phone search error:', error);
|
|
showAlert('Phone search failed: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
function displayPhoneSearchResults(results) {
|
|
const tbody = document.getElementById('customersTableBody');
|
|
const emptyState = document.getElementById('emptyState');
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
if (!results || results.length === 0) {
|
|
emptyState.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
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';
|
|
|
|
// Store customer ID as data attribute to avoid escaping issues
|
|
row.dataset.customerId = result.customer.id;
|
|
|
|
row.innerHTML = `
|
|
<td class=\"px-4 py-4 customer-cell\">
|
|
<div class=\"text-sm font-mono font-semibold text-neutral-900 dark:text-neutral-100 bg-gradient-to-br from-neutral-50 to-neutral-100 dark:from-neutral-800 dark:to-neutral-700 px-3 py-2 rounded-lg shadow-sm border border-neutral-200/50 dark:border-neutral-600/50 group-hover:shadow-md transition-shadow\" title=\"${result.customer.id}\">${result.customer.id}</div>
|
|
</td>
|
|
<td class=\"px-4 py-4 customer-cell\">
|
|
<div class=\"text-sm font-semibold text-neutral-900 dark:text-neutral-100 group-hover:text-blue-700 dark:group-hover:text-blue-300 transition-colors\" title=\"${result.customer.name}\">${result.customer.name}</div>
|
|
</td>
|
|
<td class=\"px-4 py-4 customer-cell\">
|
|
<span class="text-neutral-400 dark:text-neutral-500 text-sm font-medium">-</span>
|
|
</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}\">
|
|
${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">
|
|
<span class="mr-1">${result.location}:</span>
|
|
<span>${escapeHtml(result.phone)}</span>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class=\"px-4 py-4 customer-cell\">
|
|
<span class="text-neutral-400 dark:text-neutral-500 text-sm font-medium">-</span>
|
|
</td>
|
|
<td class=\"px-4 py-4 text-right\">
|
|
<div class=\"flex items-center justify-end space-x-2 opacity-70 group-hover:opacity-100 transition-opacity\">
|
|
<button class=\"view-phone-result-btn inline-flex items-center px-3 py-2 bg-gradient-to-r from-slate-100 to-slate-200 dark:from-slate-700 dark:to-slate-600 text-slate-700 dark:text-slate-200 hover:from-slate-200 hover:to-slate-300 dark:hover:from-slate-600 dark:hover:to-slate-500 rounded-lg text-sm font-semibold transition-all duration-200 shadow-sm hover:shadow-md border border-slate-300/50 dark:border-slate-500/50\">
|
|
<i class=\"fa-solid fa-eye mr-2\"></i>
|
|
View
|
|
</button>
|
|
<button class=\"edit-phone-result-btn inline-flex items-center px-3 py-2 bg-gradient-to-r from-blue-600 to-blue-700 dark:from-blue-700 dark:to-blue-800 text-white hover:from-blue-700 hover:to-blue-800 dark:hover:from-blue-600 dark:hover:to-blue-700 rounded-lg text-sm font-semibold transition-all duration-200 shadow-sm hover:shadow-md\">
|
|
<i class=\"fa-solid fa-pencil mr-2\"></i>
|
|
Edit
|
|
</button>
|
|
</div>
|
|
</td>
|
|
`;
|
|
|
|
// Add event listeners for clickable row (same as main customer table)
|
|
const customerCells = row.querySelectorAll('.customer-cell');
|
|
customerCells.forEach(cell => {
|
|
cell.addEventListener('click', () => viewCustomer(result.customer.id));
|
|
});
|
|
|
|
// Add event listener for the view button
|
|
const viewBtn = row.querySelector('.view-phone-result-btn');
|
|
viewBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
viewCustomer(result.customer.id);
|
|
});
|
|
|
|
// Add event listener for the edit button to avoid backslash escaping issues
|
|
const editBtn = row.querySelector('.edit-phone-result-btn');
|
|
editBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
editCustomer(result.customer.id);
|
|
});
|
|
|
|
tbody.appendChild(row);
|
|
});
|
|
}
|
|
|
|
async function loadGroups() {
|
|
try {
|
|
const response = await window.http.wrappedFetch('/api/customers/groups');
|
|
|
|
if (response.ok) {
|
|
const groups = await response.json();
|
|
const select = document.getElementById('groupFilter');
|
|
groups.forEach(g => {
|
|
const option = document.createElement('option');
|
|
option.value = g.group;
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function loadStates() {
|
|
try {
|
|
const response = await window.http.wrappedFetch('/api/customers/states');
|
|
|
|
if (response.ok) {
|
|
const states = await response.json();
|
|
const select = document.getElementById('stateFilter');
|
|
states.forEach(s => {
|
|
const option = document.createElement('option');
|
|
option.value = s.state;
|
|
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');
|
|
|
|
if (!response.ok) throw new Error('Failed to load statistics');
|
|
|
|
const stats = await response.json();
|
|
displayStats(stats);
|
|
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
showAlert('Error loading statistics: ' + error.message, 'danger');
|
|
}
|
|
}
|
|
|
|
function displayStats(stats) {
|
|
const content = document.getElementById('statsContent');
|
|
content.innerHTML = `
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">Database Overview</h3>
|
|
<div class="space-y-2">
|
|
<div class="flex justify-between">
|
|
<span class="text-neutral-600 dark:text-neutral-400">Total Customers:</span>
|
|
<span class="font-semibold text-neutral-900 dark:text-neutral-100">${stats.total_customers}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-neutral-600 dark:text-neutral-400">Phone Numbers:</span>
|
|
<span class="font-semibold text-neutral-900 dark:text-neutral-100">${stats.total_phone_numbers}</span>
|
|
</div>
|
|
<div class="flex justify-between">
|
|
<span class="text-neutral-600 dark:text-neutral-400">With Email:</span>
|
|
<span class="font-semibold text-neutral-900 dark:text-neutral-100">${stats.customers_with_email}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-3">Group Breakdown</h3>
|
|
<div class="space-y-2">
|
|
${stats.group_breakdown.map(g => `
|
|
<div class="flex justify-between">
|
|
<span class="text-neutral-600 dark:text-neutral-400">${g.group}:</span>
|
|
<span class="font-semibold text-neutral-900 dark:text-neutral-100">${g.count}</span>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('statsModal').classList.remove('hidden');
|
|
}
|
|
|
|
// 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 %} |