Files
delphi-database/templates/customers.html
2025-08-13 18:53:35 -05:00

1122 lines
66 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>
</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);
}
// 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');
});
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 %}