Files
delphi-database/templates/billing.html
2025-08-15 22:04:43 -05:00

354 lines
18 KiB
HTML

{% extends "base.html" %}
{% block title %}Billing Statements - Delphi Database{% endblock %}
{% block content %}
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold">Billing Statements</h1>
<div class="flex items-center gap-2">
<select id="historyStatus" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm">
<option value="">All statuses</option>
<option value="pending">Pending</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="cancelled">Cancelled</option>
</select>
<input id="startDate" type="date" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm" placeholder="Start date" />
<input id="endDate" type="date" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm" placeholder="End date" />
<select id="historySort" class="px-3 py-2 border border-neutral-300 dark:border-neutral-700 rounded-lg bg-white dark:bg-neutral-900 text-sm">
<option value="updated_desc">Updated (newest)</option>
<option value="updated_asc">Updated (oldest)</option>
<option value="started_desc">Started (newest)</option>
<option value="started_asc">Started (oldest)</option>
<option value="completed_desc">Completed (newest)</option>
<option value="completed_asc">Completed (oldest)</option>
</select>
<button id="historyRefresh" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm">Refresh</button>
<button id="exportCsvBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm">Export CSV</button>
</div>
</div>
<div class="bg-white dark:bg-neutral-900 rounded-xl shadow-sm dark:shadow-none border border-neutral-200 dark:border-neutral-700">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h2 class="text-lg font-semibold">Batch History</h2>
<span id="historyCount" class="text-xs text-neutral-500"></span>
</div>
<div class="p-6">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-neutral-600 dark:text-neutral-300">
<tr>
<th class="text-left py-2 pr-4">Batch ID</th>
<th class="text-left py-2 pr-4">Status</th>
<th class="text-right py-2 pr-4">Files</th>
<th class="text-left py-2 pr-4">Started</th>
<th class="text-left py-2 pr-4">Updated</th>
<th class="text-left py-2 pr-4">Completed</th>
<th class="text-right py-2">Actions</th>
</tr>
</thead>
<tbody id="historyBody" class="text-neutral-800 dark:text-neutral-200"></tbody>
</table>
</div>
<div id="historyEmpty" class="text-sm text-neutral-500 dark:text-neutral-400">No batches found</div>
<div class="mt-4 flex items-center justify-between">
<div id="pageInfo" class="text-xs text-neutral-500"></div>
<div class="flex items-center gap-2">
<label class="text-xs text-neutral-500">Page size</label>
<select id="pageSize" class="px-2 py-1 border border-neutral-300 dark:border-neutral-700 rounded bg-white dark:bg-neutral-900 text-xs">
<option value="10">10</option>
<option value="25" selected>25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
<button id="pagePrev" class="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded text-xs" disabled>Prev</button>
<button id="pageNext" class="px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded text-xs" disabled>Next</button>
</div>
</div>
</div>
</div>
<!-- Batch Details Modal -->
<div id="batchDetailsModal" class="hidden fixed inset-0 bg-black/50 z-50 p-4">
<div class="bg-white dark:bg-neutral-900 rounded-xl max-w-3xl mx-auto shadow-xl border border-neutral-200 dark:border-neutral-700">
<div class="px-6 py-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h3 class="text-lg font-semibold">Batch Details</h3>
<button onclick="closeModal('batchDetailsModal')" class="text-neutral-500 hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200">
<i class="fa-solid fa-xmark"></i>
</button>
</div>
<div class="p-6 space-y-4">
<div id="batchMeta" class="text-sm text-neutral-600 dark:text-neutral-300"></div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="text-neutral-600 dark:text-neutral-300">
<tr>
<th class="text-left py-2 pr-4">File No</th>
<th class="text-left py-2 pr-4">Status</th>
<th class="text-left py-2 pr-4">Message</th>
<th class="text-right py-2 pr-4">Actions</th>
</tr>
</thead>
<tbody id="batchFiles"></tbody>
</table>
</div>
<div id="previewWrap" class="hidden border border-neutral-200 dark:border-neutral-700 rounded">
<div class="px-3 py-2 flex items-center justify-between border-b border-neutral-200 dark:border-neutral-700">
<div class="text-sm font-medium">Preview</div>
<button id="closePreview" class="text-xs px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded">Close</button>
</div>
<iframe id="previewFrame" class="w-full h-96"></iframe>
</div>
</div>
<div class="px-6 py-4 border-t border-neutral-200 dark:border-neutral-700 flex items-center justify-end gap-3">
<button id="exportBatchCsvBtn" class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm" disabled>Export CSV</button>
<button class="px-3 py-2 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded-lg text-sm" onclick="closeModal('batchDetailsModal')">Close</button>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
(function(){
const body = document.getElementById('historyBody');
const empty = document.getElementById('historyEmpty');
const count = document.getElementById('historyCount');
const statusSel = document.getElementById('historyStatus');
const sortSel = document.getElementById('historySort');
const refreshBtn = document.getElementById('historyRefresh');
const exportBtn = document.getElementById('exportCsvBtn');
const startInput = document.getElementById('startDate');
const endInput = document.getElementById('endDate');
const pagePrev = document.getElementById('pagePrev');
const pageNext = document.getElementById('pageNext');
const pageSizeSel = document.getElementById('pageSize');
const pageInfo = document.getElementById('pageInfo');
const exportBatchBtn = document.getElementById('exportBatchCsvBtn');
let offset = 0;
let limit = parseInt(pageSizeSel.value, 10) || 25;
let lastCount = 0;
async function fetchHistory() {
const qs = new URLSearchParams();
if (statusSel.value) qs.set('status_filter', statusSel.value);
if (sortSel.value) qs.set('sort', sortSel.value);
if (startInput.value) qs.set('start_date', new Date(startInput.value).toISOString());
if (endInput.value) {
const d = new Date(endInput.value);
d.setHours(23,59,59,999);
qs.set('end_date', d.toISOString());
}
qs.set('limit', String(limit));
qs.set('offset', String(offset));
const resp = await window.http.wrappedFetch('/api/billing/statements/batch-history' + (qs.toString() ? ('?' + qs.toString()) : ''));
if (!resp.ok) return [];
return await resp.json();
}
function badge(status) {
const s = String(status || '').toUpperCase();
const cls = s==='COMPLETED' ? 'text-green-600' : s==='FAILED' ? 'text-red-600' : s==='CANCELLED' ? 'text-neutral-500' : 'text-amber-600';
return `<span class="text-xs font-medium ${cls}">${s}</span>`;
}
function renderRow(item) {
const filesLabel = `${item.successful_files}/${item.total_files} ✓ • ${item.failed_files}`;
return (
`<tr data-batch="${item.batch_id}" class="border-t border-neutral-200 dark:border-neutral-800">
<td class="py-2 pr-4 align-top"><span class="px-2 py-0.5 rounded bg-neutral-100 dark:bg-neutral-800">${item.batch_id}</span></td>
<td class="py-2 pr-4 align-top">${badge(item.status)}</td>
<td class="py-2 pr-4 align-top text-right whitespace-nowrap">${filesLabel}</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">${item.started_at || ''}</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">${item.updated_at || ''}</td>
<td class="py-2 pr-4 align-top whitespace-nowrap">${item.completed_at || ''}</td>
<td class="py-2 pr-0 align-top text-right">
<div class="flex items-center justify-end gap-2">
<button data-action="open" class="px-2 py-1 text-xs bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded">Open</button>
</div>
</td>
</tr>`);
}
async function openBatch(batchId) {
const resp = await window.http.wrappedFetch(`/api/billing/statements/batch-progress/${encodeURIComponent(batchId)}`);
if (!resp.ok) {
const err = await window.http.toError(resp, 'Failed to load batch');
alert(window.http.formatAlert(err));
return;
}
const data = await resp.json();
const metaEl = document.getElementById('batchMeta');
const filesEl = document.getElementById('batchFiles');
const previewWrap = document.getElementById('previewWrap');
const previewFrame = document.getElementById('previewFrame');
previewWrap.classList.add('hidden');
previewFrame.src = 'about:blank';
metaEl.innerHTML = `Batch <strong>${data.batch_id}</strong> • Status: <strong>${String(data.status).toUpperCase()}</strong> • Started: ${data.started_at || ''} • Completed: ${data.completed_at || ''}`;
const rows = [];
for (const f of (data.files || [])) {
const fn = f.statement_meta && f.statement_meta.filename;
const canPreview = fn && fn.toLowerCase().endsWith('.html');
rows.push(
`<tr class="border-t border-neutral-200 dark:border-neutral-800">
<td class="py-2 pr-4">${f.file_no}</td>
<td class="py-2 pr-4">${f.status}</td>
<td class="py-2 pr-4">${f.error_message || ''}</td>
<td class="py-2 pr-4 text-right">
<div class="flex items-center justify-end gap-2">
${fn ? `<a class=\"underline text-primary-600\" href=\"/api/billing/statements/${encodeURIComponent(f.file_no)}/download\" target=\"_blank\">Download</a>` : ''}
${canPreview ? `<button class=\"text-xs px-2 py-1 bg-neutral-100 dark:bg-neutral-800 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded\" data-action=\"preview\" data-file=\"${encodeURIComponent(f.file_no)}\">Preview</button>` : ''}
</div>
</td>
</tr>`
);
}
filesEl.innerHTML = rows.join('');
// Prepare data for per-batch CSV export
try {
const csvItems = (data.files || []).map(function(f){
const meta = f && f.statement_meta ? f.statement_meta : null;
return {
file_no: f && f.file_no != null ? String(f.file_no) : '',
status: f && f.status != null ? String(f.status) : '',
error_message: f && f.error_message != null ? String(f.error_message) : '',
filename: meta && meta.filename != null ? String(meta.filename) : '',
size: meta && meta.size != null ? String(meta.size) : '',
started_at: f && f.started_at != null ? String(f.started_at) : '',
completed_at: f && f.completed_at != null ? String(f.completed_at) : ''
};
});
if (exportBatchBtn) {
exportBatchBtn.disabled = csvItems.length === 0;
exportBatchBtn.setAttribute('data-export', JSON.stringify(csvItems));
exportBatchBtn.setAttribute('data-batch-id', data && data.batch_id ? String(data.batch_id) : '');
}
} catch (_) {
if (exportBatchBtn) {
exportBatchBtn.disabled = true;
exportBatchBtn.removeAttribute('data-export');
exportBatchBtn.removeAttribute('data-batch-id');
}
}
openModal('batchDetailsModal');
}
async function refresh() {
const items = await fetchHistory();
const rows = items.map(renderRow).join('');
body.innerHTML = rows;
empty.style.display = items.length ? 'none' : '';
count.textContent = items.length ? `${items.length} batches` : '';
lastCount = items.length;
// Pagination controls
pagePrev.disabled = offset <= 0;
pageNext.disabled = lastCount < limit;
const page = Math.floor(offset / limit) + 1;
pageInfo.textContent = `Page ${page}`;
exportBtn.disabled = items.length === 0;
exportBtn.setAttribute('data-export', JSON.stringify(items));
}
document.addEventListener('click', function(ev){
const btn = ev.target.closest('[data-action="open"]');
if (!btn) return;
const row = btn.closest('tr[data-batch]');
if (!row) return;
openBatch(row.getAttribute('data-batch'));
});
document.getElementById('batchDetailsModal').addEventListener('click', async function(ev){
const pbtn = ev.target.closest('[data-action="preview"]');
if (!pbtn) return;
const fileNo = decodeURIComponent(pbtn.getAttribute('data-file'));
const url = `/api/billing/statements/${encodeURIComponent(fileNo)}/download`;
const previewWrap = document.getElementById('previewWrap');
const previewFrame = document.getElementById('previewFrame');
previewFrame.src = url;
previewWrap.classList.remove('hidden');
});
document.getElementById('closePreview').addEventListener('click', function(){
const previewWrap = document.getElementById('previewWrap');
const previewFrame = document.getElementById('previewFrame');
previewFrame.src = 'about:blank';
previewWrap.classList.add('hidden');
});
statusSel.addEventListener('change', refresh);
sortSel.addEventListener('change', refresh);
refreshBtn.addEventListener('click', refresh);
startInput.addEventListener('change', function(){ offset = 0; refresh(); });
endInput.addEventListener('change', function(){ offset = 0; refresh(); });
pageSizeSel.addEventListener('change', function(){ limit = parseInt(pageSizeSel.value, 10) || 25; offset = 0; refresh(); });
pagePrev.addEventListener('click', function(){ if (offset >= limit) { offset -= limit; refresh(); } });
pageNext.addEventListener('click', function(){ if (lastCount === limit) { offset += limit; refresh(); } });
exportBtn.addEventListener('click', function(){
const dataAttr = exportBtn.getAttribute('data-export');
let items = [];
try { items = JSON.parse(dataAttr || '[]'); } catch (_) { items = []; }
if (!Array.isArray(items) || items.length === 0) return;
const headers = ['batch_id','status','total_files','successful_files','failed_files','started_at','updated_at','completed_at','processing_time_seconds'];
const lines = [headers.join(',')];
for (const it of items) {
const row = headers.map(h => {
const v = it[h] == null ? '' : String(it[h]);
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
return '"' + v.replace(/"/g,'""') + '"';
}
return v;
}).join(',');
lines.push(row);
}
const csv = lines.join('\n');
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().replace(/[:.]/g,'-');
a.download = `billing_batches_${stamp}.csv`;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 0);
});
if (exportBatchBtn) {
exportBatchBtn.addEventListener('click', function(){
const dataAttr = exportBatchBtn.getAttribute('data-export');
let items = [];
try { items = JSON.parse(dataAttr || '[]'); } catch (_) { items = []; }
if (!Array.isArray(items) || items.length === 0) return;
const headers = ['file_no','status','error_message','filename','size','started_at','completed_at'];
const lines = [headers.join(',')];
for (const it of items) {
const row = headers.map(function(h){
const v = it && it[h] != null ? String(it[h]) : '';
if (v.includes(',') || v.includes('"') || v.includes('\n')) {
return '"' + v.replace(/"/g,'""') + '"';
}
return v;
}).join(',');
lines.push(row);
}
const csv = lines.join('\n');
const blob = new Blob([csv], {type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const stamp = new Date().toISOString().replace(/[:.]/g,'-');
const batchId = exportBatchBtn.getAttribute('data-batch-id') || 'batch';
a.download = `billing_batch_${batchId}_${stamp}.csv`;
document.body.appendChild(a);
a.click();
setTimeout(function(){ URL.revokeObjectURL(url); a.remove(); }, 0);
});
}
refresh();
})();
</script>
{% endblock %}