This commit is contained in:
HotSwapp
2025-08-18 20:20:04 -05:00
parent 89b2bc0aa2
commit bac8cc4bd5
114 changed files with 30258 additions and 1341 deletions

View File

@@ -411,6 +411,7 @@
<script src="/static/js/alerts.js"></script>
<script src="/static/js/upload-helper.js"></script>
<script src="/static/js/keyboard-shortcuts.js"></script>
<script src="/static/js/notifications.js"></script>
<script src="/static/js/batch-progress.js"></script>
{% block extra_scripts %}{% endblock %}

View File

@@ -136,6 +136,75 @@
</div>
</div>
</div>
<div class="relative inline-block" id="phoneDirWrapper">
<div class="flex items-center gap-1">
<button id="phoneDirBtn" class="px-3 py-1.5 text-sm rounded-lg bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-200 hover:bg-neutral-200 dark:hover:bg-neutral-600 transition-colors" title="Generate printable phone directory">
<i class="fa-solid fa-address-book mr-1"></i>
Phone Directory
</button>
<div class="relative group">
<button type="button" class="text-neutral-400 hover:text-neutral-600 dark:text-neutral-500 dark:hover:text-neutral-300 transition-colors" title="Phone Directory Help">
<i class="fa-solid fa-circle-question text-sm"></i>
</button>
<div class="hidden group-hover:block absolute right-0 mt-2 w-80 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-3 z-30">
<div class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase mb-2">Phone Directory Help</div>
<div class="space-y-2 text-xs text-neutral-600 dark:text-neutral-300">
<p><strong>Grouping Options:</strong></p>
<ul class="list-disc list-inside space-y-1 ml-2">
<li><strong>None:</strong> Alphabetical by last name, no sections</li>
<li><strong>By Letter:</strong> A-Z sections based on first letter of last name</li>
<li><strong>By Group:</strong> Sections by customer group (Client, Attorney, etc.)</li>
<li><strong>Group + Letter:</strong> Group sections, then letter subsections within each</li>
</ul>
<p class="mt-2"><strong>Letter Buckets:</strong> Names starting with numbers or symbols go into the "#" bucket.</p>
<p class="mt-2"><strong>Page Breaks:</strong> HTML format can insert page breaks between top-level groups for printing.</p>
</div>
</div>
</div>
</div>
<div id="phoneDirPopover" class="hidden absolute right-0 mt-2 w-80 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg p-3 z-20">
<div class="text-xs font-semibold text-neutral-500 dark:text-neutral-400 uppercase mb-2">Phone Directory</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div class="col-span-2">
<label class="block text-xs text-neutral-600 dark:text-neutral-300 mb-1" for="phoneDirMode">Mode</label>
<select id="phoneDirMode" class="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800">
<option value="numbers">Numbers</option>
<option value="addresses">Addresses</option>
<option value="full">Full</option>
</select>
</div>
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-300 mb-1" for="phoneDirFormat">Format</label>
<select id="phoneDirFormat" class="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800">
<option value="html">HTML</option>
<option value="csv">CSV</option>
</select>
</div>
<div>
<label class="block text-xs text-neutral-600 dark:text-neutral-300 mb-1" for="phoneDirGrouping">Grouping</label>
<select id="phoneDirGrouping" class="w-full px-2 py-1.5 rounded border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800">
<option value="none">None</option>
<option value="letter">By Letter</option>
<option value="group">By Group</option>
<option value="group_letter">Group + Letter</option>
</select>
</div>
<div class="col-span-2">
<label class="inline-flex items-center gap-2 text-xs">
<input type="checkbox" id="phoneDirPageBreak">
<span>Page break per top-level group (HTML only)</span>
</label>
</div>
</div>
<div class="mt-3 text-xs text-neutral-500 dark:text-neutral-400">Uses current group filters and sort.</div>
<div class="mt-3 flex items-center justify-end gap-2">
<button id="downloadPhoneDirBtn" class="px-3 py-1.5 text-sm rounded-lg bg-primary-600 text-white hover:bg-primary-700 transition-colors">
<i class="fa-solid fa-download mr-1"></i>
Download
</button>
</div>
</div>
</div>
</div>
</div>
<div class="overflow-x-auto">
@@ -396,6 +465,56 @@ document.addEventListener('DOMContentLoaded', function() {
if (compactBtn && window.toggleCompactMode) {
compactBtn.addEventListener('click', window.toggleCompactMode);
}
// Support phone directory preconfiguration via URL
try {
const params = new URLSearchParams(window.location.search);
const wantsPhoneDir = params.has('phone_dir') && params.get('phone_dir') !== '0' && params.get('phone_dir') !== 'false';
const modeParam = params.get('mode');
const formatParam = params.get('format');
const groupingParam = params.get('grouping');
const pageBreakParam = params.get('page_break');
const namePrefixParam = params.get('name_prefix');
// If any params provided, set UI defaults
setTimeout(() => {
const fmt = document.getElementById('phoneDirFormat');
const grp = document.getElementById('phoneDirGrouping');
const pb = document.getElementById('phoneDirPageBreak');
const md = document.getElementById('phoneDirMode');
if (formatParam && fmt) fmt.value = formatParam;
if (groupingParam && grp) grp.value = groupingParam;
if (pageBreakParam != null && pb) pb.checked = (pageBreakParam === '1' || pageBreakParam === 'true');
if (modeParam && md) md.value = modeParam;
// If name_prefix provided and single char, also bind search field so our downloader logic includes it
if (typeof namePrefixParam === 'string' && namePrefixParam.length === 1) {
const s = document.getElementById('searchInput');
if (s) s.value = namePrefixParam;
}
}, 25);
// Auto-open popover from hash or when phone_dir=1
if (window.location.hash === '#phone-dir' || wantsPhoneDir) {
setTimeout(() => {
const btn = document.getElementById('phoneDirBtn');
if (btn) btn.click();
// If routing from hash without explicit params, set sensible defaults
if (!formatParam || !groupingParam) {
const fmt = document.getElementById('phoneDirFormat');
const grp = document.getElementById('phoneDirGrouping');
const pb = document.getElementById('phoneDirPageBreak');
if (fmt && !formatParam) fmt.value = 'html';
if (grp && !groupingParam) grp.value = 'letter';
if (pb && !pageBreakParam) pb.checked = true;
}
// Auto-trigger download when phone_dir=1
if (wantsPhoneDir) {
const dl = document.getElementById('downloadPhoneDirBtn');
if (dl) dl.click();
}
}, 60);
}
} catch (_) {}
// Initialize page size selector value
const sizeSel = document.getElementById('pageSizeSelect');
if (sizeSel) { sizeSel.value = String(window.customerPageSize); }
@@ -531,6 +650,51 @@ function setupEventListeners() {
e.stopPropagation();
columnsPopover.classList.toggle('hidden');
});
// Phone directory popover toggle
const phoneDirBtn = document.getElementById('phoneDirBtn');
const phoneDirPopover = document.getElementById('phoneDirPopover');
if (phoneDirBtn && phoneDirPopover) {
phoneDirBtn.addEventListener('click', function(e) {
e.stopPropagation();
phoneDirPopover.classList.toggle('hidden');
});
// Download action
const downloadBtn = document.getElementById('downloadPhoneDirBtn');
if (downloadBtn) {
downloadBtn.addEventListener('click', function(e) {
e.stopPropagation();
const mode = (document.getElementById('phoneDirMode')?.value || 'numbers');
const format = (document.getElementById('phoneDirFormat')?.value || 'html');
const grouping = (document.getElementById('phoneDirGrouping')?.value || 'letter');
const pageBreak = !!(document.getElementById('phoneDirPageBreak')?.checked);
const u = new URL(window.location.origin + '/api/customers/phone-book');
const p = u.searchParams;
p.set('mode', mode);
p.set('format', format);
p.set('grouping', grouping);
if (pageBreak) p.set('page_break', '1');
// Include filters and sort
const by = window.currentSortBy || 'name';
const dir = window.currentSortDir || 'asc';
p.set('sort_by', by);
p.set('sort_dir', dir);
(Array.isArray(window.currentGroupFilters) ? window.currentGroupFilters : []).forEach(v => p.append('groups', v));
// Optional name prefix: if user typed single letter quickly, offer faster slicing
const q = (document.getElementById('searchInput')?.value || '').trim();
if (q && q.length === 1) {
p.set('name_prefix', q);
}
// Trigger download
window.location.href = u.toString();
phoneDirPopover.classList.add('hidden');
});
}
// Clicking outside closes both popovers
document.addEventListener('click', function() {
phoneDirPopover.classList.add('hidden');
});
phoneDirPopover.addEventListener('click', function(e) { e.stopPropagation(); });
}
document.addEventListener('click', function() {
columnsPopover.classList.add('hidden');
});

View File

@@ -32,10 +32,18 @@
<h2 class="text-2xl font-bold mb-0" id="customer-count">-</h2>
</div>
</div>
<a href="/customers" class="text-primary-200 hover:text-white text-xs font-medium flex items-center gap-1 mt-2 transition-colors">
View all
<i class="fa-solid fa-arrow-right"></i>
</a>
<div class="flex items-center gap-3 mt-2">
<a href="/customers" class="text-primary-200 hover:text-white text-xs font-medium flex items-center gap-1 transition-colors">
View all
<i class="fa-solid fa-arrow-right"></i>
</a>
<a href="/customers#phone-dir"
class="inline-flex items-center gap-1 text-xs font-medium px-2 py-1 rounded bg-white/10 hover:bg-white/20 transition-colors"
title="Download Phone Directory">
<i class="fa-solid fa-address-book"></i>
Phone Directory
</a>
</div>
</div>
<div class="bg-success-600 text-white rounded-xl shadow-soft p-4">
@@ -145,11 +153,19 @@
<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 id="recent-activity" class="space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-300">
<i class="fa-regular fa-bell"></i>
<span>Live document events</span>
</div>
<div class="flex items-center gap-2 text-xs">
<span class="text-neutral-500">Connection:</span>
<span id="adminDocConnBadge"></span>
<button id="adminDocReconnectBtn" type="button" class="px-2 py-1 rounded bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 border border-neutral-300 dark:border-neutral-600">Reconnect</button>
</div>
</div>
<div id="adminDocEvents" class="space-y-2" aria-live="polite"></div>
</div>
</div>
</div>
@@ -242,11 +258,32 @@ document.addEventListener('DOMContentLoaded', function() {
loadDashboardData(); // Uncomment when authentication is implemented
loadRecentImports();
loadRecentActivity();
try { setupAdminNotificationCenter(); } catch (_) {}
});
async function loadRecentActivity() {
// Placeholder: existing system would populate; if an endpoint exists, hook it here.
}
function setupAdminNotificationCenter() {
const host = document.getElementById('adminDocConnBadge');
const feed = document.getElementById('adminDocEvents');
const btn = document.getElementById('adminDocReconnectBtn');
if (!host || !feed || !window.notifications) return;
const badge = window.notifications.createConnectionBadge();
host.innerHTML = '';
host.appendChild(badge.element);
const mgr = window.notifications.connectAdminDocumentStream({
onEvent: (payload) => {
window.notifications.appendEvent(feed, payload);
},
onState: (s) => badge.update(s)
});
if (btn) btn.addEventListener('click', () => { try { mgr.reconnectNow(); } catch(_) {} });
}
async function loadRecentImports() {
try {
const [statusResp, recentResp] = await Promise.all([

View File

@@ -162,7 +162,15 @@
<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 class="mt-2 flex items-center justify-between">
<div class="flex items-center gap-2 text-xs">
<span class="text-neutral-500">Live updates:</span>
<span id="docLiveBadge"></span>
</div>
<button id="reconnectDocWsBtn" type="button" class="text-xs px-2 py-1 rounded bg-neutral-100 dark:bg-neutral-700 hover:bg-neutral-200 dark:hover:bg-neutral-600 text-neutral-700 dark:text-neutral-300 border border-neutral-300 dark:border-neutral-600">Reconnect</button>
</div>
<div id="uploadProgressList" class="space-y-2 mt-3"></div>
<div id="docEventFeed" class="space-y-2 mt-3" aria-live="polite"></div>
<div id="uploadedDocuments" class="mb-6">
<p class="text-neutral-500">Uploaded documents will appear here.</p>
</div>
@@ -520,6 +528,9 @@ document.addEventListener('DOMContentLoaded', function() {
// Set up event handlers
setupEventHandlers();
// Live notifications UI for Generated tab
try { setupGeneratedTabNotifications(); } catch (_) {}
// Auto-refresh every 30 seconds
setInterval(function() {
@@ -742,14 +753,16 @@ function setupEventHandlers() {
// 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 (_) {}
});
loadUploadedDocuments()
.then(() => backfillGeneratedForFile(saved))
.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 (_) {}
}
@@ -762,6 +775,205 @@ function setupEventHandlers() {
}
}
// Live notifications for file-specific events on the Generated tab
function setupGeneratedTabNotifications() {
const badgeHost = document.getElementById('docLiveBadge');
const feed = document.getElementById('docEventFeed');
const reconnectBtn = document.getElementById('reconnectDocWsBtn');
const uploadFileNoInput = document.getElementById('uploadFileNo');
let mgr = null;
let badge = null;
function attachBadge() {
if (!badgeHost || !window.notifications || !window.notifications.createConnectionBadge) return;
const created = window.notifications.createConnectionBadge();
badge = created;
badgeHost.innerHTML = '';
badgeHost.appendChild(created.element);
}
function onEvent(payload) {
if (feed && window.notifications && window.notifications.appendEvent) {
window.notifications.appendEvent(feed, {
fileNo: payload.fileNo,
status: payload.status,
message: payload.data && (payload.data.file_name || payload.data.filename) ? (payload.data.file_name || payload.data.filename) : (payload.message || null),
timestamp: payload.timestamp,
max: 50
});
}
try { updateUploadedBadgeFromEvent(payload); } catch (_) {}
try { upsertGeneratedFromEvent(payload); } catch (_) {}
}
function onState(state) {
try { if (badge && typeof badge.update === 'function') badge.update(state); } catch (_) {}
}
function connectFor(fileNo) {
if (!fileNo || !window.notifications || !window.notifications.connectFileNotifications) return;
if (mgr && typeof mgr.close === 'function') { try { mgr.close(); } catch (_) {} }
try { loadUploadedDocuments(); } catch (_) {}
try { backfillGeneratedForFile(fileNo); } catch (_) {}
mgr = window.notifications.connectFileNotifications({ fileNo, onEvent, onState });
if (!badge) attachBadge();
}
if (reconnectBtn) {
reconnectBtn.addEventListener('click', function(){ if (mgr && typeof mgr.reconnectNow === 'function') mgr.reconnectNow(); });
}
// Connect when a valid file number is present/changes
function maybeConnect() {
const fileNo = (uploadFileNoInput && uploadFileNoInput.value || '').trim();
if (fileNo) connectFor(fileNo);
}
if (uploadFileNoInput) {
uploadFileNoInput.addEventListener('change', maybeConnect);
uploadFileNoInput.addEventListener('blur', maybeConnect);
// initial
maybeConnect();
}
}
// ---------- Status badge helpers ----------
function getStatusBadgeHtml(status) {
const s = String(status || '').toLowerCase();
let cls = 'bg-neutral-100 text-neutral-700 border border-neutral-300';
if (s === 'processing') cls = 'bg-amber-100 text-amber-700 border border-amber-400';
else if (s === 'completed' || s === 'success' || s === 'uploaded' || s === 'ready') cls = 'bg-green-100 text-green-700 border border-green-400';
else if (s === 'failed' || s === 'error') cls = 'bg-red-100 text-red-700 border border-red-400';
const text = (s || 'unknown').toUpperCase();
return `<span class="doc-status-badge inline-block px-2 py-0.5 text-xs rounded ${cls}">${text}</span>`;
}
function updateBadgeElement(el, status) {
if (!el) return;
const wrapper = el.parentElement;
const html = getStatusBadgeHtml(status);
if (window.setSafeHTML) { window.setSafeHTML(wrapper, html); }
else { wrapper.innerHTML = html; }
}
// Update status badge for Uploaded table when matching document_id or filename
function updateUploadedBadgeFromEvent(payload) {
const data = payload && payload.data ? payload.data : {};
const docId = data.document_id != null ? String(data.document_id) : null;
const filename = data.filename || data.file_name || null;
if (!docId && !filename) return;
const container = document.getElementById('uploadedDocuments');
if (!container) return;
let row = null;
if (docId) {
row = container.querySelector(`tr[data-doc-id="${CSS.escape(String(docId))}"]`);
}
if (!row && filename) {
row = container.querySelector(`tr[data-filename="${CSS.escape(String(filename))}"]`);
}
if (!row) return;
const badge = row.querySelector('.doc-status-badge');
if (!badge) return;
const status = (data && data.action === 'upload') ? 'uploaded' : payload.status;
updateBadgeElement(badge, status);
}
// Ensure generated documents table exists
function ensureGeneratedTable() {
const container = document.getElementById('generatedDocuments');
if (!container) return null;
// If already a table, return tbody
let tbody = container.querySelector('#generatedDocsTableBody');
if (tbody) return tbody;
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">Name</th>
<th class="px-4 py-2">Status</th>
<th class="px-4 py-2">Size</th>
</tr>
</thead>
<tbody id="generatedDocsTableBody"></tbody>
</table>
`;
if (window.setSafeHTML) { window.setSafeHTML(container, html); }
else { container.innerHTML = html; }
return container.querySelector('#generatedDocsTableBody');
}
// Create or update a generated doc row
function upsertGeneratedFromEvent(payload) {
const status = String(payload && payload.status || '').toLowerCase();
if (!status) return;
const data = payload && payload.data ? payload.data : {};
const tbody = ensureGeneratedTable();
if (!tbody) return;
const fileNo = payload.fileNo || data.file_no || '';
const docId = data.document_id != null ? String(data.document_id) : null;
const filename = data.filename || data.file_name || (data.template_name ? `${data.template_name} (${fileNo})` : null);
const size = data.size != null ? Number(data.size) : null;
const keySelector = docId ? `tr[data-doc-id="${CSS.escape(docId)}"]` : (filename ? `tr[data-filename="${CSS.escape(filename)}"]` : null);
let row = keySelector ? tbody.querySelector(keySelector) : null;
if (!row) {
row = document.createElement('tr');
if (docId) row.setAttribute('data-doc-id', String(docId));
if (filename) row.setAttribute('data-filename', String(filename));
const nameCell = document.createElement('td');
nameCell.className = 'px-4 py-2';
nameCell.textContent = filename || '[Unknown]';
const statusCell = document.createElement('td');
statusCell.className = 'px-4 py-2';
if (window.setSafeHTML) { window.setSafeHTML(statusCell, getStatusBadgeHtml(status)); }
else { statusCell.innerHTML = getStatusBadgeHtml(status); }
const sizeCell = document.createElement('td');
sizeCell.className = 'px-4 py-2';
sizeCell.textContent = size != null ? `${Number(size).toLocaleString()} bytes` : '';
row.appendChild(nameCell);
row.appendChild(statusCell);
row.appendChild(sizeCell);
tbody.prepend(row);
} else {
const badge = row.querySelector('.doc-status-badge');
if (badge) updateBadgeElement(badge, status);
const sizeCell = row.children[2];
if (size != null && sizeCell) sizeCell.textContent = `${Number(size).toLocaleString()} bytes`;
}
}
// Backfill current generated documents for a file before live updates begin
async function backfillGeneratedForFile(fileNo) {
try {
if (!fileNo) return;
// 1) Status backfill for processing badge
try {
const statusResp = await window.http.wrappedFetch(`/api/documents/current-status/${encodeURIComponent(fileNo)}`);
if (statusResp && statusResp.ok) {
const st = await statusResp.json();
if (st && String(st.status || '').toLowerCase() === 'processing') {
// Surface a processing row in Generated section for immediate feedback
upsertGeneratedFromEvent({ fileNo, status: 'processing', data: (st.data || {}) });
}
}
} catch (_) {}
// 2) Seed existing generated docs from uploaded list
const resp = await window.http.wrappedFetch(`/api/documents/${encodeURIComponent(fileNo)}/uploaded`);
if (!resp.ok) { return; }
const docs = await resp.json();
const generated = Array.isArray(docs) ? docs.filter((d) => String(d.description || '').toLowerCase().includes('generated')) : [];
if (!generated.length) return;
for (const d of generated) {
try {
upsertGeneratedFromEvent({
fileNo,
status: 'completed',
data: { document_id: d.id, filename: d.filename, size: d.size }
});
} catch (_) {}
}
} catch (_) {}
}
function updateUploadControlsState() {
try {
const btn = document.getElementById('uploadBtn');
@@ -797,6 +1009,8 @@ function clearUploadFileNo() {
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>';
const gen = document.getElementById('generatedDocuments');
if (gen) gen.innerHTML = '<p class="text-neutral-500">Generated documents will appear here...</p>';
} catch (_) {}
}
@@ -1395,11 +1609,12 @@ function displayUploadedDocuments(docs) {
return;
}
const rows = docs.map((d) => `
<tr>
<tr data-doc-id="${String(d.id || '')}" data-filename="${String(d.filename || '').replace(/"/g, '&quot;')}">
<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"><span class="doc-status-badge inline-block px-2 py-0.5 text-xs rounded bg-green-100 text-green-700 border border-green-400">UPLOADED</span></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>
@@ -1415,6 +1630,7 @@ function displayUploadedDocuments(docs) {
<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">Status</th>
<th class="px-4 py-2">Link</th>
<th class="px-4 py-2">Actions</th>
</tr>

View File

@@ -254,6 +254,83 @@
</div>
</div>
</div>
<!-- Closure Checklist -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6 md:col-span-2" id="closureChecklistCard" 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">Closure Checklist</h6>
<div class="flex items-center gap-2">
<input type="text" id="newChecklistName" class="px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Add checklist item...">
<label class="flex items-center gap-1 text-sm"><input type="checkbox" id="newChecklistRequired" class="mr-1"> Required</label>
<button type="button" class="px-3 py-2 bg-success-600 text-white rounded-lg hover:bg-success-700" id="addChecklistBtn">
<i class="fa-solid fa-plus mr-1"></i> Add
</button>
</div>
</div>
<ul id="checklistItems" class="space-y-2"></ul>
</div>
<!-- File Alerts -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6 md:col-span-2" id="fileAlertsCard" style="display: none;">
<div class="pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700">
<h6 class="mb-0 font-semibold">File Alerts</h6>
</div>
<div class="grid grid-cols-1 md:grid-cols-5 gap-2 mb-3">
<div>
<input type="text" id="alertType" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Type (e.g., follow_up)">
</div>
<div class="md:col-span-2">
<input type="text" id="alertTitle" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Title">
</div>
<div>
<input type="date" id="alertDate" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg">
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-1 text-sm"><input type="checkbox" id="alertNotifyAttorney" checked> Attorney</label>
<label class="flex items-center gap-1 text-sm"><input type="checkbox" id="alertNotifyAdmin"> Admin</label>
</div>
<div class="md:col-span-4">
<input type="text" id="alertMessage" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Message">
</div>
<div>
<button type="button" class="w-full px-3 py-2 bg-success-600 text-white rounded-lg hover:bg-success-700" id="createAlertBtn">
<i class="fa-solid fa-bell mr-1"></i> Create
</button>
</div>
</div>
<ul id="alertsList" class="space-y-2"></ul>
</div>
<!-- File Relationships -->
<div class="bg-white dark:bg-neutral-800 rounded-lg shadow-md p-6 md:col-span-2" id="fileRelationshipsCard" style="display: none;">
<div class="pb-2 mb-3 border-b border-neutral-200 dark:border-neutral-700">
<h6 class="mb-0 font-semibold">File Relationships</h6>
</div>
<div class="grid grid-cols-1 md:grid-cols-5 gap-2 mb-3">
<div>
<input type="text" id="relTargetFileNo" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Target File #">
</div>
<div>
<select id="relType" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg">
<option value="related">related</option>
<option value="parent">parent</option>
<option value="child">child</option>
<option value="duplicate">duplicate</option>
<option value="conflict">conflict</option>
<option value="referral">referral</option>
</select>
</div>
<div class="md:col-span-3">
<input type="text" id="relNotes" class="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Notes (optional)">
</div>
<div class="md:col-span-5">
<button type="button" class="px-3 py-2 bg-success-600 text-white rounded-lg hover:bg-success-700" id="addRelationshipBtn">
<i class="fa-solid fa-link mr-1"></i> Link Files
</button>
</div>
</div>
<ul id="relationshipsList" class="space-y-2"></ul>
</div>
</div>
</form>
</div>
@@ -430,6 +507,12 @@ function setupEventListeners() {
document.getElementById('deleteFileBtn').addEventListener('click', deleteFile);
document.getElementById('closeFileBtn').addEventListener('click', closeFile);
document.getElementById('reopenFileBtn').addEventListener('click', reopenFile);
// Checklist
document.getElementById('addChecklistBtn').addEventListener('click', addChecklistItem);
// Alerts
document.getElementById('createAlertBtn').addEventListener('click', createAlert);
// Relationships
document.getElementById('addRelationshipBtn').addEventListener('click', addRelationship);
// Other buttons
document.getElementById('statsBtn').addEventListener('click', showStats);
@@ -676,6 +759,9 @@ async function editFile(fileNo) {
document.getElementById('fileActions').style.display = 'block';
document.getElementById('financialSummaryCard').style.display = 'block';
document.getElementById('documentsCard').style.display = 'block'; // Show documents card for editing
document.getElementById('closureChecklistCard').style.display = 'block';
document.getElementById('fileAlertsCard').style.display = 'block';
document.getElementById('fileRelationshipsCard').style.display = 'block';
document.getElementById('fileNo').readOnly = true;
// Show/hide close/reopen buttons based on status
@@ -686,6 +772,9 @@ async function editFile(fileNo) {
// Load financial summary
loadFinancialSummary(fileNo);
loadDocuments(fileNo); // Load documents for editing
loadClosureChecklist(fileNo);
loadAlerts(fileNo);
loadRelationships(fileNo);
openModal('fileModal');
@@ -1214,5 +1303,342 @@ async function updateDocumentDescription(docId, description) {
showAlert('Error updating description: ' + error.message, 'danger');
}
}
// Closure Checklist
async function loadClosureChecklist(fileNo) {
try {
const res = await window.http.wrappedFetch(`/api/file-management/${encodeURIComponent(fileNo)}/closure-checklist`);
if (!res.ok) throw await window.http.toError(res, 'Failed to load checklist');
const items = await res.json();
const list = document.getElementById('checklistItems');
list.innerHTML = '';
if (!items || items.length === 0) {
list.innerHTML = '<li class="text-neutral-500 text-sm">No checklist items yet.</li>';
return;
}
items.forEach(item => {
const li = document.createElement('li');
li.dataset.itemId = item.id;
li.className = 'flex items-center justify-between gap-2 border border-neutral-200 dark:border-neutral-700 rounded-md px-3 py-2';
li.innerHTML = `
<div class="flex items-center gap-3">
<input type="checkbox" ${item.is_completed ? 'checked' : ''} onchange="toggleChecklistItem(${item.id}, this.checked)" />
<div>
<div class="font-medium">${_escapeHtml(item.item_name)}</div>
<div class="text-xs text-neutral-500">${_escapeHtml(item.item_description || '')}</div>
</div>
${item.is_required ? '<span class="text-xs px-2 py-0.5 rounded bg-red-100 text-red-700">Required</span>' : ''}
</div>
<div class="flex items-center gap-2">
<button class="px-2 py-1 text-xs border border-neutral-300 dark:border-neutral-600 rounded" onclick="editChecklistItem(${item.id})">Edit</button>
<button class="px-2 py-1 text-xs border border-red-600 text-red-700 rounded" onclick="deleteChecklistItem(${item.id})">Delete</button>
</div>
`;
list.appendChild(li);
});
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error loading checklist'), 'danger');
}
}
async function addChecklistItem() {
const name = (document.getElementById('newChecklistName').value || '').trim();
const isRequired = !!document.getElementById('newChecklistRequired').checked;
if (!editingFileNo) return;
if (!name) {
showAlert('Enter a checklist item name', 'warning');
return;
}
// optimistic add
const tempId = 'temp-' + Date.now();
const list = document.getElementById('checklistItems');
const li = document.createElement('li');
li.dataset.itemId = tempId;
li.className = 'flex items-center justify-between gap-2 border border-neutral-200 dark:border-neutral-700 rounded-md px-3 py-2 opacity-60';
li.innerHTML = `
<div class="flex items-center gap-3">
<input type="checkbox" />
<div>
<div class="font-medium">${_escapeHtml(name)}</div>
</div>
${isRequired ? '<span class="text-xs px-2 py-0.5 rounded bg-red-100 text-red-700">Required</span>' : ''}
</div>
<div class="flex items-center gap-2">
<span class="text-xs text-neutral-500">Saving...</span>
</div>
`;
list.appendChild(li);
try {
const res = await window.http.wrappedFetch(`/api/file-management/${encodeURIComponent(editingFileNo)}/closure-checklist`, {
method: 'POST',
body: JSON.stringify({ item_name: name, is_required: isRequired })
});
if (!res.ok) throw await window.http.toError(res, 'Failed to add item');
const saved = await res.json();
document.getElementById('newChecklistName').value = '';
document.getElementById('newChecklistRequired').checked = true;
// Refresh list for clean state
loadClosureChecklist(editingFileNo);
} catch (err) {
li.remove();
showAlert(window.http.formatAlert(err, 'Error adding item'), 'danger');
}
}
async function toggleChecklistItem(itemId, checked) {
try {
const res = await window.http.wrappedFetch(`/api/file-management/closure-checklist/${itemId}`, {
method: 'PUT',
body: JSON.stringify({ is_completed: !!checked })
});
if (!res.ok) throw await window.http.toError(res, 'Failed to update item');
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error updating item'), 'danger');
loadClosureChecklist(editingFileNo);
}
}
function editChecklistItem(itemId) {
const newName = prompt('Update item name (leave blank to skip):');
if (newName === null) return;
const newNotes = prompt('Notes (optional, leave blank to skip):');
updateChecklistItem(itemId, newName, newNotes || undefined);
}
async function updateChecklistItem(itemId, newName, notes) {
const payload = {};
if (newName && newName.trim()) payload.item_name = newName.trim();
if (notes !== undefined) payload.notes = notes;
try {
const res = await window.http.wrappedFetch(`/api/file-management/closure-checklist/${itemId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
if (!res.ok) throw await window.http.toError(res, 'Failed to update item');
loadClosureChecklist(editingFileNo);
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error updating item'), 'danger');
}
}
async function deleteChecklistItem(itemId) {
if (!confirm('Delete this checklist item?')) return;
const li = document.querySelector(`li[data-item-id="${itemId}"]`);
if (li) li.remove();
try {
const res = await window.http.wrappedFetch(`/api/file-management/closure-checklist/${itemId}`, { method: 'DELETE' });
if (!res.ok) throw await window.http.toError(res, 'Failed to delete item');
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error deleting item'), 'danger');
loadClosureChecklist(editingFileNo);
}
}
// Alerts
async function loadAlerts(fileNo) {
try {
const res = await window.http.wrappedFetch(`/api/file-management/${encodeURIComponent(fileNo)}/alerts?active_only=true&upcoming_only=false&limit=100`);
if (!res.ok) throw await window.http.toError(res, 'Failed to load alerts');
const alerts = await res.json();
const list = document.getElementById('alertsList');
list.innerHTML = '';
if (!alerts || alerts.length === 0) {
list.innerHTML = '<li class="text-neutral-500 text-sm">No alerts yet.</li>';
return;
}
alerts.forEach(a => {
const li = document.createElement('li');
li.dataset.alertId = a.id;
li.className = 'flex items-center justify-between gap-2 border border-neutral-200 dark:border-neutral-700 rounded-md px-3 py-2';
li.innerHTML = `
<div>
<div class="font-medium">${_escapeHtml(a.title)} <span class="text-xs text-neutral-500">(${_escapeHtml(a.alert_type)})</span></div>
<div class="text-xs text-neutral-500">${formatDate(a.alert_date)} • ${_escapeHtml(a.message || '')}</div>
</div>
<div class="flex items-center gap-2">
${a.is_acknowledged ? '<span class="text-xs text-green-700">Acknowledged</span>' : `<button class="px-2 py-1 text-xs border border-success-600 text-success-700 rounded" onclick="ackAlert(${a.id})">Acknowledge</button>`}
<button class="px-2 py-1 text-xs border border-neutral-300 dark:border-neutral-600 rounded" onclick="editAlert(${a.id})">Edit</button>
<button class="px-2 py-1 text-xs border border-red-600 text-red-700 rounded" onclick="deleteAlert(${a.id})">Delete</button>
</div>
`;
list.appendChild(li);
});
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error loading alerts'), 'danger');
}
}
async function createAlert() {
if (!editingFileNo) return;
const alert_type = (document.getElementById('alertType').value || '').trim();
const title = (document.getElementById('alertTitle').value || '').trim();
const message = (document.getElementById('alertMessage').value || '').trim();
const alert_date = document.getElementById('alertDate').value;
const notify_attorney = !!document.getElementById('alertNotifyAttorney').checked;
const notify_admin = !!document.getElementById('alertNotifyAdmin').checked;
if (!alert_type || !title || !alert_date) {
showAlert('Type, title, and date are required', 'warning');
return;
}
// optimistic row
const tempId = 'temp-' + Date.now();
const list = document.getElementById('alertsList');
const li = document.createElement('li');
li.dataset.alertId = tempId;
li.className = 'flex items-center justify-between gap-2 border border-neutral-200 dark:border-neutral-700 rounded-md px-3 py-2 opacity-60';
li.innerHTML = `
<div>
<div class="font-medium">${_escapeHtml(title)} <span class="text-xs text-neutral-500">(${_escapeHtml(alert_type)})</span></div>
<div class="text-xs text-neutral-500">${_escapeHtml(alert_date)} • ${_escapeHtml(message)}</div>
</div>
<div class="text-xs text-neutral-500">Saving...</div>
`;
list.appendChild(li);
try {
const res = await window.http.wrappedFetch(`/api/file-management/${encodeURIComponent(editingFileNo)}/alerts`, {
method: 'POST',
body: JSON.stringify({ alert_type, title, message, alert_date, notify_attorney, notify_admin })
});
if (!res.ok) throw await window.http.toError(res, 'Failed to create alert');
document.getElementById('alertType').value = '';
document.getElementById('alertTitle').value = '';
document.getElementById('alertMessage').value = '';
document.getElementById('alertDate').value = '';
document.getElementById('alertNotifyAttorney').checked = true;
document.getElementById('alertNotifyAdmin').checked = false;
loadAlerts(editingFileNo);
} catch (err) {
li.remove();
showAlert(window.http.formatAlert(err, 'Error creating alert'), 'danger');
}
}
async function ackAlert(alertId) {
try {
const res = await window.http.wrappedFetch(`/api/file-management/alerts/${alertId}/acknowledge`, { method: 'POST' });
if (!res.ok) throw await window.http.toError(res, 'Failed to acknowledge alert');
loadAlerts(editingFileNo);
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error acknowledging alert'), 'danger');
}
}
function editAlert(alertId) {
const newTitle = prompt('New title (leave blank to skip):');
if (newTitle === null) return;
const newMessage = prompt('New message (leave blank to skip):');
updateAlert(alertId, newTitle, newMessage);
}
async function updateAlert(alertId, title, message) {
const payload = {};
if (title && title.trim()) payload.title = title.trim();
if (message && message.trim()) payload.message = message.trim();
try {
const res = await window.http.wrappedFetch(`/api/file-management/alerts/${alertId}`, {
method: 'PUT',
body: JSON.stringify(payload)
});
if (!res.ok) throw await window.http.toError(res, 'Failed to update alert');
loadAlerts(editingFileNo);
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error updating alert'), 'danger');
}
}
async function deleteAlert(alertId) {
if (!confirm('Delete this alert?')) return;
const li = document.querySelector(`li[data-alert-id="${alertId}"]`);
if (li) li.remove();
try {
const res = await window.http.wrappedFetch(`/api/file-management/alerts/${alertId}`, { method: 'DELETE' });
if (!res.ok) throw await window.http.toError(res, 'Failed to delete alert');
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error deleting alert'), 'danger');
loadAlerts(editingFileNo);
}
}
// Relationships
async function loadRelationships(fileNo) {
try {
const res = await window.http.wrappedFetch(`/api/file-management/${encodeURIComponent(fileNo)}/relationships`);
if (!res.ok) throw await window.http.toError(res, 'Failed to load relationships');
const rels = await res.json();
const list = document.getElementById('relationshipsList');
list.innerHTML = '';
if (!rels || rels.length === 0) {
list.innerHTML = '<li class="text-neutral-500 text-sm">No relationships yet.</li>';
return;
}
rels.forEach(r => {
const li = document.createElement('li');
li.dataset.relationshipId = r.id;
li.className = 'flex items-center justify-between gap-2 border border-neutral-200 dark:border-neutral-700 rounded-md px-3 py-2';
li.innerHTML = `
<div>
<div class="font-medium">${_escapeHtml(r.relationship_type)} → ${_escapeHtml(r.other_file_no)}</div>
<div class="text-xs text-neutral-500">${_escapeHtml(r.notes || '')}</div>
</div>
<div>
<button class="px-2 py-1 text-xs border border-red-600 text-red-700 rounded" onclick="deleteRelationship(${r.id})">Remove</button>
</div>
`;
list.appendChild(li);
});
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error loading relationships'), 'danger');
}
}
async function addRelationship() {
const target = (document.getElementById('relTargetFileNo').value || '').trim();
const relationship_type = document.getElementById('relType').value;
const notes = (document.getElementById('relNotes').value || '').trim();
if (!editingFileNo) return;
if (!target) { showAlert('Enter a target file #', 'warning'); return; }
// optimistic
const tempId = 'temp-' + Date.now();
const list = document.getElementById('relationshipsList');
const li = document.createElement('li');
li.dataset.relationshipId = tempId;
li.className = 'flex items-center justify-between gap-2 border border-neutral-200 dark:border-neutral-700 rounded-md px-3 py-2 opacity-60';
li.innerHTML = `
<div>
<div class="font-medium">${_escapeHtml(relationship_type)} → ${_escapeHtml(target)}</div>
<div class="text-xs text-neutral-500">${_escapeHtml(notes)}</div>
</div>
<div class="text-xs text-neutral-500">Saving...</div>
`;
list.appendChild(li);
try {
const res = await window.http.wrappedFetch(`/api/file-management/${encodeURIComponent(editingFileNo)}/relationships`, {
method: 'POST',
body: JSON.stringify({ target_file_no: target, relationship_type, notes })
});
if (!res.ok) throw await window.http.toError(res, 'Failed to link files');
document.getElementById('relTargetFileNo').value = '';
document.getElementById('relNotes').value = '';
loadRelationships(editingFileNo);
} catch (err) {
li.remove();
showAlert(window.http.formatAlert(err, 'Error linking files'), 'danger');
}
}
async function deleteRelationship(id) {
if (!confirm('Remove this relationship?')) return;
const li = document.querySelector(`li[data-relationship-id="${id}"]`);
if (li) li.remove();
try {
const res = await window.http.wrappedFetch(`/api/file-management/relationships/${id}`, { method: 'DELETE' });
if (!res.ok) throw await window.http.toError(res, 'Failed to remove relationship');
} catch (err) {
showAlert(window.http.formatAlert(err, 'Error removing relationship'), 'danger');
loadRelationships(editingFileNo);
}
}
</script>
{% endblock %}

View File

@@ -56,6 +56,30 @@
<i class="fa-solid fa-circle-question"></i>
Help
</button>
<div class="hidden md:flex items-center gap-2 ml-2">
<label class="text-sm font-medium text-neutral-700 dark:text-neutral-300">Templates:</label>
<button type="button" id="downloadFilesTemplateBtn" class="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="Download FILES.csv template">
FILES
</button>
<button type="button" id="downloadLedgerTemplateBtn" class="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="Download LEDGER.csv template">
LEDGER
</button>
<button type="button" id="downloadPaymentsTemplateBtn" class="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="Download PAYMENTS.csv template">
PAYMENTS
</button>
<button type="button" id="downloadRolodexTemplateBtn" class="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="Download ROLODEX.csv template">
ROLODEX
</button>
<button type="button" id="downloadTrnsactnTemplateBtn" class="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="Download TRNSACTN.csv template">
TRNSACTN
</button>
<button type="button" id="downloadDepositsTemplateBtn" class="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="Download DEPOSITS.csv template">
DEPOSITS
</button>
<button type="button" id="downloadTemplatesBundleBtn" class="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="Download selected templates as ZIP">
Download…
</button>
</div>
</div>
</div>
</div>
@@ -166,11 +190,15 @@
<h5 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
<i class="fa-solid fa-clipboard-check"></i>
<span>File Validation Results</span>
<button type="button" class="ml-auto text-xs underline text-neutral-600 dark:text-neutral-400" onclick="toggleHeaderHelp()">Required headers help</button>
</h5>
</div>
<div class="p-6" id="validationResults">
<!-- Validation results will be shown here -->
</div>
<div class="px-6 pb-4 hidden" id="headerHelp">
<!-- Content will be populated dynamically by toggleHeaderHelp() -->
</div>
</div>
<!-- Import Progress Panel -->
@@ -273,6 +301,94 @@
</div>
<script>
// Centralized required headers map with examples
const CSV_REQUIRED_HEADERS = {
'FILES.csv': {
required: ['file_no', 'id', 'empl_num', 'file_type', 'opened', 'status', 'rate_per_hour'],
example: 'File_No,Id,Empl_Num,File_Type,Opened,Status,Rate_Per_Hour\nF-001,CLIENT-1,EMP01,CIVIL,2024-01-01,ACTIVE,150'
},
'LEDGER.csv': {
required: ['file_no', 'date', 'empl_num', 't_code', 't_type', 'amount'],
example: 'File_No,Date,Empl_Num,T_Code,T_Type,Amount\nF-001,2024-01-15,EMP01,FEE,1,500.00'
},
'PAYMENTS.csv': {
required: ['deposit_date', 'amount'],
example: 'Deposit_Date,Amount\n2024-01-15,1500.00'
},
'TRNSACTN.csv': {
required: ['file_no', 'date', 'empl_num', 't_code', 't_type', 'amount'],
example: 'File_No,Date,Empl_Num,T_Code,T_Type,Amount\nF-002,2024-02-10,EMP02,FEE,1,250.00'
},
'DEPOSITS.csv': {
required: ['deposit_date', 'total'],
example: 'Deposit_Date,Total\n2024-02-10,1500.00'
},
'ROLODEX.csv': {
required: ['id', 'last'],
example: 'Id,Last,First,A1,City,Abrev,Zip,Email\nCLIENT-1,Smith,John,123 Main St,Denver,CO,80202,john.smith@example.com'
}
};
function getRequiredHeadersText(fileType) {
const info = CSV_REQUIRED_HEADERS[fileType];
return info ? info.required.join(', ') : 'varies';
}
function getRequiredHeadersTooltip(fileType) {
const info = CSV_REQUIRED_HEADERS[fileType];
if (!info) return 'Required headers vary by file type';
return `Required: ${info.required.join(', ')}\n\nExample:\n${info.example}`;
}
function toggleHeaderHelp() {
const el = document.getElementById('headerHelp');
if (!el) return;
// Update content dynamically if not already populated
if (!el.dataset.populated) {
const content = `
<div class="p-3 bg-neutral-50 dark:bg-neutral-900 rounded text-xs text-neutral-700 dark:text-neutral-300">
<div class="font-semibold mb-2">Minimal required headers by CSV:</div>
<div class="space-y-3">
<div>
<div class="font-mono font-semibold">FILES.csv</div>
<div class="text-neutral-600 dark:text-neutral-400">Required: ${getRequiredHeadersText('FILES.csv')}</div>
<pre class="font-mono text-xs mt-1 text-neutral-500 whitespace-pre-wrap">${CSV_REQUIRED_HEADERS['FILES.csv'].example}</pre>
</div>
<div>
<div class="font-mono font-semibold">LEDGER.csv</div>
<div class="text-neutral-600 dark:text-neutral-400">Required: ${getRequiredHeadersText('LEDGER.csv')}</div>
<pre class="font-mono text-xs mt-1 text-neutral-500 whitespace-pre-wrap">${CSV_REQUIRED_HEADERS['LEDGER.csv'].example}</pre>
</div>
<div>
<div class="font-mono font-semibold">PAYMENTS.csv</div>
<div class="text-neutral-600 dark:text-neutral-400">Required: ${getRequiredHeadersText('PAYMENTS.csv')}</div>
<pre class="font-mono text-xs mt-1 text-neutral-500 whitespace-pre-wrap">${CSV_REQUIRED_HEADERS['PAYMENTS.csv'].example}</pre>
</div>
<div>
<div class="font-mono font-semibold">TRNSACTN.csv</div>
<div class="text-neutral-600 dark:text-neutral-400">Required: ${getRequiredHeadersText('TRNSACTN.csv')}</div>
<pre class="font-mono text-xs mt-1 text-neutral-500 whitespace-pre-wrap">${CSV_REQUIRED_HEADERS['TRNSACTN.csv'].example}</pre>
</div>
<div>
<div class="font-mono font-semibold">DEPOSITS.csv</div>
<div class="text-neutral-600 dark:text-neutral-400">Required: ${getRequiredHeadersText('DEPOSITS.csv')}</div>
<pre class="font-mono text-xs mt-1 text-neutral-500 whitespace-pre-wrap">${CSV_REQUIRED_HEADERS['DEPOSITS.csv'].example}</pre>
</div>
<div>
<div class="font-mono font-semibold">ROLODEX.csv</div>
<div class="text-neutral-600 dark:text-neutral-400">Required: ${getRequiredHeadersText('ROLODEX.csv')}</div>
<pre class="font-mono text-xs mt-1 text-neutral-500 whitespace-pre-wrap">${CSV_REQUIRED_HEADERS['ROLODEX.csv'].example}</pre>
</div>
</div>
</div>
`;
el.innerHTML = content;
el.dataset.populated = 'true';
}
el.classList.toggle('hidden');
}
// Import functionality
let availableFiles = {};
let importInProgress = false;
@@ -328,6 +444,20 @@ function setupEventListeners() {
document.getElementById('uploadMode').addEventListener('change', switchUploadMode);
const helpBtn = document.getElementById('importHelpBtn');
if (helpBtn) helpBtn.addEventListener('click', showImportHelp);
const tfBtn = document.getElementById('downloadFilesTemplateBtn');
const tlBtn = document.getElementById('downloadLedgerTemplateBtn');
const tpBtn = document.getElementById('downloadPaymentsTemplateBtn');
const trBtn = document.getElementById('downloadRolodexTemplateBtn');
const ttBtn = document.getElementById('downloadTrnsactnTemplateBtn');
const tdBtn = document.getElementById('downloadDepositsTemplateBtn');
const tbBtn = document.getElementById('downloadTemplatesBundleBtn');
if (tfBtn) tfBtn.addEventListener('click', () => downloadTemplateFor('FILES.csv'));
if (tlBtn) tlBtn.addEventListener('click', () => downloadTemplateFor('LEDGER.csv'));
if (tpBtn) tpBtn.addEventListener('click', () => downloadTemplateFor('PAYMENTS.csv'));
if (trBtn) trBtn.addEventListener('click', () => downloadTemplateFor('ROLODEX.csv'));
if (ttBtn) ttBtn.addEventListener('click', () => downloadTemplateFor('TRNSACTN.csv'));
if (tdBtn) tdBtn.addEventListener('click', () => downloadTemplateFor('DEPOSITS.csv'));
if (tbBtn) tbBtn.addEventListener('click', openTemplateBundleDialog);
// Validation buttons
document.getElementById('validateBtn').addEventListener('click', validateFile);
@@ -818,7 +948,7 @@ function showImportHelp() {
</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>
<code class="block mt-1">STATES.csv → GRUPLKUP.csv → EMPLOYEE.csv → FILETYPE.csv → FOOTERS.csv → FILESTAT.csv → TRNSTYPE.csv → TRNSLKUP.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.
@@ -837,6 +967,104 @@ function showImportHelp() {
}
}
async function downloadTemplateFor(type) {
try {
const resp = await window.http.wrappedFetch(`/api/import/template/${encodeURIComponent(type)}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Failed to download template for ${type}`);
}
const blob = await resp.blob();
let filename = `${(type || '').replace('.csv','')}_template.csv`;
const cd = resp.headers.get('content-disposition') || '';
const m = cd.match(/filename="?([^";]+)"?/i);
if (m && m[1]) filename = m[1];
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
} catch (error) {
showAlert('Template download failed: ' + (error?.message || 'Unknown error'), 'danger');
}
}
function openTemplateBundleDialog() {
const content = `
<div class="text-sm">
<div class="mb-2">Select CSV templates to include:</div>
<div class="space-y-2">
<label class="flex items-center gap-2"><input type="checkbox" name="tpl" value="FILES.csv" checked> <span>FILES.csv</span></label>
<label class="flex items-center gap-2"><input type="checkbox" name="tpl" value="LEDGER.csv" checked> <span>LEDGER.csv</span></label>
<label class="flex items-center gap-2"><input type="checkbox" name="tpl" value="PAYMENTS.csv" checked> <span>PAYMENTS.csv</span></label>
<label class="flex items-center gap-2"><input type="checkbox" name="tpl" value="TRNSACTN.csv" checked> <span>TRNSACTN.csv</span></label>
<label class="flex items-center gap-2"><input type="checkbox" name="tpl" value="DEPOSITS.csv" checked> <span>DEPOSITS.csv</span></label>
<label class="flex items-center gap-2"><input type="checkbox" name="tpl" value="ROLODEX.csv" checked> <span>ROLODEX.csv</span></label>
</div>
<div class="mt-3 text-xs text-neutral-500">A ZIP will be generated containing minimal templates with required headers and a sample row.</div>
</div>
`;
if (window.alerts && window.alerts.show) {
window.alerts.show(content, 'info', {
html: true,
duration: 0,
title: 'Download Templates',
actions: [
{
label: 'Download ZIP',
classes: 'px-3 py-1 rounded text-xs bg-primary-600 text-white hover:bg-primary-700',
onClick: async ({ wrapper }) => {
const inputs = wrapper.querySelectorAll('input[name="tpl"]:checked');
const files = Array.from(inputs).map(i => i.value);
if (!files.length) {
showAlert('Please select at least one template', 'warning');
return;
}
await downloadTemplatesBundle(files);
}
},
{
label: 'Cancel',
classes: 'px-3 py-1 rounded text-xs bg-neutral-200 hover:bg-neutral-300 text-neutral-800 dark:bg-neutral-700 dark:hover:bg-neutral-600 dark:text-neutral-200',
autoClose: true
}
]
});
} else {
showAlert('Template selection requires alerts UI. Please download single templates.', 'info');
}
}
async function downloadTemplatesBundle(files) {
try {
const params = new URLSearchParams();
for (const f of files) params.append('files', f);
const resp = await window.http.wrappedFetch(`/api/import/templates/bundle?${params.toString()}`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || 'Failed to download templates bundle');
}
const blob = await resp.blob();
let filename = 'csv_templates.zip';
const cd = resp.headers.get('content-disposition') || '';
const m = cd.match(/filename="?([^";]+)"?/i);
if (m && m[1]) filename = m[1];
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(url);
a.remove();
} catch (error) {
showAlert('Bundle download failed: ' + (error?.message || 'Unknown error'), 'danger');
}
}
async function validateAllFiles() {
const fileInput = document.getElementById('batchFiles');
@@ -936,7 +1164,9 @@ function displayBatchValidationResults(results, allValid) {
<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>
<strong class="text-sm flex items-center gap-2">${result.file_type}
<i class="fa-solid fa-circle-info text-neutral-500" title="${getRequiredHeadersTooltip(result.file_type)}"></i>
</strong>
</div>
<div class="text-sm text-${resultClass}-600 dark:text-${resultClass}-400 capitalize">${status}</div>
</div>
@@ -948,6 +1178,11 @@ function displayBatchValidationResults(results, allValid) {
html += `<p class="text-xs text-neutral-600 dark:text-neutral-400 mt-1">${mappedCount} mapped, ${unmappedCount} unmapped</p>`;
}
if (result.header_validation && result.header_validation.ok === false) {
const missing = (result.header_validation.missing_fields || []).join(', ');
html += `<p class="text-xs text-danger-600 dark:text-danger-400 mt-1">Missing required headers: ${missing || 'unknown'}</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>`;
}
@@ -1000,8 +1235,8 @@ function updateSelectedFiles() {
if (files.length > 0) {
// Define import order
const importOrder = [
"STATES.csv", "GRUPLKUP.csv", "EMPLOYEE.csv", "FILETYPE.csv", "FILESTAT.csv",
"TRNSTYPE.csv", "TRNSLKUP.csv", "FOOTERS.csv", "SETUP.csv", "PRINTERS.csv",
"STATES.csv", "GRUPLKUP.csv", "EMPLOYEE.csv", "FILETYPE.csv", "FOOTERS.csv", "FILESTAT.csv",
"TRNSTYPE.csv", "TRNSLKUP.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"
@@ -1238,7 +1473,9 @@ function displayBatchResults(result) {
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<i class="fa-solid fa-${statusIcon} text-${statusClass}-600 dark:text-${statusClass}-400"></i>
<strong class="text-sm">${fileResult.file_type}</strong>
<strong class="text-sm flex items-center gap-2">${fileResult.file_type}
<i class="fa-solid fa-circle-info text-neutral-500" title="${getRequiredHeadersTooltip(fileResult.file_type)}"></i>
</strong>
</div>
<div class="text-right text-sm">
${fileResult.imported_count ? `<span class="text-success-600 dark:text-success-400">${fileResult.imported_count} imported</span>` : ''}
@@ -1252,6 +1489,11 @@ function displayBatchResults(result) {
<span class=\"ml-2\">${(fileResult.auto_mapping.unmapped_headers || []).length} unmapped (stored as flexible)</span>
</div>
` : ''}
${(fileResult.header_validation && fileResult.header_validation.ok === false) ? `
<div class=\"mt-2 text-xs text-danger-600 dark:text-danger-400\">
Missing required headers: ${(fileResult.header_validation.missing_fields || []).join(', ') || 'unknown'}
</div>
` : ''}
</div>
`;
});
@@ -1369,15 +1611,20 @@ async function viewAuditDetails(auditId) {
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 files = (data.files || []).map(f => {
const hv = (f.details && f.details.header_validation) ? f.details.header_validation : null;
const hvCell = hv && hv.ok === false ? `Missing: ${(hv.missing_fields || []).join(', ')}` : '—';
return `
<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">${hvCell}</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">
@@ -1396,6 +1643,7 @@ async function viewAuditDetails(auditId) {
<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">Header Issues</th>
<th class="px-3 py-2 text-left">Message</th>
</tr>
</thead>

View File

@@ -50,7 +50,7 @@
</div>
</form>
<p class="mt-2 text-center text-sm text-gray-600 dark:text-gray-400">
Default credentials: admin / admin123
Use your admin credentials provided by your system administrator
</p>
</div>
</div>