coming together

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

View File

@@ -141,15 +141,15 @@
<i class="fa-solid fa-file-import"></i>
<span>Import</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="backup-tab" data-tab-target="#backup" type="button" role="tab">
<i class="fa-solid fa-shield-halved"></i>
<span>Backup</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="issues-tab" data-tab-target="#issues" type="button" role="tab">
<i class="fa-solid fa-bug"></i>
<span>Issues</span>
<span class="ml-1 px-2 py-0.5 bg-red-100 dark:bg-red-800 text-red-700 dark:text-red-400 text-xs rounded-full hidden" id="issues-badge">0</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="backup-tab" data-tab-target="#backup" type="button" role="tab">
<i class="fa-solid fa-shield-halved"></i>
<span>Backup</span>
</button>
</nav>
<!-- Tab Content -->
@@ -2087,36 +2087,54 @@ async function viewIssue(issueId) {
}[issue.status] || 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300');
// Update context info
document.getElementById('issueCurrentPage').textContent = issue.current_page || 'Unknown';
document.getElementById('issueBrowserInfo').textContent = issue.browser_info || 'Unknown';
document.getElementById('issueIpAddress').textContent = issue.ip_address || 'Unknown';
document.getElementById('issueDetailPage').textContent = issue.current_page || 'Unknown';
document.getElementById('issueDetailBrowser').textContent = issue.browser_info || 'Unknown';
// Update sidebar info
document.getElementById('issueDetailReporter').textContent = issue.contact_name;
document.getElementById('issueDetailEmail').textContent = issue.contact_email;
document.getElementById('issueDetailCreated').textContent = new Date(issue.created_at).toLocaleString();
document.getElementById('issueDetailUpdated').textContent = issue.updated_at ? new Date(issue.updated_at).toLocaleString() : 'Never';
// Update sidebar info (only if elements exist)
const reporterEl = document.getElementById('issueDetailReporter');
if (reporterEl) reporterEl.textContent = issue.contact_name;
const emailEl = document.getElementById('issueDetailEmail');
if (emailEl) emailEl.textContent = issue.contact_email;
const createdEl = document.getElementById('issueDetailCreated');
if (createdEl) createdEl.textContent = new Date(issue.created_at).toLocaleString();
const updatedEl = document.getElementById('issueDetailUpdated');
if (updatedEl) updatedEl.textContent = issue.updated_at ? new Date(issue.updated_at).toLocaleString() : 'Never';
if (issue.resolved_at) {
document.getElementById('issueDetailResolved').textContent = new Date(issue.resolved_at).toLocaleString();
document.getElementById('issueResolvedInfo').style.display = 'block';
const resolvedEl = document.getElementById('issueDetailResolved');
if (resolvedEl) resolvedEl.textContent = new Date(issue.resolved_at).toLocaleString();
const resolvedInfoEl = document.getElementById('issueResolvedInfo');
if (resolvedInfoEl) resolvedInfoEl.style.display = 'block';
} else {
document.getElementById('issueResolvedInfo').style.display = 'none';
const resolvedInfoEl = document.getElementById('issueResolvedInfo');
if (resolvedInfoEl) resolvedInfoEl.style.display = 'none';
}
// Update form fields for editing
document.getElementById('updateStatus').value = issue.status;
document.getElementById('updatePriority').value = issue.priority;
document.getElementById('updateAssignee').value = issue.assigned_to || '';
// Update form fields for editing (only if elements exist)
const statusEl = document.getElementById('updateStatus');
if (statusEl) statusEl.value = issue.status;
const priorityEl = document.getElementById('updatePriority');
if (priorityEl) priorityEl.value = issue.priority;
const assigneeEl = document.getElementById('updateAssignee');
if (assigneeEl) assigneeEl.value = issue.assigned_to || '';
// Store current issue ID for updates
window.currentIssueId = issue.id;
// Load users for assignment dropdown
await loadUsersForAssignment();
// Load users for assignment dropdown (if function exists)
if (typeof loadUsersForAssignment === 'function') {
await loadUsersForAssignment();
}
// Load and display responses
displayIssueResponses(issue.responses);
// Load and display responses (if function exists)
if (typeof displayIssueResponses === 'function') {
displayIssueResponses(issue.responses);
}
// Show modal
openModal('issueDetailModal');

View File

@@ -51,6 +51,14 @@
<i class="fa-solid fa-magnifying-glass"></i>
<span>Search</span>
</a>
<a id="nav-import-desktop" href="/import" data-shortcut="Alt+I" class="hidden flex items-center gap-2 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import</span>
</a>
<a id="nav-flexible-desktop" href="/flexible" class="hidden flex items-center gap-2 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-table-columns"></i>
<span>Flexible</span>
</a>
</div>
<!-- Right side items -->
@@ -113,6 +121,14 @@
<i class="fa-solid fa-magnifying-glass"></i>
<span>Search</span>
</a>
<a id="nav-import-mobile" href="/import" class="hidden flex items-center gap-3 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-cloud-arrow-up"></i>
<span>Import</span>
</a>
<a id="nav-flexible-mobile" href="/flexible" class="hidden flex items-center gap-3 px-3 py-2 rounded-lg text-primary-100 hover:text-white hover:bg-primary-700 transition-all duration-200">
<i class="fa-solid fa-table-columns"></i>
<span>Flexible</span>
</a>
</div>
</div>
</div>
@@ -190,6 +206,10 @@
<span class="text-neutral-600 dark:text-neutral-400">Documents/QDROs</span>
<kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+D</kbd>
</li>
<li class="flex items-center justify-between">
<span class="text-neutral-600 dark:text-neutral-400">Data Import</span>
<kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+I</kbd>
</li>
<li class="flex items-center justify-between">
<span class="text-neutral-600 dark:text-neutral-400">Admin Panel</span>
<kbd class="px-2 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded text-xs font-mono">Alt+A</kbd>
@@ -359,15 +379,29 @@
const el = document.getElementById(id);
if (el) el.classList.add('hidden');
}
// Lightweight shake animation utility class
(function(){
const styleId = 'inline-shake-style';
if (!document.getElementById(styleId)) {
const css = '@keyframes _shake_kf{0%,100%{transform:translateX(0)}20%{transform:translateX(-4px)}40%{transform:translateX(4px)}60%{transform:translateX(-3px)}80%{transform:translateX(3px)}}.animate-shake{animation:_shake_kf .4s ease-in-out;}';
const tag = document.createElement('style');
tag.id = styleId;
tag.type = 'text/css';
tag.appendChild(document.createTextNode(css));
document.head.appendChild(tag);
}
})();
</script>
<!-- Custom JavaScript -->
<!-- Fetch wrapper should be loaded early. It exposes window.http.wrappedFetch and also wraps global fetch for compatibility. -->
<script src="/static/js/fetch-wrapper.js"></script>
<script src="/static/js/sanitizer.js"></script>
<script src="/static/js/highlight.js"></script>
<!-- Load main.js first so global handlers are registered before other scripts -->
<script src="/static/js/main.js"></script>
<script src="/static/js/alerts.js"></script>
<script src="/static/js/upload-helper.js"></script>
<script src="/static/js/keyboard-shortcuts.js"></script>
{% block extra_scripts %}{% endblock %}
@@ -391,7 +425,8 @@
'/documents': 'Document Management',
'/import': 'Data Import',
'/search': 'Advanced Search',
'/admin': 'System Administration'
'/admin': 'System Administration',
'/flexible': 'Flexible Imports'
};
const currentPage = pageNames[path] || `Page: ${path}`;

View File

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

View File

