changes
This commit is contained in:
@@ -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 %}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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, '"')}">
|
||||
<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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user