Production readiness hardening and ops tooling

This commit is contained in:
2026-05-29 10:10:12 +07:00
parent e0b8f9af9a
commit 648e77cee9
68 changed files with 12222 additions and 848 deletions

View File

@ -130,27 +130,27 @@
</div>
</div>
<nav class="flex-1 space-y-1">
<a class="flex items-center gap-3 px-3 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold transition-all scale-98 active:opacity-80" href="#">
<a class="flex items-center gap-3 px-3 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold transition-all scale-98 active:opacity-80" href="/ui/admin-reconciliation-management">
<span class="material-symbols-outlined" data-icon="account_balance_wallet">account_balance_wallet</span>
<span class="font-label-md text-label-md">Reconciliation</span>
</a>
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/admin-dashboard-overview">
<span class="material-symbols-outlined" data-icon="security">security</span>
<span class="font-label-md text-label-md">Audit Logs</span>
</a>
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/admin-dashboard-overview">
<span class="material-symbols-outlined" data-icon="payments">payments</span>
<span class="font-label-md text-label-md">Fee Management</span>
</a>
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/settlement-batch-management">
<span class="material-symbols-outlined" data-icon="receipt_long">receipt_long</span>
<span class="font-label-md text-label-md">Settlements</span>
</a>
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/device-technical-detail">
<span class="material-symbols-outlined" data-icon="router">router</span>
<span class="font-label-md text-label-md">Device Health</span>
</a>
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/hub">
<span class="material-symbols-outlined" data-icon="contact_support">contact_support</span>
<span class="font-label-md text-label-md">Support</span>
</a>
@ -161,7 +161,7 @@
<span class="font-label-md text-label-md">Generate Report</span>
</button>
<div class="h-px bg-slate-200"></div>
<a class="flex items-center gap-3 px-3 py-3 text-danger hover:bg-red-50 rounded-xl transition-all" href="#">
<a class="flex items-center gap-3 px-3 py-3 text-danger hover:bg-red-50 rounded-xl transition-all" href="/ui/admin-login">
<span class="material-symbols-outlined" data-icon="logout">logout</span>
<span class="font-label-md text-label-md">Logout</span>
</a>
@ -174,10 +174,10 @@
<div class="flex items-center gap-8">
<h2 class="font-headline-md text-headline-md font-bold text-primary dark:text-inverse-primary">Reconciliation</h2>
<nav class="hidden lg:flex items-center gap-6">
<a class="text-primary dark:text-inverse-primary border-b-2 border-primary font-bold pb-1 transition-colors" href="#">Dashboard</a>
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="#">Merchants</a>
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="#">Operations</a>
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="#">Audit</a>
<a class="text-primary dark:text-inverse-primary border-b-2 border-primary font-bold pb-1 transition-colors" href="/ui/admin-dashboard-overview">Dashboard</a>
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="/ui/merchant-detail-view">Merchants</a>
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="/ui/settlement-batch-management">Operations</a>
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="/ui/admin-reconciliation-management">Audit</a>
</nav>
</div>
<div class="flex items-center gap-4">
@ -211,7 +211,7 @@
<span class="material-symbols-outlined text-success" data-icon="check_circle">check_circle</span>
</div>
</div>
<div class="font-metric-lg text-metric-lg tabular-nums">42,892</div>
<div id="recon-total-matched" class="font-metric-lg text-metric-lg tabular-nums">-</div>
<div class="mt-2 flex items-center gap-1">
<span class="material-symbols-outlined text-success text-[16px]" data-icon="trending_up">trending_up</span>
<span class="font-metric-sm text-metric-sm text-success">+12.4%</span>
@ -225,7 +225,7 @@
<span class="material-symbols-outlined text-danger" data-icon="error_outline">error_outline</span>
</div>
</div>
<div class="font-metric-lg text-metric-lg tabular-nums">148</div>
<div id="recon-discrepancies" class="font-metric-lg text-metric-lg tabular-nums">-</div>
<div class="mt-2 flex items-center gap-1">
<span class="material-symbols-outlined text-danger text-[16px]" data-icon="trending_down">trending_down</span>
<span class="font-metric-sm text-metric-sm text-danger">-2.1%</span>
@ -239,7 +239,7 @@
<span class="material-symbols-outlined text-warning" data-icon="hourglass_empty">hourglass_empty</span>
</div>
</div>
<div class="font-metric-lg text-metric-lg tabular-nums">1,024</div>
<div id="recon-total-batches" class="font-metric-lg text-metric-lg tabular-nums">-</div>
<div class="mt-2 flex items-center gap-1">
<span class="material-symbols-outlined text-slate-400 text-[16px]" data-icon="history">history</span>
<span class="font-metric-sm text-metric-sm text-slate-600">Avg 4h processing</span>
@ -252,7 +252,7 @@
<span class="material-symbols-outlined text-info" data-icon="account_balance">account_balance</span>
</div>
</div>
<div class="font-metric-lg text-metric-lg tabular-nums">842</div>
<div id="recon-issue-count" class="font-metric-lg text-metric-lg tabular-nums">-</div>
<div class="mt-2 flex items-center gap-1">
<span class="material-symbols-outlined text-success text-[16px]" data-icon="cloud_done">cloud_done</span>
<span class="font-metric-sm text-metric-sm text-slate-600">12 API Connections</span>
@ -294,111 +294,24 @@
<table class="w-full text-left border-collapse">
<thead>
<tr class="bg-slate-50 border-b border-slate-200 sticky top-0 z-10">
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Transaction Details</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-primary/5">System Record (Internal)</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-info/5">Bank Record (External)</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Variance</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Batch Details</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-primary/5">Batch Aggregate</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-info/5">Ledger Computed</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Issues</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
<!-- Row 1: Matched -->
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4">
<p class="font-bold text-on-surface">TXN-90283471</p>
<p class="text-[12px] text-slate-400">Oct 24, 2023 • 14:22:10</p>
</td>
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 14,500.00</td>
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 14,500.00</td>
<td class="px-6 py-4 tabular-nums text-slate-400">0.00</td>
<td class="px-6 py-4">
<span class="px-3 py-1 bg-success/10 text-success text-[12px] font-bold rounded-full">MATCHED</span>
</td>
<td class="px-6 py-4 text-right">
<button class="p-2 hover:bg-slate-100 rounded-lg text-slate-400">
<span class="material-symbols-outlined" data-icon="more_vert">more_vert</span>
</button>
</td>
</tr>
<!-- Row 2: Discrepancy -->
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4">
<p class="font-bold text-on-surface">TXN-88273412</p>
<p class="text-[12px] text-slate-400">Oct 24, 2023 • 11:05:45</p>
</td>
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 8,240.50</td>
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 8,245.50</td>
<td class="px-6 py-4 tabular-nums text-danger font-bold">- 5.00</td>
<td class="px-6 py-4">
<span class="px-3 py-1 bg-danger/10 text-danger text-[12px] font-bold rounded-full">EXCEPTION</span>
</td>
<td class="px-6 py-4 text-right">
<button class="px-3 py-1 bg-primary text-white text-[12px] font-bold rounded hover:bg-primary/90 transition-colors">
Resolve
</button>
</td>
</tr>
<!-- Row 3: Pending -->
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4">
<p class="font-bold text-on-surface">TXN-90112456</p>
<p class="text-[12px] text-slate-400">Oct 23, 2023 • 23:18:02</p>
</td>
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 1,20,000.00</td>
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium italic text-slate-400">Not Found</td>
<td class="px-6 py-4 tabular-nums text-slate-400">Pending</td>
<td class="px-6 py-4">
<span class="px-3 py-1 bg-warning/10 text-warning text-[12px] font-bold rounded-full">PENDING</span>
</td>
<td class="px-6 py-4 text-right">
<button class="p-2 hover:bg-slate-100 rounded-lg text-slate-400">
<span class="material-symbols-outlined" data-icon="refresh">refresh</span>
</button>
</td>
</tr>
<!-- Row 4: Matched -->
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4">
<p class="font-bold text-on-surface">TXN-90283472</p>
<p class="text-[12px] text-slate-400">Oct 23, 2023 • 18:45:30</p>
</td>
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 450.00</td>
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 450.00</td>
<td class="px-6 py-4 tabular-nums text-slate-400">0.00</td>
<td class="px-6 py-4">
<span class="px-3 py-1 bg-success/10 text-success text-[12px] font-bold rounded-full">MATCHED</span>
</td>
<td class="px-6 py-4 text-right">
<button class="p-2 hover:bg-slate-100 rounded-lg text-slate-400">
<span class="material-symbols-outlined" data-icon="more_vert">more_vert</span>
</button>
</td>
</tr>
<!-- Row 5: Exception (Fee Miscalc) -->
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4">
<p class="font-bold text-on-surface">TXN-90283478</p>
<p class="text-[12px] text-slate-400">Oct 23, 2023 • 16:12:11</p>
</td>
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 22,000.00</td>
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 21,560.00</td>
<td class="px-6 py-4 tabular-nums text-danger font-bold">- 440.00</td>
<td class="px-6 py-4">
<span class="px-3 py-1 bg-danger/10 text-danger text-[12px] font-bold rounded-full">EXCEPTION</span>
</td>
<td class="px-6 py-4 text-right">
<button class="px-3 py-1 bg-primary text-white text-[12px] font-bold rounded hover:bg-primary/90 transition-colors">
Resolve
</button>
</td>
<tbody id="recon-mismatch-rows" class="divide-y divide-slate-100">
<tr>
<td colspan="6" class="px-6 py-8 text-center text-slate-500">Loading settlement reconciliation...</td>
</tr>
</tbody>
</table>
</div>
<!-- Table Pagination/Footer -->
<div class="px-6 py-4 bg-slate-50 border-t border-slate-200 flex items-center justify-between">
<p class="text-label-md text-slate-500">Showing 1 to 5 of 42,892 entries</p>
<p id="recon-footer" class="text-label-md text-slate-500">Loading reconciliation report...</p>
<div class="flex gap-2">
<button class="w-8 h-8 flex items-center justify-center rounded border border-slate-200 bg-white text-slate-400 hover:bg-slate-50 disabled:opacity-50" disabled="">
<span class="material-symbols-outlined text-[18px]" data-icon="chevron_left">chevron_left</span>
@ -427,7 +340,7 @@
</button>
</div>
<div class="font-mono text-[13px] text-success leading-relaxed h-[200px] overflow-y-auto hide-scrollbar">
<pre>{
<pre id="recon-raw-payload">{
"reconciliation_id": "RECON-00492-AX",
"timestamp": "2023-10-24T14:22:10.452Z",
"system_ledger": {
@ -450,27 +363,47 @@
</div>
<!-- Verification Timeline -->
<div class="bg-white border border-slate-200 rounded-xl p-6">
<h3 class="font-headline-md text-[16px] mb-6">Recent Resolution Activity</h3>
<div class="space-y-6">
<div class="relative pl-8">
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-success ring-4 ring-success/10 z-10"></div>
<div class="absolute left-1.5 top-5 w-[2px] h-full bg-slate-100"></div>
<p class="font-bold text-on-surface leading-none mb-1">TXN-88273412 Resolved</p>
<p class="text-[12px] text-slate-500 mb-1">Manual match confirmed by Admin A12</p>
<p class="text-[11px] text-slate-400">12 minutes ago</p>
<div class="flex items-start justify-between gap-3 mb-6">
<div>
<h3 class="font-headline-md text-[16px]">Recent Adjustment Activity</h3>
<p id="recon-adjustment-summary" class="text-[12px] text-slate-500 mt-1">Loading finance adjustment report...</p>
</div>
<div class="relative pl-8">
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-primary ring-4 ring-primary/10 z-10"></div>
<div class="absolute left-1.5 top-5 w-[2px] h-full bg-slate-100"></div>
<p class="font-bold text-on-surface leading-none mb-1">Report Exported</p>
<p class="text-[12px] text-slate-500 mb-1">Full Oct report generated (PDF)</p>
<p class="text-[11px] text-slate-400">45 minutes ago</p>
<button id="download-adjustment-report" data-admin-permission="settlement:export" class="w-9 h-9 flex items-center justify-center rounded-lg border border-slate-200 text-warning hover:bg-warning/10 transition-colors" title="Download adjustment CSV">
<span class="material-symbols-outlined" data-icon="download">download</span>
</button>
</div>
<p id="adjustment-export-status" class="hidden text-[11px] text-slate-500 mb-4"></p>
<div id="adjustment-export-history" class="hidden mb-6 border border-slate-100 rounded-lg divide-y divide-slate-100 overflow-hidden"></div>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-2 mb-6">
<div>
<input id="adjustment-merchant-filter" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary" list="adjustment-merchant-options" placeholder="Search merchant" type="text"/>
<datalist id="adjustment-merchant-options"></datalist>
<p id="adjustment-merchant-hint" class="mt-1 text-[11px] text-slate-400">Loading merchants...</p>
</div>
<select id="adjustment-type-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary bg-white">
<option value="">All types</option>
<option value="credit">Credit</option>
<option value="debit">Debit</option>
</select>
<select id="adjustment-approval-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary bg-white">
<option value="">All approval</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
<input id="adjustment-from-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary" type="date"/>
<input id="adjustment-to-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary" type="date"/>
</div>
<div class="flex gap-2 mb-6">
<button id="apply-adjustment-filter" class="flex-1 px-3 py-2 bg-primary text-white rounded-lg text-[12px] font-bold hover:bg-primary/90 transition-colors">Apply Filter</button>
<button id="clear-adjustment-filter" class="px-3 py-2 border border-slate-200 rounded-lg text-[12px] font-bold hover:bg-slate-50 transition-colors">Clear</button>
</div>
<div id="recon-adjustment-activity" class="space-y-6">
<div class="relative pl-8">
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-slate-300 z-10"></div>
<p class="font-bold text-on-surface leading-none mb-1">HSBC Sync Completed</p>
<p class="text-[12px] text-slate-500 mb-1">2,400 statements fetched via API</p>
<p class="text-[11px] text-slate-400">1 hour ago</p>
<p class="font-bold text-on-surface leading-none mb-1">Loading adjustments</p>
<p class="text-[12px] text-slate-500 mb-1">Reading settlement adjustment ledger</p>
<p class="text-[11px] text-slate-400">Please wait</p>
</div>
</div>
</div>
@ -523,7 +456,425 @@
</div>
</div>
</div>
<script src="/ui/shared/admin-api.js"></script>
<script>
const api = window.AdminUIAPI;
const numberFormatter = new Intl.NumberFormat('id-ID');
let currentAdjustmentQuery = { limit: 5 };
let merchants = [];
function escapeHtml(value) {
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
}[char]));
}
function formatMoney(value) {
return api.formatMoney(value);
}
function formatDateTime(value) {
return api.formatDateTime(value);
}
function setText(id, value) {
const el = document.getElementById(id);
if (el) {
el.textContent = value;
}
}
function setExportStatus(message, tone = 'muted') {
const el = document.getElementById('adjustment-export-status');
if (!el) {
return;
}
el.textContent = message || '';
el.classList.toggle('hidden', !message);
el.classList.toggle('text-danger', tone === 'danger');
el.classList.toggle('text-success', tone === 'success');
el.classList.toggle('text-primary', tone === 'active');
el.classList.toggle('text-slate-500', tone === 'muted');
}
function dateToIsoStart(value) {
return value ? `${value}T00:00:00.000Z` : undefined;
}
function dateToIsoEnd(value) {
return value ? `${value}T23:59:59.999Z` : undefined;
}
function buildAdjustmentQuery(limit = 5) {
const merchantInput = document.getElementById('adjustment-merchant-filter')?.value.trim();
const matchedMerchant = merchants.find((merchant) =>
merchantInput &&
[merchant.id, merchant.merchant_code, merchant.brand_name, merchant.legal_name]
.filter(Boolean)
.some((value) => String(value).toLowerCase() === merchantInput.toLowerCase())
);
const merchantId = matchedMerchant?.id || merchantInput;
const adjustmentType = document.getElementById('adjustment-type-filter')?.value;
const approvalStatus = document.getElementById('adjustment-approval-filter')?.value;
const from = document.getElementById('adjustment-from-filter')?.value;
const to = document.getElementById('adjustment-to-filter')?.value;
return {
limit,
merchant_id: merchantId || undefined,
adjustment_type: adjustmentType || undefined,
approval_status: approvalStatus || undefined,
from: dateToIsoStart(from),
to: dateToIsoEnd(to)
};
}
function merchantLabel(merchant) {
return merchant?.brand_name || merchant?.legal_name || merchant?.merchant_code || merchant?.id || 'Merchant';
}
function exportStatusClass(status) {
if (status === 'completed') return 'bg-success/10 text-success';
if (status === 'failed') return 'bg-danger/10 text-danger';
if (status === 'running') return 'bg-primary/10 text-primary';
return 'bg-slate-100 text-slate-500';
}
function renderExportHistory(jobs) {
const container = document.getElementById('adjustment-export-history');
if (!container) {
return;
}
const rows = jobs || [];
container.classList.toggle('hidden', rows.length === 0);
container.innerHTML = rows.map((job) => `
<div class="flex items-center justify-between gap-3 px-3 py-2 bg-white">
<div class="min-w-0">
<p class="text-[12px] font-bold text-on-surface truncate">${escapeHtml(job.result_filename || job.id)}</p>
<p class="text-[11px] text-slate-400">${formatDateTime(job.created_at)}${job.result_size_bytes ? numberFormatter.format(job.result_size_bytes) + ' bytes' : 'waiting'}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="px-2 py-1 rounded text-[10px] font-bold uppercase ${exportStatusClass(job.status)}">${escapeHtml(job.status)}</span>
${job.download_url ? `<button data-export-download-id="${escapeHtml(job.id)}" class="w-8 h-8 flex items-center justify-center rounded border border-slate-200 text-primary hover:bg-primary/5" title="Download export"><span class="material-symbols-outlined text-[18px]">download</span></button>` : ''}
</div>
</div>
`).join('');
}
async function loadExportHistory() {
try {
const jobs = await api.listExportJobs({ job_type: 'settlement_adjustments_csv', limit: 5 });
renderExportHistory(jobs);
} catch (_error) {
renderExportHistory([]);
}
}
async function downloadExportById(jobId) {
const { blob, filename } = await api.downloadExportJob(jobId);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename || 'settlement-adjustment-report.csv';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function renderMerchantOptions(items) {
const options = document.getElementById('adjustment-merchant-options');
if (!options) {
return;
}
options.innerHTML = (items || [])
.map((merchant) => `<option value="${escapeHtml(merchant.id)}" label="${escapeHtml(merchantLabel(merchant))}"></option>`)
.join('');
setText('adjustment-merchant-hint', `${numberFormatter.format((items || []).length)} merchants available`);
}
async function loadMerchantOptions() {
try {
merchants = await api.listMerchants();
renderMerchantOptions(merchants);
} catch (error) {
setText('adjustment-merchant-hint', 'Merchant list unavailable');
}
}
function renderIssueSummary(row) {
if (!row.issues?.length) {
if (row.computed?.archived_reprocessed) {
return '<span class="text-slate-500">Archived after reprocess</span>';
}
return '<span class="text-slate-400">No variance</span>';
}
return row.issues
.slice(0, 2)
.map((issue) => `<p class="text-[12px] text-danger font-semibold">${escapeHtml(issue.message)}</p>`)
.join('') + (row.issues.length > 2 ? `<p class="text-[12px] text-slate-500">+${row.issues.length - 2} more issue(s)</p>` : '');
}
function renderRows(rows) {
const tbody = document.getElementById('recon-mismatch-rows');
if (!tbody) {
return;
}
if (!rows.length) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-8 text-center text-slate-500">No settlement batches available for reconciliation.</td>
</tr>
`;
return;
}
tbody.innerHTML = rows.map((row) => {
const batch = row.batch || {};
const computed = row.computed || {};
const isMismatch = row.status === 'mismatch';
const badgeClass = isMismatch ? 'bg-danger/10 text-danger' : 'bg-success/10 text-success';
const variance = Number(batch.net_payable_amount || 0) - Number(computed.net_payable_amount || 0);
return `
<tr class="hover:bg-slate-50/50 transition-colors">
<td class="px-6 py-4">
<p class="font-bold text-on-surface">${escapeHtml(batch.batch_code || batch.id)}</p>
<p class="text-[12px] text-slate-400">${escapeHtml(batch.merchant_id || '-')}${formatDateTime(batch.created_at)}</p>
<p class="text-[12px] text-slate-500">${numberFormatter.format(Number(batch.entry_count || 0))} entry • ${escapeHtml(batch.status || '-')}</p>
</td>
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">
<p>${formatMoney(batch.net_payable_amount)}</p>
<p class="text-[12px] text-slate-500">gross ${formatMoney(batch.gross_amount)} / fee ${formatMoney(batch.platform_fee_amount)}</p>
</td>
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">
<p>${formatMoney(computed.net_payable_amount)}</p>
<p class="text-[12px] text-slate-500">gross ${formatMoney(computed.gross_amount)} / fee ${formatMoney(computed.platform_fee_amount)}</p>
</td>
<td class="px-6 py-4">
<p class="tabular-nums ${isMismatch ? 'text-danger font-bold' : 'text-slate-400'}">${formatMoney(variance)}</p>
<div class="mt-1 space-y-1">${renderIssueSummary(row)}</div>
</td>
<td class="px-6 py-4">
<span class="px-3 py-1 ${badgeClass} text-[12px] font-bold rounded-full">${isMismatch ? 'MISMATCH' : 'MATCHED'}</span>
</td>
<td class="px-6 py-4 text-right">
<a href="/ui/settlement-batch-management" class="inline-flex px-3 py-1 bg-primary text-white text-[12px] font-bold rounded hover:bg-primary/90 transition-colors">Open</a>
</td>
</tr>
`;
}).join('');
}
function renderAdjustmentActivity(report) {
const container = document.getElementById('recon-adjustment-activity');
if (!container) {
return;
}
const rows = report?.rows || [];
setText(
'recon-adjustment-summary',
`${numberFormatter.format(Number(report?.total_count || 0))} adjustment • net ${formatMoney(report?.signed_amount || 0)}`
);
if (!rows.length) {
container.innerHTML = `
<div class="relative pl-8">
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-slate-300 z-10"></div>
<p class="font-bold text-on-surface leading-none mb-1">No adjustment yet</p>
<p class="text-[12px] text-slate-500 mb-1">Settlement corrections will appear here after admin records them.</p>
<p class="text-[11px] text-slate-400">-</p>
</div>
`;
return;
}
container.innerHTML = rows.map((item, index) => {
const isCredit = item.adjustment_type === 'credit';
const approvalStatus = item.approval_status || 'approved';
const dotClass = approvalStatus === 'pending'
? 'bg-primary ring-primary/10'
: approvalStatus === 'rejected'
? 'bg-danger ring-danger/10'
: isCredit ? 'bg-success ring-success/10' : 'bg-warning ring-warning/10';
const actionButtons = approvalStatus === 'pending'
? `
<div class="mt-3 flex gap-2">
<button data-admin-permission="settlement:adjust" data-adjustment-action="approve" data-adjustment-id="${escapeHtml(item.id)}" class="px-3 py-1.5 bg-success text-white text-[12px] font-bold rounded hover:bg-success/90 transition-colors">Approve</button>
<button data-admin-permission="settlement:adjust" data-adjustment-action="reject" data-adjustment-id="${escapeHtml(item.id)}" class="px-3 py-1.5 border border-danger/20 text-danger text-[12px] font-bold rounded hover:bg-danger/5 transition-colors">Reject</button>
</div>
`
: '';
return `
<div class="relative pl-8">
<div class="absolute left-0 top-1 w-4 h-4 rounded-full ${dotClass} ring-4 z-10"></div>
${index < rows.length - 1 ? '<div class="absolute left-1.5 top-5 w-[2px] h-full bg-slate-100"></div>' : ''}
<p class="font-bold text-on-surface leading-none mb-1">${escapeHtml(item.batch_code || item.batch_id)}</p>
<p class="text-[12px] text-slate-500 mb-1">${escapeHtml(item.adjustment_type || '-').toUpperCase()} ${formatMoney(item.signed_amount)}${escapeHtml(item.reason || '-')}</p>
<p class="text-[11px] font-bold uppercase tracking-wide ${approvalStatus === 'approved' ? 'text-success' : approvalStatus === 'rejected' ? 'text-danger' : 'text-primary'}">${escapeHtml(approvalStatus)}</p>
<p class="text-[11px] text-slate-400">${formatDateTime(item.created_at)}</p>
${actionButtons}
</div>
`;
}).join('');
api.applyPermissions?.();
}
async function handleAdjustmentApproval(event) {
const button = event.target.closest('[data-adjustment-action]');
if (!button) {
return;
}
const action = button.dataset.adjustmentAction;
const adjustmentId = button.dataset.adjustmentId;
if (!adjustmentId || !['approve', 'reject'].includes(action)) {
return;
}
const note = window.prompt(action === 'approve' ? 'Approval note (optional)' : 'Rejection note (optional)') || '';
button.disabled = true;
button.classList.add('opacity-50');
try {
if (action === 'approve') {
await api.approveSettlementAdjustment(adjustmentId, { note });
} else {
await api.rejectSettlementAdjustment(adjustmentId, { note });
}
await loadAdjustmentActivity();
} catch (error) {
alert(error.message || 'Failed to update adjustment approval');
} finally {
button.disabled = false;
button.classList.remove('opacity-50');
}
}
async function loadAdjustmentActivity() {
currentAdjustmentQuery = buildAdjustmentQuery(5);
const adjustmentReport = await api.listSettlementAdjustments(currentAdjustmentQuery);
renderAdjustmentActivity(adjustmentReport);
}
async function loadReconciliation() {
try {
api.requireToken();
currentAdjustmentQuery = buildAdjustmentQuery(5);
const [report, adjustmentReport] = await Promise.all([
api.getSettlementReconciliationReport({ limit: 100 }),
api.listSettlementAdjustments(currentAdjustmentQuery)
]);
setText('recon-total-matched', numberFormatter.format(Number(report.matched_batches || 0)));
setText('recon-discrepancies', numberFormatter.format(Number(report.mismatch_batches || 0)));
setText('recon-total-batches', numberFormatter.format(Number(report.total_batches || 0)));
setText('recon-issue-count', numberFormatter.format(Number(report.issue_count || 0)));
setText('recon-footer', `Showing ${numberFormatter.format((report.rows || []).length)} of ${numberFormatter.format(Number(report.total_batches || 0))} settlement batches`);
const raw = document.getElementById('recon-raw-payload');
if (raw) {
raw.textContent = JSON.stringify(report, null, 2);
}
renderRows(report.rows || []);
renderAdjustmentActivity(adjustmentReport);
} catch (error) {
const tbody = document.getElementById('recon-mismatch-rows');
if (tbody) {
tbody.innerHTML = `
<tr>
<td colspan="6" class="px-6 py-8 text-center text-danger">${escapeHtml(error.message || 'Failed to load reconciliation report')}</td>
</tr>
`;
}
setText('recon-footer', 'Failed to load reconciliation report');
setText('recon-adjustment-summary', 'Failed to load finance adjustment report');
}
}
async function downloadAdjustmentReport() {
const button = document.getElementById('download-adjustment-report');
if (button) {
button.disabled = true;
button.classList.add('opacity-50');
}
try {
const exportQuery = { ...currentAdjustmentQuery, limit: 5000 };
setExportStatus('Creating export job...', 'active');
let job = await api.createSettlementAdjustmentExportJob(exportQuery);
setExportStatus(`Export ${job.status}: ${job.id}`, 'active');
for (let attempt = 0; attempt < 30 && !['completed', 'failed'].includes(job.status); attempt += 1) {
await new Promise((resolve) => setTimeout(resolve, 1000));
job = await api.getExportJob(job.id);
setExportStatus(`Export ${job.status}: ${job.id}`, job.status === 'failed' ? 'danger' : 'active');
}
if (job.status !== 'completed') {
throw new Error(job.error_message || `Export job ${job.status}`);
}
const { blob, filename } = await api.downloadExportJob(job.id);
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename || 'settlement-adjustment-report.csv';
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setExportStatus(`Export completed: ${filename || job.id}`, 'success');
await loadExportHistory();
} catch (error) {
setExportStatus(error.message || 'Export failed', 'danger');
alert(error.message || 'Export failed');
} finally {
if (button) {
button.disabled = false;
button.classList.remove('opacity-50');
}
}
}
async function handleExportHistoryClick(event) {
const button = event.target.closest('[data-export-download-id]');
if (!button) {
return;
}
button.disabled = true;
button.classList.add('opacity-50');
try {
await downloadExportById(button.dataset.exportDownloadId);
} catch (error) {
alert(error.message || 'Download failed');
} finally {
button.disabled = false;
button.classList.remove('opacity-50');
}
}
async function applyAdjustmentFilter() {
const button = document.getElementById('apply-adjustment-filter');
if (button) {
button.disabled = true;
button.textContent = 'Loading...';
}
try {
await loadAdjustmentActivity();
} finally {
if (button) {
button.disabled = false;
button.textContent = 'Apply Filter';
}
}
}
async function clearAdjustmentFilter() {
['adjustment-merchant-filter', 'adjustment-type-filter', 'adjustment-approval-filter', 'adjustment-from-filter', 'adjustment-to-filter'].forEach((id) => {
const el = document.getElementById(id);
if (el) {
el.value = '';
}
});
await applyAdjustmentFilter();
}
// Micro-interactions
function toggleDrawer() {
const drawer = document.getElementById('detailDrawer');
@ -552,6 +903,15 @@
card.style.transform = 'translateY(0)';
}, 50);
});
api.applyPermissions?.();
loadReconciliation();
loadMerchantOptions();
loadExportHistory();
document.getElementById('download-adjustment-report')?.addEventListener('click', downloadAdjustmentReport);
document.getElementById('apply-adjustment-filter')?.addEventListener('click', applyAdjustmentFilter);
document.getElementById('clear-adjustment-filter')?.addEventListener('click', clearAdjustmentFilter);
document.getElementById('recon-adjustment-activity')?.addEventListener('click', handleAdjustmentApproval);
document.getElementById('adjustment-export-history')?.addEventListener('click', handleExportHistoryClick);
});
</script>
<!-- ui-nav -->
@ -563,4 +923,4 @@
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
</div>
'
</body></html>
</body></html>