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,66 +130,66 @@
</head>
<body class="bg-background text-on-background font-body-md text-body-md overflow-x-hidden">
<!-- SideNavBar -->
<aside class="w-64 h-full fixed left-0 top-0 bg-surface-container-lowest dark:bg-slate-900 border-r border-slate-200 dark:border-slate-700 flex flex-col py-6 px-4 gap-2 z-50">
<aside class="hidden lg:flex w-64 h-full fixed left-0 top-0 bg-surface-container-lowest dark:bg-slate-900 border-r border-slate-200 dark:border-slate-700 flex-col py-6 px-4 gap-2 z-50">
<div class="mb-8 px-2">
<h1 class="font-headline-md text-headline-md font-bold text-primary dark:text-primary-fixed">Soundbox Ops</h1>
<p class="font-label-md text-label-md text-on-surface-variant opacity-70">Admin Console</p>
</div>
<nav class="flex-1 space-y-1">
<!-- Active Tab: Overview -->
<a class="bg-secondary-container dark:bg-secondary text-on-secondary-container dark:text-on-secondary font-bold rounded-lg flex items-center gap-3 px-3 py-2.5 transition-transform active:scale-95" href="#">
<a class="bg-secondary-container dark:bg-secondary text-on-secondary-container dark:text-on-secondary font-bold rounded-lg flex items-center gap-3 px-3 py-2.5 transition-transform active:scale-95" href="/ui/admin-dashboard-overview">
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
<span>Overview</span>
</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="#">
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="/ui/merchant-list-management">
<span class="material-symbols-outlined" data-icon="storefront">storefront</span>
<span>Merchant Management</span>
</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="#">
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="/ui/device-registry-monitoring">
<span class="material-symbols-outlined" data-icon="speaker_group">speaker_group</span>
<span>Device Registry</span>
</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="#">
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="/ui/transaction-history-monitoring">
<span class="material-symbols-outlined" data-icon="receipt_long">receipt_long</span>
<span>Transactions</span>
</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="#">
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="/ui/settlement-batch-management">
<span class="material-symbols-outlined" data-icon="account_balance">account_balance</span>
<span>Ledger &amp; Settlement</span>
</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="#">
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2.5 rounded-lg" href="/ui/admin-system-audit-logs">
<span class="material-symbols-outlined" data-icon="history_edu">history_edu</span>
<span>Audit Control</span>
</a>
</nav>
<div class="mt-auto pt-6 border-t border-slate-100 dark:border-slate-800 space-y-1">
<button class="w-full bg-primary text-on-primary py-2.5 rounded-lg font-bold mb-4 flex items-center justify-center gap-2 hover:opacity-90 active:scale-95 transition-all">
<button id="dashboard-register-device" class="w-full bg-primary text-on-primary py-2.5 rounded-lg font-bold mb-4 flex items-center justify-center gap-2 hover:opacity-90 active:scale-95 transition-all">
<span class="material-symbols-outlined" data-icon="add">add</span>
Register New Device
</button>
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2 rounded-lg" href="#">
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2 rounded-lg" href="/ui/admin-fee-pricing-management">
<span class="material-symbols-outlined" data-icon="settings">settings</span>
<span>Settings</span>
</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2 rounded-lg" href="#">
<a class="text-on-surface-variant dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors flex items-center gap-3 px-3 py-2 rounded-lg" href="/ui/hub">
<span class="material-symbols-outlined" data-icon="help">help</span>
<span>Support</span>
</a>
</div>
</aside>
<!-- TopNavBar -->
<header class="fixed top-0 right-0 h-[72px] bg-surface-container-lowest dark:bg-slate-900 flex justify-between items-center w-[calc(100%-256px)] ml-64 px-page-padding z-40 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-6 flex-1">
<div class="relative w-full max-w-md">
<header class="fixed top-0 right-0 h-[72px] bg-surface-container-lowest dark:bg-slate-900 flex justify-between items-center w-full lg:w-[calc(100%-256px)] lg:ml-64 px-4 sm:px-page-padding z-40 border-b border-slate-200 dark:border-slate-700">
<div class="flex items-center gap-3 lg:gap-6 flex-1 min-w-0">
<div class="relative w-full max-w-md hidden sm:block">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant text-body-lg">search</span>
<input class="w-full pl-10 pr-4 py-2 bg-slate-100 dark:bg-slate-800 border-none rounded-full focus:ring-2 focus:ring-primary/20 text-body-md" placeholder="Search devices, merchants, or transactions..." type="text"/>
</div>
<div class="flex items-center gap-4">
<a class="text-primary dark:text-primary-fixed border-b-2 border-primary h-[72px] flex items-center px-2 font-bold" href="#">Dashboard</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:text-primary transition-colors h-[72px] flex items-center px-2" href="#">System Health</a>
<div class="flex items-center gap-4 overflow-x-auto whitespace-nowrap">
<a class="text-primary dark:text-primary-fixed border-b-2 border-primary h-[72px] flex items-center px-2 font-bold" href="/ui/admin-dashboard-overview">Dashboard</a>
<a class="text-on-surface-variant dark:text-slate-400 hover:text-primary transition-colors h-[72px] hidden md:flex items-center px-2" href="/ui/device-registry-monitoring">System Health</a>
</div>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 sm:gap-4 shrink-0">
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-on-surface-variant relative">
<span class="material-symbols-outlined" data-icon="notifications">notifications</span>
<span class="absolute top-2 right-2 w-2 h-2 bg-error rounded-full"></span>
@ -197,9 +197,9 @@
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-100 dark:hover:bg-slate-800 text-on-surface-variant">
<span class="material-symbols-outlined" data-icon="calendar_today">calendar_today</span>
</button>
<div class="h-8 w-[1px] bg-slate-200 dark:bg-slate-700 mx-2"></div>
<div class="h-8 w-[1px] bg-slate-200 dark:bg-slate-700 mx-2 hidden sm:block"></div>
<div class="flex items-center gap-3">
<div class="text-right">
<div class="text-right hidden sm:block">
<p class="font-bold text-body-md leading-none">Admin User</p>
<p class="text-label-md text-on-surface-variant opacity-70 leading-none mt-1">Super Administrator</p>
</div>
@ -208,14 +208,14 @@
</div>
</header>
<!-- Main Content Canvas -->
<main class="ml-64 pt-[72px] min-h-screen p-page-padding max-w-[1600px]">
<main class="lg:ml-64 pt-[72px] min-h-screen p-4 sm:p-page-padding max-w-[1600px]">
<!-- Dashboard Header & Welcome -->
<div class="mb-8 flex justify-between items-end">
<div class="mb-8 flex flex-col sm:flex-row justify-between items-start sm:items-end gap-4">
<div>
<h2 class="font-display-lg text-display-lg text-on-surface mb-1">Operational Overview</h2>
<h2 class="font-display-lg text-[28px] leading-9 sm:text-display-lg text-on-surface mb-1">Operational Overview</h2>
<p class="text-body-lg text-on-surface-variant">Real-time status of your QRIS soundbox ecosystem.</p>
</div>
<div class="flex gap-2">
<div class="flex gap-2 w-full sm:w-auto">
<button class="flex items-center gap-2 px-4 py-2 border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors font-bold text-body-md">
<span class="material-symbols-outlined text-[20px]" data-icon="filter_list">filter_list</span>
Filter View
@ -278,16 +278,16 @@
<p class="text-label-md font-label-md text-on-surface-variant uppercase tracking-wider mb-1">Success Rate</p>
<p id="kpi-success-rate" class="text-metric-lg font-metric-lg text-on-surface">99.2%</p>
</div>
<!-- Pending Settlements -->
<!-- Pending Payouts -->
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl hover:shadow-lg transition-shadow border-l-4 border-l-warning">
<div class="flex justify-between items-start mb-4">
<div class="p-2 bg-error/10 rounded-lg text-error">
<span class="material-symbols-outlined" data-icon="hourglass_empty">hourglass_empty</span>
<div class="p-2 bg-warning/10 rounded-lg text-warning">
<span class="material-symbols-outlined" data-icon="account_balance_wallet">account_balance_wallet</span>
</div>
<button class="text-primary font-bold text-label-md hover:underline">View All</button>
<a class="text-primary font-bold text-label-md hover:underline" href="/ui/settlement-batch-management">View All</a>
</div>
<p class="text-label-md font-label-md text-on-surface-variant uppercase tracking-wider mb-1">Pending Settlements</p>
<p id="kpi-pending-settlements" class="text-metric-lg font-metric-lg text-on-surface">24</p>
<p class="text-label-md font-label-md text-on-surface-variant uppercase tracking-wider mb-1">Pending Payouts</p>
<p id="kpi-pending-settlements" class="text-metric-lg font-metric-lg text-on-surface">Rp0</p>
</div>
</div>
<!-- Main Chart & Sidebar Rail Grid -->
@ -312,8 +312,7 @@
</div>
</div>
</div>
<!-- Chart Placeholder -->
<div class="h-[320px] w-full flex items-end gap-2 px-4">
<div id="transaction-trend-chart" class="h-[320px] w-full flex items-end gap-2 px-4">
<div class="flex-1 flex flex-col justify-end gap-1">
<div class="w-full bg-slate-100 rounded-t h-[40%] relative group">
<div class="absolute bottom-0 left-0 right-0 bg-primary/40 h-[70%] group-hover:bg-primary transition-colors cursor-pointer"></div>
@ -365,7 +364,7 @@
<h3 class="text-headline-md font-headline-md text-on-surface">Pending Merchant Onboarding</h3>
<p class="text-body-md text-on-surface-variant">New applications requiring review</p>
</div>
<button class="text-primary font-bold text-body-md hover:underline">View Full Queue</button>
<a class="text-primary font-bold text-body-md hover:underline" href="/ui/admin-onboarding-review-queue">View Full Queue</a>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse">
@ -488,73 +487,107 @@
</div>
</div>
</div>
<!-- Settlement / Finance Summary -->
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-headline-md font-headline-md text-on-surface">Settlement Finance</h3>
<a class="text-primary font-bold text-label-md hover:underline" href="/ui/settlement-batch-management">Open</a>
</div>
<div class="grid grid-cols-2 gap-3 text-body-md">
<div>
<p class="text-label-md text-on-surface-variant uppercase">Pending</p>
<p id="finance-pending-payout" class="font-bold text-warning">Rp0</p>
</div>
<div>
<p class="text-label-md text-on-surface-variant uppercase">Paid</p>
<p id="finance-paid-payout" class="font-bold text-success">Rp0</p>
</div>
<div>
<p class="text-label-md text-on-surface-variant uppercase">Fees</p>
<p id="finance-total-fees" class="font-bold">Rp0</p>
</div>
<div>
<p class="text-label-md text-on-surface-variant uppercase">Adjustment</p>
<p id="finance-adjustment-amount" class="font-bold">Rp0</p>
</div>
<div>
<p class="text-label-md text-on-surface-variant uppercase">Batches</p>
<p id="finance-batch-count" class="font-bold">0</p>
</div>
</div>
<div class="mt-4 pt-4 border-t border-slate-100">
<div class="flex items-center justify-between mb-2">
<p class="text-label-md text-on-surface-variant uppercase">Recent Batches</p>
<span id="finance-created-batches" class="text-label-md font-bold text-warning">0 open</span>
</div>
<div id="finance-recent-batches" class="space-y-2">
<div class="p-3 rounded-lg bg-slate-50 border border-slate-100 text-on-surface-variant text-label-md">Loading settlement batches...</div>
</div>
</div>
</div>
<!-- Dynamic QR Expiry Scheduler -->
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-headline-md font-headline-md text-on-surface">Dynamic QR Expiry</h3>
<span id="expiry-scheduler-badge" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-600">Loading</span>
</div>
<div class="grid grid-cols-2 gap-3 text-body-md">
<div>
<p class="text-label-md text-on-surface-variant uppercase">Interval</p>
<p id="expiry-scheduler-interval" class="font-bold">-</p>
</div>
<div>
<p class="text-label-md text-on-surface-variant uppercase">Limit</p>
<p id="expiry-scheduler-limit" class="font-bold">-</p>
</div>
<div>
<p class="text-label-md text-on-surface-variant uppercase">Last Run</p>
<p id="expiry-scheduler-last-run" class="font-bold">-</p>
</div>
<div>
<p class="text-label-md text-on-surface-variant uppercase">Expired</p>
<p id="expiry-scheduler-expired" class="font-bold">-</p>
</div>
</div>
<p id="expiry-scheduler-detail" class="mt-3 pt-3 border-t border-slate-100 text-label-md text-on-surface-variant">-</p>
</div>
<!-- MQTT Broker Status -->
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-headline-md font-headline-md text-on-surface">MQTT Broker</h3>
<span id="mqtt-status-badge" class="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold bg-slate-100 text-slate-600">Loading</span>
</div>
<div class="space-y-3 text-body-md">
<div class="flex justify-between gap-4">
<span class="text-on-surface-variant">Mode</span>
<span id="mqtt-mode" class="font-bold">-</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-on-surface-variant">Broker</span>
<span id="mqtt-broker-url" class="font-mono text-right text-[12px]">-</span>
</div>
<div class="flex justify-between gap-4">
<span class="text-on-surface-variant">Client</span>
<span id="mqtt-client-id" class="font-mono text-right text-[12px]">-</span>
</div>
<div class="pt-3 border-t border-slate-100">
<p class="text-label-md text-on-surface-variant uppercase mb-2">Last Message</p>
<p id="mqtt-last-message" class="font-mono text-[12px] text-slate-600 truncate">-</p>
</div>
</div>
</div>
<!-- Recent Alerts / Incidents -->
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl flex flex-col">
<div class="p-6 border-b border-slate-100 flex justify-between items-center">
<h3 class="text-headline-md font-headline-md text-on-surface">Recent Alerts</h3>
<span class="bg-error/10 text-error px-2 py-0.5 rounded text-label-md font-bold">2 Critical</span>
<span id="alert-critical-count" class="bg-error/10 text-error px-2 py-0.5 rounded text-label-md font-bold">0 Critical</span>
</div>
<div class="p-4 space-y-4 max-h-[480px] overflow-y-auto custom-scrollbar">
<!-- Alert Item -->
<div class="p-4 rounded-lg bg-error/5 border border-error/10">
<div class="flex gap-3">
<span class="material-symbols-outlined text-error" data-icon="error">error</span>
<div class="flex-1">
<p class="font-bold text-on-surface text-body-md">Terminal X-009 Offline</p>
<p class="text-label-md text-on-surface-variant mb-2">Location: Outlet Y (Sudirman Mall)</p>
<div class="flex items-center justify-between">
<span class="text-label-md text-slate-400">2 mins ago</span>
<button class="text-primary font-bold text-label-md">Dispatch Tech</button>
<div id="recent-alerts-list" class="p-4 space-y-4 max-h-[480px] overflow-y-auto custom-scrollbar">
<div class="p-4 rounded-lg bg-slate-50 border border-slate-100 text-on-surface-variant">Loading operational alerts...</div>
</div>
</div>
</div>
</div>
<!-- Alert Item -->
<div class="p-4 rounded-lg bg-warning/5 border border-warning/10">
<div class="flex gap-3">
<span class="material-symbols-outlined text-warning" data-icon="warning">warning</span>
<div class="flex-1">
<p class="font-bold text-on-surface text-body-md">Network Latency Spike</p>
<p class="text-label-md text-on-surface-variant mb-2">Impact: Cluster Jakarta Selatan</p>
<div class="flex items-center justify-between">
<span class="text-label-md text-slate-400">14 mins ago</span>
<button class="text-primary font-bold text-label-md">Investigate</button>
</div>
</div>
</div>
</div>
<!-- Alert Item -->
<div class="p-4 rounded-lg bg-error/5 border border-error/10">
<div class="flex gap-3">
<span class="material-symbols-outlined text-error" data-icon="error">error</span>
<div class="flex-1">
<p class="font-bold text-on-surface text-body-md">Repeated Auth Failure</p>
<p class="text-label-md text-on-surface-variant mb-2">Merchant: IndoFresh Mart #44</p>
<div class="flex items-center justify-between">
<span class="text-label-md text-slate-400">45 mins ago</span>
<button class="text-primary font-bold text-label-md">Lock Device</button>
</div>
</div>
</div>
</div>
<!-- Info Alert -->
<div class="p-4 rounded-lg bg-info/5 border border-info/10">
<div class="flex gap-3">
<span class="material-symbols-outlined text-info" data-icon="info">info</span>
<div class="flex-1">
<p class="font-bold text-on-surface text-body-md">New FW Update Available</p>
<p class="text-label-md text-on-surface-variant mb-2">Version 2.4.1 (Stable Build)</p>
<div class="flex items-center justify-between">
<span class="text-label-md text-slate-400">2 hours ago</span>
<button class="text-primary font-bold text-label-md">Deploy Now</button>
</div>
</div>
</div>
</div>
</div>
<button class="w-full py-4 text-on-surface-variant hover:bg-slate-50 transition-colors font-bold text-body-md border-t border-slate-100">
Clear All Notifications
</button>
<a class="w-full py-4 text-on-surface-variant hover:bg-slate-50 transition-colors font-bold text-body-md border-t border-slate-100 text-center" href="/ui/device-registry-monitoring">
Open Device Operations
</a>
</div>
</div>
</div>
@ -567,11 +600,8 @@
<span class="px-2 py-1 bg-success/20 text-success rounded text-[10px] font-bold uppercase tracking-widest">Operational</span>
</div>
</div>
<div class="font-mono text-[13px] space-y-2 opacity-80">
<p><span class="text-primary-fixed-dim">[14:32:11]</span> <span class="text-success">SUCCESS:</span> Settlement triggered for Cluster-B (Rp12.4M handled)</p>
<p><span class="text-primary-fixed-dim">[14:31:05]</span> <span class="text-info">INFO:</span> Device X-292 ping response received (latency 42ms)</p>
<p><span class="text-primary-fixed-dim">[14:29:44]</span> <span class="text-warning">WARN:</span> Merchant ID 9921 failed KYC validation step 3</p>
<p><span class="text-primary-fixed-dim">[14:28:12]</span> <span class="text-success">SUCCESS:</span> New Admin 'DevOps_Main' logged in via MFA</p>
<div id="audit-activity-stream" class="font-mono text-[13px] space-y-2 opacity-80">
<p><span class="text-info">INFO:</span> Loading audit events...</p>
</div>
<!-- Decorative backdrop for "raw data" feel -->
<div class="absolute -right-4 -bottom-4 opacity-10 pointer-events-none">
@ -582,134 +612,394 @@
<script src="/ui/shared/admin-api.js"></script>
<script>
const AdminDashboard = (() => {
const api = window.AdminUIAPI;
const merchantPendingBody = document.getElementById("pending-merchants-body");
const kpiTotalMerchants = document.getElementById("kpi-total-merchants");
const kpiDevicesOnline = document.getElementById("kpi-devices-online");
const kpiDevicesTotal = document.getElementById("kpi-devices-total");
const kpiTodaysVolume = document.getElementById("kpi-todays-volume");
const kpiSuccessRate = document.getElementById("kpi-success-rate");
const kpiPendingSettlements = document.getElementById("kpi-pending-settlements");
const healthPercent = document.getElementById("dashboard-health-percent");
const devicesOnline = document.getElementById("device-online-count");
const devicesStale = document.getElementById("device-stale-count");
const devicesOffline = document.getElementById("device-offline-count");
const api = window.AdminUIAPI;
const merchantPendingBody = document.getElementById("pending-merchants-body");
const kpiTotalMerchants = document.getElementById("kpi-total-merchants");
const kpiDevicesOnline = document.getElementById("kpi-devices-online");
const kpiDevicesTotal = document.getElementById("kpi-devices-total");
const kpiTodaysVolume = document.getElementById("kpi-todays-volume");
const kpiSuccessRate = document.getElementById("kpi-success-rate");
const kpiPendingSettlements = document.getElementById("kpi-pending-settlements");
const healthPercent = document.getElementById("dashboard-health-percent");
const devicesOnlineEl = document.getElementById("device-online-count");
const devicesStaleEl = document.getElementById("device-stale-count");
const devicesOfflineEl = document.getElementById("device-offline-count");
const financePendingPayout = document.getElementById("finance-pending-payout");
const financePaidPayout = document.getElementById("finance-paid-payout");
const financeTotalFees = document.getElementById("finance-total-fees");
const financeAdjustmentAmount = document.getElementById("finance-adjustment-amount");
const financeBatchCount = document.getElementById("finance-batch-count");
const financeCreatedBatches = document.getElementById("finance-created-batches");
const financeRecentBatches = document.getElementById("finance-recent-batches");
const chartHost = document.getElementById("transaction-trend-chart");
const alertsHost = document.getElementById("recent-alerts-list");
const alertCriticalCount = document.getElementById("alert-critical-count");
const auditStream = document.getElementById("audit-activity-stream");
const mqttBadge = document.getElementById("mqtt-status-badge");
const mqttMode = document.getElementById("mqtt-mode");
const mqttBrokerUrl = document.getElementById("mqtt-broker-url");
const mqttClientId = document.getElementById("mqtt-client-id");
const mqttLastMessage = document.getElementById("mqtt-last-message");
const expiryBadge = document.getElementById("expiry-scheduler-badge");
const expiryInterval = document.getElementById("expiry-scheduler-interval");
const expiryLimit = document.getElementById("expiry-scheduler-limit");
const expiryLastRun = document.getElementById("expiry-scheduler-last-run");
const expiryExpired = document.getElementById("expiry-scheduler-expired");
const expiryDetail = document.getElementById("expiry-scheduler-detail");
const toMerchantName = (merchant) =>
merchant?.brand_name || merchant?.legal_name || "Unknown Merchant";
const toMerchantName = (merchant) =>
merchant?.brand_name || merchant?.legal_name || "Unknown Merchant";
const normalize = (value) => String(value || "").toLowerCase();
const toDateLabel = (value) => {
if (!value) return "-";
return api.formatDateTime(value);
};
const todayRange = () => {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(start.getDate() + 1);
return { from: start.toISOString(), to: end.toISOString() };
};
const todayRange = () => {
const start = new Date();
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(start.getDate() + 1);
return { from: start.toISOString(), to: end.toISOString() };
};
const lastSevenDays = () => {
const days = [];
const start = new Date();
start.setHours(0, 0, 0, 0);
start.setDate(start.getDate() - 6);
for (let i = 0; i < 7; i += 1) {
const date = new Date(start);
date.setDate(start.getDate() + i);
days.push({
key: date.toISOString().slice(0, 10),
label: date.toLocaleDateString("en-GB", { weekday: "short" }),
amount: 0,
count: 0
});
}
return days;
};
const renderStatusBadge = (status, text) => {
if (status === "pending") {
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-warning/10 text-warning">PENDING</span>`;
const renderPendingMerchants = (merchants) => {
if (!merchantPendingBody) return;
const rows = merchants.slice(0, 5);
if (!rows.length) {
merchantPendingBody.innerHTML = '<tr><td class="px-6 py-4 text-center text-on-surface-variant" colspan="5">No pending onboarding merchants</td></tr>';
return;
}
merchantPendingBody.innerHTML = rows.map((merchant) => {
const name = toMerchantName(merchant);
const initials = name.substring(0, 2).toUpperCase();
return `
<tr class="hover:bg-slate-50 transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded bg-slate-100 flex items-center justify-center text-primary font-bold">${initials}</div>
<div>
<p class="font-bold text-on-surface">${name}</p>
<p class="text-label-md text-slate-400">ID: ${merchant.merchant_code || merchant.id}</p>
</div>
</div>
</td>
<td class="px-6 py-4 text-on-surface-variant">${merchant.category || "General"}</td>
<td class="px-6 py-4 text-on-surface-variant">${api.formatDateTime(merchant.updated_at || merchant.created_at)}</td>
<td class="px-6 py-4"><span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-warning/10 text-warning">PENDING</span></td>
<td class="px-6 py-4 text-right"><a class="text-primary font-bold hover:text-on-primary-fixed-variant transition-colors" href="/ui/admin-onboarding-review-queue">Review</a></td>
</tr>
`;
}).join("");
};
const updateStats = (summary, merchants, todayTransactions, devices) => {
const totalMerchants = merchants.length;
const online = Number(summary?.devices_online ?? summary?.active_devices ?? 0);
const degraded = Number(summary?.devices_degraded || 0);
const stale = Number(summary?.devices_stale || 0);
const offline = Number(summary?.devices_offline || 0);
const totalDevices = devices.length || online + degraded + stale + offline;
const successRate = Number(summary?.success_rate_today || 0);
const pendingSettlementAmount = Number(summary?.settlement_pending_amount || 0);
const paidToday = todayTransactions.filter((tx) => normalize(tx.status) === "paid");
const totalAmount = paidToday.reduce((acc, tx) => acc + Number(tx.amount || 0), 0);
const activePercent = totalDevices > 0 ? Math.round((online / totalDevices) * 100) : 0;
if (kpiTotalMerchants) kpiTotalMerchants.textContent = totalMerchants.toLocaleString("id-ID");
if (kpiDevicesOnline) kpiDevicesOnline.textContent = online.toLocaleString("id-ID");
if (kpiDevicesTotal) kpiDevicesTotal.textContent = totalDevices.toLocaleString("id-ID");
if (kpiTodaysVolume) kpiTodaysVolume.textContent = api.formatMoney(totalAmount);
if (kpiSuccessRate) kpiSuccessRate.textContent = `${successRate.toFixed(2)}%`;
if (kpiPendingSettlements) kpiPendingSettlements.textContent = api.formatMoney(pendingSettlementAmount);
if (healthPercent) healthPercent.textContent = `${activePercent}%`;
if (devicesOnlineEl) devicesOnlineEl.textContent = online.toLocaleString("id-ID");
if (devicesStaleEl) devicesStaleEl.textContent = (stale + degraded).toLocaleString("id-ID");
if (devicesOfflineEl) devicesOfflineEl.textContent = offline.toLocaleString("id-ID");
};
const renderTrend = (transactions) => {
if (!chartHost) return;
const buckets = lastSevenDays();
const bucketMap = new Map(buckets.map((bucket) => [bucket.key, bucket]));
transactions.forEach((tx) => {
const key = String(tx.created_at || "").slice(0, 10);
const bucket = bucketMap.get(key);
if (!bucket) return;
bucket.count += 1;
if (normalize(tx.status) === "paid") {
bucket.amount += Number(tx.amount || 0);
}
if (status === "inactive") {
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-danger/10 text-danger">INACTIVE</span>`;
});
const maxAmount = Math.max(...buckets.map((bucket) => bucket.amount), 1);
chartHost.innerHTML = buckets.map((bucket) => {
const height = Math.max(8, Math.round((bucket.amount / maxAmount) * 100));
return `
<div class="flex-1 flex flex-col justify-end gap-1 min-w-0">
<div class="w-full bg-slate-100 rounded-t h-[260px] relative group overflow-hidden">
<div class="absolute bottom-0 left-0 right-0 bg-primary/70 group-hover:bg-primary transition-colors cursor-pointer" style="height:${height}%"></div>
<div class="absolute inset-x-1 bottom-2 text-center text-[10px] font-bold text-slate-700 opacity-0 group-hover:opacity-100 transition-opacity">${api.formatMoney(bucket.amount)}</div>
</div>
<span class="text-center text-label-md text-slate-400">${bucket.label}</span>
<span class="text-center text-[10px] text-slate-400">${bucket.count} tx</span>
</div>
`;
}).join("");
};
const renderMqttStatus = (status) => {
if (!status) return;
const publisher = status.publisher || status;
const connected = publisher.connected === true;
const mode = publisher.mode || "simulator";
if (mqttMode) mqttMode.textContent = mode.toUpperCase();
if (mqttBrokerUrl) mqttBrokerUrl.textContent = publisher.broker_url || "-";
if (mqttClientId) mqttClientId.textContent = publisher.client_id || "-";
if (mqttBadge) {
mqttBadge.textContent = mode === "broker" ? (connected ? "Connected" : "Configured") : "Simulator";
mqttBadge.className = `inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold ${connected ? "bg-success/10 text-success" : mode === "broker" ? "bg-warning/10 text-warning" : "bg-slate-100 text-slate-600"}`;
}
const latest = Array.isArray(status.last_messages) ? status.last_messages[0] : null;
if (mqttLastMessage) {
mqttLastMessage.textContent = latest ? `${latest.publish_status} ${latest.message_type} -> ${latest.topic}` : "No MQTT trace yet";
}
};
const renderExpiryScheduler = (status) => {
if (!status) return;
const enabled = status.enabled === true;
const running = status.running === true;
if (expiryBadge) {
expiryBadge.textContent = running ? "Running" : enabled ? "Enabled" : "Disabled";
expiryBadge.className = `inline-flex items-center px-2.5 py-1 rounded-full text-xs font-bold ${enabled ? "bg-success/10 text-success" : "bg-slate-100 text-slate-600"}`;
}
if (expiryInterval) expiryInterval.textContent = `${Math.round(Number(status.interval_ms || 0) / 1000)}s`;
if (expiryLimit) expiryLimit.textContent = String(status.limit || "-");
if (expiryLastRun) expiryLastRun.textContent = api.formatDateTime(status.last_finished_at || status.last_started_at);
if (expiryExpired) expiryExpired.textContent = String(status.last_result?.expired_count ?? 0);
if (expiryDetail) {
const scanned = status.last_result?.scanned ?? 0;
const skipped = status.last_result?.skipped_count ?? 0;
expiryDetail.textContent = status.last_error
? `Last error: ${status.last_error}`
: `Last sweep scanned ${scanned} dynamic transaction(s), skipped ${skipped}.`;
}
};
const renderFinanceSummary = (summary, batches) => {
const rows = Array.isArray(batches) ? batches : [];
const pendingAmount = Number(summary?.settlement_pending_amount || 0);
const paidAmount = Number(summary?.settlement_paid_amount || 0);
const adjustedPaidAmount = Number(summary?.settlement_adjusted_paid_amount ?? paidAmount);
const adjustmentAmount = Number(summary?.settlement_adjustment_amount || 0);
const feeAmount = Number(summary?.settlement_platform_fee_amount || 0);
const createdCount = Number(summary?.settlement_created_batches || 0);
const paidCount = Number(summary?.settlement_paid_batches || 0);
const totalCount = Number(summary?.settlement_total_batches || rows.length || 0);
if (financePendingPayout) financePendingPayout.textContent = api.formatMoney(pendingAmount);
if (financePaidPayout) financePaidPayout.textContent = api.formatMoney(adjustedPaidAmount);
if (financeTotalFees) financeTotalFees.textContent = api.formatMoney(feeAmount);
if (financeAdjustmentAmount) {
financeAdjustmentAmount.textContent = api.formatMoney(adjustmentAmount);
financeAdjustmentAmount.className = `font-bold ${adjustmentAmount < 0 ? "text-danger" : adjustmentAmount > 0 ? "text-success" : ""}`;
}
if (financeBatchCount) financeBatchCount.textContent = `${totalCount.toLocaleString("id-ID")} total`;
if (financeCreatedBatches) financeCreatedBatches.textContent = `${createdCount.toLocaleString("id-ID")} open`;
if (!financeRecentBatches) return;
if (!rows.length) {
financeRecentBatches.innerHTML = '<div class="p-3 rounded-lg bg-slate-50 border border-slate-100 text-on-surface-variant text-label-md">No settlement batches yet</div>';
return;
}
financeRecentBatches.innerHTML = rows.slice(0, 4).map((batch) => {
const isPaid = normalize(batch.status) === "paid";
const badgeClass = isPaid ? "bg-success/10 text-success" : "bg-warning/10 text-warning";
return `
<div class="flex items-center justify-between gap-3 p-3 rounded-lg border border-slate-100 bg-slate-50">
<div class="min-w-0">
<p class="font-bold text-on-surface text-label-md truncate">${batch.batch_code || batch.id}</p>
<p class="text-[11px] text-on-surface-variant">${api.formatDateTime(batch.created_at)} · ${Number(batch.entry_count || 0).toLocaleString("id-ID")} item</p>
</div>
<div class="text-right shrink-0">
<p class="font-bold text-label-md">${api.formatMoney(batch.net_payable_amount || 0)}</p>
<div class="flex items-center justify-end gap-2 mt-1">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold ${badgeClass}">${String(batch.status || "-").toUpperCase()}</span>
<button class="finance-export-batch text-primary text-[11px] font-bold hover:underline" data-batch-id="${batch.id}" data-batch-code="${batch.batch_code || batch.id}">CSV</button>
</div>
</div>
</div>
`;
}).join("");
financeRecentBatches.querySelectorAll(".finance-export-batch").forEach((button) => {
button.addEventListener("click", () => downloadSettlementCsv(button.dataset.batchId, button.dataset.batchCode));
});
};
const downloadSettlementCsv = async (batchId, batchCode) => {
if (!batchId) return;
const token = api.requireToken();
const response = await fetch(`/admin/settlement-batches/${encodeURIComponent(batchId)}/export.csv`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error(`CSV download failed with status ${response.status}`);
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${batchCode || batchId}-payout-report.csv`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
};
const alertCard = ({ severity, icon, title, detail, time, actionHref }) => {
const styles = severity === "critical"
? { box: "bg-error/5 border-error/10", icon: "text-error" }
: severity === "warning"
? { box: "bg-warning/5 border-warning/10", icon: "text-warning" }
: { box: "bg-info/5 border-info/10", icon: "text-info" };
return `
<div class="p-4 rounded-lg ${styles.box} border">
<div class="flex gap-3">
<span class="material-symbols-outlined ${styles.icon}">${icon}</span>
<div class="flex-1">
<p class="font-bold text-on-surface text-body-md">${title}</p>
<p class="text-label-md text-on-surface-variant mb-2">${detail}</p>
<div class="flex items-center justify-between">
<span class="text-label-md text-slate-400">${time}</span>
<a class="text-primary font-bold text-label-md" href="${actionHref || "/ui/device-registry-monitoring"}">Open</a>
</div>
</div>
</div>
</div>
`;
};
const renderAlerts = ({ devices, failedNotifications, mqttStatus }) => {
if (!alertsHost) return;
const alerts = [];
const publisherStatus = mqttStatus?.publisher || mqttStatus || {};
devices
.filter((device) => ["offline", "stale", "degraded"].includes(normalize(device.health_summary?.status || device.derived_status)))
.slice(0, 4)
.forEach((device) => {
const status = normalize(device.health_summary?.status || device.derived_status);
alerts.push({
severity: status === "offline" ? "critical" : "warning",
icon: status === "offline" ? "error" : "warning",
title: `${device.device_code || device.id} ${status.toUpperCase()}`,
detail: (device.health_summary?.reasons || []).join(", ") || `Last heartbeat: ${api.formatDateTime(device.latest_heartbeat?.timestamp)}`,
time: typeof device.health_summary?.age_seconds === "number" ? `${device.health_summary.age_seconds}s age` : "Device health",
actionHref: `/ui/device-technical-detail?device_id=${device.id}`
});
});
failedNotifications.slice(0, 3).forEach((notification) => {
alerts.push({
severity: "critical",
icon: "notifications_off",
title: "Failed notification",
detail: `${notification.transaction_id || "-"} ${notification.reason || ""}`,
time: api.formatDateTime(notification.created_at),
actionHref: "/ui/transaction-history-monitoring"
});
});
if (publisherStatus?.mode === "broker" && publisherStatus.connected !== true) {
alerts.push({
severity: "warning",
icon: "cell_tower",
title: "MQTT broker configured",
detail: "No active publisher session yet. It will connect on first publish.",
time: publisherStatus.broker_url || "broker mode",
actionHref: "/ui/device-registry-monitoring"
});
}
if (publisherStatus?.forced_fail_all || Number(publisherStatus?.forced_fail_device_count || 0) > 0) {
alerts.push({
severity: "critical",
icon: "error",
title: "MQTT forced failure enabled",
detail: `${publisherStatus.forced_fail_device_count || 0} device-specific forced failure rules`,
time: "Publisher config",
actionHref: "/ui/device-registry-monitoring"
});
}
const critical = alerts.filter((item) => item.severity === "critical").length;
if (alertCriticalCount) alertCriticalCount.textContent = `${critical} Critical`;
alertsHost.innerHTML = alerts.length
? alerts.slice(0, 8).map(alertCard).join("")
: '<div class="p-4 rounded-lg bg-success/5 border border-success/10 text-success font-bold">No active operational alerts</div>';
};
const renderAuditStream = (logs) => {
if (!auditStream) return;
const rows = (Array.isArray(logs) ? logs : []).slice(0, 6);
if (!rows.length) {
auditStream.innerHTML = '<p><span class="text-info">INFO:</span> No audit events available</p>';
return;
}
auditStream.innerHTML = rows.map((entry) => {
const action = entry.action || "audit.event";
const isSuccess = /create|approve|paid|ack|retry|bind|update|patch/.test(action);
return `<p><span class="text-primary-fixed-dim">[${api.formatDateTime(entry.created_at || entry.timestamp)}]</span> <span class="${isSuccess ? "text-success" : "text-info"}">${isSuccess ? "EVENT" : "INFO"}:</span> ${action} ${entry.entity_type || ""} ${entry.entity_id || ""}</p>`;
}).join("");
};
const load = async () => {
try {
api.requireToken();
const { from, to } = todayRange();
const [summary, merchants, devices, todayTx, allTx, failedNotifications, mqttStatus, expiryStatus, auditLogs, settlementBatches] = await Promise.all([
api.getDashboardSummary(),
api.listMerchants(),
api.listDevices(),
api.listTransactions({ from, to }),
api.listTransactions(),
api.listFailedNotifications({ limit: 10 }),
api.getMqttStatus({ limit: 5 }),
api.getDynamicQrExpiryScheduler(),
api.listAuditLogs({ limit: 6 }),
api.listSettlementBatches({ limit: 6 })
]);
renderPendingMerchants((merchants || []).filter((merchant) => merchant.onboarding_status === "pending"));
updateStats(summary, merchants || [], todayTx || [], devices || []);
renderFinanceSummary(summary, settlementBatches || []);
renderTrend(allTx || []);
renderMqttStatus(mqttStatus);
renderExpiryScheduler(expiryStatus);
renderAlerts({ devices: devices || [], failedNotifications: failedNotifications || [], mqttStatus });
renderAuditStream(auditLogs || []);
} catch (error) {
console.error("[admin-dashboard] failed loading data", error);
if (alertsHost) {
alertsHost.innerHTML = '<div class="p-4 rounded-lg bg-error/5 border border-error/10 text-error font-bold">Unable to load dashboard data</div>';
}
return `<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-bold bg-success/10 text-success">ACTIVE</span>`;
};
}
};
const renderPendingMerchants = (merchants) => {
if (!merchantPendingBody) return;
if (!merchants.length) {
merchantPendingBody.innerHTML = `
<tr>
<td class="px-6 py-4 text-center text-on-surface-variant" colspan="5">
No pending onboarding merchants
</td>
</tr>
`;
return;
}
merchantPendingBody.innerHTML = merchants
.map((merchant) => {
const initials = (toMerchantName(merchant) || "").substring(0, 2).toUpperCase();
const created = toDateLabel(merchant.updated_at);
const status = merchant.onboarding_status || "approved";
return `
<tr class="hover:bg-slate-50 transition-colors group">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-8 h-8 rounded bg-slate-100 flex items-center justify-center text-primary font-bold">${initials}</div>
<div>
<p class="font-bold text-on-surface">${toMerchantName(merchant)}</p>
<p class="text-label-md text-slate-400">ID: ${merchant.merchant_code || merchant.id}</p>
</div>
</div>
</td>
<td class="px-6 py-4 text-on-surface-variant">Retail</td>
<td class="px-6 py-4 text-on-surface-variant">${created}</td>
<td class="px-6 py-4">${renderStatusBadge(status)}</td>
<td class="px-6 py-4 text-right">
<button class="text-primary font-bold hover:text-on-primary-fixed-variant transition-colors">Review</button>
</td>
</tr>
`;
})
.join("");
};
const updateStats = (summary, merchants, todayTransactions) => {
const totalMerchants = merchants.length;
const devicesOnlineTotal = Number(summary?.active_devices || 0);
const devicesStale = Number(summary?.devices_stale || 0);
const devicesOffline = Number(summary?.devices_offline || 0);
const devicesTotal = devicesOnlineTotal + devicesStale + devicesOffline;
const successRate = Number(summary?.success_rate_today || 0);
const pendingNotifications = Number(summary?.pending_notifications || 0);
const totalAmount = todayTransactions.reduce((acc, tx) => acc + Number(tx.amount || 0), 0);
const activePercent = devicesTotal > 0
? Math.round((devicesOnlineTotal / devicesTotal) * 100)
: 0;
if (kpiTotalMerchants) kpiTotalMerchants.textContent = totalMerchants.toLocaleString("id-ID");
if (kpiDevicesOnline) kpiDevicesOnline.textContent = devicesOnlineTotal.toLocaleString("id-ID");
if (kpiDevicesTotal) kpiDevicesTotal.textContent = devicesTotal.toLocaleString("id-ID");
if (kpiTodaysVolume) kpiTodaysVolume.textContent = api.formatMoney(totalAmount);
if (kpiSuccessRate) kpiSuccessRate.textContent = `${successRate.toFixed(2)}%`;
if (kpiPendingSettlements) kpiPendingSettlements.textContent = pendingNotifications.toLocaleString("id-ID");
if (healthPercent) healthPercent.textContent = `${activePercent}%`;
if (devicesOnline) devicesOnline.textContent = devicesOnlineTotal.toLocaleString("id-ID");
if (devicesStale) devicesStale.textContent = devicesStale.toLocaleString("id-ID");
if (devicesOffline) devicesOffline.textContent = devicesOffline.toLocaleString("id-ID");
};
const load = async () => {
try {
api.requireToken();
const { from, to } = todayRange();
const [summary, merchants, todayTx, allTx] = await Promise.all([
api.getDashboardSummary(),
api.listMerchants(),
api.listTransactions({ from, to }),
api.listTransactions()
]);
const pendingMerchants = merchants.filter((merchant) => merchant.onboarding_status === "pending");
renderPendingMerchants(pendingMerchants);
const normalizedAllTx = allTx.map((tx) => tx);
updateStats(summary, merchants, todayTx);
return normalizedAllTx;
} catch (error) {
console.error("[admin-dashboard] failed loading data", error);
}
};
return { load };
return { load };
})();
AdminDashboard.load();