working on backend
This commit is contained in:
353
templates/billing.html
Normal file
353
templates/billing.html
Normal file
@@ -0,0 +1,353 @@
|
||||
{% 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 %}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user