@@ -121,6 +121,11 @@
<span class="font-medium">Global Search</span>
<kbd class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Ctrl+F</kbd>
</button>
<button onclick="window.location.href='/import'" class="w-full flex flex-col items-center justify-center p-4 bg-neutral-50 dark:bg-neutral-900/50 hover:bg-neutral-100 dark:hover:bg-neutral-900 rounded-lg border border-neutral-200 dark:border-neutral-700 transition-colors duration-200">
<i class="fa-solid fa-cloud-arrow-up text-2xl text-primary-600 mb-1"></i>
<span class="font-medium">Import Data</span>
<kbd class="text-xs text-neutral-500 dark:text-neutral-400 mt-1">Alt+I</kbd>
</button>
</div>
</div>
</div>
@@ -130,14 +135,22 @@
<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-clock-rotate-left"></i>
<span>Recent Activity</span>
<span>Recent Activity & Imports</span>
</h5>
</div>
<div class="p-6" id="recent-activity">
<div class="p-6 space-y-4">
<div id="recent-imports">
<div class="flex flex-col items-center justify-center py-4 text-neutral-500 dark:text-neutral-400">
<i class="fa-solid fa-file-arrow-up text-2xl mb-2"></i>
<p>Loading recent imports...</p>
</div>
</div>
<div id="recent-activity">
<div class="flex flex-col items-center justify-center py-4 text-neutral-500 dark:text-neutral-400">
<i class="fa-solid fa-hourglass-half text-2xl mb-2"></i>
<p>Loading recent activity...</p>
</div>
</div>
</div>
</div>
</div>
@@ -227,6 +240,81 @@ function globalSearch() {
// Load data on page load
document.addEventListener('DOMContentLoaded', function() {
loadDashboardData(); // Uncomment when authentication is implemented
loadRecentImports();
loadRecentActivity();
});
async function loadRecentActivity() {
// Placeholder: existing system would populate; if an endpoint exists, hook it here.
}
async function loadRecentImports() {
try {
const [statusResp, recentResp] = await Promise.all([
window.http.wrappedFetch('/api/import/status'),
window.http.wrappedFetch('/api/import/recent-batches?limit=5')
]);
if (!statusResp.ok) return;
const status = await statusResp.json();
const recent = recentResp && recentResp.ok ? (await recentResp.json()).recent || [] : [];
const entries = Object.entries(status || {});
const total = entries.reduce((sum, [, v]) => sum + (v && v.record_count ? v.record_count : 0), 0);
const top = entries
.filter(([, v]) => (v && v.record_count) > 0)
.slice(0, 6)
.map(([k, v]) => ({ name: k, count: v.record_count, table: v.table_name }));
const container = document.getElementById('recent-imports');
if (!container) return;
if (entries.length === 0 && recent.length === 0) {
container.innerHTML = '<p class="text-neutral-500">No import status available.</p>';
return;
}
const items = top.map(({ name, count }) => `
<div class="flex items-center justify-between py-1 text-sm">
<span class="font-mono">${name}</span>
<span class="inline-block px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-700">${Number(count).toLocaleString()}</span>
</div>
`).join('');
const recentRows = (recent || []).map(r => `
<tr>
<td class="px-2 py-1"><span class="inline-block px-2 py-0.5 rounded text-xs ${r.status === 'success' ? 'bg-green-100 text-green-700' : (r.status === 'completed_with_errors' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700')}">${r.status}</span></td>
<td class="px-2 py-1 text-xs">${r.started_at ? new Date(r.started_at).toLocaleString() : ''}</td>
<td class="px-2 py-1 text-xs">${r.finished_at ? new Date(r.finished_at).toLocaleString() : ''}</td>
<td class="px-2 py-1 text-xs">${r.successful_files}/${r.total_files}</td>
<td class="px-2 py-1 text-xs">${Number(r.total_imported || 0).toLocaleString()}</td>
</tr>
`).join('');
const html = `
<div>
<div class="flex items-center justify-between mb-2">
<h6 class="text-sm font-semibold flex items-center gap-2"><i class="fa-solid fa-file-arrow-up"></i> Recent Import Status</h6>
<a href="/import" class="text-primary-600 hover:underline text-sm">Open Import</a>
</div>
<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg p-3">${items || '<p class="text-neutral-500 text-sm">No imported data yet.</p>'}</div>
<div class="mt-2 text-xs text-neutral-600 dark:text-neutral-400">Total records across tracked CSVs: <strong>${Number(total).toLocaleString()}</strong></div>
<div class="mt-3">
<h6 class="text-sm font-semibold mb-1">Last 5 Batch Uploads</h6>
<div class="overflow-x-auto">
<table class="w-full text-xs border border-neutral-200 dark:border-neutral-700 rounded">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th class="px-2 py-1 text-left">Status</th>
<th class="px-2 py-1 text-left">Started</th>
<th class="px-2 py-1 text-left">Finished</th>
<th class="px-2 py-1 text-left">Files</th>
<th class="px-2 py-1 text-left">Imported</th>
</tr>
</thead>
<tbody>
${recentRows || '<tr><td class="px-2 py-2 text-neutral-500" colspan="5">No recent batch uploads</td></tr>'}
</tbody>
</table>
</div>
</div>
</div>
`;
container.innerHTML = html;
} catch (_) {}
}
</script>
{% endblock %}

View File

@@ -133,8 +133,41 @@
<div id="generated" class="tabcontent p-6 hidden">
<div class="mt-3 bg-white dark:bg-neutral-800 rounded-xl shadow-soft border border-neutral-200 dark:border-neutral-700">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700"><h5 class="mb-0 font-semibold"><i class="fa-regular fa-file-pdf"></i> Generated Documents</h5></div>
<div class="p-6"><div id="generatedDocuments"><p class="text-neutral-500">Generated documents will appear here...</p></div></div>
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700"><h5 class="mb-0 font-semibold"><i class="fa-regular fa-file-pdf"></i> Generated & Uploaded Documents</h5></div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-3 items-end mb-4">
<div>
<label for="uploadFileNo" class="block text-sm font-medium mb-1">File Number</label>
<div class="flex gap-2">
<input type="text" 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" id="uploadFileNo" placeholder="Enter file #">
<button type="button" id="clearUploadFileNoBtn" class="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700" title="Clear">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div id="uploadFileNoError" class="text-xs text-red-600 mt-1 hidden">Please enter a file number</div>
</div>
<div>
<label for="uploadInput" class="block text-sm font-medium mb-1">Choose File</label>
<input type="file" id="uploadInput" class="block w-full text-sm text-neutral-900 dark:text-neutral-100 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-semibold file:bg-neutral-100 dark:file:bg-neutral-700 file:text-neutral-700 dark:file:text-neutral-200 hover:file:bg-neutral-200 dark:hover:file:bg-neutral-600" />
<div id="uploadInputError" class="text-xs text-red-600 mt-1 hidden">Please choose a file to upload</div>
</div>
<div class="flex gap-2">
<button type="button" id="uploadBtn" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg transition-colors"><i class="fa-solid fa-upload mr-2"></i>Upload</button>
<button type="button" id="refreshUploadsBtn" class="px-4 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-100 dark:hover:bg-neutral-700 transition-colors"><i class="fa-solid fa-rotate-right mr-2"></i>Refresh</button>
</div>
</div>
<div id="uploadDropZone" class="mt-3 p-6 border-2 border-dashed border-neutral-300 dark:border-neutral-600 rounded-lg text-center text-neutral-500">
<i class="fa-solid fa-cloud-arrow-up text-2xl mb-2"></i>
<div>Drag & drop files here to upload</div>
<div class="text-xs mt-1">or use the chooser above and click Upload</div>
</div>
<div id="uploadingIndicator" class="mt-2 text-sm text-neutral-500 hidden"><i class="fa-solid fa-spinner animate-spin mr-2"></i>Uploading…</div>
<div id="uploadProgressList" class="space-y-2 mt-3"></div>
<div id="uploadedDocuments" class="mb-6">
<p class="text-neutral-500">Uploaded documents will appear here.</p>
</div>
<div id="generatedDocuments"><p class="text-neutral-500">Generated documents will appear here...</p></div>
</div>
</div>
</div>
</div>
@@ -432,8 +465,36 @@
</div>
</div>
<!-- Edit Upload Description Modal -->
<div class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4" id="editUploadModal">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-md w-full">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="text-lg font-semibold">Edit Description</h5>
<button type="button" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300" onclick="closeModal('editUploadModal')"><i class="fa-solid fa-xmark"></i></button>
</div>
<div class="px-6 py-4">
<input type="hidden" id="editUploadId">
<label for="editUploadDescription" class="block text-sm font-medium mb-1">Description</label>
<textarea id="editUploadDescription" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" rows="4" placeholder="Enter description..."></textarea>
</div>
<div class="flex items-center justify-end gap-2 px-6 py-4 border-t border-neutral-200 dark:border-neutral-700">
<button type="button" 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" onclick="closeModal('editUploadModal')">Cancel</button>
<button type="button" class="px-4 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg" id="saveEditUploadBtn"><i class="fa-regular fa-circle-check"></i> Save</button>
</div>
</div>
</div>
<script>
// Document Management JavaScript
// Example: Upload with correlation-aware alerts
// -------------------------------------------------
// const input = document.querySelector('#uploadInput');
// const fileNo = 'ABC-123';
// const form = new FormData();
// form.append('file', input.files[0]);
// uploadWithAlerts(`/api/documents/upload/${fileNo}`, form)
// .then(() => alerts.success('Upload completed', { duration: 3000 }))
// .catch(() => {/* failure already alerted with Ref: <cid> */});
document.addEventListener('DOMContentLoaded', function() {
// Check authentication first
const token = localStorage.getItem('auth_token');
@@ -590,6 +651,153 @@ function setupEventHandlers() {
// Refresh buttons
document.getElementById('refreshTemplatesBtn').addEventListener('click', loadTemplates);
document.getElementById('refreshQdrosBtn').addEventListener('click', loadQdros);
const refreshUploadsBtn = document.getElementById('refreshUploadsBtn');
if (refreshUploadsBtn) refreshUploadsBtn.addEventListener('click', loadUploadedDocuments);
const uploadBtn = document.getElementById('uploadBtn');
if (uploadBtn) uploadBtn.addEventListener('click', handleUploadClick);
const saveEditUploadBtn = document.getElementById('saveEditUploadBtn');
if (saveEditUploadBtn) saveEditUploadBtn.addEventListener('click', saveEditUpload);
const dropZone = document.getElementById('uploadDropZone');
if (dropZone) initUploadDropZone(dropZone);
const clearUploadBtn = document.getElementById('clearUploadFileNoBtn');
if (clearUploadBtn) clearUploadBtn.addEventListener('click', clearUploadFileNo);
// Upload controls enable/disable
const fileNoInput = document.getElementById('uploadFileNo');
const uploadInput = document.getElementById('uploadInput');
if (fileNoInput) {
fileNoInput.addEventListener('input', updateUploadControlsState);
fileNoInput.addEventListener('change', updateUploadControlsState);
fileNoInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const fileNo = (fileNoInput.value || '').trim();
const hasFile = !!(uploadInput && uploadInput.files && uploadInput.files.length > 0);
if (fileNo && hasFile) {
const btn = document.getElementById('uploadBtn');
if (btn && btn.disabled) return;
handleUploadClick();
} else if (!hasFile && uploadInput) {
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.remove('hidden');
uploadInput.focus();
}
}
});
}
if (uploadInput) {
uploadInput.addEventListener('change', () => {
updateUploadControlsState();
try {
const fileNoInputEl = document.getElementById('uploadFileNo');
const fileNoVal = (fileNoInputEl?.value || '').trim();
const hasFile = !!(uploadInput && uploadInput.files && uploadInput.files.length > 0);
if (hasFile && !fileNoVal && fileNoInputEl) {
fileNoInputEl.focus();
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.remove('hidden');
shakeElement(fileNoInputEl);
}
} catch (_) {}
});
uploadInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
const hasFile = !!(uploadInput && uploadInput.files && uploadInput.files.length > 0);
if (fileNo && hasFile) {
const btn = document.getElementById('uploadBtn');
if (btn && btn.disabled) return;
handleUploadClick();
} else if (!fileNo) {
const err = document.getElementById('uploadFileNoError');
if (err) err.classList.remove('hidden');
const fileNoInputEl = document.getElementById('uploadFileNo');
if (fileNoInputEl) {
fileNoInputEl.focus();
shakeElement(fileNoInputEl);
}
}
} else if (e.key === 'Escape') {
e.preventDefault();
try {
uploadInput.value = '';
updateUploadControlsState();
uploadInput.focus();
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.add('hidden');
} catch (_) {}
}
});
}
updateUploadControlsState();
// Persist and restore last used upload file number
const fileNoInput = document.getElementById('uploadFileNo');
if (fileNoInput) {
try {
const saved = localStorage.getItem('docs_last_upload_file_no');
if (saved) {
fileNoInput.value = saved;
// Auto-load uploads for restored file number and show one-time hint
try {
if ((saved || '').trim()) {
loadUploadedDocuments().then(() => {
try {
if (!sessionStorage.getItem('docs_auto_loaded_hint_shown')) {
showAlert(`Loaded uploads for file ${saved}`, 'info');
sessionStorage.setItem('docs_auto_loaded_hint_shown', '1');
}
} catch (_) {}
});
}
} catch (_) {}
}
const persist = () => {
try { localStorage.setItem('docs_last_upload_file_no', (fileNoInput.value || '').trim()); } catch (_) {}
};
fileNoInput.addEventListener('input', persist);
fileNoInput.addEventListener('change', persist);
} catch (_) {}
}
}
function updateUploadControlsState() {
try {
const btn = document.getElementById('uploadBtn');
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
const input = document.getElementById('uploadInput');
const hasFile = !!(input && input.files && input.files.length > 0);
const enabled = !!fileNo && hasFile;
if (btn) {
btn.disabled = !enabled;
btn.classList.toggle('opacity-50', !enabled);
btn.classList.toggle('cursor-not-allowed', !enabled);
btn.setAttribute('aria-disabled', String(!enabled));
}
// Inline error messages
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.toggle('hidden', !!fileNo);
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.toggle('hidden', hasFile);
} catch (_) {}
}
function shakeElement(el) {
try {
el.classList.add('animate-shake');
setTimeout(() => el.classList.remove('animate-shake'), 400);
} catch (_) {}
}
function clearUploadFileNo() {
try {
const input = document.getElementById('uploadFileNo');
if (input) input.value = '';
try { localStorage.removeItem('docs_last_upload_file_no'); } catch (_) {}
const container = document.getElementById('uploadedDocuments');
if (container) container.innerHTML = '<p class="text-neutral-500">No uploads found for this file.</p>';
} catch (_) {}
}
// Authorization and JSON headers are injected by window.http.wrappedFetch
@@ -633,6 +841,209 @@ async function loadTemplates() {
}
}
function initUploadDropZone(zoneEl) {
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter','dragover','dragleave','drop'].forEach(eventName => {
zoneEl.addEventListener(eventName, preventDefaults, false);
});
zoneEl.addEventListener('dragover', () => zoneEl.classList.add('bg-neutral-50', 'dark:bg-neutral-900/20'));
zoneEl.addEventListener('dragleave', () => zoneEl.classList.remove('bg-neutral-50', 'dark:bg-neutral-900/20', 'border-red-400'));
zoneEl.addEventListener('drop', async (e) => {
zoneEl.classList.remove('bg-neutral-50', 'dark:bg-neutral-900/20', 'border-red-400');
const dt = e.dataTransfer;
const files = dt && dt.files ? Array.from(dt.files) : [];
if (!files.length) return;
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
if (!fileNo) {
zoneEl.classList.add('border-red-400');
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.remove('hidden');
showAlert('Please enter a file number', 'warning');
return;
}
try { localStorage.setItem('docs_last_upload_file_no', fileNo); } catch (_) {}
setUploadingState(true);
await concurrentUploads(files, fileNo, 3);
setUploadingState(false);
loadUploadedDocuments();
});
}
function createUploadItem(file) {
const list = document.getElementById('uploadProgressList');
const item = document.createElement('div');
item.className = 'flex items-center justify-between p-3 border rounded-lg';
const name = document.createElement('div');
name.className = 'text-sm font-medium truncate max-w-[60%]';
name.textContent = file.name;
const right = document.createElement('div');
right.className = 'flex items-center gap-3';
const status = document.createElement('span');
status.className = 'text-xs text-neutral-500';
status.textContent = 'Queued';
const barWrap = document.createElement('div');
barWrap.className = 'w-40 h-2 bg-neutral-200 dark:bg-neutral-700 rounded overflow-hidden';
const bar = document.createElement('div');
bar.className = 'h-2 bg-primary-500 w-0 transition-all';
bar.style.width = '0%';
barWrap.appendChild(bar);
const cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.title = 'Cancel upload';
cancelBtn.className = 'px-2 py-1 border border-neutral-400 text-neutral-600 rounded hover:bg-neutral-100 text-xs';
cancelBtn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
cancelBtn.disabled = true;
right.appendChild(status);
right.appendChild(barWrap);
right.appendChild(cancelBtn);
item.appendChild(name);
item.appendChild(right);
list.appendChild(item);
let abortFn = null;
cancelBtn.addEventListener('click', () => {
if (typeof abortFn === 'function') {
status.textContent = 'Cancelling…';
cancelBtn.disabled = true;
abortFn();
}
});
return { item, status, bar, cancelBtn, setAbort: (fn) => { abortFn = fn; cancelBtn.disabled = !fn; } };
}
function setUploadingState(isUploading) {
try {
const uploadBtn = document.getElementById('uploadBtn');
const dropZone = document.getElementById('uploadDropZone');
const indicator = document.getElementById('uploadingIndicator');
if (uploadBtn) uploadBtn.disabled = !!isUploading;
if (dropZone) dropZone.classList.toggle('opacity-50', !!isUploading);
if (dropZone) dropZone.classList.toggle('pointer-events-none', !!isUploading);
if (indicator) indicator.classList.toggle('hidden', !isUploading);
} catch (_) {}
}
async function concurrentUploads(files, fileNo, concurrency = 3) {
const queue = Array.from(files);
const active = new Set();
let completed = 0;
function startNext() {
if (queue.length === 0 || active.size >= concurrency) return null;
const file = queue.shift();
const ui = createUploadItem(file);
const controller = new AbortController();
ui.setAbort(() => controller.abort());
const task = (async () => {
try {
ui.status.textContent = 'Uploading…';
ui.bar.style.width = '25%';
const form = new FormData();
form.append('file', file);
await uploadWithAlerts(`/api/documents/upload/${encodeURIComponent(fileNo)}`, form, { alertTitle: 'Upload failed', extraOptions: { signal: controller.signal } });
ui.bar.style.width = '100%';
ui.status.textContent = 'Done';
ui.status.className = 'text-xs text-green-600';
} catch (err) {
if (err && (err.name === 'AbortError' || /aborted/i.test(String(err && err.message)))) {
ui.status.textContent = 'Canceled';
ui.status.className = 'text-xs text-yellow-600';
ui.bar.style.width = '100%';
ui.bar.classList.remove('bg-primary-500');
ui.bar.classList.add('bg-yellow-500');
} else {
ui.status.textContent = 'Failed';
ui.status.className = 'text-xs text-red-600';
ui.bar.style.width = '100%';
ui.bar.classList.remove('bg-primary-500');
ui.bar.classList.add('bg-red-500');
// Keep failed item visible and allow dismiss
try {
ui.item.classList.add('bg-red-50');
ui.item.classList.add('border');
ui.item.classList.add('border-red-300');
ui.cancelBtn.disabled = false;
ui.cancelBtn.title = 'Dismiss';
ui.cancelBtn.addEventListener('click', () => {
try {
ui.item.style.transition = 'opacity 250ms ease-in-out';
ui.item.style.opacity = '0';
setTimeout(() => ui.item.remove(), 260);
} catch (_) { ui.item.remove(); }
});
} catch (_) {}
}
} finally {
completed += 1;
active.delete(task);
ui.setAbort(null);
startNext();
}
})();
active.add(task);
return task;
}
// Kick off initial batch
const starters = [];
for (let i = 0; i < concurrency; i++) {
const t = startNext();
if (t) starters.push(t);
}
await Promise.allSettled(starters);
// Drain the rest
while (active.size > 0 || queue.length > 0) {
if (queue.length > 0) startNext();
await Promise.race(Array.from(active));
}
const anyFailed = Array.from(document.querySelectorAll('#uploadProgressList .text-red-600')).length > 0;
if (!anyFailed) {
showAlert('All uploads completed', 'success');
}
cleanupUploadProgress(true);
}
function cleanupUploadProgress(preserveFailures = true) {
try {
const list = document.getElementById('uploadProgressList');
if (!list) return;
const items = Array.from(list.children);
items.forEach((item) => {
try {
const statusEl = item.querySelector('span.text-xs');
const isFailed = preserveFailures && statusEl && statusEl.classList.contains('text-red-600');
if (isFailed) {
// Ensure a dismiss button exists
const hasDismiss = item.querySelector('button');
if (!hasDismiss) {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'px-2 py-1 border border-neutral-400 text-neutral-600 rounded hover:bg-neutral-100 text-xs';
btn.title = 'Dismiss';
btn.innerHTML = '<i class="fa-solid fa-xmark"></i>';
btn.addEventListener('click', () => {
try {
item.style.transition = 'opacity 250ms ease-in-out';
item.style.opacity = '0';
setTimeout(() => item.remove(), 260);
} catch (_) { item.remove(); }
});
const right = item.querySelector('.flex.items-center.gap-3');
if (right) right.appendChild(btn);
}
return;
}
item.style.transition = 'opacity 250ms ease-in-out';
item.style.opacity = '0';
setTimeout(() => item.remove(), 260);
} catch (_) {}
});
} catch (_) {}
}
function displayTemplates(templates) {
const tbody = document.getElementById('templatesTableBody');
tbody.innerHTML = '';
@@ -954,6 +1365,168 @@ function showAlert(message, type = 'info') {
}
}
// Uploads UI
async function loadUploadedDocuments() {
try {
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
if (!fileNo) {
showAlert('Enter a file number to view uploads', 'warning');
return;
}
const resp = await window.http.wrappedFetch(`/api/documents/${encodeURIComponent(fileNo)}/uploaded`);
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to load uploads');
const msg = window.http.formatAlert(err, 'Failed to load uploads');
showAlert(msg, 'danger');
return;
}
const docs = await resp.json();
displayUploadedDocuments(docs);
} catch (error) {
console.error('Error loading uploaded documents', error);
showAlert('Failed to load uploads', 'danger');
}
}
function displayUploadedDocuments(docs) {
const container = document.getElementById('uploadedDocuments');
if (!Array.isArray(docs) || docs.length === 0) {
container.innerHTML = '<p class="text-neutral-500">No uploads found for this file.</p>';
return;
}
const rows = docs.map((d) => `
<tr>
<td class="px-4 py-2">${d.id || ''}</td>
<td class="px-4 py-2">${d.filename || ''}</td>
<td class="px-4 py-2">${(d.type || '').split('/').pop()}</td>
<td class="px-4 py-2">${Number(d.size || 0).toLocaleString()} bytes</td>
<td class="px-4 py-2"><a href="/${d.path || ''}" target="_blank" class="text-primary-600 hover:underline">View</a></td>
<td class="px-4 py-2">
<button class="px-2 py-1 border border-cyan-600 text-cyan-600 rounded hover:bg-blue-100 mr-2" title="Edit description" onclick="openEditUploadModal(${JSON.stringify(String(d.id || ''))}, ${JSON.stringify(String(d.description || ''))})"><i class="fa-solid fa-pencil"></i></button>
<button class="px-2 py-1 border border-danger-600 text-danger-600 rounded hover:bg-red-100" title="Delete" onclick="deleteUploadedDocument(${JSON.stringify(String(d.id || ''))})"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
`).join('');
const html = `
<table class="w-full text-sm text-left border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<thead>
<tr>
<th class="px-4 py-2">ID</th>
<th class="px-4 py-2">Name</th>
<th class="px-4 py-2">Type</th>
<th class="px-4 py-2">Size</th>
<th class="px-4 py-2">Link</th>
<th class="px-4 py-2">Actions</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>
`;
if (window.setSafeHTML) { window.setSafeHTML(container, html); } else { container.innerHTML = html; }
}
async function handleUploadClick() {
const fileNo = (document.getElementById('uploadFileNo')?.value || '').trim();
const input = document.getElementById('uploadInput');
if (!fileNo) {
showAlert('Please enter a file number', 'warning');
const fileNoErr = document.getElementById('uploadFileNoError');
if (fileNoErr) fileNoErr.classList.remove('hidden');
return;
}
try { localStorage.setItem('docs_last_upload_file_no', fileNo); } catch (_) {}
if (!input || !input.files || input.files.length === 0) {
showAlert('Please choose a file to upload', 'warning');
const inputErr = document.getElementById('uploadInputError');
if (inputErr) inputErr.classList.remove('hidden');
return;
}
const form = new FormData();
form.append('file', input.files[0]);
setUploadingState(true);
try {
await uploadWithAlerts(`/api/documents/upload/${encodeURIComponent(fileNo)}`, form);
if (window.alerts && window.alerts.success) {
window.alerts.success('Upload completed', { duration: 3000 });
} else {
showAlert('Upload completed', 'success');
}
// refresh list
loadUploadedDocuments();
// clear chooser
input.value = '';
} catch (_) {
// Error already alerted by helper
} finally {
setUploadingState(false);
}
}
async function deleteUploadedDocument(docId) {
try {
if (!docId) {
showAlert('Invalid document id', 'warning');
return;
}
if (!confirm('Are you sure you want to delete this uploaded document?')) return;
const resp = await window.http.wrappedFetch(`/api/documents/uploaded/${encodeURIComponent(String(docId))}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to delete upload');
const msg = window.http.formatAlert(err, 'Failed to delete upload');
showAlert(msg, 'danger');
return;
}
showAlert('Upload deleted successfully', 'success');
loadUploadedDocuments();
} catch (error) {
const msg = window.http && window.http.formatAlert && error instanceof Error
? window.http.formatAlert(error, 'Failed to delete upload')
: 'Failed to delete upload';
showAlert(msg, 'danger');
}
}
function openEditUploadModal(docId, currentDescription) {
try {
document.getElementById('editUploadId').value = String(docId || '');
document.getElementById('editUploadDescription').value = String(currentDescription || '');
openModal('editUploadModal');
} catch (error) {
showAlert('Unable to open editor', 'danger');
}
}
async function saveEditUpload() {
const docId = document.getElementById('editUploadId').value;
const description = document.getElementById('editUploadDescription').value;
if (!docId) {
showAlert('Invalid document id', 'warning');
return;
}
try {
const form = new FormData();
form.append('description', description || '');
const resp = await window.http.wrappedFetch(`/api/documents/uploaded/${encodeURIComponent(String(docId))}`, {
method: 'PUT',
body: form
});
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to update description');
const msg = window.http.formatAlert(err, 'Failed to update description');
showAlert(msg, 'danger');
return;
}
showAlert('Description updated', 'success');
closeModal('editUploadModal');
loadUploadedDocuments();
} catch (error) {
const msg = window.http && window.http.formatAlert && error instanceof Error
? window.http.formatAlert(error, 'Failed to update description')
: 'Failed to update description';
showAlert(msg, 'danger');
}
}
// Lightweight client error logger specific to Documents page
async function logClientError({ message, action = null, error = null, extra = null }) {
try {
@@ -1185,6 +1758,12 @@ function openTab(evt, tabName) {
loadTemplates();
} else if (tabName === 'qdros') {
loadQdros();
} else if (tabName === 'generated') {
const fileNoInput = document.getElementById('uploadFileNo');
const uploadInput = document.getElementById('uploadInput');
if (fileNoInput && (fileNoInput.value || '').trim() && uploadInput) {
uploadInput.focus();
}
}
}
</script>

View File

@@ -3,8 +3,7 @@
{% block title %}File Cabinet - Delphi Database{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="space-y-6">
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-3">
@@ -13,7 +12,7 @@
</div>
<h1 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100">File Cabinet</h1>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3">
<button id="addFileBtn" 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 File</span>
@@ -23,7 +22,7 @@
<i class="fa-solid fa-chart-line"></i>
<span>Statistics</span>
</button>
<button id="advancedSearchBtn" class="flex items-center gap-2 px-4 py-2 bg-secondary-600 text-white hover:bg-secondary-700 rounded-lg transition-colors duration-200">
<button id="advancedSearchBtn" 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-magnifying-glass"></i>
<span>Advanced Search</span>
</button>
@@ -31,8 +30,15 @@
</div>
<!-- Search and Filter Panel -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6">
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
<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-5 gap-4">
<div class="md:col-span-3">
<label for="searchInput" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Search Files</label>
<div class="relative">
@@ -41,7 +47,8 @@
<button id="searchBtn" class="absolute right-2 top-1/2 transform -translate-y-1/2 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"></i>
</button>
</div>
</div>
</div>
</div>
<div>
<label for="statusFilter" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Status</label>
@@ -62,10 +69,10 @@
</select>
</div>
<div class="flex items-center gap-2">
<button id="clearFiltersBtn" class="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200">
<button id="clearFiltersBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors duration-200">
<i class="fa-regular fa-circle-xmark"></i> Clear
</button>
<button id="refreshBtn" class="px-3 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors duration-200">
<button id="refreshBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors duration-200">
<i class="fa-solid fa-rotate-right"></i> Refresh
</button>
</div>
@@ -73,32 +80,39 @@
</div>
<!-- File List -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden" id="filesTable">
<thead class="bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-100">
<tr>
<th data-sort="file_no" class="px-4 py-2 uppercase text-xs tracking-wider">File #</th>
<th data-sort="client_name" class="px-4 py-2 uppercase text-xs tracking-wider">Client</th>
<th data-sort="regarding" class="px-4 py-2 uppercase text-xs tracking-wider">Matter</th>
<th data-sort="file_type" class="px-4 py-2 uppercase text-xs tracking-wider">Type</th>
<th data-sort="status" class="px-4 py-2 uppercase text-xs tracking-wider">Status</th>
<th data-sort="employee" class="px-4 py-2 uppercase text-xs tracking-wider">Attorney</th>
<th data-sort="opened" class="px-4 py-2 uppercase text-xs tracking-wider">Opened</th>
<th data-sort="amount_owing" class="px-4 py-2 text-right uppercase text-xs tracking-wider">Balance</th>
<th class="px-4 py-2 uppercase text-xs tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="filesTableBody">
<!-- File rows will be populated here -->
</tbody>
</table>
<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-regular fa-folder-open"></i>
<span>File List</span>
</h5>
</div>
<div class="p-0">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="filesTable">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File #</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Client</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Matter</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Type</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Status</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Attorney</th>
<th data-sort="date" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Opened</th>
<th data-sort="number" class="px-4 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Balance</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="filesTableBody">
<!-- File rows will be populated here -->
</tbody>
</table>
</div>
<!-- Pagination -->
<nav aria-label="File pagination" class="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800/50 flex items-center justify-center" id="pagination"></nav>
</div>
<!-- Pagination -->
<nav aria-label="File pagination" class="mt-6 flex items-center justify-center" id="pagination"></nav>
</div>
</div>
</div>
<!-- File Modal -->
@@ -203,7 +217,7 @@
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6" id="financialSummaryCard" style="display: none;">
<div class="flex items-center justify-between pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700">
<h6 class="mb-0 font-semibold">Financial Summary</h6>
<button type="button" class="px-2 py-1 border border-info-600 text-info-600 rounded hover:bg-blue-100" id="viewFullFinancialBtn"><i class="fa-solid fa-calculator"></i> View Details</button>
<button type="button" class="px-3 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg transition-colors text-sm font-medium" id="viewFullFinancialBtn"><i class="fa-solid fa-calculator mr-2"></i> View Details</button>
</div>
<div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4" id="financialSummary">
@@ -215,22 +229,22 @@
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6 md:col-span-2" id="documentsCard" style="display: none;">
<div class="flex items-center justify-between pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700">
<h6 class="mb-0 font-semibold">Documents</h6>
<button type="button" class="px-2 py-1 border border-success-600 text-success-600 rounded hover:bg-green-100" id="uploadDocumentBtn"><i class="fa-solid fa-upload"></i> Upload</button>
<button type="button" class="px-3 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors text-sm font-medium" id="uploadDocumentBtn"><i class="fa-solid fa-upload mr-2"></i> Upload</button>
</div>
<div>
<div class="mb-3">
<input type="file" id="documentFile" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg">
<input type="text" id="documentDescription" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg mt-2" placeholder="Description (optional)">
</div>
<div class="overflow-x-auto">
<div class="overflow-x-auto">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden" id="documentsTable">
<thead>
<tr>
<th>Filename</th>
<th>Description</th>
<th>Uploaded</th>
<th>Size</th>
<th>Actions</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Filename</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Description</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Uploaded</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Size</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="documentsTableBody">
@@ -335,13 +349,13 @@
</div>
<div class="overflow-y-auto max-h-96">
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100 border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<thead>
<tr>
<th>ID</th>
<th>Name</th>
<th>City, State</th>
<th>Group</th>
<th>Action</th>
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">ID</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Name</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">City, State</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Group</th>
<th class="px-3 py-2 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Action</th>
</tr>
</thead>
<tbody id="clientSelectionTableBody">
@@ -435,6 +449,35 @@ function setupEventListeners() {
document.getElementById('fileNo').addEventListener('blur', validateFileNumber);
}
// Highlight helpers
function _escapeHtml(text) {
try { if (window.htmlSanitizer && typeof window.htmlSanitizer.escape === 'function') { return window.htmlSanitizer.escape(text); } } catch (_) {}
const str = String(text == null ? '' : text);
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function _buildTokens(raw) {
return String(raw || '')
.trim()
.replace(/[,_;:]+/g, ' ')
.split(/\s+/)
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
.filter(Boolean);
}
function highlightText(text, tokens) {
if (!text) return '';
const unique = Array.from(new Set(tokens || []));
if (unique.length === 0) return _escapeHtml(text);
let safe = _escapeHtml(String(text));
try {
unique.forEach(tok => {
const esc = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${esc})`, 'ig');
safe = safe.replace(re, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">$1</mark>');
});
} catch (_) {}
return safe;
}
async function loadLookupData() {
try {
// Load all lookup data in parallel
@@ -514,6 +557,7 @@ async function loadFiles(page = 0, filters = {}) {
function displayFiles(files) {
const tbody = document.getElementById('filesTableBody');
tbody.innerHTML = '';
const tokens = _buildTokens(document.getElementById('searchInput') ? document.getElementById('searchInput').value : '');
if (files.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="text-center text-neutral-500">No files found</td></tr>';
@@ -523,19 +567,19 @@ function displayFiles(files) {
files.forEach(file => {
const row = document.createElement('tr');
row.innerHTML = `
<td class="px-4 py-2"><strong>${file.file_no}</strong></td>
<td class="px-4 py-2"><strong>${highlightText(file.file_no, tokens)}</strong></td>
<td class="px-4 py-2">
<div>
<div>${file.client_name || 'Unknown Client'}</div>
<div class="text-xs text-neutral-500">${file.client_id}</div>
<div>${highlightText(file.client_name || 'Unknown Client', tokens)}</div>
<div class="text-xs text-neutral-500">${highlightText(file.client_id || '', tokens)}</div>
</div>
</td>
<td class="px-4 py-2">
${file.regarding ? `<div>${file.regarding.substring(0, 50)}${file.regarding.length > 50 ? '...' : ''}</div>` : '<em class="text-neutral-500">No description</em>'}
${file.regarding ? `<div>${highlightText(file.regarding.substring(0, 50) + (file.regarding.length > 50 ? '...' : ''), tokens)}</div>` : '<em class="text-neutral-500">No description</em>'}
</td>
<td class="px-4 py-2">${file.file_type}</td>
<td class="px-4 py-2"><span class="${getStatusBadgeClass(file.status)}">${file.status}</span></td>
<td class="px-4 py-2">${file.empl_num}</td>
<td class="px-4 py-2">${highlightText(file.file_type || '', tokens)}</td>
<td class="px-4 py-2"><span class="${getStatusBadgeClass(file.status)}">${highlightText(file.status || '', tokens)}</span></td>
<td class="px-4 py-2">${highlightText(file.empl_num || '', tokens)}</td>
<td class="px-4 py-2">${formatDate(file.opened)}</td>
<td class="px-4 py-2 text-right">
<strong class="${file.amount_owing > 0 ? 'text-success-600' : 'text-neutral-500'}">
@@ -544,8 +588,8 @@ function displayFiles(files) {
</td>
<td class="px-4 py-2">
<div class="flex items-center gap-2">
<button class="px-2 py-1 bg-primary-600 text-white rounded hover:bg-primary-700" onclick="editFile('${file.file_no}')"><i class="fa-solid fa-pencil"></i></button>
<button class="px-2 py-1 bg-info-600 text-white rounded hover:bg-info-700" onclick="viewFile('${file.file_no}')"><i class="fa-regular fa-eye"></i></button>
<button class="inline-flex items-center px-3 py-2 bg-primary-600 text-white hover:bg-primary-700 rounded-lg text-sm font-semibold transition-colors" onclick="editFile('${String(file.file_no).replace(/"/g, '&quot;')}')"><i class="fa-solid fa-pencil mr-1.5"></i> Edit</button>
<button class="inline-flex items-center px-3 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg text-sm font-semibold transition-colors" onclick="viewFile('${String(file.file_no).replace(/"/g, '&quot;')}')"><i class="fa-regular fa-eye mr-1.5"></i> View</button>
</div>
</td>
`;
@@ -868,16 +912,17 @@ async function searchClients() {
function displayClientOptions(clients) {
const tbody = document.getElementById('clientSelectionTableBody');
tbody.innerHTML = '';
const tokens = _buildTokens(document.getElementById('clientSearchInput') ? document.getElementById('clientSearchInput').value : '');
clients.forEach(client => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${client.id}</td>
<td>${client.first || ''} ${client.last}</td>
<td>${client.city || ''}, ${client.abrev || ''}</td>
<td>${client.group || ''}</td>
<td>${highlightText(client.id || '', tokens)}</td>
<td>${highlightText(`${client.first || ''} ${client.last || ''}`.trim(), tokens)}</td>
<td>${highlightText(`${client.city || ''}, ${client.abrev || ''}`.trim(), tokens)}</td>
<td>${highlightText(client.group || '', tokens)}</td>
<td>
<button class="px-2 py-1 bg-primary-600 text-white rounded hover:bg-primary-700 text-sm" onclick="selectClient('${client.id}', '${(client.first || '') + ' ' + client.last}', '${client.city || ''}, ${client.abrev || ''}')">Select</button>
<button class="px-2 py-1 bg-primary-600 text-white rounded hover:bg-primary-700 text-sm" onclick="selectClient('${String(client.id).replace(/"/g, '&quot;')}', '${((client.first || '') + ' ' + (client.last || '')).replace(/"/g, '&quot;')}', '${(`${client.city || ''}, ${client.abrev || ''}`).replace(/"/g, '&quot;')}')">Select</button>
</td>
`;
tbody.appendChild(row);

View File

@@ -3,7 +3,7 @@
{% block title %}Financial/Ledger - Delphi Database{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
<div class="space-y-6">
<!-- Page Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div class="flex items-center gap-3">
@@ -61,9 +61,9 @@
</div>
<!-- Recent Time Entries -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md overflow-hidden mb-6">
<div class="flex justify-between items-center p-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold"><i class="fa-solid fa-clock-rotate-left mr-2"></i>Recent Time Entries</h2>
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft overflow-hidden">
<div class="flex justify-between items-center px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-clock-rotate-left"></i><span>Recent Time Entries</span></h2>
<div class="flex gap-2">
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="recentDaysFilter">
<option value="7">Last 7 days</option>
@@ -73,25 +73,31 @@
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="employeeFilter">
<option value="">All Employees</option>
</select>
<button class="px-3 py-1 bg-gray-200 dark:bg-neutral-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-neutral-600 transition-colors" id="refreshRecentBtn">
<select class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200" id="statusFilter">
<option value="">All Status</option>
<option value="billed">Billed</option>
<option value="unbilled">Unbilled</option>
</select>
<input type="search" id="descSearch" placeholder="Search description..." class="px-3 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg text-sm text-neutral-900 dark:text-neutral-100 transition-all duration-200 w-48" />
<button class="px-3 py-1 bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded-lg hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" id="refreshRecentBtn">
<i class="fa-solid fa-rotate-right"></i>
</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-neutral-100 dark:bg-neutral-700">
<tr>
<th class="px-4 py-2">Date</th>
<th class="px-4 py-2">File</th>
<th class="px-4 py-2">Client</th>
<th class="px-4 py-2">Employee</th>
<th class="px-4 py-2">Hours</th>
<th class="px-4 py-2">Rate</th>
<th class="px-4 py-2">Amount</th>
<th class="px-4 py-2">Description</th>
<th class="px-4 py-2">Status</th>
<th class="px-4 py-2">Actions</th>
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="recentEntriesTable">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th data-sort="date" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Date</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Client</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Employee</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Hours</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Rate</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Description</th>
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Status</th>
<th class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="recentEntriesTableBody">
@@ -99,6 +105,7 @@
</tbody>
</table>
</div>
<div class="flex items-center justify-between px-6 py-3 border-t border-neutral-200 dark:border-neutral-700 text-sm" id="recentPagination" aria-live="polite"></div>
</div>
<!-- Action Cards Row -->
@@ -125,17 +132,17 @@
</div>
</div>
<div class="lg:col-span-2">
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md">
<div class="p-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold"><i class="fa-solid fa-chart-column mr-2"></i>Top Files by Balance</h2>
<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">
<h2 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2"><i class="fa-solid fa-chart-column"></i><span>Top Files by Balance</span></h2>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-neutral-100 dark:bg-neutral-700">
<tr>
<th class="px-4 py-2">File</th>
<th class="px-4 py-2">Total Charges</th>
<th class="px-4 py-2">Amount Owing</th>
<table class="w-full text-sm text-left text-neutral-900 dark:text-neutral-100" id="topFilesTable">
<thead class="bg-neutral-50 dark:bg-neutral-800/50">
<tr class="border-b border-neutral-200 dark:border-neutral-700">
<th data-sort="text" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">File</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Total Charges</th>
<th data-sort="number" class="px-4 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">Amount Owing</th>
</tr>
</thead>
<tbody id="topFilesTableBody">
@@ -482,6 +489,7 @@
let dashboardData = null;
let recentEntries = [];
let unbilledData = null;
let recentEditSnapshots = {};
// Authorization and JSON headers are injected by window.http.wrappedFetch
@@ -509,10 +517,54 @@ document.addEventListener('DOMContentLoaded', function() {
});
function initializeFinancialPage() {
// Initialize any data tables or components
// Initialize sortable tables using shared helper
try { initializeDataTable('recentEntriesTable'); } catch (_) {}
try { initializeDataTable('topFilesTable'); } catch (_) {}
// Persisted filters restore
try {
const saved = JSON.parse(localStorage.getItem('financial_recent_filters') || '{}');
if (saved && typeof saved === 'object') {
if (saved.days && document.getElementById('recentDaysFilter')) document.getElementById('recentDaysFilter').value = String(saved.days);
if (typeof saved.employee === 'string' && document.getElementById('employeeFilter')) document.getElementById('employeeFilter').value = saved.employee;
if (typeof saved.status === 'string' && document.getElementById('statusFilter')) document.getElementById('statusFilter').value = saved.status;
if (typeof saved.query === 'string' && document.getElementById('descSearch')) document.getElementById('descSearch').value = saved.query;
}
} catch (_) {}
// Persist sort state on header clicks
attachSortPersistence('recentEntriesTable', 'financial_recent_sort');
attachSortPersistence('topFilesTable', 'financial_top_sort');
console.log('Financial page initialized');
}
// Highlight helpers
function _finEscapeHtml(text) {
try { if (window.htmlSanitizer && typeof window.htmlSanitizer.escape === 'function') { return window.htmlSanitizer.escape(text); } } catch (_) {}
const str = String(text == null ? '' : text);
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\"/g, '&quot;').replace(/'/g, '&#39;');
}
function _finBuildTokens(raw) {
return String(raw || '')
.trim()
.replace(/[,_;:]+/g, ' ')
.split(/\s+/)
.map(t => t.replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, ''))
.filter(Boolean);
}
function finHighlightText(text, tokens) {
if (!text) return '';
const unique = Array.from(new Set(tokens || []));
if (unique.length === 0) return _finEscapeHtml(text);
let safe = _finEscapeHtml(String(text));
try {
unique.forEach(tok => {
const esc = tok.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const re = new RegExp(`(${esc})`, 'ig');
safe = safe.replace(re, '<mark class="bg-yellow-200 text-neutral-900 rounded px-0.5">$1</mark>');
});
} catch (_) {}
return safe;
}
function setupEventListeners() {
// Quick actions
document.getElementById('quickTimeBtn').addEventListener('click', showQuickTimeModal);
@@ -530,9 +582,28 @@ function setupEventListeners() {
document.getElementById('billSelectedBtn').addEventListener('click', billSelectedEntries);
// Filters
document.getElementById('recentDaysFilter').addEventListener('change', loadRecentTimeEntries);
document.getElementById('employeeFilter').addEventListener('change', loadRecentTimeEntries);
document.getElementById('recentDaysFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
document.getElementById('employeeFilter').addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
document.getElementById('refreshRecentBtn').addEventListener('click', loadRecentTimeEntries);
const statusFilterEl = document.getElementById('statusFilter');
if (statusFilterEl) statusFilterEl.addEventListener('change', () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); });
const descSearchEl = document.getElementById('descSearch');
if (descSearchEl) descSearchEl.addEventListener('input', (typeof debounce === 'function' ? debounce(() => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }, 300) : () => { setRecentServerState({ page: 1 }); loadRecentTimeEntries(); }));
// Persist filters on change
const persistFilters = () => {
const payload = {
days: parseInt(document.getElementById('recentDaysFilter')?.value || '7', 10),
employee: document.getElementById('employeeFilter')?.value || '',
status: document.getElementById('statusFilter')?.value || '',
query: document.getElementById('descSearch')?.value || ''
};
try { localStorage.setItem('financial_recent_filters', JSON.stringify(payload)); } catch (_) {}
};
['recentDaysFilter','employeeFilter','statusFilter','descSearch'].forEach(id => {
const el = document.getElementById(id);
if (!el) return;
el.addEventListener(id === 'descSearch' ? 'input' : 'change', persistFilters);
});
// File selection buttons
document.getElementById('selectFileBtn').addEventListener('click', () => showFileSelector('quickTimeFile'));
@@ -580,33 +651,57 @@ function updateDashboardSummary(data) {
`;
tbody.appendChild(row);
});
// Reapply previous sort state if any
reapplyTableSort('topFilesTable');
}
async function loadRecentTimeEntries() {
const days = document.getElementById('recentDaysFilter').value;
const employee = document.getElementById('employeeFilter').value;
const status = (document.getElementById('statusFilter')?.value || '').trim();
const q = (document.getElementById('descSearch')?.value || '').trim();
try {
const params = new URLSearchParams({ days });
const { page, limit, sort_by, sort_dir } = getRecentServerState();
const params = new URLSearchParams({ days, page, limit, sort_by, sort_dir });
if (employee) params.append('employee', employee);
if (status) params.append('status', status);
if (q) params.append('q', q);
const response = await window.http.wrappedFetch(`/api/financial/time-entries/recent?${params}`);
if (!response.ok) throw new Error('Failed to load recent entries');
const data = await response.json();
recentEntries = data.entries;
displayRecentTimeEntries(data.entries);
renderRecentPagination(data.total_count, data.page, data.limit);
refreshRecentEntriesView();
} catch (error) {
console.error('Error loading recent entries:', error);
showAlert('Error loading recent time entries: ' + error.message, 'danger');
}
}
function refreshRecentEntriesView() {
displayRecentTimeEntries(recentEntries || []);
}
function applyRecentEntriesFilters(entries) {
const status = (document.getElementById('statusFilter')?.value || '').toLowerCase();
const query = (document.getElementById('descSearch')?.value || '').trim().toLowerCase();
if (!entries || entries.length === 0) return [];
return entries.filter(e => {
let statusOk = true;
if (status === 'billed') statusOk = !!e.billed;
else if (status === 'unbilled') statusOk = !e.billed;
const text = [e.description, e.client_name, e.file_no, e.employee].filter(Boolean).join(' ').toLowerCase();
const queryOk = query ? text.includes(query) : true;
return statusOk && queryOk;
});
}
function displayRecentTimeEntries(entries) {
const tbody = document.getElementById('recentEntriesTableBody');
tbody.innerHTML = '';
const tokens = _finBuildTokens(document.getElementById('descSearch') ? document.getElementById('descSearch').value : '');
if (entries.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="text-center text-neutral-500">No recent time entries found</td></tr>';
@@ -615,28 +710,128 @@ function displayRecentTimeEntries(entries) {
entries.forEach(entry => {
const row = document.createElement('tr');
row.setAttribute('data-entry-id', String(entry.id));
row.innerHTML = `
<td>${formatDate(entry.date)}</td>
<td><strong>${entry.file_no}</strong></td>
<td>${entry.client_name}</td>
<td>${entry.employee}</td>
<td><strong>${finHighlightText(entry.file_no, tokens)}</strong></td>
<td>${finHighlightText(entry.client_name, tokens)}</td>
<td>${finHighlightText(entry.employee, tokens)}</td>
<td class="text-center">${entry.hours}</td>
<td class="text-right">${formatCurrency(entry.rate)}</td>
<td class="text-right text-green-600"><strong>${formatCurrency(entry.amount)}</strong></td>
<td class="small">${entry.description ? entry.description.substring(0, 50) + (entry.description.length > 50 ? '...' : '') : ''}</td>
<td class="small">${entry.description ? finHighlightText(entry.description.substring(0, 50) + (entry.description.length > 50 ? '...' : ''), tokens) : ''}</td>
<td>
<span class="inline-block px-2 py-0.5 text-xs rounded ${entry.billed ? 'bg-green-100 text-green-700 border border-green-400' : 'bg-yellow-100 text-yellow-700 border border-yellow-500'}">
${entry.billed ? 'Billed' : 'Unbilled'}
</span>
</td>
<td>
<button class="px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100" onclick="editTimeEntry(${entry.id})">
<button class="px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100" onclick="editTimeEntry(${entry.id})" title="Edit">
<i class="fa-solid fa-pencil"></i>
</button>
${entry.billed ? '' : `
<button class="ml-1 px-2 py-1 border border-danger-600 text-danger-600 rounded hover:bg-danger-50" onclick="deleteTimeEntry(${entry.id})" title="Delete">
<i class="fa-regular fa-trash-can"></i>
</button>
`}
</td>
`;
tbody.appendChild(row);
});
// Reapply previous sort state if any
reapplyTableSort('recentEntriesTable');
}
// Preserve and reapply table sort state across re-renders
function reapplyTableSort(tableId) {
const table = document.getElementById(tableId);
if (!table) return;
const headerAsc = table.querySelector('th.sort-asc');
const headerDesc = table.querySelector('th.sort-desc');
const header = headerAsc || headerDesc;
if (header) {
// The shared sortTable determines direction based on presence of 'sort-asc' BEFORE it removes classes
if (headerAsc) header.classList.remove('sort-asc');
else if (headerDesc) header.classList.add('sort-asc');
try { sortTable(table, header); } catch (_) {}
return;
}
// No current sort in DOM; attempt to restore persisted sort
const storageKey = tableId === 'recentEntriesTable' ? 'financial_recent_sort' : (tableId === 'topFilesTable' ? 'financial_top_sort' : null);
if (!storageKey) return;
let spec = null;
try { spec = JSON.parse(localStorage.getItem(storageKey) || 'null'); } catch (_) { spec = null; }
if (!spec || typeof spec.columnIndex !== 'number' || !spec.direction) return;
const headerRow = table.querySelector('thead tr');
if (!headerRow) return;
const th = headerRow.children[spec.columnIndex];
if (!th) return;
if (String(spec.direction).toLowerCase() === 'asc') th.classList.remove('sort-asc');
else th.classList.add('sort-asc');
try { sortTable(table, th); } catch (_) {}
}
function attachSortPersistence(tableId, storageKey) {
const table = document.getElementById(tableId);
if (!table) return;
const headers = table.querySelectorAll('th[data-sort]');
headers.forEach((header, idx) => {
header.addEventListener('click', () => {
// Determine target server sort field for recent table
if (tableId === 'recentEntriesTable') {
const fieldMap = ['date','file_no','client_name','empl_num','hours','rate','amount','description','billed'];
const sortBy = fieldMap[idx] || 'date';
// Toggle direction based on current persisted direction
const current = getRecentServerState();
const nextDir = (current.sort_by === sortBy) ? (current.sort_dir === 'asc' ? 'desc' : 'asc') : 'asc';
setRecentServerState({ sort_by: sortBy, sort_dir: nextDir, page: 1 });
loadRecentTimeEntries();
}
// Persist client-side header state for visual cues
setTimeout(() => {
const isAsc = header.classList.contains('sort-asc');
const direction = isAsc ? 'asc' : (header.classList.contains('sort-desc') ? 'desc' : 'asc');
const payload = { columnIndex: idx, direction };
try { localStorage.setItem(storageKey, JSON.stringify(payload)); } catch (_) {}
}, 0);
});
});
}
function getRecentServerState() {
let saved = null;
try { saved = JSON.parse(localStorage.getItem('financial_recent_server') || 'null'); } catch (_) { saved = null; }
const defaults = { page: 1, limit: 50, sort_by: 'date', sort_dir: 'desc' };
return Object.assign({}, defaults, saved || {});
}
function setRecentServerState(partial) {
const current = getRecentServerState();
const next = Object.assign({}, current, partial || {});
try { localStorage.setItem('financial_recent_server', JSON.stringify(next)); } catch (_) {}
return next;
}
function renderRecentPagination(totalCount, page, limit) {
const container = document.getElementById('recentPagination');
if (!container) return;
const totalPages = Math.max(1, Math.ceil((totalCount || 0) / (limit || 50)));
const canPrev = page > 1;
const canNext = page < totalPages;
container.innerHTML = `
<div>
Showing page ${page} of ${totalPages} (${totalCount || 0} entries)
</div>
<div class="space-x-2">
<button class="px-3 py-1 border rounded ${canPrev ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canPrev ? '' : 'disabled'} id="recentPrevPage">Prev</button>
<button class="px-3 py-1 border rounded ${canNext ? 'hover:bg-neutral-100 dark:hover:bg-neutral-700' : 'opacity-50 cursor-not-allowed'}" ${canNext ? '' : 'disabled'} id="recentNextPage">Next</button>
</div>
`;
const prevBtn = document.getElementById('recentPrevPage');
const nextBtn = document.getElementById('recentNextPage');
if (prevBtn) prevBtn.addEventListener('click', () => { setRecentServerState({ page: page - 1 }); loadRecentTimeEntries(); });
if (nextBtn) nextBtn.addEventListener('click', () => { setRecentServerState({ page: page + 1 }); loadRecentTimeEntries(); });
}
async function loadEmployeeOptions() {
@@ -854,6 +1049,7 @@ function displayUnbilledItems(data) {
const container = document.getElementById('unbilledItemsContainer');
container.innerHTML = '';
const tokens = _finBuildTokens(document.getElementById('unbilledFileFilter') ? document.getElementById('unbilledFileFilter').value : '');
if (data.files.length === 0) {
container.innerHTML = '<div class="text-center text-neutral-500 p-4">No unbilled entries found</div>';
@@ -866,8 +1062,8 @@ function displayUnbilledItems(data) {
fileCard.innerHTML = `
<div class="px-4 py-3 flex justify-between items-center border-b border-neutral-200 dark:border-neutral-700">
<div>
<strong>${file.file_no}</strong> - ${file.client_name}
<br><small class="text-neutral-500">${file.matter}</small>
<strong>${finHighlightText(file.file_no, tokens)}</strong> - ${finHighlightText(file.client_name, tokens)}
<br><small class="text-neutral-500">${finHighlightText(file.matter, tokens)}</small>
</div>
<div class="text-right">
<div><strong>Total: ${formatCurrency(file.total_amount)}</strong></div>
@@ -900,12 +1096,12 @@ function displayUnbilledItems(data) {
data-entry-id="${entry.id}" data-file="${file.file_no}">
</td>
<td>${formatDate(entry.date)}</td>
<td>${entry.type}</td>
<td>${entry.employee}</td>
<td>${finHighlightText(entry.type, tokens)}</td>
<td>${finHighlightText(entry.employee, tokens)}</td>
<td class="text-center">${entry.quantity}</td>
<td class="text-right">${formatCurrency(entry.rate)}</td>
<td class="text-right text-green-600">${formatCurrency(entry.amount)}</td>
<td class="small">${entry.description || ''}</td>
<td class="small">${entry.description ? finHighlightText(entry.description, tokens) : ''}</td>
</tr>
`).join('')}
</tbody>
@@ -1057,6 +1253,136 @@ function showFileSelector(targetInputId) {
document.getElementById(targetInputId).focus();
}
// Inline edit for recent time entries
function editTimeEntry(entryId) {
const row = document.querySelector(`tr[data-entry-id="${entryId}"]`);
if (!row) return;
if (row.dataset.editing === 'true') {
saveEditedEntry(entryId, row);
return;
}
const entry = (recentEntries || []).find(e => e.id === entryId);
if (!entry) return;
if (entry.billed) {
showAlert('This entry is billed and cannot be edited.', 'warning');
return;
}
// Snapshot for rollback
recentEditSnapshots[entryId] = {
entry: { ...entry },
rowHTML: row.innerHTML
};
row.dataset.editing = 'true';
const hoursCell = row.children[4];
const rateCell = row.children[5];
const amountCell = row.children[6];
const descCell = row.children[7];
const actionsCell = row.children[9];
const currentHours = Number(entry.hours) || 0;
const rateValue = Number(entry.rate) || 0;
hoursCell.innerHTML = `<input type="number" class="w-20 px-2 py-1 border rounded" step="0.25" min="0" value="${currentHours}">`;
descCell.innerHTML = `<input type="text" class="w-full px-2 py-1 border rounded" value="${entry.description ? String(entry.description).replace(/"/g, '&quot;') : ''}">`;
const hoursInput = hoursCell.querySelector('input');
hoursInput.addEventListener('input', () => {
const h = parseFloat(hoursInput.value) || 0;
const amt = h * rateValue;
amountCell.innerHTML = `<strong>${formatCurrency(amt)}</strong>`;
});
actionsCell.innerHTML = `
<button class="px-2 py-1 border border-green-600 text-green-700 rounded hover:bg-green-100 mr-1" onclick="editTimeEntry(${entryId})" title="Save">
<i class="fa-regular fa-circle-check"></i>
</button>
<button class="px-2 py-1 border border-neutral-500 text-neutral-700 rounded hover:bg-neutral-100" onclick="cancelEditEntry(${entryId})" title="Cancel">
<i class="fa-solid fa-xmark"></i>
</button>
`;
}
async function saveEditedEntry(entryId, rowEl) {
const row = rowEl || document.querySelector(`tr[data-entry-id="${entryId}"]`);
if (!row) return;
const hoursInput = row.children[4].querySelector('input');
const descInput = row.children[7].querySelector('input');
const rateText = row.children[5].textContent || '';
const rate = parseFloat((rateText.replace(/[^0-9.\-]/g, ''))) || 0;
const newHours = parseFloat(hoursInput && hoursInput.value ? hoursInput.value : '0') || 0;
const newDesc = descInput ? descInput.value : '';
const newAmount = newHours * rate;
const payload = { quantity: newHours, amount: newAmount, note: newDesc };
const entryIndex = (recentEntries || []).findIndex(e => e.id === entryId);
if (entryIndex === -1) return;
// Optimistic UI update
const snapshot = recentEditSnapshots[entryId];
recentEntries[entryIndex] = { ...recentEntries[entryIndex], hours: newHours, amount: newAmount, description: newDesc };
// Render non-editing cells immediately
row.children[4].innerHTML = `${newHours}`;
row.children[6].innerHTML = `<strong>${formatCurrency(newAmount)}</strong>`;
row.children[7].innerHTML = `${newDesc ? newDesc.substring(0, 50) + (newDesc.length > 50 ? '...' : '') : ''}`;
row.children[9].innerHTML = `
<button class=\"px-2 py-1 border border-blue-600 text-blue-600 rounded hover:bg-blue-100\" onclick=\"editTimeEntry(${entryId})\" title=\"Edit\">\n <i class=\"fa-solid fa-pencil\"></i>\n </button>
`;
delete row.dataset.editing;
try {
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: 'Update failed' }));
throw new Error(err.detail || 'Update failed');
}
showAlert('Entry updated successfully', 'success');
loadDashboardData();
// Resort if needed
reapplyTableSort('recentEntriesTable');
} catch (error) {
// Rollback
if (snapshot && snapshot.entry) {
recentEntries[entryIndex] = { ...snapshot.entry };
}
refreshRecentEntriesView();
showAlert('Failed to update entry: ' + error.message, 'danger');
} finally {
delete recentEditSnapshots[entryId];
}
}
function cancelEditEntry(entryId) {
// Simply re-render current filtered view
delete recentEditSnapshots[entryId];
refreshRecentEntriesView();
}
async function deleteTimeEntry(entryId) {
const idx = (recentEntries || []).findIndex(e => e.id === entryId);
if (idx === -1) return;
const entry = recentEntries[idx];
if (entry.billed) {
showAlert('This entry is billed and cannot be deleted.', 'warning');
return;
}
const ok = window.confirm('Delete this time entry? This cannot be undone.');
if (!ok) return;
const backup = { index: idx, entry: { ...entry } };
recentEntries.splice(idx, 1);
refreshRecentEntriesView();
try {
const resp = await window.http.wrappedFetch(`/api/financial/ledger/${entryId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({ detail: 'Delete failed' }));
throw new Error(err.detail || 'Delete failed');
}
showAlert('Entry deleted', 'success');
loadDashboardData();
} catch (error) {
// rollback
const restoreIndex = Math.min(backup.index, recentEntries.length);
recentEntries.splice(restoreIndex, 0, backup.entry);
refreshRecentEntriesView();
showAlert('Failed to delete entry: ' + error.message, 'danger');
}
}
async function validateQuickTimeFile() {
const fileNo = document.getElementById('quickTimeFile').value;
if (!fileNo) return;

113
templates/flexible.html Normal file
View File

@@ -0,0 +1,113 @@
{% extends "base.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-semibold">Flexible Imports</h1>
<div class="flex items-center gap-2">
<button id="exportCsvBtn" class="px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors">
<i class="fa-solid fa-file-csv mr-2"></i> Export CSV
</button>
</div>
</div>
<div class="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 p-4">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium mb-1">File Type</label>
<select id="filterFileType" class="w-full rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 p-2">
<option value="">All</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Target Table</label>
<select id="filterTargetTable" class="w-full rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 p-2">
<option value="">All</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Quick Search</label>
<input id="quickSearch" type="text" placeholder="Search file type, target table, keys and values" class="w-full rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 p-2" />
</div>
<div class="flex items-end">
<button id="applyFiltersBtn" class="w-full md:w-auto px-4 py-2 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded-lg border border-neutral-200 dark:border-neutral-600">Apply</button>
</div>
</div>
<!-- Key filter chips -->
<div id="keyChipsContainer" class="mt-3 hidden">
<div class="flex items-center gap-2 flex-wrap">
<span class="text-xs text-neutral-500">Filters:</span>
<div id="keyChips" class="flex items-center gap-2 flex-wrap"></div>
<button id="clearKeyChips" class="ml-auto text-xs text-neutral-600 hover:text-neutral-800 dark:text-neutral-300 dark:hover:text-white">Clear</button>
</div>
</div>
</div>
<div class="bg-white dark:bg-neutral-800 rounded-xl border border-neutral-200 dark:border-neutral-700 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-neutral-100 dark:bg-neutral-700 text-left">
<th class="px-3 py-2">ID</th>
<th class="px-3 py-2">File Type</th>
<th class="px-3 py-2">Target Table</th>
<th class="px-3 py-2">PK</th>
<th class="px-3 py-2">Unmapped Preview</th>
<th class="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody id="flexibleRows" class="divide-y divide-neutral-200 dark:divide-neutral-700">
</tbody>
</table>
</div>
<div class="flex items-center justify-between p-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="text-xs text-neutral-500" id="rowsMeta">Loading...</div>
<div class="flex items-center gap-2">
<button id="prevPageBtn" class="px-3 py-1.5 bg-neutral-100 dark:bg-neutral-700 disabled:opacity-50 rounded-lg">Prev</button>
<button id="nextPageBtn" class="px-3 py-1.5 bg-neutral-100 dark:bg-neutral-700 disabled:opacity-50 rounded-lg">Next</button>
</div>
</div>
</div>
<!-- Row detail modal -->
<div id="flexibleDetailModal" class="hidden fixed inset-0 bg-black/60 z-50 overflow-y-auto" aria-hidden="true">
<div class="flex min-h-full items-center justify-center p-4">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl max-w-4xl w-full max-h-[85vh] overflow-hidden">
<div class="flex items-center justify-between px-6 py-4 border-b border-neutral-200 dark:border-neutral-700">
<h2 class="text-lg font-semibold">Flexible Row <span id="detailRowId"></span></h2>
<div class="flex items-center gap-2">
<button id="detailExportBtn" class="px-3 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm">
<i class="fa-solid fa-file-csv mr-1"></i> Export CSV
</button>
<button onclick="closeModal('flexibleDetailModal')" class="text-neutral-500 hover:text-neutral-700">
<i class="fa-solid fa-xmark text-xl"></i>
</button>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3 text-xs text-neutral-600 dark:text-neutral-300">
<div>File Type: <span id="detailFileType" class="font-mono"></span></div>
<div>Target Table: <span id="detailTargetTable" class="font-mono"></span></div>
<div>PK Field: <span id="detailPkField" class="font-mono"></span></div>
<div>PK Value: <span id="detailPkValue" class="font-mono"></span></div>
</div>
<div class="border border-neutral-200 dark:border-neutral-700 rounded-lg overflow-hidden">
<pre id="detailJson" class="p-4 text-xs bg-neutral-50 dark:bg-neutral-900 overflow-auto max-h-[60vh]"></pre>
</div>
</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="closeModal('flexibleDetailModal')" 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>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="/static/js/flexible.js"></script>
{% endblock %}

View File

@@ -49,17 +49,21 @@
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Mode:</label>
<select id="uploadMode" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm">
<option value="batch">Batch Upload (Recommended)</option>
<option value="single">Single File</option>
<option value="batch">Batch Upload</option>
</select>
<button type="button" id="importHelpBtn" class="ml-2 px-2 py-1 text-xs border border-neutral-300 dark:border-neutral-600 rounded hover:bg-neutral-100 dark:hover:bg-neutral-700" title="How to select and order files">
<i class="fa-solid fa-circle-question"></i>
Help
</button>
</div>
</div>
</div>
<div class="p-6">
<!-- Single File Upload Form -->
<form id="importForm" enctype="multipart/form-data" class="single-upload">
<form id="importForm" enctype="multipart/form-data" class="single-upload hidden">
<div class="grid grid-cols-1 md:grid-cols-12 gap-4">
<div class="md:col-span-4">
<div class="md:col-span-4" id="fileTypeContainer">
<label for="fileType" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Data Type *</label>
<select 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" id="fileType" name="fileType" required>
<option value="">Select data type...</option>
@@ -72,10 +76,16 @@
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Select the CSV file to import</div>
</div>
<div class="md:col-span-2 flex items-end">
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="replaceExisting" name="replaceExisting">
<span class="text-sm">Replace existing</span>
</label>
<div class="flex flex-col gap-2">
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="replaceExisting" name="replaceExisting">
<span class="text-sm">Replace existing</span>
</label>
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="flexibleOnly" name="flexibleOnly">
<span class="text-sm">Flexible-only</span>
</label>
</div>
</div>
</div>
@@ -90,6 +100,9 @@
<span>Import Data</span>
</button>
</div>
<p class="mt-2 text-xs text-neutral-500 dark:text-neutral-400" id="flexibleHint" style="display:none;">
Flexible-only: Upload any CSV. All columns will be stored as flexible JSON and visible in Flexible Imports.
</p>
</div>
</form>
@@ -98,15 +111,24 @@
<div class="space-y-4">
<div>
<label for="batchFiles" class="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Select Multiple CSV Files *</label>
<input type="file" 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 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-primary-100 file:text-primary-700 hover:file:bg-primary-200 transition-all duration-200" id="batchFiles" name="batchFiles" accept=".csv" multiple required>
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-1">Select multiple CSV files (max 20). Files will be imported in optimal dependency order.</div>
<div class="relative">
<input type="file" 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 file:mr-4 file:py-2 file:px-4 file:rounded file:border-0 file:text-sm file:font-semibold file:bg-primary-100 file:text-primary-700 hover:file:bg-primary-200 transition-all duration-200" id="batchFiles" name="batchFiles" accept=".csv" multiple required>
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
Select all your CSV files at once (max 25). Files will be validated and imported in dependency order automatically.
<br><strong>Tip:</strong> Use Ctrl+A in the file dialog to select all CSV files from your export folder.
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<div class="space-y-2">
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-primary-600 border-neutral-300 rounded" type="checkbox" id="batchReplaceExisting" name="batchReplaceExisting">
<span class="text-sm">Replace existing data</span>
<span class="text-sm font-medium">Replace existing data</span>
</label>
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 text-success-600 border-neutral-300 rounded" type="checkbox" id="validateAllFirst" name="validateAllFirst" checked>
<span class="text-sm font-medium text-success-700 dark:text-success-400">Validate all files before import</span>
</label>
</div>
<div class="text-right">
@@ -116,13 +138,17 @@
<div id="selectedFilesList" class="hidden">
<h6 class="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Selected Files (Import Order):</h6>
<div class="bg-neutral-50 dark:bg-neutral-900 rounded-lg p-3 max-h-32 overflow-y-auto" id="filesList"></div>
<div class="bg-neutral-50 dark:bg-neutral-900 rounded-lg p-3 max-h-40 overflow-y-auto" id="filesList"></div>
</div>
<div class="flex items-center gap-3">
<button type="button" class="px-4 py-2 bg-info-600 text-white hover:bg-info-700 rounded-lg transition-colors duration-200 flex items-center gap-2" id="batchValidateBtn">
<i class="fa-solid fa-clipboard-check"></i>
<span>Validate All Files</span>
</button>
<button type="submit" class="px-4 py-2 bg-success-600 text-white hover:bg-success-700 rounded-lg transition-colors duration-200 flex items-center gap-2" id="batchImportBtn">
<i class="fa-solid fa-layer-group"></i>
<span>Batch Import</span>
<span>Import All Files</span>
</button>
<button type="button" 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 flex items-center gap-2" id="clearBatchBtn">
<i class="fa-solid fa-xmark"></i>
@@ -189,6 +215,22 @@
</div>
</div>
<!-- Recent Batch Uploads Panel -->
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-xl shadow-soft" id="recentBatchesPanel">
<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-clock-rotate-left"></i>
<span>Recent Batch Uploads</span>
</h5>
</div>
<div class="p-6" id="recentBatches">
<div class="flex flex-col items-center justify-center py-4 text-neutral-500 dark:text-neutral-400">
<i class="fa-solid fa-file-arrow-up text-2xl mb-2"></i>
<p>Loading recent batches...</p>
</div>
</div>
</div>
<!-- Data Management 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">
@@ -234,6 +276,7 @@
// Import functionality
let availableFiles = {};
let importInProgress = false;
let recentState = { limit: 5, offset: 0, status: 'all', start: '', end: '' };
// Authorization is injected by window.http.wrappedFetch
@@ -245,10 +288,35 @@ document.addEventListener('DOMContentLoaded', function() {
window.location.href = '/login';
return;
}
// Ensure admin only access for this page
(async () => {
try {
const resp = await window.http.wrappedFetch('/api/auth/me');
if (!resp.ok) {
window.location.href = '/login';
return;
}
const me = await resp.json();
if (!me || !me.is_admin) {
window.location.href = '/';
return;
}
} catch (_) {
window.location.href = '/login';
return;
}
})();
loadAvailableFiles();
loadImportStatus();
loadRecentBatches(false);
setupEventListeners();
// Set batch mode as default after a short delay to ensure DOM is ready
setTimeout(() => {
document.getElementById('uploadMode').value = 'batch';
switchUploadMode();
}, 100);
});
function setupEventListeners() {
@@ -258,9 +326,12 @@ function setupEventListeners() {
// Upload mode switching
document.getElementById('uploadMode').addEventListener('change', switchUploadMode);
const helpBtn = document.getElementById('importHelpBtn');
if (helpBtn) helpBtn.addEventListener('click', showImportHelp);
// Validation button
// Validation buttons
document.getElementById('validateBtn').addEventListener('click', validateFile);
document.getElementById('batchValidateBtn').addEventListener('click', validateAllFiles);
// File type selection
document.getElementById('fileType').addEventListener('change', updateFileTypeDescription);
@@ -278,6 +349,24 @@ function setupEventListeners() {
// Other buttons
document.getElementById('backupBtn').addEventListener('click', downloadBackup);
document.getElementById('viewLogsBtn').addEventListener('click', viewLogs);
const flexibleOnly = document.getElementById('flexibleOnly');
if (flexibleOnly) {
flexibleOnly.addEventListener('change', () => {
const isFlex = flexibleOnly.checked;
const fileTypeContainer = document.getElementById('fileTypeContainer');
const fileType = document.getElementById('fileType');
const hint = document.getElementById('flexibleHint');
if (isFlex) {
if (fileTypeContainer) fileTypeContainer.classList.add('opacity-50');
if (fileType) fileType.required = false;
if (hint) hint.style.display = '';
} else {
if (fileTypeContainer) fileTypeContainer.classList.remove('opacity-50');
if (fileType) fileType.required = true;
if (hint) hint.style.display = 'none';
}
});
}
}
async function loadAvailableFiles() {
@@ -398,10 +487,11 @@ function updateFileTypeDescription() {
}
async function validateFile() {
const flexibleOnly = document.getElementById('flexibleOnly').checked;
const fileType = document.getElementById('fileType').value;
const fileInput = document.getElementById('csvFile');
if (!fileType || !fileInput.files[0]) {
if ((!flexibleOnly && !fileType) || !fileInput.files[0]) {
showAlert('Please select both data type and CSV file', 'warning');
return;
}
@@ -412,7 +502,9 @@ async function validateFile() {
try {
showProgress(true, 'Validating file...');
const response = await window.http.wrappedFetch(`/api/import/validate/${fileType}`, {
const endpoint = flexibleOnly ? '/api/import/upload-flexible' : `/api/import/validate/${fileType}`;
const method = flexibleOnly ? 'POST' : 'POST';
const response = await window.http.wrappedFetch(endpoint, {
method: 'POST',
body: formData
});
@@ -423,7 +515,23 @@ async function validateFile() {
}
const result = await response.json();
displayValidationResults(result);
if (flexibleOnly) {
// Synthesize a validation-like display for flexible-only
displayValidationResults({
valid: true,
headers: {
found: result.auto_mapping?.unmapped_headers || [],
mapped: {},
unmapped: result.auto_mapping?.unmapped_headers || [],
},
sample_data: [],
validation_errors: [],
total_errors: 0,
auto_mapping: { suggestions: {} },
});
} else {
displayValidationResults(result);
}
} catch (error) {
console.error('Validation error:', error);
@@ -446,24 +554,37 @@ function displayValidationResults(result) {
html += `
<div class="p-4 bg-${statusClass}-100 dark:bg-${statusClass}-900/30 rounded-lg mb-4">
<i class="fa-solid fa-${statusIcon} mr-2"></i>
<span class="font-medium">File validation ${result.valid ? 'passed' : 'failed'}</span>
<span class="font-medium">File validation ${result.valid ? 'passed' : 'completed with issues'}</span>
</div>
`;
// Headers validation
html += '<h6 class="text-sm font-semibold mb-2">Column Headers</h6>';
if (result.headers.missing.length > 0) {
html += `<div class="p-3 bg-warning-100 dark:bg-warning-900/30 rounded-lg mb-2">
<strong class="text-warning-700 dark:text-warning-300">Missing columns:</strong> ${result.headers.missing.join(', ')}
</div>`;
// Auto-discovery mapping summary
html += '<h6 class="text-sm font-semibold mb-2">Auto-Discovery Mapping</h6>';
const mapped = (result.headers && result.headers.mapped) || {};
const unmapped = (result.headers && result.headers.unmapped) || [];
const suggestions = (result.auto_mapping && result.auto_mapping.suggestions) || {};
const mappedCount = Object.keys(mapped).length;
const unmappedCount = unmapped.length;
html += `<div class="p-3 bg-neutral-100 dark:bg-neutral-900/30 rounded-lg mb-3 text-sm">
<div>Mapped columns: <strong>${mappedCount}</strong> | Unmapped columns: <strong>${unmappedCount}</strong></div>
</div>`;
if (mappedCount > 0) {
html += '<div class="overflow-x-auto mb-3"><table class="w-full text-sm"><thead><tr class="bg-neutral-100 dark:bg-neutral-700"><th class="px-3 py-2 text-left font-medium">CSV Column</th><th class="px-3 py-2 text-left font-medium">Mapped To</th></tr></thead><tbody class="divide-y divide-neutral-200 dark:divide-neutral-700">';
Object.entries(mapped).forEach(([csvCol, dbField]) => {
html += `<tr><td class="px-3 py-2">${csvCol}</td><td class="px-3 py-2 font-mono">${dbField}</td></tr>`;
});
html += '</tbody></table></div>';
}
if (result.headers.extra.length > 0) {
html += `<div class="p-3 bg-info-100 dark:bg-info-900/30 rounded-lg mb-2">
<strong class="text-info-700 dark:text-info-300">Extra columns:</strong> ${result.headers.extra.join(', ')}
</div>`;
}
if (result.headers.missing.length === 0 && result.headers.extra.length === 0) {
html += '<div class="p-3 bg-success-100 dark:bg-success-900/30 rounded-lg mb-2">All expected columns found</div>';
if (unmappedCount > 0) {
html += '<div class="p-3 bg-warning-100 dark:bg-warning-900/30 rounded-lg mb-2">Some columns were not recognized and will be stored as flexible JSON data:</div>';
html += '<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="bg-neutral-100 dark:bg-neutral-700"><th class="px-3 py-2 text-left font-medium">Unmapped CSV Column</th><th class="px-3 py-2 text-left font-medium">Top Suggestions</th></tr></thead><tbody class="divide-y divide-neutral-200 dark:divide-neutral-700">';
unmapped.forEach(col => {
const sug = suggestions[col] || [];
const sugText = sug.map(([name, score]) => `${name} (${(score*100).toFixed(0)}%)`).join(', ');
html += `<tr><td class="px-3 py-2">${col}</td><td class="px-3 py-2 text-neutral-600 dark:text-neutral-400">${sugText || '—'}</td></tr>`;
});
html += '</tbody></table></div>';
}
// Sample data
@@ -511,11 +632,12 @@ async function handleImport(event) {
return;
}
const flexibleOnly = document.getElementById('flexibleOnly').checked;
const fileType = document.getElementById('fileType').value;
const fileInput = document.getElementById('csvFile');
const replaceExisting = document.getElementById('replaceExisting').checked;
if (!fileType || !fileInput.files[0]) {
if ((!flexibleOnly && !fileType) || !fileInput.files[0]) {
showAlert('Please select both data type and CSV file', 'warning');
return;
}
@@ -529,10 +651,12 @@ async function handleImport(event) {
try {
showProgress(true, 'Importing data...');
const response = await window.http.wrappedFetch(`/api/import/upload/${fileType}`, {
method: 'POST',
body: formData
});
let response;
if (flexibleOnly) {
response = await window.http.wrappedFetch('/api/import/upload-flexible', { method: 'POST', body: formData });
} else {
response = await window.http.wrappedFetch(`/api/import/upload/${fileType}`, { method: 'POST', body: formData });
}
if (!response.ok) {
const error = await response.json();
@@ -574,6 +698,19 @@ function displayImportResults(result) {
</div>
`;
if (result.auto_mapping) {
const mappedCount = Object.keys(result.auto_mapping.mapped_headers || {}).length;
const unmappedCount = (result.auto_mapping.unmapped_headers || []).length;
const flexSaved = result.auto_mapping.flexible_saved_rows || 0;
html += `
<div class="p-3 bg-neutral-100 dark:bg-neutral-900/30 rounded-lg mb-4 text-sm">
<strong>Auto-Discovery Summary</strong>
<div class="mt-1">Mapped columns: ${mappedCount} | Unmapped stored as flexible JSON: ${unmappedCount}</div>
<div>Rows with flexible data saved: ${flexSaved}</div>
</div>
`;
}
if (result.errors && result.errors.length > 0) {
html += '<h6 class="text-sm font-semibold mb-2">Import Errors</h6>';
html += '<div class="p-3 bg-danger-100 dark:bg-danger-900/30 rounded-lg">';
@@ -651,15 +788,206 @@ function switchUploadMode() {
const singleForm = document.querySelector('.single-upload');
const batchForm = document.querySelector('.batch-upload');
console.log('Switch mode to:', mode);
console.log('Single form found:', !!singleForm);
console.log('Batch form found:', !!batchForm);
if (mode === 'batch') {
singleForm.classList.add('hidden');
batchForm.classList.remove('hidden');
if (singleForm) singleForm.classList.add('hidden');
if (batchForm) batchForm.classList.remove('hidden');
console.log('Switched to batch mode');
} else {
singleForm.classList.remove('hidden');
batchForm.classList.add('hidden');
if (singleForm) singleForm.classList.remove('hidden');
if (batchForm) batchForm.classList.add('hidden');
console.log('Switched to single mode');
}
}
function showImportHelp() {
const tips = `
<div class="space-y-2 text-sm">
<div class="p-2 bg-neutral-100 dark:bg-neutral-900/40 rounded">
<strong>Recommended flow:</strong>
<ol class="list-decimal list-inside mt-1 space-y-1">
<li>Choose Batch Upload mode.</li>
<li>Select all exported CSVs at once (Cmd+A/ Ctrl+A).</li>
<li>Keep file names exactly as exported (e.g., STATES.csv, GRUPLKUP.csv, …).</li>
<li>Optionally Validate All first to catch header/format issues.</li>
<li>Click Import All Files.</li>
</ol>
</div>
<div>
Files will be imported in dependency order automatically:
<code class="block mt-1">STATES.csv → GRUPLKUP.csv → EMPLOYEE.csv → FILETYPE.csv → FILESTAT.csv → TRNSTYPE.csv → TRNSLKUP.csv → FOOTERS.csv → SETUP.csv → PRINTERS.csv → ROLODEX.csv → PHONE.csv → FILES.csv → LEDGER.csv → TRNSACTN.csv → QDROS.csv → PENSIONS.csv → PLANINFO.csv → PAYMENTS.csv → DEPOSITS.csv → FILENOTS.csv → FORM_INX.csv → FORM_LST.csv → FVARLKUP.csv → RVARLKUP.csv</code>
</div>
<div>
Unrecognized columns are saved as flexible JSON automatically. Unknown CSVs fall back to flexible-only storage.
</div>
<div class="text-xs text-neutral-500 mt-1">
Tip: Use Replace Existing to clear a table before importing its file.
</div>
</div>`;
if (window.alerts && window.alerts.show) {
window.alerts.show(tips, 'info', { html: true, duration: 0, title: 'Import Help' });
} else if (window.showNotification) {
window.showNotification('See import tips in console', 'info');
console.log('[Import Help]', tips);
} else {
alert('Batch mode → select all CSVs → Validate (optional) → Import. Files auto-ordered. Unknown columns saved as flexible.');
}
}
async function validateAllFiles() {
const fileInput = document.getElementById('batchFiles');
if (!fileInput.files || fileInput.files.length === 0) {
showAlert('Please select at least one CSV file', 'warning');
return;
}
if (fileInput.files.length > 25) {
showAlert('Maximum 25 files allowed per batch', 'warning');
return;
}
showProgress(true, 'Validating all selected files...');
try {
const formData = new FormData();
for (let file of fileInput.files) {
formData.append('files', file);
}
const response = await window.http.wrappedFetch('/api/import/batch-validate', {
method: 'POST',
body: formData
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Batch validation failed');
}
const result = await response.json();
displayBatchValidationResults(result.batch_validation_results, result.summary.all_valid);
} catch (error) {
console.error('Batch validation error:', error);
showAlert('Batch validation failed: ' + error.message, 'danger');
} finally {
showProgress(false);
}
}
function displayBatchValidationResults(results, allValid) {
const panel = document.getElementById('validationPanel');
const container = document.getElementById('validationResults');
const statusClass = allValid ? 'success' : 'warning';
const statusIcon = allValid ? 'circle-check text-success-600' : 'triangle-exclamation text-warning-600';
const validCount = results.filter(r => r.valid).length;
const invalidCount = results.filter(r => !r.valid && r.error !== 'Unsupported file type').length;
const errorCount = results.filter(r => r.error && !r.valid).length;
const unsupportedCount = results.filter(r => r.error === 'Unsupported file type').length;
let html = `
<div class="p-4 bg-${statusClass}-100 dark:bg-${statusClass}-900/30 rounded-lg mb-4">
<div class="flex items-center gap-2">
<i class="fa-solid fa-${statusIcon}"></i>
<span class="font-medium">Batch validation ${allValid ? 'passed' : 'completed with issues'}</span>
</div>
<div class="text-sm mt-2">
Validated ${results.length} files:
<span class="text-success-600 dark:text-success-400">${validCount} valid</span>,
<span class="text-warning-600 dark:text-warning-400">${invalidCount} invalid</span>,
<span class="text-danger-600 dark:text-danger-400">${errorCount} errors</span>,
<span class="text-neutral-600 dark:text-neutral-400">${unsupportedCount} unsupported</span>
</div>
</div>
`;
html += '<h6 class="text-sm font-semibold mb-3">File Validation Details</h6>';
html += '<div class="space-y-2">';
results.forEach(result => {
let resultClass, resultIcon, status;
if (result.valid) {
resultClass = 'success';
resultIcon = 'circle-check';
status = 'valid';
} else if (result.error === 'Unsupported file type') {
resultClass = 'neutral';
resultIcon = 'circle-info';
status = 'unsupported';
} else if (result.error && result.error.includes('failed')) {
resultClass = 'danger';
resultIcon = 'circle-xmark';
status = 'error';
} else {
resultClass = 'warning';
resultIcon = 'triangle-exclamation';
status = 'invalid';
}
html += `
<div class="p-3 bg-${resultClass}-100 dark:bg-${resultClass}-900/30 rounded-lg">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fa-solid fa-${resultIcon} text-${resultClass}-600 dark:text-${resultClass}-400"></i>
<strong class="text-sm">${result.file_type}</strong>
</div>
<div class="text-sm text-${resultClass}-600 dark:text-${resultClass}-400 capitalize">${status}</div>
</div>
`;
if (result.headers && result.headers.mapped) {
const mappedCount = Object.keys(result.headers.mapped).length;
const unmappedCount = (result.headers.unmapped || []).length;
html += `<p class="text-xs text-neutral-600 dark:text-neutral-400 mt-1">${mappedCount} mapped, ${unmappedCount} unmapped</p>`;
}
if (result.total_errors > 0) {
html += `<p class="text-xs text-neutral-600 dark:text-neutral-400 mt-1">${result.total_errors} data validation errors found</p>`;
}
if (result.error) {
html += `<p class="text-xs text-${resultClass}-600 dark:text-${resultClass}-400 mt-1">${result.error}</p>`;
}
html += `</div>`;
});
html += '</div>';
if (allValid) {
html += `
<div class="mt-4 p-3 bg-success-100 dark:bg-success-900/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fa-solid fa-thumbs-up text-success-600"></i>
<span class="text-sm font-medium text-success-700 dark:text-success-300">All files passed validation! Ready for import.</span>
</div>
</div>
`;
} else {
html += `
<div class="mt-4 p-3 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
<div class="flex items-center gap-2">
<i class="fa-solid fa-exclamation-triangle text-warning-600"></i>
<span class="text-sm font-medium text-warning-700 dark:text-warning-300">Some files have issues. Review the details above before importing.</span>
</div>
</div>
`;
}
container.innerHTML = html;
panel.classList.remove('hidden');
// Scroll to validation panel
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function updateSelectedFiles() {
const fileInput = document.getElementById('batchFiles');
const countSpan = document.getElementById('selectedFilesCount');
@@ -734,19 +1062,91 @@ async function handleBatchImport(event) {
const fileInput = document.getElementById('batchFiles');
const replaceExisting = document.getElementById('batchReplaceExisting').checked;
const validateFirst = document.getElementById('validateAllFirst').checked;
if (!fileInput.files || fileInput.files.length === 0) {
showAlert('Please select at least one CSV file', 'warning');
return;
}
if (fileInput.files.length > 20) {
showAlert('Maximum 20 files allowed per batch', 'warning');
if (fileInput.files.length > 25) {
showAlert('Maximum 25 files allowed per batch', 'warning');
return;
}
importInProgress = true;
// Validate all files first if option is selected
if (validateFirst) {
try {
showProgress(true, 'Pre-validating all files before import...');
const validationResults = [];
let hasErrors = false;
for (let i = 0; i < fileInput.files.length; i++) {
const file = fileInput.files[i];
const fileName = file.name;
showProgress(true, `Pre-validating ${fileName} (${i + 1}/${fileInput.files.length})...`);
if (availableFiles.available_files && availableFiles.available_files.includes(fileName)) {
try {
const formData = new FormData();
formData.append('file', file);
const response = await window.http.wrappedFetch(`/api/import/validate/${fileName}`, {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
if (!result.valid) {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: result.validation_errors || [] });
}
} else {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: ['Validation request failed'] });
}
} catch (error) {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: [error.message] });
}
} else {
hasErrors = true;
validationResults.push({ fileName, valid: false, errors: ['Unsupported file type'] });
}
}
if (hasErrors) {
importInProgress = false;
showProgress(false);
let errorMessage = 'Pre-validation found issues in the following files:\n\n';
validationResults.forEach(result => {
if (!result.valid) {
errorMessage += `• ${result.fileName}: ${result.errors.join(', ')}\n`;
}
});
errorMessage += '\nPlease fix these issues before importing, or disable "Validate all files before import" to proceed anyway.';
showAlert(errorMessage, 'danger');
return;
} else {
showAlert('All files passed pre-validation. Proceeding with import...', 'success');
}
} catch (error) {
importInProgress = false;
showProgress(false);
showAlert('Pre-validation failed: ' + error.message, 'danger');
return;
}
}
// Continue with import if validation passed or was skipped
const formData = new FormData();
for (let file of fileInput.files) {
formData.append('files', file);
@@ -846,6 +1246,12 @@ function displayBatchResults(result) {
</div>
</div>
<p class="text-sm text-neutral-600 dark:text-neutral-400 mt-1">${fileResult.message}</p>
${fileResult.auto_mapping ? `
<div class=\"mt-2 text-xs text-neutral-600 dark:text-neutral-400\">
<span>${Object.keys(fileResult.auto_mapping.mapped_headers || {}).length} mapped</span>
<span class=\"ml-2\">${(fileResult.auto_mapping.unmapped_headers || []).length} unmapped (stored as flexible)</span>
</div>
` : ''}
</div>
`;
});
@@ -856,6 +1262,198 @@ function displayBatchResults(result) {
panel.classList.remove('hidden');
}
async function loadRecentBatches(append) {
try {
const params = new URLSearchParams();
params.set('limit', String(recentState.limit));
params.set('offset', String(recentState.offset));
if (recentState.status && recentState.status !== 'all') params.set('status', recentState.status);
if (recentState.start) params.set('start', recentState.start);
if (recentState.end) params.set('end', recentState.end);
const resp = await window.http.wrappedFetch(`/api/import/recent-batches?${params.toString()}`);
if (!resp.ok) return;
const data = await resp.json();
const rows = (data.recent || []).map(r => `
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-800 cursor-pointer" onclick="viewAuditDetails(${r.id})">
<td class="px-3 py-2 text-sm"><span class="inline-block px-2 py-0.5 rounded ${r.status === 'success' ? 'bg-green-100 text-green-700' : (r.status === 'completed_with_errors' ? 'bg-yellow-100 text-yellow-700' : (r.status === 'running' ? 'bg-blue-100 text-blue-700' : 'bg-red-100 text-red-700'))}">${r.status}</span></td>
<td class="px-3 py-2 text-sm">${r.started_at ? new Date(r.started_at).toLocaleString() : ''}</td>
<td class="px-3 py-2 text-sm">${r.finished_at ? new Date(r.finished_at).toLocaleString() : ''}</td>
<td class="px-3 py-2 text-sm">${r.successful_files}/${r.total_files}</td>
<td class="px-3 py-2 text-sm">${Number(r.total_imported || 0).toLocaleString()}</td>
<td class="px-3 py-2 text-right text-sm"><button class="px-2 py-1 border rounded" onclick="event.stopPropagation();downloadAuditJson(${r.id})">JSON</button></td>
</tr>
`).join('');
if (!append) {
const html = `
<div class="mb-3 flex flex-wrap items-end gap-2">
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-400 mb-1">Status</label>
<select id="recentStatusFilter" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm">
<option value="all" ${recentState.status==='all'?'selected':''}>All</option>
<option value="running" ${recentState.status==='running'?'selected':''}>Running</option>
<option value="success" ${recentState.status==='success'?'selected':''}>Success</option>
<option value="completed_with_errors" ${recentState.status==='completed_with_errors'?'selected':''}>Completed with errors</option>
<option value="failed" ${recentState.status==='failed'?'selected':''}>Failed</option>
</select>
</div>
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-400 mb-1">Start</label>
<input id="recentStartFilter" type="datetime-local" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm" value="${recentState.start || ''}">
</div>
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-400 mb-1">End</label>
<input id="recentEndFilter" type="datetime-local" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm" value="${recentState.end || ''}">
</div>
<div class="ml-auto">
<button id="recentApplyBtn" class="px-3 py-1.5 bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 rounded">Apply</button>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm border border-neutral-200 dark:border-neutral-700 rounded">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Started</th>
<th class="px-3 py-2 text-left">Finished</th>
<th class="px-3 py-2 text-left">Files</th>
<th class="px-3 py-2 text-left">Imported</th>
<th class="px-3 py-2 text-right">Actions</th>
</tr>
</thead>
<tbody id="recentBatchesTableBody">
${rows || '<tr><td class="px-3 py-3 text-neutral-500" colspan="6">No batch uploads</td></tr>'}
</tbody>
</table>
</div>
<div class="mt-3 text-center">
<button id="recentLoadMoreBtn" class="px-3 py-1.5 border rounded ${((data.offset||0)+(data.recent?.length||0)) >= (data.total||0) ? 'opacity-50 cursor-not-allowed' : ''}">Load more</button>
<span class="ml-2 text-xs text-neutral-500">Showing ${(data.offset||0)+(data.recent?.length||0)} of ${data.total||0}</span>
</div>
`;
document.getElementById('recentBatches').innerHTML = html;
const statusEl = document.getElementById('recentStatusFilter');
const startEl = document.getElementById('recentStartFilter');
const endEl = document.getElementById('recentEndFilter');
const applyBtn = document.getElementById('recentApplyBtn');
const loadMoreBtn = document.getElementById('recentLoadMoreBtn');
if (applyBtn) applyBtn.addEventListener('click', (e) => {
e.preventDefault();
recentState.status = statusEl.value || 'all';
recentState.start = startEl.value || '';
recentState.end = endEl.value || '';
recentState.offset = 0;
loadRecentBatches(false);
});
if (loadMoreBtn) loadMoreBtn.addEventListener('click', (e) => {
e.preventDefault();
if (((data.offset||0)+(data.recent?.length||0)) >= (data.total||0)) return;
recentState.offset = (data.offset||0) + (data.recent?.length||0);
loadRecentBatches(true);
});
} else {
const tbody = document.getElementById('recentBatchesTableBody');
if (tbody) tbody.insertAdjacentHTML('beforeend', rows);
const showing = recentState.offset + (data.recent?.length || 0);
const loadMoreBtn = document.getElementById('recentLoadMoreBtn');
if (loadMoreBtn && showing >= (data.total||0)) {
loadMoreBtn.classList.add('opacity-50', 'cursor-not-allowed');
}
}
} catch (_) {}
}
async function viewAuditDetails(auditId) {
try {
const resp = await window.http.wrappedFetch(`/api/import/recent-batches/${auditId}`);
if (!resp.ok) return;
const data = await resp.json();
const files = (data.files || []).map(f => `
<tr>
<td class="px-3 py-2 text-sm font-mono">${f.file_type}</td>
<td class="px-3 py-2 text-sm"><span class="inline-block px-2 py-0.5 rounded ${f.status === 'success' ? 'bg-green-100 text-green-700' : (f.status === 'completed_with_errors' ? 'bg-yellow-100 text-yellow-700' : 'bg-red-100 text-red-700')}">${f.status}</span></td>
<td class="px-3 py-2 text-sm">${f.imported_count}</td>
<td class="px-3 py-2 text-sm">${f.errors}</td>
<td class="px-3 py-2 text-sm">${f.message || ''}</td>
</tr>
`).join('');
const hasFailed = Number(data.audit.failed_files || 0) > 0;
const content = `
<div class="space-y-2 text-sm">
<div><strong>Status:</strong> ${data.audit.status}</div>
<div><strong>Started:</strong> ${data.audit.started_at ? new Date(data.audit.started_at).toLocaleString() : ''}</div>
<div><strong>Finished:</strong> ${data.audit.finished_at ? new Date(data.audit.finished_at).toLocaleString() : ''}</div>
<div><strong>Files:</strong> ${data.audit.successful_files}/${data.audit.total_files}
<button class="ml-2 px-2 py-1 border rounded" onclick="downloadAuditJson(${data.audit.id})">Download JSON</button>
${hasFailed ? `<button class="ml-2 px-2 py-1 border rounded" onclick="rerunFailedFiles(${data.audit.id})">Rerun failed files</button>` : ''}
</div>
<div class="overflow-x-auto mt-2">
<table class="w-full text-sm border border-neutral-200 dark:border-neutral-700 rounded">
<thead class="bg-neutral-50 dark:bg-neutral-800">
<tr>
<th class="px-3 py-2 text-left">File</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Imported</th>
<th class="px-3 py-2 text-left">Errors</th>
<th class="px-3 py-2 text-left">Message</th>
</tr>
</thead>
<tbody>
${files || '<tr><td class="px-3 py-3 text-neutral-500" colspan="5">No file records</td></tr>'}
</tbody>
</table>
</div>
</div>
`;
if (window.alerts && window.alerts.show) {
window.alerts.show(content, 'info', { html: true, duration: 0, title: `Batch #${data.audit.id}` });
} else {
alert(`Batch ${data.audit.id}: ${data.audit.status}`);
}
} catch (_) {}
}
async function downloadAuditJson(auditId) {
try {
const resp = await window.http.wrappedFetch(`/api/import/recent-batches/${auditId}`);
if (!resp.ok) return;
const data = await resp.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `import_audit_${auditId}.json`;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
} catch (_) {}
}
async function rerunFailedFiles(auditId) {
try {
const confirmReplace = confirm('Replace existing records for these file types before rerun? Click OK to replace, Cancel to append.');
const formData = new FormData();
if (confirmReplace) formData.append('replace_existing', 'true');
showProgress(true, 'Re-running failed files...');
const resp = await window.http.wrappedFetch(`/api/import/recent-batches/${auditId}/rerun-failed`, { method: 'POST', body: formData });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Rerun failed');
}
const result = await resp.json();
displayBatchResults(result);
await loadRecentBatches(false);
showAlert('Rerun completed', 'success');
} catch (e) {
console.error('Rerun failed', e);
showAlert('Rerun failed: ' + (e?.message || 'Unknown error'), 'danger');
} finally {
showProgress(false);
}
}
function showAlert(message, type = 'info') {
if (window.alerts && typeof window.alerts.show === 'function') {
window.alerts.show(message, type);

View File

@@ -441,6 +441,8 @@ function initializeAdvancedSearch() {
});
}
// Use shared highlight utilities
async function loadSearchFacets() {
try {
const response = await window.http.wrappedFetch('/api/search/facets');
@@ -744,6 +746,9 @@ function displaySearchResults(data) {
const resultsContainer = document.getElementById('searchResults');
const statusElement = document.getElementById('searchStatus');
const facetsCard = document.getElementById('facetsCard');
const tokens = (window.highlightUtils && typeof window.highlightUtils.buildTokens === 'function')
? window.highlightUtils.buildTokens(currentSearchCriteria.query || '')
: [];
// Update status
const executionTime = data.stats?.search_execution_time || 0;
@@ -777,6 +782,9 @@ function displaySearchResults(data) {
data.results.forEach(result => {
const typeIcon = getTypeIcon(result.type);
const typeBadge = getTypeBadge(result.type);
const matchHtml = (window.highlightUtils && typeof window.highlightUtils.formatSnippet === 'function')
? window.highlightUtils.formatSnippet(result.highlight, tokens)
: (result.highlight || '');
resultsHTML += `
<div class="search-result-item border-b py-3">
@@ -787,7 +795,7 @@ function displaySearchResults(data) {
<div class="flex-1">
<div class="flex justify-between items-start mb-1">
<h6 class="mb-1">
<a href="${result.url}" class="hover:underline">${result.title}</a>
<a href="${result.url}" class="hover:underline">${window.highlightUtils ? window.highlightUtils.highlight(result.title || '', tokens) : (result.title || '')}</a>
${typeBadge}
</h6>
<div class="text-right">
@@ -795,16 +803,20 @@ function displaySearchResults(data) {
${result.updated_at ? `<br><small class="text-neutral-500">${formatDate(result.updated_at)}</small>` : ''}
</div>
</div>
<p class="mb-1 text-neutral-500">${result.description}</p>
${result.highlight ? `<div class="text-sm text-info-600"><strong>Match:</strong> ${result.highlight}</div>` : ''}
${displayResultMetadata(result.metadata)}
<p class="mb-1 text-neutral-500">${window.highlightUtils ? window.highlightUtils.highlight(result.description || '', tokens) : (result.description || '')}</p>
${matchHtml ? `<div class="text-sm text-info-600"><strong>Match:</strong> ${matchHtml}</div>` : ''}
${displayResultMetadata(result.metadata, tokens)}
</div>
</div>
</div>
`;
});
resultsContainer.innerHTML = resultsHTML;
if (window.setSafeHTML) {
window.setSafeHTML(resultsContainer, resultsHTML);
} else {
resultsContainer.innerHTML = resultsHTML;
}
// Display pagination
if (data.page_info.total_pages > 1) {
@@ -903,14 +915,23 @@ function getTypeBadge(type) {
return badges[type] || '';
}
function displayResultMetadata(metadata) {
function displayResultMetadata(metadata, tokens) {
if (!metadata) return '';
let metadataHTML = '<div class="text-sm text-neutral-500 mt-1">';
Object.entries(metadata).forEach(([key, value]) => {
if (value && key !== 'phones') { // Skip complex objects
metadataHTML += `<span class="mr-3"><strong>${key.replace('_', ' ')}:</strong> ${value}</span>`;
const label = (window.highlightUtils && window.highlightUtils.escape)
? window.highlightUtils.escape(String(key).replace('_', ' '))
: String(key).replace('_', ' ');
const valStr = typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
? String(value)
: '';
const valueHtml = (window.highlightUtils && typeof window.highlightUtils.highlight === 'function')
? window.highlightUtils.highlight(valStr, Array.isArray(tokens) ? tokens : [])
: valStr;
metadataHTML += `<span class="mr-3"><strong>${label}:</strong> ${valueHtml}</span>`;
}
});