Production readiness hardening and ops tooling
This commit is contained in:
@ -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 & 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();
|
||||
|
||||
Reference in New Issue
Block a user