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

@ -133,27 +133,27 @@
</div>
</div>
<nav class="flex-1 flex flex-col gap-1">
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/admin-reconciliation-management">
<span class="material-symbols-outlined" data-icon="account_balance_wallet">account_balance_wallet</span>
<span>Reconciliation</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/admin-dashboard-overview">
<span class="material-symbols-outlined" data-icon="security">security</span>
<span>Audit Logs</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/admin-dashboard-overview">
<span class="material-symbols-outlined" data-icon="payments">payments</span>
<span>Fee Management</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold font-label-md text-label-md" href="#">
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold font-label-md text-label-md" href="/ui/merchant-settlement-history">
<span class="material-symbols-outlined" data-icon="receipt_long">receipt_long</span>
<span>Settlements</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/device-technical-detail">
<span class="material-symbols-outlined" data-icon="router">router</span>
<span>Device Health</span>
</a>
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/hub">
<span class="material-symbols-outlined" data-icon="contact_support">contact_support</span>
<span>Support</span>
</a>
@ -163,10 +163,10 @@
<span class="material-symbols-outlined" data-icon="add_chart">add_chart</span>
Generate Report
</button>
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
<button id="merchant-logout" class="w-full flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md">
<span class="material-symbols-outlined" data-icon="logout">logout</span>
<span>Logout</span>
</a>
</button>
</div>
</aside>
<!-- Main Content Area -->
@ -184,6 +184,10 @@
<div class="flex items-center gap-4">
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors" data-icon="notifications">notifications</span>
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors" data-icon="settings">settings</span>
<div class="text-right">
<p id="merchant-session-name" class="text-[12px] font-bold text-on-surface leading-tight">Merchant User</p>
<p id="merchant-session-role" class="text-[11px] text-slate-500 leading-tight">session</p>
</div>
<div class="h-10 w-10 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
<img alt="Administrator Avatar" class="w-full h-full object-cover" data-alt="A professional headshot of a smiling fintech administrator in a bright, modern corporate office. High-key lighting highlights the reliable and expert persona, matching a clean light-mode aesthetic with soft blue and grey tones." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAULoyxr37TXy6dAfagS4FSEQl7UpTG5XXKiqbc3Lt55-L4oWWcAjgJSHLfIRx3w_TYphBzQLrW9XdgtYvGMwDlsGslyaOunv0ANbViiiH0eSWWUrweqkulmIFSgKdqKoxSKdQ8L5ouHalFrIJtx0Lff4GQ-YmlbRwDJAfjaYzNFCucdQ4X7Dt7iIx83NWQ0Mf6PlAchGv7WoVm7K3TI8C6HkpMzpYhiphfN1LYtRDR4i-kW-Uk8Gcv2tPYheVSH_5-r9spt16EOl4"/>
</div>
@ -199,7 +203,7 @@
<div class="flex justify-between items-start">
<div>
<p class="font-label-md text-label-md text-slate-500 mb-1">Available Balance</p>
<h3 class="font-metric-lg text-metric-lg text-on-background">$12,480.50</h3>
<h3 id="merchant-pending-payout" class="font-metric-lg text-metric-lg text-on-background">Rp0</h3>
</div>
<div class="w-10 h-10 bg-primary-container/10 rounded-lg flex items-center justify-center text-primary">
<span class="material-symbols-outlined" data-icon="account_balance">account_balance</span>
@ -218,14 +222,14 @@
<div class="flex justify-between items-start">
<div>
<p class="font-label-md text-label-md text-slate-500 mb-1">Next Payout Date</p>
<h3 class="font-metric-lg text-metric-lg text-on-background">Oct 24, 2023</h3>
<h3 id="merchant-next-payout-date" class="font-metric-lg text-metric-lg text-on-background">-</h3>
</div>
<div class="w-10 h-10 bg-warning/10 rounded-lg flex items-center justify-center text-warning">
<span class="material-symbols-outlined" data-icon="event">event</span>
</div>
</div>
<div class="mt-4">
<p class="font-label-md text-label-md text-slate-400">Estimated: <span class="text-on-surface font-semibold">$3,150.00</span></p>
<p class="font-label-md text-label-md text-slate-400">Estimated: <span id="merchant-next-payout-amount" class="text-on-surface font-semibold">Rp0</span></p>
</div>
</div>
<!-- Total Settled MTD -->
@ -233,18 +237,18 @@
<div class="flex justify-between items-start">
<div>
<p class="font-label-md text-label-md text-slate-500 mb-1">Total Settled (MTD)</p>
<h3 class="font-metric-lg text-metric-lg text-on-background">$45,210.00</h3>
<h3 id="merchant-paid-payout" class="font-metric-lg text-metric-lg text-on-background">Rp0</h3>
</div>
<div class="w-10 h-10 bg-success/10 rounded-lg flex items-center justify-center text-success">
<span class="material-symbols-outlined" data-icon="payments">payments</span>
</div>
</div>
<div class="mt-4 flex items-center gap-2">
<span class="text-success font-metric-sm text-metric-sm flex items-center">
<span class="material-symbols-outlined text-[16px]" data-icon="trending_up">trending_up</span>
12.5%
<span id="merchant-adjustment-amount" class="text-slate-500 font-metric-sm text-metric-sm flex items-center">
<span class="material-symbols-outlined text-[16px]" data-icon="balance">balance</span>
Adj Rp0
</span>
<span class="text-slate-400 font-label-md text-label-md">vs last month</span>
<span class="text-slate-400 font-label-md text-label-md">included</span>
</div>
</div>
</div>
@ -262,11 +266,11 @@
</div>
<div class="flex items-center gap-2">
<span class="font-label-md text-label-md text-slate-500 whitespace-nowrap">Status:</span>
<select class="border-slate-200 rounded-lg text-body-md focus:ring-primary focus:border-primary py-1 px-3">
<select id="merchant-settlement-status-filter" class="border-slate-200 rounded-lg text-body-md focus:ring-primary focus:border-primary py-1 px-3">
<option>All Statuses</option>
<option>Processed</option>
<option>Pending</option>
<option>Failed</option>
<option value="paid">Paid</option>
<option value="created">Pending</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
@ -296,7 +300,7 @@
<th class="px-6 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">
<tbody id="merchant-settlement-rows" class="divide-y divide-slate-100">
<!-- Row 1: Processed -->
<tr class="hover:bg-slate-50 transition-colors h-row-height group">
<td class="px-6 font-label-md text-label-md font-semibold text-primary">SET-902341</td>
@ -394,7 +398,7 @@
</div>
<!-- Pagination -->
<div class="px-6 py-4 flex items-center justify-between border-t border-slate-100 bg-white">
<p class="font-label-md text-label-md text-slate-500">Showing <span class="font-semibold text-on-surface">1 - 5</span> of <span class="font-semibold text-on-surface">124</span> disbursements</p>
<p id="merchant-settlement-summary" class="font-label-md text-label-md text-slate-500">Loading settlement batches...</p>
<div class="flex gap-2">
<button class="p-2 border border-slate-200 rounded-lg text-slate-400 hover:text-primary hover:bg-slate-50 transition-all">
<span class="material-symbols-outlined text-[20px]" data-icon="chevron_left">chevron_left</span>
@ -529,8 +533,8 @@
<div class="space-y-6">
<div class="p-4 bg-slate-50 rounded-xl">
<p class="font-label-md text-label-md text-slate-500">Net Amount Paid</p>
<p class="font-metric-lg text-metric-lg text-primary">$2,401.00</p>
<span class="inline-flex items-center mt-2 px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success">
<p id="drawer-net-amount" class="font-metric-lg text-metric-lg text-primary">Rp0</p>
<span id="drawer-status-badge" class="inline-flex items-center mt-2 px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success">
Successful Transfer
</span>
</div>
@ -538,19 +542,19 @@
<h4 class="font-label-md text-label-md font-bold uppercase text-slate-400">Breakdown</h4>
<div class="flex justify-between items-center py-2 border-b border-slate-100">
<span class="text-body-md text-slate-600">Gross Processing Volume</span>
<span class="font-metric-sm text-metric-sm">$2,450.00</span>
<span id="drawer-gross-amount" class="font-metric-sm text-metric-sm">Rp0</span>
</div>
<div class="flex justify-between items-center py-2 border-b border-slate-100">
<span class="text-body-md text-slate-600">Platform Fees (2%)</span>
<span class="font-metric-sm text-metric-sm text-danger">-$49.00</span>
<span class="text-body-md text-slate-600">Platform Fees</span>
<span id="drawer-fee-amount" class="font-metric-sm text-metric-sm text-danger">Rp0</span>
</div>
<div class="flex justify-between items-center py-2 border-b border-slate-100">
<span class="text-body-md text-slate-600">Adjustment/Refunds</span>
<span class="font-metric-sm text-metric-sm">$0.00</span>
<span id="drawer-adjustment-amount" class="font-metric-sm text-metric-sm">Rp0</span>
</div>
<div class="flex justify-between items-center py-2">
<span class="text-body-md font-bold">Total Disbursed</span>
<span class="font-metric-sm text-metric-sm font-bold text-primary">$2,401.00</span>
<span id="drawer-total-amount" class="font-metric-sm text-metric-sm font-bold text-primary">Rp0</span>
</div>
</div>
<div class="space-y-4 pt-6">
@ -560,14 +564,14 @@
<span class="material-symbols-outlined text-slate-500" data-icon="account_balance">account_balance</span>
</div>
<div>
<p class="font-label-md text-label-md font-bold">HDFC Bank India</p>
<p class="text-body-md text-slate-500">Checking Account •••• 4492</p>
<p class="font-label-md text-label-md font-bold">Settlement Account</p>
<p id="drawer-destination" class="text-body-md text-slate-500">-</p>
</div>
</div>
</div>
<div class="space-y-4 pt-6">
<h4 class="font-label-md text-label-md font-bold uppercase text-slate-400">Transfer Log</h4>
<div class="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-slate-200">
<div id="drawer-event-log" class="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-slate-200">
<div class="relative">
<div class="absolute -left-[22px] top-1 w-4 h-4 rounded-full bg-success ring-4 ring-white"></div>
<p class="font-label-md text-label-md font-bold">Transfer Initiated</p>
@ -586,7 +590,7 @@
</div>
</div>
<div class="pt-8 flex flex-col gap-3">
<button class="w-full bg-primary text-on-primary py-3 px-4 rounded-xl font-bold flex items-center justify-center gap-2">
<button id="drawer-download-report" class="w-full bg-primary text-on-primary py-3 px-4 rounded-xl font-bold flex items-center justify-center gap-2">
<span class="material-symbols-outlined" data-icon="download">download</span>
Download Proof of Transfer
</button>
@ -599,7 +603,161 @@
<!-- Overlay for drawer -->
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[90] hidden opacity-0 transition-opacity duration-300" id="drawerOverlay" onclick="toggleDrawer(false)"></div>
</main>
<script src="/ui/shared/merchant-api.js"></script>
<script>
const MerchantSettlementUI = (() => {
const api = window.MerchantUIAPI;
const rowsEl = document.getElementById('merchant-settlement-rows');
const statusFilter = document.getElementById('merchant-settlement-status-filter');
const summaryEl = document.getElementById('merchant-settlement-summary');
const downloadBtn = document.getElementById('drawer-download-report');
const logoutBtn = document.getElementById('merchant-logout');
let activeBatchId = null;
let activeBatchCode = '';
const normalize = (value) => String(value || '').toLowerCase();
const money = (value) => api.formatMoney(value);
const dt = (value) => api.formatDateTime(value);
const setText = (id, value) => {
const el = document.getElementById(id);
if (el) el.textContent = value || '-';
};
const statusClass = (status) => {
if (status === 'paid') return 'bg-success/10 text-success';
if (status === 'failed' || status === 'cancelled') return 'bg-danger/10 text-danger';
return 'bg-warning/10 text-warning';
};
const statusLabel = (status) => status === 'created' ? 'PENDING' : String(status || '-').toUpperCase();
const renderRows = (batches, merchant) => {
if (!rowsEl) return;
if (!batches.length) {
rowsEl.innerHTML = '<tr><td colspan="7" class="px-6 py-10 text-center text-slate-500">No settlement batches available.</td></tr>';
if (summaryEl) summaryEl.textContent = 'Showing 0 disbursements';
return;
}
rowsEl.innerHTML = batches.map((batch) => `
<tr class="hover:bg-slate-50 transition-colors h-row-height group cursor-pointer" data-batch-id="${batch.id}">
<td class="px-6 font-label-md text-label-md font-semibold text-primary">${batch.batch_code}</td>
<td class="px-6 font-body-md text-body-md text-on-surface">${dt(batch.paid_at || batch.created_at)}</td>
<td class="px-6 font-body-md text-body-md text-slate-500">${merchant.settlement_account_reference || '-'}</td>
<td class="px-6 font-body-md text-body-md text-right tabular-nums">${money(batch.gross_amount)}</td>
<td class="px-6 font-body-md text-body-md text-right tabular-nums font-semibold">${money(batch.net_payable_amount)}</td>
<td class="px-6"><span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass(batch.status)}">${statusLabel(batch.status)}</span></td>
<td class="px-6 text-right">
<button class="text-primary hover:underline font-label-md text-label-md flex items-center gap-1 ml-auto group-hover:opacity-100 opacity-0 transition-opacity">
<span class="material-symbols-outlined text-[16px]" data-icon="visibility">visibility</span>
Detail
</button>
</td>
</tr>
`).join('');
rowsEl.querySelectorAll('tr[data-batch-id]').forEach((row) => {
row.addEventListener('click', () => openDrawer(row.dataset.batchId, merchant));
});
if (summaryEl) summaryEl.textContent = `Showing ${batches.length} disbursement(s)`;
};
const renderEvents = (events) => {
const host = document.getElementById('drawer-event-log');
if (!host) return;
const rows = Array.isArray(events) ? events : [];
if (!rows.length) {
host.innerHTML = '<div class="relative"><div class="absolute -left-[22px] top-1 w-4 h-4 rounded-full bg-slate-300 ring-4 ring-white"></div><p class="font-label-md text-label-md font-bold">No payout events yet</p><p class="text-xs text-slate-400">-</p></div>';
return;
}
host.innerHTML = rows.map((event) => {
const label = String(event.event_type || '').replace(/_/g, ' ').toUpperCase();
const actor = `${event.actor_type || 'system'}${event.actor_id ? ` · ${event.actor_id}` : ''}`;
return `
<div class="relative">
<div class="absolute -left-[22px] top-1 w-4 h-4 rounded-full bg-success ring-4 ring-white"></div>
<p class="font-label-md text-label-md font-bold">${label}</p>
<p class="text-xs text-slate-400">${dt(event.created_at)} · ${actor}</p>
</div>
`;
}).join('');
};
const openDrawer = async (batchId, merchant) => {
const payload = await api.getSettlementBatch(batchId);
const batch = payload.batch;
const metadata = batch.metadata_json || {};
const adjustmentAmount = Array.isArray(payload.adjustments)
? payload.adjustments
.filter((item) => (item.approval_status || 'approved') === 'approved')
.reduce((sum, item) => sum + Number(item.signed_amount || 0), 0)
: Number(metadata.total_adjustment_amount || 0);
const adjustedNetAmount = Number(batch.net_payable_amount || 0) + adjustmentAmount;
activeBatchId = batch.id;
activeBatchCode = batch.batch_code;
setText('drawer-net-amount', money(adjustedNetAmount));
setText('drawer-gross-amount', money(batch.gross_amount));
setText('drawer-fee-amount', `-${money(batch.platform_fee_amount)}`);
setText('drawer-adjustment-amount', money(adjustmentAmount));
setText('drawer-total-amount', money(adjustedNetAmount));
setText('drawer-destination', merchant.settlement_account_reference || '-');
const badge = document.getElementById('drawer-status-badge');
if (badge) {
badge.textContent = statusLabel(batch.status);
badge.className = `inline-flex items-center mt-2 px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass(batch.status)}`;
}
renderEvents(payload.events || []);
toggleDrawer(true);
};
const downloadActiveCsv = async () => {
if (!activeBatchId) return;
const { token, merchantId } = api.requireSession();
const response = await fetch(`/merchant/settlement-batches/${encodeURIComponent(activeBatchId)}/export.csv`, {
headers: {
Authorization: `Bearer ${token}`,
'X-Merchant-Id': merchantId
}
});
if (!response.ok) throw new Error(`CSV export failed: ${response.status}`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${activeBatchCode || activeBatchId}-merchant-payout-report.csv`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const load = async () => {
api.requireSession();
const [profile, summary, batches] = await Promise.all([
api.getProfile(),
api.getSettlementSummary(),
api.listSettlementBatches({
limit: 100,
status: statusFilter?.value && statusFilter.value !== 'All Statuses' ? statusFilter.value : undefined
})
]);
const merchant = profile?.merchant || profile;
const user = profile?.user || api.getSessionUser?.();
setText('merchant-session-name', user?.name || merchant.brand_name || merchant.legal_name || 'Merchant User');
setText('merchant-session-role', user?.role_name ? `${String(user.role_name).toUpperCase()} · ${api.getAuthMode?.() || 'session'}` : api.getAuthMode?.() || 'session');
setText('merchant-pending-payout', money(summary.pending_amount));
setText('merchant-paid-payout', money(summary.adjusted_paid_amount ?? summary.paid_amount));
setText('merchant-adjustment-amount', `Adj ${money(summary.adjustment_amount || 0)}`);
setText('merchant-next-payout-amount', money(summary.pending_amount));
setText('merchant-next-payout-date', Number(summary.created_batches || 0) > 0 ? 'On next payout run' : '-');
renderRows(batches || [], merchant);
};
statusFilter?.addEventListener('change', load);
downloadBtn?.addEventListener('click', downloadActiveCsv);
logoutBtn?.addEventListener('click', () => {
api.clearSession();
window.location.href = '/ui/merchant-login';
});
return { load };
})();
function toggleDrawer(isOpen) {
const drawer = document.getElementById('detailDrawer');
const overlay = document.getElementById('drawerOverlay');
@ -615,10 +773,8 @@
}
}
// Simulating row click for detail view
document.querySelectorAll('tbody tr').forEach(row => {
row.style.cursor = 'pointer';
row.addEventListener('click', () => toggleDrawer(true));
MerchantSettlementUI.load().catch((error) => {
console.error('[merchant-settlement] failed loading data', error);
});
</script>
<!-- ui-nav -->
@ -630,4 +786,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>