coming together
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"')}')"><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, '"')}')"><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, '"')}', '${((client.first || '') + ' ' + (client.last || '')).replace(/"/g, '"')}', '${(`${client.city || ''}, ${client.abrev || ''}`).replace(/"/g, '"')}')">Select</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
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, '"') : ''}">`;
|
||||
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
113
templates/flexible.html
Normal 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 %}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user