finishing QDRO section

This commit is contained in:
HotSwapp
2025-08-15 17:19:51 -05:00
parent 006ef3d7b1
commit abc7f289d1
22 changed files with 2753 additions and 46 deletions

View File

@@ -141,6 +141,10 @@
<i class="fa-solid fa-print"></i>
<span>Printers</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="qdro-notifications-tab" data-tab-target="#qdro-notifications" type="button" role="tab">
<i class="fa-solid fa-bell"></i>
<span>QDRO Notifications</span>
</button>
<button class="flex items-center gap-2 px-6 py-4 text-sm font-medium border-b-2 border-transparent hover:border-primary-300 text-neutral-600 dark:text-neutral-400 hover:text-primary-600 dark:hover:text-primary-400 hover:bg-neutral-50 dark:hover:bg-neutral-700/50 transition-all duration-200" id="issues-tab" data-tab-target="#issues" type="button" role="tab">
<i class="fa-solid fa-bug"></i>
<span>Issues</span>
@@ -618,6 +622,48 @@
</div>
<!-- QDRO Notifications Tab -->
<div id="qdro-notifications" role="tabpanel" class="hidden">
<div class="grid grid-cols-1 gap-6">
<div class="bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow">
<div class="px-4 py-3 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h5 class="m-0 font-semibold"><i class="fa-solid fa-bell"></i> QDRO Notification Routes</h5>
<div class="flex items-center gap-2">
<select id="qdro-route-scope-filter" class="px-2 py-1 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded text-sm">
<option value="">All scopes</option>
<option value="file">file</option>
<option value="plan">plan</option>
</select>
<button type="button" class="px-3 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded text-sm" onclick="showCreateNotificationRouteModal()">
<i class="fas fa-plus"></i> Add Route
</button>
</div>
</div>
<div class="p-4">
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-700">
<table class="min-w-full divide-y divide-neutral-200 dark:divide-neutral-700">
<thead class="bg-primary-600 text-white">
<tr>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Scope</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Identifier</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Email To</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Webhook URL</th>
<th class="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="qdro-routes-table-body" class="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
<tr>
<td colspan="5" class="text-center px-4 py-4 text-neutral-500">Loading routes...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Backup Tab -->
<div id="backup" role="tabpanel" class="hidden">
<div class="flex flex-wrap -mx-4">
@@ -1000,6 +1046,57 @@
</div>
</div>
<!-- QDRO Notification Route Modal -->
<div id="qdroRouteModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 p-4">
<div class="mx-auto w-full max-w-xl">
<div class="bg-white dark:bg-neutral-800 rounded-xl shadow-xl border border-neutral-200 dark:border-neutral-700">
<div class="flex items-center justify-between px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<h5 class="font-semibold" id="qdroRouteModalTitle">Add Route</h5>
<button type="button" class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200" onclick="closeModal('qdroRouteModal')">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="px-4 py-4">
<form id="qdroRouteForm">
<input type="hidden" id="qdroRouteMode" value="create">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">Scope *</label>
<select id="routeScope" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" required>
<option value="">Select...</option>
<option value="file">file</option>
<option value="plan">plan</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Identifier *</label>
<input id="routeIdentifier" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="file_no or plan_id" required>
</div>
</div>
<div class="mt-3">
<label class="block text-sm font-medium mb-1">Email To</label>
<input id="routeEmailTo" type="email" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="name@example.com">
</div>
<div class="mt-3">
<label class="block text-sm font-medium mb-1">Webhook URL</label>
<input id="routeWebhookUrl" type="url" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="https://...">
</div>
<div class="mt-3">
<label class="block text-sm font-medium mb-1">Webhook Secret</label>
<input id="routeWebhookSecret" class="w-full px-3 py-2 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg" placeholder="Optional (leave blank to keep existing)">
</div>
<p class="text-xs text-neutral-500 dark:text-neutral-400 mt-2">At least one of Email or Webhook URL must be provided. Secret is only updated if a new value is entered.</p>
</form>
</div>
<div class="flex items-center justify-end gap-2 px-4 py-3 border-t border-neutral-200 dark:border-neutral-700">
<button type="button" class="px-3 py-1.5 border border-neutral-300 dark:border-neutral-600 rounded hover:bg-neutral-50 dark:hover:bg-neutral-700" onclick="closeModal('qdroRouteModal')">Cancel</button>
<button type="button" class="px-3 py-1.5 bg-primary-600 hover:bg-primary-700 text-white rounded" onclick="saveNotificationRoute()">Save</button>
</div>
</div>
</div>
</div>
<!-- Settings Modal -->
<div id="settingModal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 p-4">
<div class="mx-auto w-full max-w-xl">
@@ -1173,9 +1270,165 @@ function onTabShown(tabName) {
loadSettings();
} else if (tabName === 'printers') {
loadPrinters();
} else if (tabName === 'qdro-notifications') {
loadQDRONotificationRoutes();
}
}
// QDRO Notification Routes
async function loadQDRONotificationRoutes() {
try {
const scopeFilter = document.getElementById('qdro-route-scope-filter');
const selectedScope = scopeFilter ? scopeFilter.value : '';
const url = selectedScope ? `/api/admin/qdro/notification-routes?scope=${encodeURIComponent(selectedScope)}` : '/api/admin/qdro/notification-routes';
const response = await window.http.wrappedFetch(url);
const data = await response.json();
renderQDRORoutesTable(data.items || []);
} catch (error) {
console.error('Failed to load QDRO routes:', error);
const tbody = document.getElementById('qdro-routes-table-body');
if (tbody) tbody.innerHTML = '<tr><td colspan="5" class="text-center text-danger-600 dark:text-danger-400">Failed to load routes</td></tr>';
showAlert('Failed to load QDRO notification routes', 'error');
}
}
function renderQDRORoutesTable(items) {
const tbody = document.getElementById('qdro-routes-table-body');
if (!tbody) return;
if (!items.length) {
tbody.innerHTML = '<tr><td colspan="5" class="text-center text-neutral-500">No routes found</td></tr>';
return;
}
tbody.innerHTML = items.map(route => `
<tr>
<td class="px-4 py-2"><code>${escapeHtml(route.scope)}</code></td>
<td class="px-4 py-2"><code>${escapeHtml(route.identifier)}</code></td>
<td class="px-4 py-2">${route.email_to ? escapeHtml(route.email_to) : '-'}</td>
<td class="px-4 py-2">${route.webhook_url ? `<a href="${escapeAttr(route.webhook_url)}" target="_blank" class="text-primary-600 hover:underline">link</a>` : '-'}</td>
<td class="px-4 py-2">
<div class="flex items-center gap-2">
<button class="px-2 py-1 border border-primary-600 text-primary-700 dark:text-primary-200 rounded text-xs hover:bg-primary-50 dark:hover:bg-primary-900/20" onclick="editNotificationRoute('${escapeJs(route.scope)}','${escapeJs(route.identifier)}')" title="Edit">
<i class="fas fa-edit"></i>
</button>
<button class="px-2 py-1 border border-danger-600 text-danger-700 dark:text-danger-200 rounded text-xs hover:bg-danger-50 dark:hover:bg-danger-900/20" onclick="deleteNotificationRoute('${escapeJs(route.scope)}','${escapeJs(route.identifier)}')" title="Delete">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
`).join('');
}
function showCreateNotificationRouteModal() {
document.getElementById('qdroRouteModalTitle').textContent = 'Add Route';
document.getElementById('qdroRouteMode').value = 'create';
document.getElementById('routeScope').disabled = false;
document.getElementById('routeIdentifier').readOnly = false;
document.getElementById('qdroRouteForm').reset();
openModal('qdroRouteModal');
}
function editNotificationRoute(scope, identifier) {
document.getElementById('qdroRouteModalTitle').textContent = 'Edit Route';
document.getElementById('qdroRouteMode').value = 'edit';
document.getElementById('routeScope').value = scope;
document.getElementById('routeScope').disabled = true;
document.getElementById('routeIdentifier').value = identifier;
document.getElementById('routeIdentifier').readOnly = true;
// Pre-fill from the current row in table
const row = Array.from(document.querySelectorAll('#qdro-routes-table-body tr')).find(tr => {
const tds = tr.querySelectorAll('td');
return tds.length >= 2 && tds[0].textContent.trim() === scope && tds[1].textContent.trim() === identifier;
});
if (row) {
const tds = row.querySelectorAll('td');
const email = tds[2].textContent.trim();
const link = tds[3].querySelector('a');
document.getElementById('routeEmailTo').value = email === '-' ? '' : email;
document.getElementById('routeWebhookUrl').value = link ? link.getAttribute('href') : '';
document.getElementById('routeWebhookSecret').value = '';
}
openModal('qdroRouteModal');
}
async function saveNotificationRoute() {
// Client-side validation
const scope = document.getElementById('routeScope').value.trim();
const identifier = document.getElementById('routeIdentifier').value.trim();
const email_to = document.getElementById('routeEmailTo').value.trim();
const webhook_url = document.getElementById('routeWebhookUrl').value.trim();
const webhook_secret = document.getElementById('routeWebhookSecret').value.trim();
if (!scope) {
showAlert('Scope is required', 'error');
return;
}
if (!identifier) {
showAlert('Identifier is required', 'error');
return;
}
if (!email_to && !webhook_url) {
showAlert('Provide at least Email or Webhook URL', 'error');
return;
}
if (email_to && !/^\S+@\S+\.\S+$/.test(email_to)) {
showAlert('Invalid email address', 'error');
return;
}
if (webhook_url && !/^https?:\/\//i.test(webhook_url)) {
showAlert('Webhook URL must start with http(s)://', 'error');
return;
}
try {
const response = await window.http.wrappedFetch('/api/admin/qdro/notification-routes', {
method: 'POST',
body: JSON.stringify({ scope, identifier, email_to: email_to || null, webhook_url: webhook_url || null, webhook_secret: webhook_secret || null })
});
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showAlert(err.detail || 'Failed to save route', 'error');
return;
}
closeModal('qdroRouteModal');
showAlert('Route saved', 'success');
loadQDRONotificationRoutes();
} catch (error) {
console.error('Failed to save route:', error);
showAlert('Failed to save route', 'error');
}
}
async function deleteNotificationRoute(scope, identifier) {
if (!confirm(`Delete route for ${scope}:${identifier}?`)) return;
try {
const response = await window.http.wrappedFetch(`/api/admin/qdro/notification-routes/${encodeURIComponent(scope)}/${encodeURIComponent(identifier)}`, { method: 'DELETE' });
if (!response.ok) {
const err = await response.json().catch(() => ({}));
showAlert(err.detail || 'Failed to delete route', 'error');
return;
}
showAlert('Route deleted', 'success');
loadQDRONotificationRoutes();
} catch (error) {
console.error('Failed to delete route:', error);
showAlert('Failed to delete route', 'error');
}
}
// Basic HTML escaping helpers
function escapeHtml(str) {
if (str == null) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function escapeAttr(str) { return escapeHtml(str); }
function escapeJs(str) { return String(str).replace(/['"\\]/g, '\\$&'); }
// Initialize admin dashboard
document.addEventListener('DOMContentLoaded', function() {
// Check admin permissions first
@@ -1195,6 +1448,14 @@ document.addEventListener('DOMContentLoaded', function() {
// Tabs setup
initializeTabs();
// QDRO routes filter change handler
const qdroScopeFilter = document.getElementById('qdro-route-scope-filter');
if (qdroScopeFilter) {
qdroScopeFilter.addEventListener('change', () => {
loadQDRONotificationRoutes();
});
}
// Auto-refresh every 5 minutes
setInterval(loadSystemHealth, 300000);