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();
|
||||
|
||||
@ -130,27 +130,27 @@
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold transition-all scale-98 active:opacity-80" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold transition-all scale-98 active:opacity-80" href="/ui/admin-reconciliation-management">
|
||||
<span class="material-symbols-outlined" data-icon="account_balance_wallet">account_balance_wallet</span>
|
||||
<span class="font-label-md text-label-md">Reconciliation</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined" data-icon="security">security</span>
|
||||
<span class="font-label-md text-label-md">Audit Logs</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined" data-icon="payments">payments</span>
|
||||
<span class="font-label-md text-label-md">Fee Management</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/settlement-batch-management">
|
||||
<span class="material-symbols-outlined" data-icon="receipt_long">receipt_long</span>
|
||||
<span class="font-label-md text-label-md">Settlements</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/device-technical-detail">
|
||||
<span class="material-symbols-outlined" data-icon="router">router</span>
|
||||
<span class="font-label-md text-label-md">Device Health</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all scale-98 active:opacity-80" href="/ui/hub">
|
||||
<span class="material-symbols-outlined" data-icon="contact_support">contact_support</span>
|
||||
<span class="font-label-md text-label-md">Support</span>
|
||||
</a>
|
||||
@ -161,7 +161,7 @@
|
||||
<span class="font-label-md text-label-md">Generate Report</span>
|
||||
</button>
|
||||
<div class="h-px bg-slate-200"></div>
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-danger hover:bg-red-50 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-3 text-danger hover:bg-red-50 rounded-xl transition-all" href="/ui/admin-login">
|
||||
<span class="material-symbols-outlined" data-icon="logout">logout</span>
|
||||
<span class="font-label-md text-label-md">Logout</span>
|
||||
</a>
|
||||
@ -174,10 +174,10 @@
|
||||
<div class="flex items-center gap-8">
|
||||
<h2 class="font-headline-md text-headline-md font-bold text-primary dark:text-inverse-primary">Reconciliation</h2>
|
||||
<nav class="hidden lg:flex items-center gap-6">
|
||||
<a class="text-primary dark:text-inverse-primary border-b-2 border-primary font-bold pb-1 transition-colors" href="#">Dashboard</a>
|
||||
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="#">Merchants</a>
|
||||
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="#">Operations</a>
|
||||
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="#">Audit</a>
|
||||
<a class="text-primary dark:text-inverse-primary border-b-2 border-primary font-bold pb-1 transition-colors" href="/ui/admin-dashboard-overview">Dashboard</a>
|
||||
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="/ui/merchant-detail-view">Merchants</a>
|
||||
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="/ui/settlement-batch-management">Operations</a>
|
||||
<a class="text-on-surface-variant dark:text-slate-400 pb-1 hover:text-primary transition-colors" href="/ui/admin-reconciliation-management">Audit</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
@ -211,7 +211,7 @@
|
||||
<span class="material-symbols-outlined text-success" data-icon="check_circle">check_circle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-metric-lg text-metric-lg tabular-nums">42,892</div>
|
||||
<div id="recon-total-matched" class="font-metric-lg text-metric-lg tabular-nums">-</div>
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-success text-[16px]" data-icon="trending_up">trending_up</span>
|
||||
<span class="font-metric-sm text-metric-sm text-success">+12.4%</span>
|
||||
@ -225,7 +225,7 @@
|
||||
<span class="material-symbols-outlined text-danger" data-icon="error_outline">error_outline</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-metric-lg text-metric-lg tabular-nums">148</div>
|
||||
<div id="recon-discrepancies" class="font-metric-lg text-metric-lg tabular-nums">-</div>
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-danger text-[16px]" data-icon="trending_down">trending_down</span>
|
||||
<span class="font-metric-sm text-metric-sm text-danger">-2.1%</span>
|
||||
@ -239,7 +239,7 @@
|
||||
<span class="material-symbols-outlined text-warning" data-icon="hourglass_empty">hourglass_empty</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-metric-lg text-metric-lg tabular-nums">1,024</div>
|
||||
<div id="recon-total-batches" class="font-metric-lg text-metric-lg tabular-nums">-</div>
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-slate-400 text-[16px]" data-icon="history">history</span>
|
||||
<span class="font-metric-sm text-metric-sm text-slate-600">Avg 4h processing</span>
|
||||
@ -252,7 +252,7 @@
|
||||
<span class="material-symbols-outlined text-info" data-icon="account_balance">account_balance</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="font-metric-lg text-metric-lg tabular-nums">842</div>
|
||||
<div id="recon-issue-count" class="font-metric-lg text-metric-lg tabular-nums">-</div>
|
||||
<div class="mt-2 flex items-center gap-1">
|
||||
<span class="material-symbols-outlined text-success text-[16px]" data-icon="cloud_done">cloud_done</span>
|
||||
<span class="font-metric-sm text-metric-sm text-slate-600">12 API Connections</span>
|
||||
@ -294,111 +294,24 @@
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr class="bg-slate-50 border-b border-slate-200 sticky top-0 z-10">
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Transaction Details</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-primary/5">System Record (Internal)</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-info/5">Bank Record (External)</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Variance</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Batch Details</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-primary/5">Batch Aggregate</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider bg-info/5">Ledger Computed</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Issues</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-4 font-label-md text-label-md text-slate-500 uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<!-- Row 1: Matched -->
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-bold text-on-surface">TXN-90283471</p>
|
||||
<p class="text-[12px] text-slate-400">Oct 24, 2023 • 14:22:10</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 14,500.00</td>
|
||||
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 14,500.00</td>
|
||||
<td class="px-6 py-4 tabular-nums text-slate-400">0.00</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 bg-success/10 text-success text-[12px] font-bold rounded-full">MATCHED</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="p-2 hover:bg-slate-100 rounded-lg text-slate-400">
|
||||
<span class="material-symbols-outlined" data-icon="more_vert">more_vert</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 2: Discrepancy -->
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-bold text-on-surface">TXN-88273412</p>
|
||||
<p class="text-[12px] text-slate-400">Oct 24, 2023 • 11:05:45</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 8,240.50</td>
|
||||
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 8,245.50</td>
|
||||
<td class="px-6 py-4 tabular-nums text-danger font-bold">- 5.00</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 bg-danger/10 text-danger text-[12px] font-bold rounded-full">EXCEPTION</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="px-3 py-1 bg-primary text-white text-[12px] font-bold rounded hover:bg-primary/90 transition-colors">
|
||||
Resolve
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 3: Pending -->
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-bold text-on-surface">TXN-90112456</p>
|
||||
<p class="text-[12px] text-slate-400">Oct 23, 2023 • 23:18:02</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 1,20,000.00</td>
|
||||
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium italic text-slate-400">Not Found</td>
|
||||
<td class="px-6 py-4 tabular-nums text-slate-400">Pending</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 bg-warning/10 text-warning text-[12px] font-bold rounded-full">PENDING</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="p-2 hover:bg-slate-100 rounded-lg text-slate-400">
|
||||
<span class="material-symbols-outlined" data-icon="refresh">refresh</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 4: Matched -->
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-bold text-on-surface">TXN-90283472</p>
|
||||
<p class="text-[12px] text-slate-400">Oct 23, 2023 • 18:45:30</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 450.00</td>
|
||||
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 450.00</td>
|
||||
<td class="px-6 py-4 tabular-nums text-slate-400">0.00</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 bg-success/10 text-success text-[12px] font-bold rounded-full">MATCHED</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="p-2 hover:bg-slate-100 rounded-lg text-slate-400">
|
||||
<span class="material-symbols-outlined" data-icon="more_vert">more_vert</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 5: Exception (Fee Miscalc) -->
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-bold text-on-surface">TXN-90283478</p>
|
||||
<p class="text-[12px] text-slate-400">Oct 23, 2023 • 16:12:11</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">₹ 22,000.00</td>
|
||||
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">₹ 21,560.00</td>
|
||||
<td class="px-6 py-4 tabular-nums text-danger font-bold">- 440.00</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 bg-danger/10 text-danger text-[12px] font-bold rounded-full">EXCEPTION</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<button class="px-3 py-1 bg-primary text-white text-[12px] font-bold rounded hover:bg-primary/90 transition-colors">
|
||||
Resolve
|
||||
</button>
|
||||
</td>
|
||||
<tbody id="recon-mismatch-rows" class="divide-y divide-slate-100">
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-slate-500">Loading settlement reconciliation...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Table Pagination/Footer -->
|
||||
<div class="px-6 py-4 bg-slate-50 border-t border-slate-200 flex items-center justify-between">
|
||||
<p class="text-label-md text-slate-500">Showing 1 to 5 of 42,892 entries</p>
|
||||
<p id="recon-footer" class="text-label-md text-slate-500">Loading reconciliation report...</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="w-8 h-8 flex items-center justify-center rounded border border-slate-200 bg-white text-slate-400 hover:bg-slate-50 disabled:opacity-50" disabled="">
|
||||
<span class="material-symbols-outlined text-[18px]" data-icon="chevron_left">chevron_left</span>
|
||||
@ -427,7 +340,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="font-mono text-[13px] text-success leading-relaxed h-[200px] overflow-y-auto hide-scrollbar">
|
||||
<pre>{
|
||||
<pre id="recon-raw-payload">{
|
||||
"reconciliation_id": "RECON-00492-AX",
|
||||
"timestamp": "2023-10-24T14:22:10.452Z",
|
||||
"system_ledger": {
|
||||
@ -450,27 +363,47 @@
|
||||
</div>
|
||||
<!-- Verification Timeline -->
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-6">
|
||||
<h3 class="font-headline-md text-[16px] mb-6">Recent Resolution Activity</h3>
|
||||
<div class="space-y-6">
|
||||
<div class="relative pl-8">
|
||||
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-success ring-4 ring-success/10 z-10"></div>
|
||||
<div class="absolute left-1.5 top-5 w-[2px] h-full bg-slate-100"></div>
|
||||
<p class="font-bold text-on-surface leading-none mb-1">TXN-88273412 Resolved</p>
|
||||
<p class="text-[12px] text-slate-500 mb-1">Manual match confirmed by Admin A12</p>
|
||||
<p class="text-[11px] text-slate-400">12 minutes ago</p>
|
||||
<div class="flex items-start justify-between gap-3 mb-6">
|
||||
<div>
|
||||
<h3 class="font-headline-md text-[16px]">Recent Adjustment Activity</h3>
|
||||
<p id="recon-adjustment-summary" class="text-[12px] text-slate-500 mt-1">Loading finance adjustment report...</p>
|
||||
</div>
|
||||
<div class="relative pl-8">
|
||||
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-primary ring-4 ring-primary/10 z-10"></div>
|
||||
<div class="absolute left-1.5 top-5 w-[2px] h-full bg-slate-100"></div>
|
||||
<p class="font-bold text-on-surface leading-none mb-1">Report Exported</p>
|
||||
<p class="text-[12px] text-slate-500 mb-1">Full Oct report generated (PDF)</p>
|
||||
<p class="text-[11px] text-slate-400">45 minutes ago</p>
|
||||
<button id="download-adjustment-report" data-admin-permission="settlement:export" class="w-9 h-9 flex items-center justify-center rounded-lg border border-slate-200 text-warning hover:bg-warning/10 transition-colors" title="Download adjustment CSV">
|
||||
<span class="material-symbols-outlined" data-icon="download">download</span>
|
||||
</button>
|
||||
</div>
|
||||
<p id="adjustment-export-status" class="hidden text-[11px] text-slate-500 mb-4"></p>
|
||||
<div id="adjustment-export-history" class="hidden mb-6 border border-slate-100 rounded-lg divide-y divide-slate-100 overflow-hidden"></div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-5 gap-2 mb-6">
|
||||
<div>
|
||||
<input id="adjustment-merchant-filter" class="w-full border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary" list="adjustment-merchant-options" placeholder="Search merchant" type="text"/>
|
||||
<datalist id="adjustment-merchant-options"></datalist>
|
||||
<p id="adjustment-merchant-hint" class="mt-1 text-[11px] text-slate-400">Loading merchants...</p>
|
||||
</div>
|
||||
<select id="adjustment-type-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary bg-white">
|
||||
<option value="">All types</option>
|
||||
<option value="credit">Credit</option>
|
||||
<option value="debit">Debit</option>
|
||||
</select>
|
||||
<select id="adjustment-approval-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary bg-white">
|
||||
<option value="">All approval</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
<input id="adjustment-from-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary" type="date"/>
|
||||
<input id="adjustment-to-filter" class="border border-slate-200 rounded-lg px-3 py-2 text-[12px] focus:ring-primary focus:border-primary" type="date"/>
|
||||
</div>
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button id="apply-adjustment-filter" class="flex-1 px-3 py-2 bg-primary text-white rounded-lg text-[12px] font-bold hover:bg-primary/90 transition-colors">Apply Filter</button>
|
||||
<button id="clear-adjustment-filter" class="px-3 py-2 border border-slate-200 rounded-lg text-[12px] font-bold hover:bg-slate-50 transition-colors">Clear</button>
|
||||
</div>
|
||||
<div id="recon-adjustment-activity" class="space-y-6">
|
||||
<div class="relative pl-8">
|
||||
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-slate-300 z-10"></div>
|
||||
<p class="font-bold text-on-surface leading-none mb-1">HSBC Sync Completed</p>
|
||||
<p class="text-[12px] text-slate-500 mb-1">2,400 statements fetched via API</p>
|
||||
<p class="text-[11px] text-slate-400">1 hour ago</p>
|
||||
<p class="font-bold text-on-surface leading-none mb-1">Loading adjustments</p>
|
||||
<p class="text-[12px] text-slate-500 mb-1">Reading settlement adjustment ledger</p>
|
||||
<p class="text-[11px] text-slate-400">Please wait</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -523,7 +456,425 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/shared/admin-api.js"></script>
|
||||
<script>
|
||||
const api = window.AdminUIAPI;
|
||||
const numberFormatter = new Intl.NumberFormat('id-ID');
|
||||
let currentAdjustmentQuery = { limit: 5 };
|
||||
let merchants = [];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
return api.formatMoney(value);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
return api.formatDateTime(value);
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.textContent = value;
|
||||
}
|
||||
}
|
||||
|
||||
function setExportStatus(message, tone = 'muted') {
|
||||
const el = document.getElementById('adjustment-export-status');
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.textContent = message || '';
|
||||
el.classList.toggle('hidden', !message);
|
||||
el.classList.toggle('text-danger', tone === 'danger');
|
||||
el.classList.toggle('text-success', tone === 'success');
|
||||
el.classList.toggle('text-primary', tone === 'active');
|
||||
el.classList.toggle('text-slate-500', tone === 'muted');
|
||||
}
|
||||
|
||||
function dateToIsoStart(value) {
|
||||
return value ? `${value}T00:00:00.000Z` : undefined;
|
||||
}
|
||||
|
||||
function dateToIsoEnd(value) {
|
||||
return value ? `${value}T23:59:59.999Z` : undefined;
|
||||
}
|
||||
|
||||
function buildAdjustmentQuery(limit = 5) {
|
||||
const merchantInput = document.getElementById('adjustment-merchant-filter')?.value.trim();
|
||||
const matchedMerchant = merchants.find((merchant) =>
|
||||
merchantInput &&
|
||||
[merchant.id, merchant.merchant_code, merchant.brand_name, merchant.legal_name]
|
||||
.filter(Boolean)
|
||||
.some((value) => String(value).toLowerCase() === merchantInput.toLowerCase())
|
||||
);
|
||||
const merchantId = matchedMerchant?.id || merchantInput;
|
||||
const adjustmentType = document.getElementById('adjustment-type-filter')?.value;
|
||||
const approvalStatus = document.getElementById('adjustment-approval-filter')?.value;
|
||||
const from = document.getElementById('adjustment-from-filter')?.value;
|
||||
const to = document.getElementById('adjustment-to-filter')?.value;
|
||||
return {
|
||||
limit,
|
||||
merchant_id: merchantId || undefined,
|
||||
adjustment_type: adjustmentType || undefined,
|
||||
approval_status: approvalStatus || undefined,
|
||||
from: dateToIsoStart(from),
|
||||
to: dateToIsoEnd(to)
|
||||
};
|
||||
}
|
||||
|
||||
function merchantLabel(merchant) {
|
||||
return merchant?.brand_name || merchant?.legal_name || merchant?.merchant_code || merchant?.id || 'Merchant';
|
||||
}
|
||||
|
||||
function exportStatusClass(status) {
|
||||
if (status === 'completed') return 'bg-success/10 text-success';
|
||||
if (status === 'failed') return 'bg-danger/10 text-danger';
|
||||
if (status === 'running') return 'bg-primary/10 text-primary';
|
||||
return 'bg-slate-100 text-slate-500';
|
||||
}
|
||||
|
||||
function renderExportHistory(jobs) {
|
||||
const container = document.getElementById('adjustment-export-history');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const rows = jobs || [];
|
||||
container.classList.toggle('hidden', rows.length === 0);
|
||||
container.innerHTML = rows.map((job) => `
|
||||
<div class="flex items-center justify-between gap-3 px-3 py-2 bg-white">
|
||||
<div class="min-w-0">
|
||||
<p class="text-[12px] font-bold text-on-surface truncate">${escapeHtml(job.result_filename || job.id)}</p>
|
||||
<p class="text-[11px] text-slate-400">${formatDateTime(job.created_at)} • ${job.result_size_bytes ? numberFormatter.format(job.result_size_bytes) + ' bytes' : 'waiting'}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<span class="px-2 py-1 rounded text-[10px] font-bold uppercase ${exportStatusClass(job.status)}">${escapeHtml(job.status)}</span>
|
||||
${job.download_url ? `<button data-export-download-id="${escapeHtml(job.id)}" class="w-8 h-8 flex items-center justify-center rounded border border-slate-200 text-primary hover:bg-primary/5" title="Download export"><span class="material-symbols-outlined text-[18px]">download</span></button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function loadExportHistory() {
|
||||
try {
|
||||
const jobs = await api.listExportJobs({ job_type: 'settlement_adjustments_csv', limit: 5 });
|
||||
renderExportHistory(jobs);
|
||||
} catch (_error) {
|
||||
renderExportHistory([]);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadExportById(jobId) {
|
||||
const { blob, filename } = await api.downloadExportJob(jobId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename || 'settlement-adjustment-report.csv';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function renderMerchantOptions(items) {
|
||||
const options = document.getElementById('adjustment-merchant-options');
|
||||
if (!options) {
|
||||
return;
|
||||
}
|
||||
options.innerHTML = (items || [])
|
||||
.map((merchant) => `<option value="${escapeHtml(merchant.id)}" label="${escapeHtml(merchantLabel(merchant))}"></option>`)
|
||||
.join('');
|
||||
setText('adjustment-merchant-hint', `${numberFormatter.format((items || []).length)} merchants available`);
|
||||
}
|
||||
|
||||
async function loadMerchantOptions() {
|
||||
try {
|
||||
merchants = await api.listMerchants();
|
||||
renderMerchantOptions(merchants);
|
||||
} catch (error) {
|
||||
setText('adjustment-merchant-hint', 'Merchant list unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
function renderIssueSummary(row) {
|
||||
if (!row.issues?.length) {
|
||||
if (row.computed?.archived_reprocessed) {
|
||||
return '<span class="text-slate-500">Archived after reprocess</span>';
|
||||
}
|
||||
return '<span class="text-slate-400">No variance</span>';
|
||||
}
|
||||
return row.issues
|
||||
.slice(0, 2)
|
||||
.map((issue) => `<p class="text-[12px] text-danger font-semibold">${escapeHtml(issue.message)}</p>`)
|
||||
.join('') + (row.issues.length > 2 ? `<p class="text-[12px] text-slate-500">+${row.issues.length - 2} more issue(s)</p>` : '');
|
||||
}
|
||||
|
||||
function renderRows(rows) {
|
||||
const tbody = document.getElementById('recon-mismatch-rows');
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-slate-500">No settlement batches available for reconciliation.</td>
|
||||
</tr>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rows.map((row) => {
|
||||
const batch = row.batch || {};
|
||||
const computed = row.computed || {};
|
||||
const isMismatch = row.status === 'mismatch';
|
||||
const badgeClass = isMismatch ? 'bg-danger/10 text-danger' : 'bg-success/10 text-success';
|
||||
const variance = Number(batch.net_payable_amount || 0) - Number(computed.net_payable_amount || 0);
|
||||
return `
|
||||
<tr class="hover:bg-slate-50/50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<p class="font-bold text-on-surface">${escapeHtml(batch.batch_code || batch.id)}</p>
|
||||
<p class="text-[12px] text-slate-400">${escapeHtml(batch.merchant_id || '-')} • ${formatDateTime(batch.created_at)}</p>
|
||||
<p class="text-[12px] text-slate-500">${numberFormatter.format(Number(batch.entry_count || 0))} entry • ${escapeHtml(batch.status || '-')}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 bg-primary/5 tabular-nums font-medium">
|
||||
<p>${formatMoney(batch.net_payable_amount)}</p>
|
||||
<p class="text-[12px] text-slate-500">gross ${formatMoney(batch.gross_amount)} / fee ${formatMoney(batch.platform_fee_amount)}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4 bg-info/5 tabular-nums font-medium">
|
||||
<p>${formatMoney(computed.net_payable_amount)}</p>
|
||||
<p class="text-[12px] text-slate-500">gross ${formatMoney(computed.gross_amount)} / fee ${formatMoney(computed.platform_fee_amount)}</p>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<p class="tabular-nums ${isMismatch ? 'text-danger font-bold' : 'text-slate-400'}">${formatMoney(variance)}</p>
|
||||
<div class="mt-1 space-y-1">${renderIssueSummary(row)}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span class="px-3 py-1 ${badgeClass} text-[12px] font-bold rounded-full">${isMismatch ? 'MISMATCH' : 'MATCHED'}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-right">
|
||||
<a href="/ui/settlement-batch-management" class="inline-flex px-3 py-1 bg-primary text-white text-[12px] font-bold rounded hover:bg-primary/90 transition-colors">Open</a>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderAdjustmentActivity(report) {
|
||||
const container = document.getElementById('recon-adjustment-activity');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const rows = report?.rows || [];
|
||||
setText(
|
||||
'recon-adjustment-summary',
|
||||
`${numberFormatter.format(Number(report?.total_count || 0))} adjustment • net ${formatMoney(report?.signed_amount || 0)}`
|
||||
);
|
||||
if (!rows.length) {
|
||||
container.innerHTML = `
|
||||
<div class="relative pl-8">
|
||||
<div class="absolute left-0 top-1 w-4 h-4 rounded-full bg-slate-300 z-10"></div>
|
||||
<p class="font-bold text-on-surface leading-none mb-1">No adjustment yet</p>
|
||||
<p class="text-[12px] text-slate-500 mb-1">Settlement corrections will appear here after admin records them.</p>
|
||||
<p class="text-[11px] text-slate-400">-</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = rows.map((item, index) => {
|
||||
const isCredit = item.adjustment_type === 'credit';
|
||||
const approvalStatus = item.approval_status || 'approved';
|
||||
const dotClass = approvalStatus === 'pending'
|
||||
? 'bg-primary ring-primary/10'
|
||||
: approvalStatus === 'rejected'
|
||||
? 'bg-danger ring-danger/10'
|
||||
: isCredit ? 'bg-success ring-success/10' : 'bg-warning ring-warning/10';
|
||||
const actionButtons = approvalStatus === 'pending'
|
||||
? `
|
||||
<div class="mt-3 flex gap-2">
|
||||
<button data-admin-permission="settlement:adjust" data-adjustment-action="approve" data-adjustment-id="${escapeHtml(item.id)}" class="px-3 py-1.5 bg-success text-white text-[12px] font-bold rounded hover:bg-success/90 transition-colors">Approve</button>
|
||||
<button data-admin-permission="settlement:adjust" data-adjustment-action="reject" data-adjustment-id="${escapeHtml(item.id)}" class="px-3 py-1.5 border border-danger/20 text-danger text-[12px] font-bold rounded hover:bg-danger/5 transition-colors">Reject</button>
|
||||
</div>
|
||||
`
|
||||
: '';
|
||||
return `
|
||||
<div class="relative pl-8">
|
||||
<div class="absolute left-0 top-1 w-4 h-4 rounded-full ${dotClass} ring-4 z-10"></div>
|
||||
${index < rows.length - 1 ? '<div class="absolute left-1.5 top-5 w-[2px] h-full bg-slate-100"></div>' : ''}
|
||||
<p class="font-bold text-on-surface leading-none mb-1">${escapeHtml(item.batch_code || item.batch_id)}</p>
|
||||
<p class="text-[12px] text-slate-500 mb-1">${escapeHtml(item.adjustment_type || '-').toUpperCase()} ${formatMoney(item.signed_amount)} • ${escapeHtml(item.reason || '-')}</p>
|
||||
<p class="text-[11px] font-bold uppercase tracking-wide ${approvalStatus === 'approved' ? 'text-success' : approvalStatus === 'rejected' ? 'text-danger' : 'text-primary'}">${escapeHtml(approvalStatus)}</p>
|
||||
<p class="text-[11px] text-slate-400">${formatDateTime(item.created_at)}</p>
|
||||
${actionButtons}
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
api.applyPermissions?.();
|
||||
}
|
||||
|
||||
async function handleAdjustmentApproval(event) {
|
||||
const button = event.target.closest('[data-adjustment-action]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
const action = button.dataset.adjustmentAction;
|
||||
const adjustmentId = button.dataset.adjustmentId;
|
||||
if (!adjustmentId || !['approve', 'reject'].includes(action)) {
|
||||
return;
|
||||
}
|
||||
const note = window.prompt(action === 'approve' ? 'Approval note (optional)' : 'Rejection note (optional)') || '';
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-50');
|
||||
try {
|
||||
if (action === 'approve') {
|
||||
await api.approveSettlementAdjustment(adjustmentId, { note });
|
||||
} else {
|
||||
await api.rejectSettlementAdjustment(adjustmentId, { note });
|
||||
}
|
||||
await loadAdjustmentActivity();
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to update adjustment approval');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAdjustmentActivity() {
|
||||
currentAdjustmentQuery = buildAdjustmentQuery(5);
|
||||
const adjustmentReport = await api.listSettlementAdjustments(currentAdjustmentQuery);
|
||||
renderAdjustmentActivity(adjustmentReport);
|
||||
}
|
||||
|
||||
async function loadReconciliation() {
|
||||
try {
|
||||
api.requireToken();
|
||||
currentAdjustmentQuery = buildAdjustmentQuery(5);
|
||||
const [report, adjustmentReport] = await Promise.all([
|
||||
api.getSettlementReconciliationReport({ limit: 100 }),
|
||||
api.listSettlementAdjustments(currentAdjustmentQuery)
|
||||
]);
|
||||
setText('recon-total-matched', numberFormatter.format(Number(report.matched_batches || 0)));
|
||||
setText('recon-discrepancies', numberFormatter.format(Number(report.mismatch_batches || 0)));
|
||||
setText('recon-total-batches', numberFormatter.format(Number(report.total_batches || 0)));
|
||||
setText('recon-issue-count', numberFormatter.format(Number(report.issue_count || 0)));
|
||||
setText('recon-footer', `Showing ${numberFormatter.format((report.rows || []).length)} of ${numberFormatter.format(Number(report.total_batches || 0))} settlement batches`);
|
||||
const raw = document.getElementById('recon-raw-payload');
|
||||
if (raw) {
|
||||
raw.textContent = JSON.stringify(report, null, 2);
|
||||
}
|
||||
renderRows(report.rows || []);
|
||||
renderAdjustmentActivity(adjustmentReport);
|
||||
} catch (error) {
|
||||
const tbody = document.getElementById('recon-mismatch-rows');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
<td colspan="6" class="px-6 py-8 text-center text-danger">${escapeHtml(error.message || 'Failed to load reconciliation report')}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
setText('recon-footer', 'Failed to load reconciliation report');
|
||||
setText('recon-adjustment-summary', 'Failed to load finance adjustment report');
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAdjustmentReport() {
|
||||
const button = document.getElementById('download-adjustment-report');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-50');
|
||||
}
|
||||
try {
|
||||
const exportQuery = { ...currentAdjustmentQuery, limit: 5000 };
|
||||
setExportStatus('Creating export job...', 'active');
|
||||
let job = await api.createSettlementAdjustmentExportJob(exportQuery);
|
||||
setExportStatus(`Export ${job.status}: ${job.id}`, 'active');
|
||||
|
||||
for (let attempt = 0; attempt < 30 && !['completed', 'failed'].includes(job.status); attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
job = await api.getExportJob(job.id);
|
||||
setExportStatus(`Export ${job.status}: ${job.id}`, job.status === 'failed' ? 'danger' : 'active');
|
||||
}
|
||||
|
||||
if (job.status !== 'completed') {
|
||||
throw new Error(job.error_message || `Export job ${job.status}`);
|
||||
}
|
||||
|
||||
const { blob, filename } = await api.downloadExportJob(job.id);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename || 'settlement-adjustment-report.csv';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
setExportStatus(`Export completed: ${filename || job.id}`, 'success');
|
||||
await loadExportHistory();
|
||||
} catch (error) {
|
||||
setExportStatus(error.message || 'Export failed', 'danger');
|
||||
alert(error.message || 'Export failed');
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExportHistoryClick(event) {
|
||||
const button = event.target.closest('[data-export-download-id]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
button.classList.add('opacity-50');
|
||||
try {
|
||||
await downloadExportById(button.dataset.exportDownloadId);
|
||||
} catch (error) {
|
||||
alert(error.message || 'Download failed');
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
button.classList.remove('opacity-50');
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAdjustmentFilter() {
|
||||
const button = document.getElementById('apply-adjustment-filter');
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = 'Loading...';
|
||||
}
|
||||
try {
|
||||
await loadAdjustmentActivity();
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = 'Apply Filter';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearAdjustmentFilter() {
|
||||
['adjustment-merchant-filter', 'adjustment-type-filter', 'adjustment-approval-filter', 'adjustment-from-filter', 'adjustment-to-filter'].forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.value = '';
|
||||
}
|
||||
});
|
||||
await applyAdjustmentFilter();
|
||||
}
|
||||
|
||||
// Micro-interactions
|
||||
function toggleDrawer() {
|
||||
const drawer = document.getElementById('detailDrawer');
|
||||
@ -552,6 +903,15 @@
|
||||
card.style.transform = 'translateY(0)';
|
||||
}, 50);
|
||||
});
|
||||
api.applyPermissions?.();
|
||||
loadReconciliation();
|
||||
loadMerchantOptions();
|
||||
loadExportHistory();
|
||||
document.getElementById('download-adjustment-report')?.addEventListener('click', downloadAdjustmentReport);
|
||||
document.getElementById('apply-adjustment-filter')?.addEventListener('click', applyAdjustmentFilter);
|
||||
document.getElementById('clear-adjustment-filter')?.addEventListener('click', clearAdjustmentFilter);
|
||||
document.getElementById('recon-adjustment-activity')?.addEventListener('click', handleAdjustmentApproval);
|
||||
document.getElementById('adjustment-export-history')?.addEventListener('click', handleExportHistoryClick);
|
||||
});
|
||||
</script>
|
||||
<!-- ui-nav -->
|
||||
@ -563,4 +923,4 @@
|
||||
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
||||
</div>
|
||||
'
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
@ -133,28 +133,28 @@
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/admin-reconciliation-management">
|
||||
<span class="material-symbols-outlined">account_balance_wallet</span>
|
||||
<span class="font-label-md">Reconciliation</span>
|
||||
</a>
|
||||
<!-- ACTIVE TAB: Audit Logs -->
|
||||
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container text-on-secondary-container rounded-xl font-bold transition-all scale-98" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container text-on-secondary-container rounded-xl font-bold transition-all scale-98" href="/ui/admin-system-audit-logs">
|
||||
<span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">security</span>
|
||||
<span class="font-label-md">Audit Logs</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined">payments</span>
|
||||
<span class="font-label-md">Fee Management</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/settlement-batch-management">
|
||||
<span class="material-symbols-outlined">receipt_long</span>
|
||||
<span class="font-label-md">Settlements</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/device-technical-detail">
|
||||
<span class="material-symbols-outlined">router</span>
|
||||
<span class="font-label-md">Device Health</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary hover:bg-slate-100 rounded-xl transition-all" href="/ui/hub">
|
||||
<span class="material-symbols-outlined">contact_support</span>
|
||||
<span class="font-label-md">Support</span>
|
||||
</a>
|
||||
@ -163,7 +163,7 @@
|
||||
Generate Report
|
||||
</button>
|
||||
<div class="border-t border-slate-100 pt-4">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-danger hover:bg-error-container/30 rounded-xl transition-all" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-danger hover:bg-error-container/30 rounded-xl transition-all" href="/ui/admin-login">
|
||||
<span class="material-symbols-outlined">logout</span>
|
||||
<span class="font-label-md">Logout</span>
|
||||
</a>
|
||||
@ -182,7 +182,7 @@
|
||||
<!-- Global Search -->
|
||||
<div class="relative w-80">
|
||||
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
|
||||
<input class="w-full bg-slate-50 border-none rounded-lg py-2 pl-10 pr-4 text-body-md focus:ring-2 focus:ring-primary/20 placeholder:text-slate-400" placeholder="Search by Entity ID, User, or IP..." type="text"/>
|
||||
<input id="audit-search" class="w-full bg-slate-50 border-none rounded-lg py-2 pl-10 pr-4 text-body-md focus:ring-2 focus:ring-primary/20 placeholder:text-slate-400" placeholder="Search by Entity ID, User, or IP..." type="text"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class="w-10 h-10 flex items-center justify-center text-slate-500 hover:bg-slate-100 rounded-full transition-colors relative">
|
||||
@ -203,38 +203,60 @@
|
||||
<section class="flex flex-wrap items-center gap-4 bg-white p-4 rounded-xl border border-slate-200">
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="font-label-md text-slate-500">Action Type:</span>
|
||||
<select class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<select id="audit-action-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<option>All Actions</option>
|
||||
<option>Create</option>
|
||||
<option>Update</option>
|
||||
<option>Delete</option>
|
||||
<option>Login</option>
|
||||
<option value="login">All Login Events</option>
|
||||
<option value="admin.login.failed">Admin Login Failed</option>
|
||||
<option value="admin.login.success">Admin Login Success</option>
|
||||
<option value="merchant.login.failed">Merchant Login Failed</option>
|
||||
<option value="merchant.login.success">Merchant Login Success</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="font-label-md text-slate-500">User Role:</span>
|
||||
<select class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<option>All Roles</option>
|
||||
<option>Super Admin</option>
|
||||
<option>Operator</option>
|
||||
<option>Support</option>
|
||||
<select id="audit-entity-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0">
|
||||
<option value="">All Entities</option>
|
||||
<option value="admin_session">Admin Session</option>
|
||||
<option value="merchant_session">Merchant Session</option>
|
||||
<option value="transaction">Transaction</option>
|
||||
<option value="settlement_batch">Settlement Batch</option>
|
||||
<option value="device">Device</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="material-symbols-outlined text-slate-400 text-sm">calendar_today</span>
|
||||
<span class="font-label-md text-slate-500">Date Range:</span>
|
||||
<button class="text-label-md font-bold text-primary">Last 24 Hours</button>
|
||||
<span class="font-label-md text-slate-500">From:</span>
|
||||
<input id="audit-from-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0" type="date"/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-1.5 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="material-symbols-outlined text-slate-400 text-sm">calendar_today</span>
|
||||
<span class="font-label-md text-slate-500">To:</span>
|
||||
<input id="audit-to-filter" class="bg-transparent border-none p-0 text-label-md font-bold text-primary focus:ring-0" type="date"/>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<button class="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 hover:bg-slate-50 rounded-lg text-label-md font-medium transition-colors">
|
||||
<button id="audit-login-preset" class="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 hover:bg-slate-50 rounded-lg text-label-md font-medium transition-colors">
|
||||
<span class="material-symbols-outlined text-sm">filter_list</span>
|
||||
More Filters
|
||||
Login Events
|
||||
</button>
|
||||
<button class="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-label-md font-medium hover:bg-black transition-colors">
|
||||
<button id="audit-refresh" class="flex items-center gap-2 px-4 py-2 bg-slate-900 text-white rounded-lg text-label-md font-medium hover:bg-black transition-colors">
|
||||
<span class="material-symbols-outlined text-sm">download</span>
|
||||
Export CSV
|
||||
Refresh
|
||||
</button>
|
||||
</section>
|
||||
<section class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="text-label-md text-slate-500 uppercase">Total Events</p>
|
||||
<p id="audit-total-count" class="text-metric-lg font-metric-lg text-slate-900">0</p>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="text-label-md text-slate-500 uppercase">Login Failed</p>
|
||||
<p id="audit-login-failed-count" class="text-metric-lg font-metric-lg text-danger">0</p>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="text-label-md text-slate-500 uppercase">Login Success</p>
|
||||
<p id="audit-login-success-count" class="text-metric-lg font-metric-lg text-success">0</p>
|
||||
</div>
|
||||
</section>
|
||||
<!-- Audit Table -->
|
||||
<section class="bg-white rounded-xl border border-slate-200 overflow-hidden shadow-sm">
|
||||
<div class="overflow-x-auto data-table-container scrollbar-hide">
|
||||
@ -250,7 +272,7 @@
|
||||
<th class="px-6 py-4 font-label-md text-slate-500 uppercase tracking-wider text-right">Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tbody id="audit-log-rows" class="divide-y divide-slate-100">
|
||||
<!-- Row 1 -->
|
||||
<tr class="hover:bg-slate-50 transition-colors group">
|
||||
<td class="px-6 py-3.5 whitespace-nowrap">
|
||||
@ -416,8 +438,8 @@
|
||||
<div class="absolute right-0 top-0 h-full w-[500px] bg-white shadow-2xl translate-x-full transition-transform duration-300 flex flex-col" id="drawer-content">
|
||||
<div class="p-6 border-b border-slate-200 flex justify-between items-center bg-slate-50">
|
||||
<div>
|
||||
<h3 class="font-headline-md text-slate-900">Audit Detail Payload</h3>
|
||||
<p class="text-xs text-slate-500 font-mono">ID: 550e8400-e29b-41d4-a716-446655440000</p>
|
||||
<h3 id="audit-drawer-title" class="font-headline-md text-slate-900">Audit Detail Payload</h3>
|
||||
<p id="audit-drawer-id" class="text-xs text-slate-500 font-mono">ID: -</p>
|
||||
</div>
|
||||
<button class="w-10 h-10 flex items-center justify-center rounded-full hover:bg-slate-200 text-slate-500 transition-colors" onclick="toggleDrawer()">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
@ -429,11 +451,11 @@
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="text-xs text-slate-500">Method</span>
|
||||
<p class="font-bold text-primary">PATCH</p>
|
||||
<p id="audit-drawer-action" class="font-bold text-primary">-</p>
|
||||
</div>
|
||||
<div class="p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<span class="text-xs text-slate-500">Source Agent</span>
|
||||
<p class="font-bold text-primary">Web-Admin/2.4.1</p>
|
||||
<span class="text-xs text-slate-500">Source IP</span>
|
||||
<p id="audit-drawer-ip" class="font-bold text-primary">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -446,7 +468,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-slate-900 rounded-xl p-6 overflow-hidden relative group">
|
||||
<pre class="font-mono text-[13px] text-green-400 overflow-x-auto scrollbar-hide">{
|
||||
<pre id="audit-drawer-json" class="font-mono text-[13px] text-green-400 overflow-x-auto scrollbar-hide">{
|
||||
"action": "UPDATE_MERCHANT_FEE",
|
||||
"metadata": {
|
||||
"merchant_id": "MID-88219-X",
|
||||
@ -483,13 +505,157 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/shared/admin-api.js"></script>
|
||||
<script>
|
||||
function toggleDrawer() {
|
||||
const api = window.AdminUIAPI;
|
||||
let auditLogs = [];
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '').replace(/[&<>"']/g, (char) => ({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[char]));
|
||||
}
|
||||
|
||||
function setText(id, value) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value;
|
||||
}
|
||||
|
||||
function isoStart(value) {
|
||||
return value ? `${value}T00:00:00.000Z` : undefined;
|
||||
}
|
||||
|
||||
function isoEnd(value) {
|
||||
return value ? `${value}T23:59:59.999Z` : undefined;
|
||||
}
|
||||
|
||||
function formatDateParts(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return { date: '-', time: '-' };
|
||||
}
|
||||
return {
|
||||
date: new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' }).format(date),
|
||||
time: new Intl.DateTimeFormat('en-GB', { timeStyle: 'medium' }).format(date)
|
||||
};
|
||||
}
|
||||
|
||||
function statusForAction(action) {
|
||||
if (String(action || '').includes('.failed')) return { label: 'Failed', cls: 'bg-danger/10 text-danger', dot: 'bg-danger' };
|
||||
if (String(action || '').includes('.success')) return { label: 'Success', cls: 'bg-success/10 text-success', dot: 'bg-success' };
|
||||
return { label: 'Recorded', cls: 'bg-info/10 text-info', dot: 'bg-info' };
|
||||
}
|
||||
|
||||
function buildQuery() {
|
||||
const action = document.getElementById('audit-action-filter')?.value;
|
||||
const entityType = document.getElementById('audit-entity-filter')?.value;
|
||||
return {
|
||||
limit: 100,
|
||||
action: action && action !== 'login' ? action : undefined,
|
||||
action_contains: action === 'login' ? '.login.' : undefined,
|
||||
entity_type: entityType || undefined,
|
||||
from: isoStart(document.getElementById('audit-from-filter')?.value),
|
||||
to: isoEnd(document.getElementById('audit-to-filter')?.value)
|
||||
};
|
||||
}
|
||||
|
||||
function filteredRows() {
|
||||
const needle = (document.getElementById('audit-search')?.value || '').trim().toLowerCase();
|
||||
if (!needle) return auditLogs;
|
||||
return auditLogs.filter((entry) => [
|
||||
entry.id,
|
||||
entry.actor_id,
|
||||
entry.action,
|
||||
entry.entity_type,
|
||||
entry.entity_id,
|
||||
entry.source_ip,
|
||||
entry.request_id,
|
||||
entry.trace_id
|
||||
].filter(Boolean).some((value) => String(value).toLowerCase().includes(needle)));
|
||||
}
|
||||
|
||||
function renderSummary(rows) {
|
||||
setText('audit-total-count', rows.length);
|
||||
setText('audit-login-failed-count', rows.filter((row) => String(row.action || '').includes('login.failed')).length);
|
||||
setText('audit-login-success-count', rows.filter((row) => String(row.action || '').includes('login.success')).length);
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const tbody = document.getElementById('audit-log-rows');
|
||||
const rows = filteredRows();
|
||||
renderSummary(rows);
|
||||
if (!tbody) return;
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="px-6 py-10 text-center text-slate-500">No audit events found.</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = rows.map((entry) => {
|
||||
const when = formatDateParts(entry.created_at);
|
||||
const status = statusForAction(entry.action);
|
||||
return `
|
||||
<tr class="hover:bg-slate-50 transition-colors group">
|
||||
<td class="px-6 py-3.5 whitespace-nowrap">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-metric-sm text-slate-900">${escapeHtml(when.date)}</span>
|
||||
<span class="text-xs text-slate-500">${escapeHtml(when.time)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3.5 whitespace-nowrap">
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium text-slate-900">${escapeHtml(entry.actor_id || '-')}</span>
|
||||
<span class="text-[10px] bg-slate-100 text-slate-600 px-1.5 py-0.5 rounded-full w-fit font-bold uppercase">${escapeHtml(entry.actor_type || 'system')}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3.5"><span class="text-body-md font-medium text-slate-900">${escapeHtml(entry.action || '-')}</span></td>
|
||||
<td class="px-6 py-3.5"><code class="font-mono text-xs text-primary bg-primary-fixed/30 px-2 py-1 rounded">${escapeHtml(entry.entity_id || '-')}</code></td>
|
||||
<td class="px-6 py-3.5 whitespace-nowrap text-slate-500 text-xs">${escapeHtml(entry.source_ip || '-')}</td>
|
||||
<td class="px-6 py-3.5">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold ${status.cls}">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${status.dot}"></span> ${status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3.5 text-right">
|
||||
<button data-audit-id="${escapeHtml(entry.id)}" class="text-primary hover:underline font-label-md">View JSON</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function openDrawer(entry) {
|
||||
if (!entry) return;
|
||||
setText('audit-drawer-title', entry.action || 'Audit Detail Payload');
|
||||
setText('audit-drawer-id', `ID: ${entry.id}`);
|
||||
setText('audit-drawer-action', entry.action || '-');
|
||||
setText('audit-drawer-ip', entry.source_ip || '-');
|
||||
const json = {
|
||||
id: entry.id,
|
||||
actor_type: entry.actor_type,
|
||||
actor_id: entry.actor_id,
|
||||
action: entry.action,
|
||||
entity_type: entry.entity_type,
|
||||
entity_id: entry.entity_id,
|
||||
before_json: entry.before_json,
|
||||
after_json: entry.after_json,
|
||||
source_ip: entry.source_ip,
|
||||
request_id: entry.request_id,
|
||||
trace_id: entry.trace_id,
|
||||
created_at: entry.created_at
|
||||
};
|
||||
setText('audit-drawer-json', JSON.stringify(json, null, 2));
|
||||
toggleDrawer(true);
|
||||
}
|
||||
|
||||
function toggleDrawer(forceOpen) {
|
||||
const drawer = document.getElementById('payload-drawer');
|
||||
const overlay = document.getElementById('drawer-overlay');
|
||||
const content = document.getElementById('drawer-content');
|
||||
|
||||
if (drawer.classList.contains('invisible')) {
|
||||
const shouldOpen = forceOpen === true || drawer.classList.contains('invisible');
|
||||
if (shouldOpen) {
|
||||
drawer.classList.remove('invisible');
|
||||
setTimeout(() => {
|
||||
overlay.classList.replace('opacity-0', 'opacity-100');
|
||||
@ -498,20 +664,47 @@
|
||||
} else {
|
||||
overlay.classList.replace('opacity-100', 'opacity-0');
|
||||
content.classList.replace('translate-x-0', 'translate-x-full');
|
||||
setTimeout(() => {
|
||||
drawer.classList.add('invisible');
|
||||
}, 300);
|
||||
setTimeout(() => drawer.classList.add('invisible'), 300);
|
||||
}
|
||||
}
|
||||
|
||||
// Close on overlay click
|
||||
document.getElementById('drawer-overlay').addEventListener('click', toggleDrawer);
|
||||
|
||||
// Escape key to close
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !document.getElementById('payload-drawer').classList.contains('invisible')) {
|
||||
toggleDrawer();
|
||||
async function loadAuditLogs() {
|
||||
try {
|
||||
api.requireToken();
|
||||
auditLogs = await api.listAuditLogs(buildQuery());
|
||||
renderRows();
|
||||
} catch (error) {
|
||||
const tbody = document.getElementById('audit-log-rows');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `<tr><td colspan="7" class="px-6 py-10 text-center text-danger">${escapeHtml(error.message || 'Failed to load audit logs')}</td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('drawer-overlay')?.addEventListener('click', () => toggleDrawer(false));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !document.getElementById('payload-drawer').classList.contains('invisible')) {
|
||||
toggleDrawer(false);
|
||||
}
|
||||
});
|
||||
document.getElementById('audit-log-rows')?.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('[data-audit-id]');
|
||||
if (!button) return;
|
||||
openDrawer(auditLogs.find((entry) => entry.id === button.dataset.auditId));
|
||||
});
|
||||
['audit-action-filter', 'audit-entity-filter', 'audit-from-filter', 'audit-to-filter'].forEach((id) => {
|
||||
document.getElementById(id)?.addEventListener('change', loadAuditLogs);
|
||||
});
|
||||
document.getElementById('audit-search')?.addEventListener('input', renderRows);
|
||||
document.getElementById('audit-refresh')?.addEventListener('click', loadAuditLogs);
|
||||
document.getElementById('audit-login-preset')?.addEventListener('click', () => {
|
||||
document.getElementById('audit-action-filter').value = 'login';
|
||||
document.getElementById('audit-entity-filter').value = '';
|
||||
loadAuditLogs();
|
||||
});
|
||||
api.applyPermissions?.();
|
||||
loadAuditLogs();
|
||||
});
|
||||
</script>
|
||||
<!-- ui-nav -->
|
||||
@ -523,4 +716,4 @@
|
||||
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
||||
</div>
|
||||
'
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
@ -126,37 +126,37 @@
|
||||
<p class="text-label-md text-on-surface-variant">Admin Console</p>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined text-[20px]">dashboard</span>
|
||||
<span class="font-body-md">Overview</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/merchant-detail-view">
|
||||
<span class="material-symbols-outlined text-[20px]">storefront</span>
|
||||
<span class="font-body-md">Merchant Management</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 bg-secondary-container text-on-secondary-container font-bold rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 bg-secondary-container text-on-secondary-container font-bold rounded-lg group" href="/ui/device-technical-detail">
|
||||
<span class="material-symbols-outlined text-[20px]">speaker_group</span>
|
||||
<span class="font-body-md">Device Registry</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/transaction-history-monitoring">
|
||||
<span class="material-symbols-outlined text-[20px]">receipt_long</span>
|
||||
<span class="font-body-md">Transactions</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/settlement-batch-management">
|
||||
<span class="material-symbols-outlined text-[20px]">account_balance</span>
|
||||
<span class="font-body-md">Ledger & Settlement</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/admin-reconciliation-management">
|
||||
<span class="material-symbols-outlined text-[20px]">history_edu</span>
|
||||
<span class="font-body-md">Audit Control</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-auto border-t border-slate-100 pt-4 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/hub">
|
||||
<span class="material-symbols-outlined text-[20px]">settings</span>
|
||||
<span class="font-body-md">Settings</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2 text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg group" href="/ui/hub">
|
||||
<span class="material-symbols-outlined text-[20px]">help</span>
|
||||
<span class="font-body-md">Support</span>
|
||||
</a>
|
||||
@ -192,7 +192,7 @@
|
||||
<div class="p-page-padding">
|
||||
<!-- Breadcrumb & Back Action -->
|
||||
<div class="flex items-center gap-2 mb-6 text-on-surface-variant">
|
||||
<a class="flex items-center hover:text-primary transition-colors" href="#">
|
||||
<a class="flex items-center hover:text-primary transition-colors" href="/ui/device-registry-monitoring">
|
||||
<span class="material-symbols-outlined text-sm mr-1">arrow_back</span>
|
||||
<span class="text-label-md">Back to Registry</span>
|
||||
</a>
|
||||
@ -394,6 +394,13 @@ Loading
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-slate-300">chevron_right</span>
|
||||
</button>
|
||||
<button id="rotate-device-credential" class="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50 transition-all group">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="material-symbols-outlined text-on-surface-variant group-hover:text-primary">vpn_key</span>
|
||||
<span class="font-body-md font-bold">Rotate MQTT Credential</span>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-slate-300">chevron_right</span>
|
||||
</button>
|
||||
<div class="pt-2">
|
||||
<button class="w-full py-2.5 bg-danger/10 text-danger border border-danger/20 font-bold text-body-md rounded-lg hover:bg-danger/20 transition-all flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined text-[20px]">delete_forever</span>
|
||||
@ -402,6 +409,36 @@ Loading
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- MQTT Credential Panel -->
|
||||
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-card-padding shadow-sm">
|
||||
<div class="flex items-start justify-between gap-4 mb-4">
|
||||
<div>
|
||||
<p class="text-label-md text-on-surface-variant mb-1">MQTT Credential</p>
|
||||
<h4 class="font-headline-md text-headline-md text-on-surface" id="device-credential-status">Not Issued</h4>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary">key</span>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold">Username</p>
|
||||
<p class="code-font text-[12px] text-on-surface break-all" id="device-mqtt-username">-</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold">Issued</p>
|
||||
<p class="text-body-md text-on-surface" id="device-credential-issued">-</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold">Rotated</p>
|
||||
<p class="text-body-md text-on-surface" id="device-credential-rotated">-</p>
|
||||
</div>
|
||||
</div>
|
||||
<button id="rotate-device-credential-secondary" class="w-full py-2.5 bg-primary text-on-primary font-bold text-body-md rounded-lg hover:opacity-90 active:scale-95 transition-all flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined text-[20px]">sync_lock</span>
|
||||
Rotate Credential
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Device Health Timeline -->
|
||||
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-card-padding shadow-sm">
|
||||
<h4 class="font-headline-md text-headline-md mb-6">Device Events</h4>
|
||||
@ -419,6 +456,50 @@ Loading
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="credential-modal" class="fixed inset-0 z-[100] hidden items-center justify-center bg-slate-900/60 px-4">
|
||||
<div class="bg-surface-container-lowest rounded-xl border border-slate-200 shadow-2xl w-full max-w-xl overflow-hidden">
|
||||
<div class="p-card-padding border-b border-slate-100 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="font-headline-md text-headline-md text-on-surface">MQTT Credential Rotated</h3>
|
||||
<p class="text-body-md text-on-surface-variant mt-1">Password ini hanya ditampilkan satu kali.</p>
|
||||
</div>
|
||||
<button id="credential-modal-close" class="w-10 h-10 rounded-lg hover:bg-slate-100 flex items-center justify-center text-on-surface-variant">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-card-padding space-y-4">
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold mb-1">Username</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="credential-modal-username" class="code-font flex-1 bg-slate-100 border border-slate-200 rounded-lg px-3 py-2 text-[13px] break-all">-</code>
|
||||
<button id="credential-copy-username" class="w-10 h-10 rounded-lg border border-slate-200 hover:bg-slate-50 flex items-center justify-center text-on-surface-variant" title="Copy username">
|
||||
<span class="material-symbols-outlined text-[20px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold mb-1">Password</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<code id="credential-modal-password" class="code-font flex-1 bg-slate-900 text-green-400 rounded-lg px-3 py-2 text-[13px] break-all">-</code>
|
||||
<button id="credential-copy-password" class="w-10 h-10 rounded-lg border border-slate-200 hover:bg-slate-50 flex items-center justify-center text-on-surface-variant" title="Copy password">
|
||||
<span class="material-symbols-outlined text-[20px]">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[11px] uppercase tracking-wider text-slate-500 font-bold mb-1">Mosquitto Command</p>
|
||||
<code id="credential-modal-command" class="code-font block bg-slate-100 border border-slate-200 rounded-lg px-3 py-2 text-[12px] break-all">-</code>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button id="credential-copy-command" class="px-4 py-2 border border-slate-200 rounded-lg font-bold text-body-md hover:bg-slate-50 flex items-center gap-2">
|
||||
<span class="material-symbols-outlined text-[20px]">terminal</span>
|
||||
Copy Command
|
||||
</button>
|
||||
<button id="credential-modal-done" class="px-4 py-2 bg-primary text-on-primary rounded-lg font-bold text-body-md hover:opacity-90">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="/ui/shared/admin-api.js"></script>
|
||||
<script>
|
||||
const DeviceDetail = (() => {
|
||||
@ -429,13 +510,25 @@ Loading
|
||||
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const deviceId = qs.get("device_id") || qs.get("deviceId") || qs.get("id") || "";
|
||||
let activeDeviceId = deviceId;
|
||||
const stream = document.getElementById("payload-stream");
|
||||
const clearBtn = document.getElementById("clearConsole");
|
||||
const exportBtn = document.getElementById("export-device-logs");
|
||||
const refreshBtn = document.getElementById("refresh-device-state");
|
||||
const viewAllEventsBtn = document.getElementById("view-all-events");
|
||||
const rotateCredentialButtons = [
|
||||
document.getElementById("rotate-device-credential"),
|
||||
document.getElementById("rotate-device-credential-secondary")
|
||||
].filter(Boolean);
|
||||
const credentialModal = document.getElementById("credential-modal");
|
||||
const credentialModalClose = document.getElementById("credential-modal-close");
|
||||
const credentialModalDone = document.getElementById("credential-modal-done");
|
||||
const credentialCopyUsername = document.getElementById("credential-copy-username");
|
||||
const credentialCopyPassword = document.getElementById("credential-copy-password");
|
||||
const credentialCopyCommand = document.getElementById("credential-copy-command");
|
||||
const backLink = document.querySelector("a[href='#']");
|
||||
const eventHost = document.getElementById("device-events");
|
||||
let latestCredentialCommand = "";
|
||||
|
||||
const els = {
|
||||
breadcrumbCode: document.getElementById("device-breadcrumb-code"),
|
||||
@ -460,7 +553,14 @@ Loading
|
||||
configVersion: document.getElementById("device-config-version"),
|
||||
configStatus: document.getElementById("device-config-status"),
|
||||
configDetail: document.getElementById("device-config-detail"),
|
||||
configRetry: document.getElementById("device-config-retry")
|
||||
configRetry: document.getElementById("device-config-retry"),
|
||||
credentialStatus: document.getElementById("device-credential-status"),
|
||||
mqttUsername: document.getElementById("device-mqtt-username"),
|
||||
credentialIssued: document.getElementById("device-credential-issued"),
|
||||
credentialRotated: document.getElementById("device-credential-rotated"),
|
||||
credentialModalUsername: document.getElementById("credential-modal-username"),
|
||||
credentialModalPassword: document.getElementById("credential-modal-password"),
|
||||
credentialModalCommand: document.getElementById("credential-modal-command")
|
||||
};
|
||||
|
||||
const setText = (el, value, fallback = "-") => {
|
||||
@ -740,12 +840,79 @@ Loading
|
||||
}
|
||||
};
|
||||
|
||||
const shellQuote = (value) => `'${String(value || "").replace(/'/g, "'\\''")}'`;
|
||||
|
||||
const renderCredentialSummary = (device) => {
|
||||
const status = device?.credential_status || "not_issued";
|
||||
const statusLabel = String(status).replace(/_/g, " ").toUpperCase();
|
||||
setText(els.credentialStatus, statusLabel);
|
||||
if (els.credentialStatus) {
|
||||
els.credentialStatus.className = `font-headline-md text-headline-md ${status === "active" ? "text-success" : status === "revoked" ? "text-danger" : "text-on-surface"}`;
|
||||
}
|
||||
setText(els.mqttUsername, device?.mqtt_username || device?.id || "-");
|
||||
setText(els.credentialIssued, formatDateTime(device?.credential_issued_at, "-"));
|
||||
setText(els.credentialRotated, formatDateTime(device?.credential_rotated_at, "-"));
|
||||
};
|
||||
|
||||
const setRotateLoading = (loading) => {
|
||||
rotateCredentialButtons.forEach((button) => {
|
||||
button.disabled = loading;
|
||||
button.classList.toggle("opacity-60", loading);
|
||||
const label = button.querySelector(".font-body-md, .text-body-md");
|
||||
if (label) {
|
||||
label.textContent = loading ? "Rotating..." : "Rotate MQTT Credential";
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const showCredentialModal = (credential) => {
|
||||
const username = credential?.mqtt_username || "";
|
||||
const password = credential?.mqtt_password || "";
|
||||
latestCredentialCommand = `sudo mosquitto_passwd -b /etc/mosquitto/passwd ${shellQuote(username)} ${shellQuote(password)}`;
|
||||
setText(els.credentialModalUsername, username);
|
||||
setText(els.credentialModalPassword, password);
|
||||
setText(els.credentialModalCommand, latestCredentialCommand);
|
||||
credentialModal?.classList.remove("hidden");
|
||||
credentialModal?.classList.add("flex");
|
||||
};
|
||||
|
||||
const closeCredentialModal = () => {
|
||||
credentialModal?.classList.add("hidden");
|
||||
credentialModal?.classList.remove("flex");
|
||||
setText(els.credentialModalPassword, "-");
|
||||
latestCredentialCommand = "";
|
||||
};
|
||||
|
||||
const copyText = async (value) => {
|
||||
if (!value || !navigator.clipboard) {
|
||||
return;
|
||||
}
|
||||
await navigator.clipboard.writeText(value);
|
||||
};
|
||||
|
||||
const rotateCredential = async () => {
|
||||
if (!activeDeviceId) {
|
||||
return;
|
||||
}
|
||||
setRotateLoading(true);
|
||||
try {
|
||||
const result = await api.rotateDeviceCredential(activeDeviceId);
|
||||
renderCredentialSummary(result.device);
|
||||
showCredentialModal(result.credential);
|
||||
} catch (error) {
|
||||
console.error("[device detail] credential rotate failed", error);
|
||||
setText(els.credentialStatus, "ROTATE FAILED");
|
||||
} finally {
|
||||
setRotateLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfigStatus = async () => {
|
||||
if (!deviceId) {
|
||||
if (!activeDeviceId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const status = await api.getDeviceConfigStatus(deviceId);
|
||||
const status = await api.getDeviceConfigStatus(activeDeviceId);
|
||||
renderConfigStatus(status);
|
||||
} catch (error) {
|
||||
setText(els.configDetail, "Unable to load config status.");
|
||||
@ -789,23 +956,35 @@ Loading
|
||||
const loadDevice = async () => {
|
||||
try {
|
||||
api.requireToken();
|
||||
if (!deviceId) {
|
||||
let selectedDeviceId = deviceId;
|
||||
if (!selectedDeviceId) {
|
||||
const devices = await api.listDevices();
|
||||
selectedDeviceId = Array.isArray(devices) && devices.length ? devices[0].id : "";
|
||||
}
|
||||
activeDeviceId = selectedDeviceId;
|
||||
|
||||
if (!selectedDeviceId) {
|
||||
setText(els.breadcrumbCode, "Missing device_id");
|
||||
setText(els.title, "Missing device");
|
||||
return;
|
||||
}
|
||||
|
||||
const [device, heartbeats] = await Promise.all([
|
||||
api.getDevice(deviceId),
|
||||
const [device, heartbeatResponse] = await Promise.all([
|
||||
api.getDevice(selectedDeviceId),
|
||||
(async () => {
|
||||
try {
|
||||
return await api.getDeviceHeartbeats(deviceId);
|
||||
return await api.getDeviceHeartbeats(selectedDeviceId);
|
||||
} catch (error) {
|
||||
console.warn("[device detail] heartbeat fetch failed", error);
|
||||
return [];
|
||||
}
|
||||
})()
|
||||
]);
|
||||
const heartbeats = Array.isArray(heartbeatResponse)
|
||||
? heartbeatResponse
|
||||
: Array.isArray(heartbeatResponse?.heartbeats)
|
||||
? heartbeatResponse.heartbeats
|
||||
: [];
|
||||
|
||||
const modelCode = device.device_code || device.code || device.serial_number || device.id || "Unknown Device";
|
||||
setText(els.breadcrumbCode, modelCode);
|
||||
@ -824,6 +1003,7 @@ Loading
|
||||
const latestMetric = extractHeartbeatMetrics(latest);
|
||||
setDerivedStatus(device, heartbeats);
|
||||
renderHealthSummary(device.health_summary);
|
||||
renderCredentialSummary(device);
|
||||
setText(
|
||||
els.firmwareVersion,
|
||||
device.firmware_version || device.fw_version || device.firmware || "-"
|
||||
@ -841,18 +1021,29 @@ Loading
|
||||
};
|
||||
|
||||
refreshBtn?.addEventListener("click", loadDevice);
|
||||
rotateCredentialButtons.forEach((button) => button.addEventListener("click", rotateCredential));
|
||||
credentialModalClose?.addEventListener("click", closeCredentialModal);
|
||||
credentialModalDone?.addEventListener("click", closeCredentialModal);
|
||||
credentialModal?.addEventListener("click", (event) => {
|
||||
if (event.target === credentialModal) {
|
||||
closeCredentialModal();
|
||||
}
|
||||
});
|
||||
credentialCopyUsername?.addEventListener("click", () => copyText(els.credentialModalUsername?.textContent || ""));
|
||||
credentialCopyPassword?.addEventListener("click", () => copyText(els.credentialModalPassword?.textContent || ""));
|
||||
credentialCopyCommand?.addEventListener("click", () => copyText(latestCredentialCommand));
|
||||
els.configRetry?.addEventListener("click", async () => {
|
||||
if (!deviceId || !els.configRetry) {
|
||||
if (!activeDeviceId || !els.configRetry) {
|
||||
return;
|
||||
}
|
||||
els.configRetry.disabled = true;
|
||||
els.configRetry.textContent = "Retrying...";
|
||||
try {
|
||||
await api.retryDeviceConfigPush(deviceId, {});
|
||||
await api.retryDeviceConfigPush(activeDeviceId, {});
|
||||
await loadConfigStatus();
|
||||
} catch (error) {
|
||||
try {
|
||||
await api.retryDeviceConfigPush(deviceId, { force: true });
|
||||
await api.retryDeviceConfigPush(activeDeviceId, { force: true });
|
||||
await loadConfigStatus();
|
||||
} catch (retryError) {
|
||||
els.configRetry.textContent = "Retry Failed";
|
||||
|
||||
@ -490,16 +490,22 @@
|
||||
const loadMerchant = async () => {
|
||||
try {
|
||||
api.requireToken();
|
||||
if (!merchantId) {
|
||||
let selectedMerchantId = merchantId;
|
||||
if (!selectedMerchantId) {
|
||||
const merchants = await api.listMerchants();
|
||||
selectedMerchantId = Array.isArray(merchants) && merchants.length ? merchants[0].id : "";
|
||||
}
|
||||
|
||||
if (!selectedMerchantId) {
|
||||
showToast("Missing merchant_id in URL");
|
||||
return;
|
||||
}
|
||||
|
||||
const [merchant, outlets, devices, txs] = await Promise.all([
|
||||
api.getMerchant(merchantId),
|
||||
api.listOutlets({ merchant_id: merchantId }),
|
||||
api.listDevices({ merchant_id: merchantId }),
|
||||
api.listTransactions({ merchant_id: merchantId })
|
||||
api.getMerchant(selectedMerchantId),
|
||||
api.listOutlets({ merchant_id: selectedMerchantId }),
|
||||
api.listDevices({ merchant_id: selectedMerchantId }),
|
||||
api.listTransactions({ merchant_id: selectedMerchantId })
|
||||
]);
|
||||
|
||||
const name = merchant.legal_name || merchant.brand_name || "Merchant";
|
||||
|
||||
@ -139,12 +139,12 @@
|
||||
<p class="font-body-lg text-body-lg text-on-surface-variant">Selamat datang kembali. Silakan masuk untuk mengelola transaksi Anda.</p>
|
||||
</div>
|
||||
<!-- Login Form -->
|
||||
<form class="space-y-6" onsubmit="event.preventDefault();">
|
||||
<form id="merchant-login-form" class="space-y-6">
|
||||
<div>
|
||||
<label class="block font-label-md text-label-md text-on-surface-variant mb-2 ml-1" for="email">Email Bisnis</label>
|
||||
<label class="block font-label-md text-label-md text-on-surface-variant mb-2 ml-1" for="email">Merchant ID / Code</label>
|
||||
<div class="relative group">
|
||||
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline group-focus-within:text-primary transition-colors">mail</span>
|
||||
<input class="w-full pl-12 pr-4 py-3.5 bg-surface-container-lowest border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-500 font-body-md text-on-surface" id="email" name="email" placeholder="nama@bisnisanda.com" type="email"/>
|
||||
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline group-focus-within:text-primary transition-colors">storefront</span>
|
||||
<input class="w-full pl-12 pr-4 py-3.5 bg-surface-container-lowest border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-500 font-body-md text-on-surface" id="email" name="email" placeholder="merchant id atau m_xxxxxx" type="text" required/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@ -154,7 +154,7 @@
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<span class="material-symbols-outlined absolute left-4 top-1/2 -translate-y-1/2 text-outline group-focus-within:text-primary transition-colors">lock</span>
|
||||
<input class="w-full pl-12 pr-12 py-3.5 bg-surface-container-lowest border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-500 font-body-md text-on-surface" id="password" name="password" placeholder="••••••••" type="password"/>
|
||||
<input class="w-full pl-12 pr-12 py-3.5 bg-surface-container-lowest border border-slate-200 rounded-xl focus:ring-2 focus:ring-primary focus:border-transparent outline-none transition-all placeholder:text-slate-500 font-body-md text-on-surface" id="password" name="password" placeholder="••••••••" type="password" required/>
|
||||
<button class="absolute right-4 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface transition-colors" type="button">
|
||||
<span class="material-symbols-outlined">visibility</span>
|
||||
</button>
|
||||
@ -164,6 +164,7 @@
|
||||
<input class="w-4 h-4 text-primary bg-surface-container-lowest border-slate-200 rounded focus:ring-primary" id="remember" type="checkbox"/>
|
||||
<label class="ml-2 font-body-md text-body-md text-on-surface-variant" for="remember">Ingat saya selama 30 hari</label>
|
||||
</div>
|
||||
<p id="merchant-login-feedback" class="text-danger text-sm hidden"></p>
|
||||
<button class="w-full py-4 bg-primary hover:bg-surface-tint text-on-primary font-headline-md text-headline-md rounded-xl shadow-lg shadow-primary/20 active:scale-[0.98] transition-all flex justify-center items-center gap-2" type="submit">
|
||||
<span>Merchant Sign In</span>
|
||||
<span class="material-symbols-outlined">login</span>
|
||||
@ -254,6 +255,7 @@
|
||||
<a class="font-label-md text-label-md text-primary" href="#">Syarat Layanan</a>
|
||||
</div>
|
||||
</footer>
|
||||
<script src="/ui/shared/merchant-api.js"></script>
|
||||
<script>
|
||||
// Simple micro-interactions
|
||||
document.querySelectorAll('input').forEach(input => {
|
||||
@ -273,6 +275,23 @@
|
||||
passInput.setAttribute('type', type);
|
||||
toggleBtn.querySelector('span').innerText = type === 'password' ? 'visibility' : 'visibility_off';
|
||||
});
|
||||
|
||||
const form = document.getElementById('merchant-login-form');
|
||||
const feedback = document.getElementById('merchant-login-feedback');
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
feedback.classList.add('hidden');
|
||||
try {
|
||||
await MerchantUIAPI.login({
|
||||
username: document.getElementById('email').value.trim(),
|
||||
password: document.getElementById('password').value
|
||||
});
|
||||
window.location.href = '/ui/merchant-settlement-history';
|
||||
} catch (error) {
|
||||
feedback.textContent = error.message || 'Unable to sign in';
|
||||
feedback.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<!-- ui-nav -->
|
||||
<div id="__sb_nav" style="position:fixed;left:16px;bottom:16px;z-index:9999;background:#fff;border:1px solid #e2e8f0;padding:8px 10px;border-radius:8px;box-shadow:0 6px 24px rgba(15,23,42,0.12);font-family:Inter,Arial,sans-serif;font-size:12px;line-height:1.4">
|
||||
@ -283,4 +302,4 @@
|
||||
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
||||
</div>
|
||||
'
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
@ -133,27 +133,27 @@
|
||||
</div>
|
||||
</div>
|
||||
<nav class="flex-1 flex flex-col gap-1">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/admin-reconciliation-management">
|
||||
<span class="material-symbols-outlined" data-icon="account_balance_wallet">account_balance_wallet</span>
|
||||
<span>Reconciliation</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined" data-icon="security">security</span>
|
||||
<span>Audit Logs</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined" data-icon="payments">payments</span>
|
||||
<span>Fee Management</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold font-label-md text-label-md" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container dark:bg-on-secondary-fixed-variant text-on-secondary-container dark:text-on-secondary-fixed rounded-xl font-bold font-label-md text-label-md" href="/ui/merchant-settlement-history">
|
||||
<span class="material-symbols-outlined" data-icon="receipt_long">receipt_long</span>
|
||||
<span>Settlements</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/device-technical-detail">
|
||||
<span class="material-symbols-outlined" data-icon="router">router</span>
|
||||
<span>Device Health</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="/ui/hub">
|
||||
<span class="material-symbols-outlined" data-icon="contact_support">contact_support</span>
|
||||
<span>Support</span>
|
||||
</a>
|
||||
@ -163,10 +163,10 @@
|
||||
<span class="material-symbols-outlined" data-icon="add_chart">add_chart</span>
|
||||
Generate Report
|
||||
</button>
|
||||
<a class="flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md" href="#">
|
||||
<button id="merchant-logout" class="w-full flex items-center gap-3 px-4 py-3 text-secondary dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-xl transition-all font-label-md text-label-md">
|
||||
<span class="material-symbols-outlined" data-icon="logout">logout</span>
|
||||
<span>Logout</span>
|
||||
</a>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
<!-- Main Content Area -->
|
||||
@ -184,6 +184,10 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors" data-icon="notifications">notifications</span>
|
||||
<span class="material-symbols-outlined text-on-surface-variant cursor-pointer hover:text-primary transition-colors" data-icon="settings">settings</span>
|
||||
<div class="text-right">
|
||||
<p id="merchant-session-name" class="text-[12px] font-bold text-on-surface leading-tight">Merchant User</p>
|
||||
<p id="merchant-session-role" class="text-[11px] text-slate-500 leading-tight">session</p>
|
||||
</div>
|
||||
<div class="h-10 w-10 rounded-full bg-slate-200 overflow-hidden border border-slate-300">
|
||||
<img alt="Administrator Avatar" class="w-full h-full object-cover" data-alt="A professional headshot of a smiling fintech administrator in a bright, modern corporate office. High-key lighting highlights the reliable and expert persona, matching a clean light-mode aesthetic with soft blue and grey tones." src="https://lh3.googleusercontent.com/aida-public/AB6AXuAULoyxr37TXy6dAfagS4FSEQl7UpTG5XXKiqbc3Lt55-L4oWWcAjgJSHLfIRx3w_TYphBzQLrW9XdgtYvGMwDlsGslyaOunv0ANbViiiH0eSWWUrweqkulmIFSgKdqKoxSKdQ8L5ouHalFrIJtx0Lff4GQ-YmlbRwDJAfjaYzNFCucdQ4X7Dt7iIx83NWQ0Mf6PlAchGv7WoVm7K3TI8C6HkpMzpYhiphfN1LYtRDR4i-kW-Uk8Gcv2tPYheVSH_5-r9spt16EOl4"/>
|
||||
</div>
|
||||
@ -199,7 +203,7 @@
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-label-md text-label-md text-slate-500 mb-1">Available Balance</p>
|
||||
<h3 class="font-metric-lg text-metric-lg text-on-background">$12,480.50</h3>
|
||||
<h3 id="merchant-pending-payout" class="font-metric-lg text-metric-lg text-on-background">Rp0</h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-primary-container/10 rounded-lg flex items-center justify-center text-primary">
|
||||
<span class="material-symbols-outlined" data-icon="account_balance">account_balance</span>
|
||||
@ -218,14 +222,14 @@
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-label-md text-label-md text-slate-500 mb-1">Next Payout Date</p>
|
||||
<h3 class="font-metric-lg text-metric-lg text-on-background">Oct 24, 2023</h3>
|
||||
<h3 id="merchant-next-payout-date" class="font-metric-lg text-metric-lg text-on-background">-</h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-warning/10 rounded-lg flex items-center justify-center text-warning">
|
||||
<span class="material-symbols-outlined" data-icon="event">event</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<p class="font-label-md text-label-md text-slate-400">Estimated: <span class="text-on-surface font-semibold">$3,150.00</span></p>
|
||||
<p class="font-label-md text-label-md text-slate-400">Estimated: <span id="merchant-next-payout-amount" class="text-on-surface font-semibold">Rp0</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Total Settled MTD -->
|
||||
@ -233,18 +237,18 @@
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-label-md text-label-md text-slate-500 mb-1">Total Settled (MTD)</p>
|
||||
<h3 class="font-metric-lg text-metric-lg text-on-background">$45,210.00</h3>
|
||||
<h3 id="merchant-paid-payout" class="font-metric-lg text-metric-lg text-on-background">Rp0</h3>
|
||||
</div>
|
||||
<div class="w-10 h-10 bg-success/10 rounded-lg flex items-center justify-center text-success">
|
||||
<span class="material-symbols-outlined" data-icon="payments">payments</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-success font-metric-sm text-metric-sm flex items-center">
|
||||
<span class="material-symbols-outlined text-[16px]" data-icon="trending_up">trending_up</span>
|
||||
12.5%
|
||||
<span id="merchant-adjustment-amount" class="text-slate-500 font-metric-sm text-metric-sm flex items-center">
|
||||
<span class="material-symbols-outlined text-[16px]" data-icon="balance">balance</span>
|
||||
Adj Rp0
|
||||
</span>
|
||||
<span class="text-slate-400 font-label-md text-label-md">vs last month</span>
|
||||
<span class="text-slate-400 font-label-md text-label-md">included</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -262,11 +266,11 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-label-md text-label-md text-slate-500 whitespace-nowrap">Status:</span>
|
||||
<select class="border-slate-200 rounded-lg text-body-md focus:ring-primary focus:border-primary py-1 px-3">
|
||||
<select id="merchant-settlement-status-filter" class="border-slate-200 rounded-lg text-body-md focus:ring-primary focus:border-primary py-1 px-3">
|
||||
<option>All Statuses</option>
|
||||
<option>Processed</option>
|
||||
<option>Pending</option>
|
||||
<option>Failed</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="created">Pending</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
@ -296,7 +300,7 @@
|
||||
<th class="px-6 font-label-md text-label-md text-slate-500 uppercase tracking-wider text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<tbody id="merchant-settlement-rows" class="divide-y divide-slate-100">
|
||||
<!-- Row 1: Processed -->
|
||||
<tr class="hover:bg-slate-50 transition-colors h-row-height group">
|
||||
<td class="px-6 font-label-md text-label-md font-semibold text-primary">SET-902341</td>
|
||||
@ -394,7 +398,7 @@
|
||||
</div>
|
||||
<!-- Pagination -->
|
||||
<div class="px-6 py-4 flex items-center justify-between border-t border-slate-100 bg-white">
|
||||
<p class="font-label-md text-label-md text-slate-500">Showing <span class="font-semibold text-on-surface">1 - 5</span> of <span class="font-semibold text-on-surface">124</span> disbursements</p>
|
||||
<p id="merchant-settlement-summary" class="font-label-md text-label-md text-slate-500">Loading settlement batches...</p>
|
||||
<div class="flex gap-2">
|
||||
<button class="p-2 border border-slate-200 rounded-lg text-slate-400 hover:text-primary hover:bg-slate-50 transition-all">
|
||||
<span class="material-symbols-outlined text-[20px]" data-icon="chevron_left">chevron_left</span>
|
||||
@ -529,8 +533,8 @@
|
||||
<div class="space-y-6">
|
||||
<div class="p-4 bg-slate-50 rounded-xl">
|
||||
<p class="font-label-md text-label-md text-slate-500">Net Amount Paid</p>
|
||||
<p class="font-metric-lg text-metric-lg text-primary">$2,401.00</p>
|
||||
<span class="inline-flex items-center mt-2 px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success">
|
||||
<p id="drawer-net-amount" class="font-metric-lg text-metric-lg text-primary">Rp0</p>
|
||||
<span id="drawer-status-badge" class="inline-flex items-center mt-2 px-2.5 py-0.5 rounded-full text-xs font-medium bg-success/10 text-success">
|
||||
Successful Transfer
|
||||
</span>
|
||||
</div>
|
||||
@ -538,19 +542,19 @@
|
||||
<h4 class="font-label-md text-label-md font-bold uppercase text-slate-400">Breakdown</h4>
|
||||
<div class="flex justify-between items-center py-2 border-b border-slate-100">
|
||||
<span class="text-body-md text-slate-600">Gross Processing Volume</span>
|
||||
<span class="font-metric-sm text-metric-sm">$2,450.00</span>
|
||||
<span id="drawer-gross-amount" class="font-metric-sm text-metric-sm">Rp0</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-slate-100">
|
||||
<span class="text-body-md text-slate-600">Platform Fees (2%)</span>
|
||||
<span class="font-metric-sm text-metric-sm text-danger">-$49.00</span>
|
||||
<span class="text-body-md text-slate-600">Platform Fees</span>
|
||||
<span id="drawer-fee-amount" class="font-metric-sm text-metric-sm text-danger">Rp0</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2 border-b border-slate-100">
|
||||
<span class="text-body-md text-slate-600">Adjustment/Refunds</span>
|
||||
<span class="font-metric-sm text-metric-sm">$0.00</span>
|
||||
<span id="drawer-adjustment-amount" class="font-metric-sm text-metric-sm">Rp0</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center py-2">
|
||||
<span class="text-body-md font-bold">Total Disbursed</span>
|
||||
<span class="font-metric-sm text-metric-sm font-bold text-primary">$2,401.00</span>
|
||||
<span id="drawer-total-amount" class="font-metric-sm text-metric-sm font-bold text-primary">Rp0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 pt-6">
|
||||
@ -560,14 +564,14 @@
|
||||
<span class="material-symbols-outlined text-slate-500" data-icon="account_balance">account_balance</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="font-label-md text-label-md font-bold">HDFC Bank India</p>
|
||||
<p class="text-body-md text-slate-500">Checking Account •••• 4492</p>
|
||||
<p class="font-label-md text-label-md font-bold">Settlement Account</p>
|
||||
<p id="drawer-destination" class="text-body-md text-slate-500">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-4 pt-6">
|
||||
<h4 class="font-label-md text-label-md font-bold uppercase text-slate-400">Transfer Log</h4>
|
||||
<div class="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-slate-200">
|
||||
<div id="drawer-event-log" class="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-slate-200">
|
||||
<div class="relative">
|
||||
<div class="absolute -left-[22px] top-1 w-4 h-4 rounded-full bg-success ring-4 ring-white"></div>
|
||||
<p class="font-label-md text-label-md font-bold">Transfer Initiated</p>
|
||||
@ -586,7 +590,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-8 flex flex-col gap-3">
|
||||
<button class="w-full bg-primary text-on-primary py-3 px-4 rounded-xl font-bold flex items-center justify-center gap-2">
|
||||
<button id="drawer-download-report" class="w-full bg-primary text-on-primary py-3 px-4 rounded-xl font-bold flex items-center justify-center gap-2">
|
||||
<span class="material-symbols-outlined" data-icon="download">download</span>
|
||||
Download Proof of Transfer
|
||||
</button>
|
||||
@ -599,7 +603,161 @@
|
||||
<!-- Overlay for drawer -->
|
||||
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[90] hidden opacity-0 transition-opacity duration-300" id="drawerOverlay" onclick="toggleDrawer(false)"></div>
|
||||
</main>
|
||||
<script src="/ui/shared/merchant-api.js"></script>
|
||||
<script>
|
||||
const MerchantSettlementUI = (() => {
|
||||
const api = window.MerchantUIAPI;
|
||||
const rowsEl = document.getElementById('merchant-settlement-rows');
|
||||
const statusFilter = document.getElementById('merchant-settlement-status-filter');
|
||||
const summaryEl = document.getElementById('merchant-settlement-summary');
|
||||
const downloadBtn = document.getElementById('drawer-download-report');
|
||||
const logoutBtn = document.getElementById('merchant-logout');
|
||||
let activeBatchId = null;
|
||||
let activeBatchCode = '';
|
||||
|
||||
const normalize = (value) => String(value || '').toLowerCase();
|
||||
const money = (value) => api.formatMoney(value);
|
||||
const dt = (value) => api.formatDateTime(value);
|
||||
const setText = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value || '-';
|
||||
};
|
||||
const statusClass = (status) => {
|
||||
if (status === 'paid') return 'bg-success/10 text-success';
|
||||
if (status === 'failed' || status === 'cancelled') return 'bg-danger/10 text-danger';
|
||||
return 'bg-warning/10 text-warning';
|
||||
};
|
||||
const statusLabel = (status) => status === 'created' ? 'PENDING' : String(status || '-').toUpperCase();
|
||||
|
||||
const renderRows = (batches, merchant) => {
|
||||
if (!rowsEl) return;
|
||||
if (!batches.length) {
|
||||
rowsEl.innerHTML = '<tr><td colspan="7" class="px-6 py-10 text-center text-slate-500">No settlement batches available.</td></tr>';
|
||||
if (summaryEl) summaryEl.textContent = 'Showing 0 disbursements';
|
||||
return;
|
||||
}
|
||||
rowsEl.innerHTML = batches.map((batch) => `
|
||||
<tr class="hover:bg-slate-50 transition-colors h-row-height group cursor-pointer" data-batch-id="${batch.id}">
|
||||
<td class="px-6 font-label-md text-label-md font-semibold text-primary">${batch.batch_code}</td>
|
||||
<td class="px-6 font-body-md text-body-md text-on-surface">${dt(batch.paid_at || batch.created_at)}</td>
|
||||
<td class="px-6 font-body-md text-body-md text-slate-500">${merchant.settlement_account_reference || '-'}</td>
|
||||
<td class="px-6 font-body-md text-body-md text-right tabular-nums">${money(batch.gross_amount)}</td>
|
||||
<td class="px-6 font-body-md text-body-md text-right tabular-nums font-semibold">${money(batch.net_payable_amount)}</td>
|
||||
<td class="px-6"><span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass(batch.status)}">${statusLabel(batch.status)}</span></td>
|
||||
<td class="px-6 text-right">
|
||||
<button class="text-primary hover:underline font-label-md text-label-md flex items-center gap-1 ml-auto group-hover:opacity-100 opacity-0 transition-opacity">
|
||||
<span class="material-symbols-outlined text-[16px]" data-icon="visibility">visibility</span>
|
||||
Detail
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
rowsEl.querySelectorAll('tr[data-batch-id]').forEach((row) => {
|
||||
row.addEventListener('click', () => openDrawer(row.dataset.batchId, merchant));
|
||||
});
|
||||
if (summaryEl) summaryEl.textContent = `Showing ${batches.length} disbursement(s)`;
|
||||
};
|
||||
|
||||
const renderEvents = (events) => {
|
||||
const host = document.getElementById('drawer-event-log');
|
||||
if (!host) return;
|
||||
const rows = Array.isArray(events) ? events : [];
|
||||
if (!rows.length) {
|
||||
host.innerHTML = '<div class="relative"><div class="absolute -left-[22px] top-1 w-4 h-4 rounded-full bg-slate-300 ring-4 ring-white"></div><p class="font-label-md text-label-md font-bold">No payout events yet</p><p class="text-xs text-slate-400">-</p></div>';
|
||||
return;
|
||||
}
|
||||
host.innerHTML = rows.map((event) => {
|
||||
const label = String(event.event_type || '').replace(/_/g, ' ').toUpperCase();
|
||||
const actor = `${event.actor_type || 'system'}${event.actor_id ? ` · ${event.actor_id}` : ''}`;
|
||||
return `
|
||||
<div class="relative">
|
||||
<div class="absolute -left-[22px] top-1 w-4 h-4 rounded-full bg-success ring-4 ring-white"></div>
|
||||
<p class="font-label-md text-label-md font-bold">${label}</p>
|
||||
<p class="text-xs text-slate-400">${dt(event.created_at)} · ${actor}</p>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const openDrawer = async (batchId, merchant) => {
|
||||
const payload = await api.getSettlementBatch(batchId);
|
||||
const batch = payload.batch;
|
||||
const metadata = batch.metadata_json || {};
|
||||
const adjustmentAmount = Array.isArray(payload.adjustments)
|
||||
? payload.adjustments
|
||||
.filter((item) => (item.approval_status || 'approved') === 'approved')
|
||||
.reduce((sum, item) => sum + Number(item.signed_amount || 0), 0)
|
||||
: Number(metadata.total_adjustment_amount || 0);
|
||||
const adjustedNetAmount = Number(batch.net_payable_amount || 0) + adjustmentAmount;
|
||||
activeBatchId = batch.id;
|
||||
activeBatchCode = batch.batch_code;
|
||||
setText('drawer-net-amount', money(adjustedNetAmount));
|
||||
setText('drawer-gross-amount', money(batch.gross_amount));
|
||||
setText('drawer-fee-amount', `-${money(batch.platform_fee_amount)}`);
|
||||
setText('drawer-adjustment-amount', money(adjustmentAmount));
|
||||
setText('drawer-total-amount', money(adjustedNetAmount));
|
||||
setText('drawer-destination', merchant.settlement_account_reference || '-');
|
||||
const badge = document.getElementById('drawer-status-badge');
|
||||
if (badge) {
|
||||
badge.textContent = statusLabel(batch.status);
|
||||
badge.className = `inline-flex items-center mt-2 px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass(batch.status)}`;
|
||||
}
|
||||
renderEvents(payload.events || []);
|
||||
toggleDrawer(true);
|
||||
};
|
||||
|
||||
const downloadActiveCsv = async () => {
|
||||
if (!activeBatchId) return;
|
||||
const { token, merchantId } = api.requireSession();
|
||||
const response = await fetch(`/merchant/settlement-batches/${encodeURIComponent(activeBatchId)}/export.csv`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'X-Merchant-Id': merchantId
|
||||
}
|
||||
});
|
||||
if (!response.ok) throw new Error(`CSV export failed: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `${activeBatchCode || activeBatchId}-merchant-payout-report.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
api.requireSession();
|
||||
const [profile, summary, batches] = await Promise.all([
|
||||
api.getProfile(),
|
||||
api.getSettlementSummary(),
|
||||
api.listSettlementBatches({
|
||||
limit: 100,
|
||||
status: statusFilter?.value && statusFilter.value !== 'All Statuses' ? statusFilter.value : undefined
|
||||
})
|
||||
]);
|
||||
const merchant = profile?.merchant || profile;
|
||||
const user = profile?.user || api.getSessionUser?.();
|
||||
setText('merchant-session-name', user?.name || merchant.brand_name || merchant.legal_name || 'Merchant User');
|
||||
setText('merchant-session-role', user?.role_name ? `${String(user.role_name).toUpperCase()} · ${api.getAuthMode?.() || 'session'}` : api.getAuthMode?.() || 'session');
|
||||
setText('merchant-pending-payout', money(summary.pending_amount));
|
||||
setText('merchant-paid-payout', money(summary.adjusted_paid_amount ?? summary.paid_amount));
|
||||
setText('merchant-adjustment-amount', `Adj ${money(summary.adjustment_amount || 0)}`);
|
||||
setText('merchant-next-payout-amount', money(summary.pending_amount));
|
||||
setText('merchant-next-payout-date', Number(summary.created_batches || 0) > 0 ? 'On next payout run' : '-');
|
||||
renderRows(batches || [], merchant);
|
||||
};
|
||||
|
||||
statusFilter?.addEventListener('change', load);
|
||||
downloadBtn?.addEventListener('click', downloadActiveCsv);
|
||||
logoutBtn?.addEventListener('click', () => {
|
||||
api.clearSession();
|
||||
window.location.href = '/ui/merchant-login';
|
||||
});
|
||||
return { load };
|
||||
})();
|
||||
|
||||
function toggleDrawer(isOpen) {
|
||||
const drawer = document.getElementById('detailDrawer');
|
||||
const overlay = document.getElementById('drawerOverlay');
|
||||
@ -615,10 +773,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Simulating row click for detail view
|
||||
document.querySelectorAll('tbody tr').forEach(row => {
|
||||
row.style.cursor = 'pointer';
|
||||
row.addEventListener('click', () => toggleDrawer(true));
|
||||
MerchantSettlementUI.load().catch((error) => {
|
||||
console.error('[merchant-settlement] failed loading data', error);
|
||||
});
|
||||
</script>
|
||||
<!-- ui-nav -->
|
||||
@ -630,4 +786,4 @@
|
||||
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
||||
</div>
|
||||
'
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
@ -123,38 +123,38 @@
|
||||
<p class="font-label-md text-label-md text-slate-500 uppercase tracking-wider">Admin Console</p>
|
||||
</div>
|
||||
<nav class="flex-1 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="/ui/admin-dashboard-overview">
|
||||
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
|
||||
<span>Overview</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="/ui/merchant-detail-view">
|
||||
<span class="material-symbols-outlined" data-icon="storefront">storefront</span>
|
||||
<span>Merchant Management</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="/ui/device-technical-detail">
|
||||
<span class="material-symbols-outlined" data-icon="speaker_group">speaker_group</span>
|
||||
<span>Device Registry</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="/ui/transaction-history-monitoring">
|
||||
<span class="material-symbols-outlined" data-icon="receipt_long">receipt_long</span>
|
||||
<span>Transactions</span>
|
||||
</a>
|
||||
<!-- Active Tab -->
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 bg-secondary-container text-on-secondary-container font-bold rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 bg-secondary-container text-on-secondary-container font-bold rounded-lg" href="/ui/settlement-batch-management">
|
||||
<span class="material-symbols-outlined" data-icon="account_balance" style="font-variation-settings: 'FILL' 1;">account_balance</span>
|
||||
<span>Ledger & Settlement</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="/ui/admin-reconciliation-management">
|
||||
<span class="material-symbols-outlined" data-icon="history_edu">history_edu</span>
|
||||
<span>Audit Control</span>
|
||||
</a>
|
||||
</nav>
|
||||
<div class="mt-auto border-t border-slate-100 pt-4 space-y-1">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="/ui/hub">
|
||||
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="#">
|
||||
<a class="flex items-center gap-3 px-3 py-2.5 font-body-md text-on-surface-variant hover:bg-slate-100 transition-colors rounded-lg" href="/ui/hub">
|
||||
<span class="material-symbols-outlined" data-icon="help">help</span>
|
||||
<span>Support</span>
|
||||
</a>
|
||||
@ -170,8 +170,8 @@
|
||||
<input class="w-full bg-slate-50 border-none rounded-lg pl-10 pr-4 py-2 text-body-md focus:ring-2 focus:ring-primary/20 focus:bg-white transition-all" placeholder="Search batch or merchant..." type="text"/>
|
||||
</div>
|
||||
<nav class="hidden md:flex gap-6 items-center">
|
||||
<a class="font-body-md font-bold text-primary border-b-2 border-primary h-[72px] flex items-center" href="#">Dashboard</a>
|
||||
<a class="font-body-md text-on-surface-variant hover:text-primary transition-colors h-[72px] flex items-center" href="#">System Health</a>
|
||||
<a class="font-body-md font-bold text-primary border-b-2 border-primary h-[72px] flex items-center" href="/ui/admin-dashboard-overview">Dashboard</a>
|
||||
<a class="font-body-md text-on-surface-variant hover:text-primary transition-colors h-[72px] flex items-center" href="/ui/admin-dashboard-overview">System Health</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
@ -193,7 +193,7 @@
|
||||
<h2 class="font-display-lg text-display-lg text-on-surface">Disbursement Batches</h2>
|
||||
<p class="font-body-lg text-slate-500 mt-1">Manage and track bulk merchant payouts across all bank partners.</p>
|
||||
</div>
|
||||
<button class="inline-flex items-center gap-2 bg-primary text-white px-6 py-3 rounded-xl font-bold hover:opacity-90 active:scale-95 transition-all shadow-lg shadow-primary/20">
|
||||
<button id="generate-settlement-batch" data-admin-permission="settlement:write" class="inline-flex items-center gap-2 bg-primary text-white px-6 py-3 rounded-xl font-bold hover:opacity-90 active:scale-95 transition-all shadow-lg shadow-primary/20 disabled:opacity-60">
|
||||
<span class="material-symbols-outlined" data-icon="add_circle">add_circle</span>
|
||||
Generate New Batch
|
||||
</button>
|
||||
@ -203,35 +203,35 @@
|
||||
<div class="bg-white p-card-padding border border-slate-200 rounded-xl">
|
||||
<p class="font-label-md text-slate-500 mb-2 uppercase tracking-wide">Pending Payouts</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="font-metric-lg text-metric-lg">₹ 14.2M</h3>
|
||||
<h3 id="kpi-pending-payouts" class="font-metric-lg text-metric-lg">Rp 0</h3>
|
||||
<span class="font-metric-sm text-success flex items-center gap-0.5">
|
||||
<span class="material-symbols-outlined !text-[18px]" data-icon="trending_up">trending_up</span> 12%
|
||||
<span class="material-symbols-outlined !text-[18px]" data-icon="trending_up">trending_up</span> Created
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-card-padding border border-slate-200 rounded-xl">
|
||||
<p class="font-label-md text-slate-500 mb-2 uppercase tracking-wide">Processing</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="font-metric-lg text-metric-lg">24</h3>
|
||||
<h3 id="kpi-created-batches" class="font-metric-lg text-metric-lg">0</h3>
|
||||
<span class="font-metric-sm text-warning flex items-center gap-0.5">
|
||||
<span class="material-symbols-outlined !text-[18px]" data-icon="sync">sync</span> Active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-card-padding border border-slate-200 rounded-xl">
|
||||
<p class="font-label-md text-slate-500 mb-2 uppercase tracking-wide">Avg. Success Rate</p>
|
||||
<p class="font-label-md text-slate-500 mb-2 uppercase tracking-wide">Paid Batches</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="font-metric-lg text-metric-lg">99.4%</h3>
|
||||
<h3 id="kpi-paid-batches" class="font-metric-lg text-metric-lg">0</h3>
|
||||
<span class="font-metric-sm text-success flex items-center gap-0.5">
|
||||
<span class="material-symbols-outlined !text-[18px]" data-icon="check_circle">check_circle</span> Stable
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-white p-card-padding border border-slate-200 rounded-xl">
|
||||
<p class="font-label-md text-slate-500 mb-2 uppercase tracking-wide">Total Fees (MTD)</p>
|
||||
<p class="font-label-md text-slate-500 mb-2 uppercase tracking-wide">Total Fees</p>
|
||||
<div class="flex items-end justify-between">
|
||||
<h3 class="font-metric-lg text-metric-lg">₹ 420K</h3>
|
||||
<span class="font-metric-sm text-slate-500">vs ₹ 380K</span>
|
||||
<h3 id="kpi-total-fees" class="font-metric-lg text-metric-lg">Rp 0</h3>
|
||||
<span id="kpi-total-adjustments" class="font-metric-sm text-slate-500">Adj Rp 0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -240,11 +240,12 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2 px-3 py-2 border border-slate-200 rounded-lg hover:border-primary cursor-pointer transition-colors">
|
||||
<span class="material-symbols-outlined text-slate-400" data-icon="account_balance">account_balance</span>
|
||||
<select class="bg-transparent border-none p-0 text-body-md focus:ring-0 cursor-pointer">
|
||||
<option>All Bank Partners</option>
|
||||
<option>HDFC Bank</option>
|
||||
<option>ICICI Bank</option>
|
||||
<option>Yes Bank</option>
|
||||
<select id="settlement-status-filter" class="bg-transparent border-none p-0 text-body-md focus:ring-0 cursor-pointer">
|
||||
<option value="">All Statuses</option>
|
||||
<option value="created">Created</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 px-3 py-2 border border-slate-200 rounded-lg hover:border-primary cursor-pointer transition-colors">
|
||||
@ -278,97 +279,15 @@
|
||||
<th class="px-6"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-slate-100">
|
||||
<!-- Row 1: Completed -->
|
||||
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer">
|
||||
<td class="px-6 font-body-md font-bold text-primary tabular-nums">#BAT-20231015-01</td>
|
||||
<td class="px-6 font-body-md text-on-surface-variant">Oct 15, 08:00 - 12:00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">1,240</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 4,24,500.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 1,240.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums font-bold">₹ 4,23,260.00</td>
|
||||
<td class="px-6">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-success/10 text-success font-label-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success"></span> Completed
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 text-right">
|
||||
<span class="material-symbols-outlined text-slate-400 group-hover:text-primary transition-colors" data-icon="chevron_right">chevron_right</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 2: Processing -->
|
||||
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer">
|
||||
<td class="px-6 font-body-md font-bold text-primary tabular-nums">#BAT-20231015-02</td>
|
||||
<td class="px-6 font-body-md text-on-surface-variant">Oct 15, 12:00 - 16:00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">850</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 2,12,000.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 850.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums font-bold">₹ 2,11,150.00</td>
|
||||
<td class="px-6">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-warning/10 text-warning font-label-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-warning animate-pulse"></span> Processing
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 text-right">
|
||||
<span class="material-symbols-outlined text-slate-400 group-hover:text-primary transition-colors" data-icon="chevron_right">chevron_right</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 3: Failed -->
|
||||
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer">
|
||||
<td class="px-6 font-body-md font-bold text-primary tabular-nums">#BAT-20231014-42</td>
|
||||
<td class="px-6 font-body-md text-on-surface-variant">Oct 14, 20:00 - 23:59</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">412</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 95,400.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 412.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums font-bold">₹ 94,988.00</td>
|
||||
<td class="px-6">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-danger/10 text-danger font-label-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-danger"></span> Failed
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 text-right">
|
||||
<span class="material-symbols-outlined text-slate-400 group-hover:text-primary transition-colors" data-icon="chevron_right">chevron_right</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 4: Completed -->
|
||||
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer">
|
||||
<td class="px-6 font-body-md font-bold text-primary tabular-nums">#BAT-20231014-41</td>
|
||||
<td class="px-6 font-body-md text-on-surface-variant">Oct 14, 16:00 - 20:00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">2,104</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 12,45,200.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 2,104.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums font-bold">₹ 12,43,096.00</td>
|
||||
<td class="px-6">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-success/10 text-success font-label-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success"></span> Completed
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 text-right">
|
||||
<span class="material-symbols-outlined text-slate-400 group-hover:text-primary transition-colors" data-icon="chevron_right">chevron_right</span>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Row 5: Completed -->
|
||||
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer">
|
||||
<td class="px-6 font-body-md font-bold text-primary tabular-nums">#BAT-20231014-40</td>
|
||||
<td class="px-6 font-body-md text-on-surface-variant">Oct 14, 12:00 - 16:00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">1,892</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 8,12,050.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">₹ 1,892.00</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums font-bold">₹ 8,10,158.00</td>
|
||||
<td class="px-6">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-success/10 text-success font-label-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-success"></span> Completed
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 text-right">
|
||||
<span class="material-symbols-outlined text-slate-400 group-hover:text-primary transition-colors" data-icon="chevron_right">chevron_right</span>
|
||||
</td>
|
||||
<tbody id="settlement-batch-rows" class="divide-y divide-slate-100">
|
||||
<tr>
|
||||
<td colspan="8" class="px-6 py-10 text-center text-slate-500">Loading settlement batches...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Pagination -->
|
||||
<div class="bg-white px-6 py-4 border-t border-slate-100 flex items-center justify-between">
|
||||
<p class="text-body-md text-slate-500">Showing <span class="font-bold text-on-surface">1 - 5</span> of 248 batches</p>
|
||||
<p id="settlement-pagination-summary" class="text-body-md text-slate-500">Showing 0 batches</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-2 border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50" disabled="">
|
||||
<span class="material-symbols-outlined" data-icon="chevron_left">chevron_left</span>
|
||||
@ -399,19 +318,34 @@
|
||||
<span class="font-body-md font-bold" id="drawerBatchId">#BAT-20231015-01</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<span class="font-label-md text-slate-500 uppercase">Settlement Bank</span>
|
||||
<span class="flex items-center gap-2 font-body-md">
|
||||
<span class="w-6 h-6 bg-blue-100 rounded flex items-center justify-center text-[10px] font-bold text-blue-700">HDFC</span>
|
||||
HDFC Bank Ltd.
|
||||
</span>
|
||||
<span class="font-label-md text-slate-500 uppercase">Merchant ID</span>
|
||||
<span id="drawerMerchantId" class="font-body-md font-bold text-right break-all">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 rounded-xl p-4 mb-8">
|
||||
<p class="font-label-md text-slate-500 mb-3 uppercase">Verification Progress</p>
|
||||
<p class="font-label-md text-slate-500 mb-3 uppercase">Settlement Status</p>
|
||||
<div class="relative h-2 w-full bg-slate-200 rounded-full overflow-hidden mb-2">
|
||||
<div class="absolute inset-y-0 left-0 bg-success w-[100%] transition-all duration-1000"></div>
|
||||
<div id="drawerProgressBar" class="absolute inset-y-0 left-0 bg-warning w-[40%] transition-all duration-1000"></div>
|
||||
</div>
|
||||
<p id="drawerStatusText" class="text-label-md text-warning font-bold">CREATED</p>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4 mb-8">
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="font-label-md text-slate-500 uppercase mb-1">Gross</p>
|
||||
<p id="drawerGrossAmount" class="font-body-md font-bold tabular-nums">Rp 0</p>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="font-label-md text-slate-500 uppercase mb-1">Fee</p>
|
||||
<p id="drawerFeeAmount" class="font-body-md font-bold tabular-nums">Rp 0</p>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="font-label-md text-slate-500 uppercase mb-1">Net Payable</p>
|
||||
<p id="drawerNetAmount" class="font-body-md font-bold tabular-nums">Rp 0</p>
|
||||
</div>
|
||||
<div class="bg-white border border-slate-200 rounded-xl p-4">
|
||||
<p class="font-label-md text-slate-500 uppercase mb-1">Entries</p>
|
||||
<p id="drawerEntryCount" class="font-body-md font-bold tabular-nums">0</p>
|
||||
</div>
|
||||
<p class="text-label-md text-success font-bold">100% KYC Verified & Cleaned</p>
|
||||
</div>
|
||||
<div class="space-y-6">
|
||||
<h4 class="font-body-md font-bold text-on-surface">Timeline</h4>
|
||||
@ -419,67 +353,536 @@
|
||||
<div class="relative">
|
||||
<span class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-success border-4 border-white shadow-sm"></span>
|
||||
<p class="text-body-md font-bold">Batch Initialized</p>
|
||||
<p class="text-label-md text-slate-500">Oct 15, 2023 • 08:00 AM</p>
|
||||
<p id="drawerCreatedAt" class="text-label-md text-slate-500">-</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-success border-4 border-white shadow-sm"></span>
|
||||
<p class="text-body-md font-bold">Merchant Ledger Locked</p>
|
||||
<p class="text-label-md text-slate-500">Oct 15, 2023 • 08:15 AM</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-success border-4 border-white shadow-sm"></span>
|
||||
<p class="text-body-md font-bold">Bank File Uploaded (SFTP)</p>
|
||||
<p class="text-label-md text-slate-500">Oct 15, 2023 • 09:30 AM</p>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-success border-4 border-white shadow-sm"></span>
|
||||
<span id="drawerPaidDot" class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-slate-300 border-4 border-white shadow-sm"></span>
|
||||
<p class="text-body-md font-bold">Batch Settled</p>
|
||||
<p class="text-label-md text-slate-500">Oct 15, 2023 • 11:45 AM</p>
|
||||
<p id="drawerPaidAt" class="text-label-md text-slate-500">Not paid yet</p>
|
||||
<p id="drawerPaidReference" class="text-label-md text-slate-500">Reference: -</p>
|
||||
<p id="drawerPaidNote" class="text-label-md text-slate-500">Note: -</p>
|
||||
<p id="drawerAdjustmentSummary" class="text-label-md text-slate-500">Adjustments: Rp 0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-8 space-y-4">
|
||||
<h4 class="font-body-md font-bold text-on-surface">Payout Event History</h4>
|
||||
<div id="drawerSettlementEvents" class="relative pl-6 border-l-2 border-slate-100 space-y-5">
|
||||
<div class="relative">
|
||||
<span class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-slate-300 border-4 border-white shadow-sm"></span>
|
||||
<p class="text-body-md font-bold">Loading events</p>
|
||||
<p class="text-label-md text-slate-500">-</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="settlement-paid-form" class="mt-8 bg-slate-50 border border-slate-200 rounded-xl p-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-body-md font-bold text-on-surface">Confirm Payout</p>
|
||||
<p class="text-label-md text-slate-500">Record manual payout proof before closing this batch.</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-warning" data-icon="receipt_long">receipt_long</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-paid-at">Paid At</label>
|
||||
<input id="settlement-paid-at" type="datetime-local" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-primary/30" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-paid-reference">Reference No</label>
|
||||
<input id="settlement-paid-reference" type="text" maxlength="120" placeholder="Bank transfer / payout reference" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-primary/30" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-paid-note">Note</label>
|
||||
<textarea id="settlement-paid-note" maxlength="500" rows="3" placeholder="Optional reconciliation note" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-primary/30"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
<form id="settlement-reference-form" class="mt-8 bg-primary/5 border border-primary/10 rounded-xl p-4 space-y-4 hidden">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-body-md font-bold text-on-surface">Update Payout Reference</p>
|
||||
<p class="text-label-md text-slate-500">Correct bank transfer reference or reconciliation note for a paid batch.</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary" data-icon="edit_note">edit_note</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-reference-update">Reference No</label>
|
||||
<input id="settlement-reference-update" type="text" maxlength="120" placeholder="Bank transfer / payout reference" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-primary/30" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-note-update">Note</label>
|
||||
<textarea id="settlement-note-update" maxlength="500" rows="3" placeholder="Optional reconciliation note" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-primary/30"></textarea>
|
||||
</div>
|
||||
<button id="update-settlement-reference" data-admin-permission="settlement:pay" type="button" class="w-full px-4 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors disabled:opacity-50">Update Reference</button>
|
||||
</form>
|
||||
<form id="settlement-adjustment-form" class="mt-8 bg-warning/5 border border-warning/20 rounded-xl p-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-body-md font-bold text-on-surface">Record Adjustment</p>
|
||||
<p class="text-label-md text-slate-500">Track payout dispute or correction without changing ledger entries.</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-warning" data-icon="balance">balance</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-adjustment-type">Type</label>
|
||||
<select id="settlement-adjustment-type" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-warning/30">
|
||||
<option value="credit">Credit Merchant</option>
|
||||
<option value="debit">Debit Merchant</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-adjustment-amount">Amount</label>
|
||||
<input id="settlement-adjustment-amount" type="number" min="1" step="0.01" placeholder="0" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-warning/30" required />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-adjustment-reason">Reason</label>
|
||||
<input id="settlement-adjustment-reason" type="text" maxlength="300" placeholder="Required adjustment reason" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-warning/30" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-adjustment-note">Note</label>
|
||||
<textarea id="settlement-adjustment-note" maxlength="500" rows="3" placeholder="Optional internal note" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-warning/30"></textarea>
|
||||
</div>
|
||||
<button id="record-settlement-adjustment" data-admin-permission="settlement:adjust" type="button" class="w-full px-4 py-3 bg-warning text-slate-900 rounded-xl font-bold hover:opacity-90 transition-colors disabled:opacity-50">Record Adjustment</button>
|
||||
</form>
|
||||
<form id="settlement-resolution-form" class="mt-8 bg-danger/5 border border-danger/10 rounded-xl p-4 space-y-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-body-md font-bold text-on-surface">Exception Resolution</p>
|
||||
<p class="text-label-md text-slate-500">Mark payout as failed or cancel the batch before it is paid.</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-danger" data-icon="report">report</span>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-resolution-reason">Reason</label>
|
||||
<input id="settlement-resolution-reason" type="text" maxlength="300" placeholder="Required reason" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-danger/30" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-label-md font-bold text-slate-500 uppercase mb-1" for="settlement-resolution-note">Note</label>
|
||||
<textarea id="settlement-resolution-note" maxlength="500" rows="3" placeholder="Optional internal note" class="w-full px-3 py-2 rounded-lg border border-slate-200 bg-white focus:outline-none focus:ring-2 focus:ring-danger/30"></textarea>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button id="mark-settlement-failed" data-admin-permission="settlement:write" type="button" class="px-4 py-3 bg-white border border-danger/20 text-danger rounded-xl font-bold hover:bg-danger/5 transition-colors disabled:opacity-50">Mark Failed</button>
|
||||
<button id="cancel-settlement-batch" data-admin-permission="settlement:write" type="button" class="px-4 py-3 bg-white border border-slate-200 text-slate-700 rounded-xl font-bold hover:bg-slate-100 transition-colors disabled:opacity-50">Cancel Batch</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="settlement-reprocess-panel" class="mt-8 bg-primary/5 border border-primary/10 rounded-xl p-4 space-y-3 hidden">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p class="font-body-md font-bold text-on-surface">Reprocess Payable</p>
|
||||
<p id="settlement-reprocess-detail" class="text-label-md text-slate-500">Move entries into a new created batch.</p>
|
||||
</div>
|
||||
<span class="material-symbols-outlined text-primary" data-icon="sync_alt">sync_alt</span>
|
||||
</div>
|
||||
<button id="reprocess-settlement-batch" data-admin-permission="settlement:write" type="button" class="w-full px-4 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors disabled:opacity-50">Reprocess Batch</button>
|
||||
</div>
|
||||
<div class="mt-12">
|
||||
<p class="font-label-md text-slate-500 mb-2 uppercase">Raw API Response</p>
|
||||
<div class="bg-slate-900 rounded-lg p-4 font-mono text-[12px] text-primary-fixed-dim relative group">
|
||||
<button class="absolute top-2 right-2 text-slate-500 hover:text-white transition-colors opacity-0 group-hover:opacity-100">
|
||||
<span class="material-symbols-outlined !text-[18px]" data-icon="content_copy">content_copy</span>
|
||||
</button>
|
||||
<pre>{
|
||||
"batch_id": "BAT-20231015-01",
|
||||
"status": "COMPLETED",
|
||||
"merchant_count": 1240,
|
||||
"net_payout": 423260.00,
|
||||
"currency": "INR",
|
||||
"bank_ref": "HDFC_91230491_SET"
|
||||
}</pre>
|
||||
<pre id="drawerRawJson">{}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 border-t border-slate-100 bg-slate-50 flex gap-3">
|
||||
<button class="flex-1 px-4 py-3 bg-white border border-slate-200 rounded-xl font-bold hover:bg-slate-100 transition-colors">Download CSV</button>
|
||||
<button class="flex-1 px-4 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors">Re-run Reconciliation</button>
|
||||
<select id="settlement-export-format" class="w-[180px] px-3 py-3 bg-white border border-slate-200 rounded-xl font-body-md focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option value="standard">General CSV</option>
|
||||
<option value="bank_generic">Bank Upload</option>
|
||||
</select>
|
||||
<button id="download-settlement-csv" data-admin-permission="settlement:export" class="flex-1 px-4 py-3 bg-white border border-slate-200 rounded-xl font-bold hover:bg-slate-100 transition-colors disabled:opacity-50">Download CSV</button>
|
||||
<button id="mark-settlement-paid" data-admin-permission="settlement:pay" class="flex-1 px-4 py-3 bg-primary text-white rounded-xl font-bold hover:opacity-90 transition-colors disabled:opacity-50">Confirm Paid</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<script src="/ui/shared/admin-api.js"></script>
|
||||
<script>
|
||||
function toggleDrawer() {
|
||||
const drawer = document.getElementById('detailDrawer');
|
||||
if (drawer.classList.contains('translate-x-full')) {
|
||||
drawer.classList.remove('translate-x-full');
|
||||
} else {
|
||||
drawer.classList.add('translate-x-full');
|
||||
}
|
||||
}
|
||||
const SettlementUI = (() => {
|
||||
const api = window.AdminUIAPI;
|
||||
if (!api) return;
|
||||
|
||||
// Simulating row click to open drawer
|
||||
document.querySelectorAll('tbody tr').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
const batchId = row.querySelector('td:first-child').textContent;
|
||||
document.getElementById('drawerBatchId').textContent = batchId;
|
||||
toggleDrawer();
|
||||
const rowsEl = document.getElementById('settlement-batch-rows');
|
||||
const generateBtn = document.getElementById('generate-settlement-batch');
|
||||
const statusFilter = document.getElementById('settlement-status-filter');
|
||||
const summaryEl = document.getElementById('settlement-pagination-summary');
|
||||
const drawer = document.getElementById('detailDrawer');
|
||||
const markPaidBtn = document.getElementById('mark-settlement-paid');
|
||||
const downloadCsvBtn = document.getElementById('download-settlement-csv');
|
||||
const exportFormatSelect = document.getElementById('settlement-export-format');
|
||||
const paidForm = document.getElementById('settlement-paid-form');
|
||||
const paidAtInput = document.getElementById('settlement-paid-at');
|
||||
const paidReferenceInput = document.getElementById('settlement-paid-reference');
|
||||
const paidNoteInput = document.getElementById('settlement-paid-note');
|
||||
const referenceForm = document.getElementById('settlement-reference-form');
|
||||
const referenceUpdateInput = document.getElementById('settlement-reference-update');
|
||||
const noteUpdateInput = document.getElementById('settlement-note-update');
|
||||
const updateReferenceBtn = document.getElementById('update-settlement-reference');
|
||||
const adjustmentForm = document.getElementById('settlement-adjustment-form');
|
||||
const adjustmentTypeInput = document.getElementById('settlement-adjustment-type');
|
||||
const adjustmentAmountInput = document.getElementById('settlement-adjustment-amount');
|
||||
const adjustmentReasonInput = document.getElementById('settlement-adjustment-reason');
|
||||
const adjustmentNoteInput = document.getElementById('settlement-adjustment-note');
|
||||
const recordAdjustmentBtn = document.getElementById('record-settlement-adjustment');
|
||||
const resolutionForm = document.getElementById('settlement-resolution-form');
|
||||
const resolutionReasonInput = document.getElementById('settlement-resolution-reason');
|
||||
const resolutionNoteInput = document.getElementById('settlement-resolution-note');
|
||||
const markFailedBtn = document.getElementById('mark-settlement-failed');
|
||||
const cancelBatchBtn = document.getElementById('cancel-settlement-batch');
|
||||
const reprocessPanel = document.getElementById('settlement-reprocess-panel');
|
||||
const reprocessDetail = document.getElementById('settlement-reprocess-detail');
|
||||
const reprocessBtn = document.getElementById('reprocess-settlement-batch');
|
||||
const settlementEventsEl = document.getElementById('drawerSettlementEvents');
|
||||
let batches = [];
|
||||
let activeBatchId = null;
|
||||
let activeBatchCode = '';
|
||||
let adminProfile = null;
|
||||
|
||||
const money = (value) => api.formatMoney ? api.formatMoney(value) : `Rp ${Number(value || 0).toLocaleString('id-ID')}`;
|
||||
const dt = (value) => api.formatDateTime ? api.formatDateTime(value) : (value || '-');
|
||||
const setText = (id, value) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.textContent = value || '-';
|
||||
};
|
||||
const toDateTimeLocalValue = (value) => {
|
||||
const date = value ? new Date(value) : new Date();
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
const offsetMs = date.getTimezoneOffset() * 60 * 1000;
|
||||
return new Date(date.getTime() - offsetMs).toISOString().slice(0, 16);
|
||||
};
|
||||
const can = (permission) => api.hasPermission ? api.hasPermission(permission, adminProfile) : true;
|
||||
|
||||
const statusClass = (status) => {
|
||||
if (status === 'paid') return 'bg-success/10 text-success';
|
||||
if (status === 'failed' || status === 'cancelled') return 'bg-danger/10 text-danger';
|
||||
return 'bg-warning/10 text-warning';
|
||||
};
|
||||
|
||||
const statusDot = (status) => {
|
||||
if (status === 'paid') return 'bg-success';
|
||||
if (status === 'failed' || status === 'cancelled') return 'bg-danger';
|
||||
return 'bg-warning animate-pulse';
|
||||
};
|
||||
|
||||
const renderSettlementEvents = (events) => {
|
||||
if (!settlementEventsEl) return;
|
||||
const rows = Array.isArray(events) ? events : [];
|
||||
if (!rows.length) {
|
||||
settlementEventsEl.innerHTML = '<div class="relative"><span class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-slate-300 border-4 border-white shadow-sm"></span><p class="text-body-md font-bold">No payout events yet</p><p class="text-label-md text-slate-500">-</p></div>';
|
||||
return;
|
||||
}
|
||||
settlementEventsEl.innerHTML = rows.map((event) => {
|
||||
const label = String(event.event_type || '').replace(/_/g, ' ').toUpperCase();
|
||||
const actor = `${event.actor_type || 'system'}${event.actor_id ? ` · ${event.actor_id}` : ''}`;
|
||||
return `
|
||||
<div class="relative">
|
||||
<span class="absolute -left-[33px] top-0 w-4 h-4 rounded-full bg-success border-4 border-white shadow-sm"></span>
|
||||
<p class="text-body-md font-bold">${label}</p>
|
||||
<p class="text-label-md text-slate-500">${dt(event.created_at)} · ${actor}</p>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
};
|
||||
|
||||
const renderKpis = () => {
|
||||
const created = batches.filter((batch) => batch.status === 'created');
|
||||
const paid = batches.filter((batch) => batch.status === 'paid');
|
||||
const pendingAmount = created.reduce((sum, batch) => sum + Number(batch.net_payable_amount || 0), 0);
|
||||
const totalFees = batches.reduce((sum, batch) => sum + Number(batch.platform_fee_amount || 0), 0);
|
||||
const totalAdjustments = batches.reduce((sum, batch) => sum + Number(batch.metadata_json?.total_adjustment_amount || 0), 0);
|
||||
setText('kpi-pending-payouts', money(pendingAmount));
|
||||
setText('kpi-created-batches', String(created.length));
|
||||
setText('kpi-paid-batches', String(paid.length));
|
||||
setText('kpi-total-fees', money(totalFees));
|
||||
setText('kpi-total-adjustments', `Adj ${money(totalAdjustments)}`);
|
||||
};
|
||||
|
||||
const renderRows = () => {
|
||||
if (!rowsEl) return;
|
||||
if (!batches.length) {
|
||||
rowsEl.innerHTML = '<tr><td colspan="8" class="px-6 py-10 text-center text-slate-500">No settlement batches yet.</td></tr>';
|
||||
summaryEl.textContent = 'Showing 0 batches';
|
||||
renderKpis();
|
||||
return;
|
||||
}
|
||||
|
||||
rowsEl.innerHTML = batches.map((batch) => `
|
||||
<tr class="h-[52px] hover:bg-slate-50 transition-colors group cursor-pointer" data-batch-id="${batch.id}">
|
||||
<td class="px-6 font-body-md font-bold text-primary tabular-nums">${batch.batch_code}</td>
|
||||
<td class="px-6 font-body-md text-on-surface-variant">${dt(batch.cutoff_at)}</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">${batch.entry_count}</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">${money(batch.gross_amount)}</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums">${money(batch.platform_fee_amount)}</td>
|
||||
<td class="px-6 font-body-md text-right tabular-nums font-bold">${money(batch.net_payable_amount)}</td>
|
||||
<td class="px-6">
|
||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full ${statusClass(batch.status)} font-label-md">
|
||||
<span class="w-1.5 h-1.5 rounded-full ${statusDot(batch.status)}"></span> ${String(batch.status).toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 text-right">
|
||||
<span class="material-symbols-outlined text-slate-400 group-hover:text-primary transition-colors" data-icon="chevron_right">chevron_right</span>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
|
||||
rowsEl.querySelectorAll('tr[data-batch-id]').forEach((row) => {
|
||||
row.addEventListener('click', () => openDrawer(row.dataset.batchId));
|
||||
});
|
||||
summaryEl.textContent = `Showing ${batches.length} batches`;
|
||||
renderKpis();
|
||||
};
|
||||
|
||||
const loadBatches = async () => {
|
||||
try {
|
||||
api.requireToken();
|
||||
const query = { limit: 100 };
|
||||
if (statusFilter.value) query.status = statusFilter.value;
|
||||
batches = await api.listSettlementBatches(query);
|
||||
renderRows();
|
||||
} catch (error) {
|
||||
rowsEl.innerHTML = '<tr><td colspan="8" class="px-6 py-10 text-center text-danger">Unable to load settlement batches.</td></tr>';
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleDrawer = function toggleDrawer(forceOpen) {
|
||||
if (!drawer) return;
|
||||
const shouldOpen = typeof forceOpen === 'boolean' ? forceOpen : drawer.classList.contains('translate-x-full');
|
||||
drawer.classList.toggle('translate-x-full', !shouldOpen);
|
||||
};
|
||||
|
||||
const openDrawer = async (batchId) => {
|
||||
activeBatchId = batchId;
|
||||
const payload = await api.getSettlementBatch(batchId);
|
||||
const batch = payload.batch;
|
||||
activeBatchCode = batch.batch_code;
|
||||
const metadata = batch.metadata_json || {};
|
||||
const formalAdjustmentAmount = Array.isArray(payload.adjustments)
|
||||
? payload.adjustments
|
||||
.filter((item) => (item.approval_status || 'approved') === 'approved')
|
||||
.reduce((sum, item) => sum + Number(item.signed_amount || 0), 0)
|
||||
: Number(metadata.total_adjustment_amount || 0);
|
||||
setText('drawerBatchId', batch.batch_code);
|
||||
setText('drawerMerchantId', batch.merchant_id);
|
||||
setText('drawerStatusText', String(batch.status).toUpperCase());
|
||||
setText('drawerGrossAmount', money(batch.gross_amount));
|
||||
setText('drawerFeeAmount', money(batch.platform_fee_amount));
|
||||
setText('drawerNetAmount', money(batch.net_payable_amount));
|
||||
setText('drawerEntryCount', String(batch.entry_count));
|
||||
setText('drawerCreatedAt', dt(batch.created_at));
|
||||
setText('drawerPaidAt', batch.paid_at ? dt(batch.paid_at) : 'Not paid yet');
|
||||
setText('drawerPaidReference', `Reference: ${metadata.paid_reference || '-'}`);
|
||||
setText('drawerPaidNote', `Note: ${metadata.paid_note || '-'}`);
|
||||
setText('drawerAdjustmentSummary', `Adjustments: ${money(formalAdjustmentAmount)}`);
|
||||
setText('drawerStatusText', batch.failure_reason ? `${String(batch.status).toUpperCase()} · ${batch.failure_reason}` : String(batch.status).toUpperCase());
|
||||
renderSettlementEvents(payload.events || []);
|
||||
document.getElementById('drawerRawJson').textContent = JSON.stringify(payload, null, 2);
|
||||
const finalStatus = ['paid', 'failed', 'cancelled'].includes(batch.status);
|
||||
const progressColor = batch.status === 'paid' ? 'bg-success' : batch.status === 'created' ? 'bg-warning' : 'bg-danger';
|
||||
document.getElementById('drawerProgressBar').className = `absolute inset-y-0 left-0 ${progressColor} ${finalStatus ? 'w-[100%]' : 'w-[40%]'} transition-all duration-1000`;
|
||||
document.getElementById('drawerPaidDot').className = `absolute -left-[33px] top-0 w-4 h-4 rounded-full ${finalStatus ? progressColor : 'bg-slate-300'} border-4 border-white shadow-sm`;
|
||||
markPaidBtn.disabled = batch.status !== 'created' || !can('settlement:pay');
|
||||
markPaidBtn.textContent = batch.status === 'created' ? 'Confirm Paid' : 'Paid';
|
||||
if (paidForm) paidForm.classList.toggle('hidden', batch.status !== 'created');
|
||||
if (referenceForm) referenceForm.classList.toggle('hidden', batch.status !== 'paid');
|
||||
if (resolutionForm) resolutionForm.classList.toggle('hidden', batch.status !== 'created');
|
||||
const canAdjust = !metadata.reprocessed_to_batch_id;
|
||||
if (adjustmentForm) adjustmentForm.classList.toggle('hidden', !canAdjust);
|
||||
const canReprocess = ['failed', 'cancelled'].includes(batch.status) && !metadata.reprocessed_to_batch_id;
|
||||
if (reprocessPanel) reprocessPanel.classList.toggle('hidden', !canReprocess);
|
||||
if (reprocessDetail) {
|
||||
reprocessDetail.textContent = metadata.reprocessed_to_batch_id
|
||||
? `Already reprocessed to ${metadata.reprocessed_to_batch_id}.`
|
||||
: 'Move payable entries into a new created batch.';
|
||||
}
|
||||
if (paidAtInput) paidAtInput.value = toDateTimeLocalValue(new Date());
|
||||
if (paidReferenceInput) paidReferenceInput.value = '';
|
||||
if (paidNoteInput) paidNoteInput.value = '';
|
||||
if (referenceUpdateInput) referenceUpdateInput.value = metadata.paid_reference || '';
|
||||
if (noteUpdateInput) noteUpdateInput.value = metadata.paid_note || '';
|
||||
if (updateReferenceBtn) {
|
||||
updateReferenceBtn.disabled = batch.status !== 'paid' || !can('settlement:pay');
|
||||
updateReferenceBtn.textContent = 'Update Reference';
|
||||
}
|
||||
if (adjustmentTypeInput) adjustmentTypeInput.value = 'credit';
|
||||
if (adjustmentAmountInput) adjustmentAmountInput.value = '';
|
||||
if (adjustmentReasonInput) adjustmentReasonInput.value = '';
|
||||
if (adjustmentNoteInput) adjustmentNoteInput.value = '';
|
||||
if (recordAdjustmentBtn) {
|
||||
recordAdjustmentBtn.disabled = !canAdjust || !can('settlement:adjust');
|
||||
recordAdjustmentBtn.textContent = 'Record Adjustment';
|
||||
}
|
||||
if (resolutionReasonInput) resolutionReasonInput.value = '';
|
||||
if (resolutionNoteInput) resolutionNoteInput.value = '';
|
||||
if (markFailedBtn) {
|
||||
markFailedBtn.disabled = batch.status !== 'created' || !can('settlement:write');
|
||||
markFailedBtn.textContent = 'Mark Failed';
|
||||
}
|
||||
if (cancelBatchBtn) {
|
||||
cancelBatchBtn.disabled = batch.status !== 'created' || !can('settlement:write');
|
||||
cancelBatchBtn.textContent = 'Cancel Batch';
|
||||
}
|
||||
if (reprocessBtn) {
|
||||
reprocessBtn.disabled = !canReprocess || !can('settlement:write');
|
||||
reprocessBtn.textContent = 'Reprocess Batch';
|
||||
}
|
||||
downloadCsvBtn.disabled = !can('settlement:export');
|
||||
window.toggleDrawer(true);
|
||||
};
|
||||
|
||||
generateBtn?.addEventListener('click', async () => {
|
||||
generateBtn.disabled = true;
|
||||
generateBtn.innerHTML = '<span class="material-symbols-outlined">sync</span>Generating...';
|
||||
try {
|
||||
await api.createSettlementBatches({ cutoff_at: new Date().toISOString() });
|
||||
await loadBatches();
|
||||
} finally {
|
||||
generateBtn.disabled = false;
|
||||
generateBtn.innerHTML = '<span class="material-symbols-outlined" data-icon="add_circle">add_circle</span>Generate New Batch';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
statusFilter?.addEventListener('change', loadBatches);
|
||||
markPaidBtn?.addEventListener('click', async () => {
|
||||
if (!activeBatchId) return;
|
||||
if (paidForm && !paidForm.reportValidity()) return;
|
||||
const paidAt = paidAtInput?.value ? new Date(paidAtInput.value).toISOString() : new Date().toISOString();
|
||||
const paidReference = paidReferenceInput?.value.trim();
|
||||
const paidNote = paidNoteInput?.value.trim();
|
||||
markPaidBtn.disabled = true;
|
||||
markPaidBtn.textContent = 'Saving...';
|
||||
try {
|
||||
await api.markSettlementBatchPaid(activeBatchId, {
|
||||
paid_at: paidAt,
|
||||
paid_reference: paidReference,
|
||||
paid_note: paidNote
|
||||
});
|
||||
await loadBatches();
|
||||
await openDrawer(activeBatchId);
|
||||
} catch (error) {
|
||||
markPaidBtn.disabled = false;
|
||||
markPaidBtn.textContent = 'Confirm Paid';
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const resolveBatch = async (status) => {
|
||||
if (!activeBatchId) return;
|
||||
if (resolutionForm && !resolutionForm.reportValidity()) return;
|
||||
const reason = resolutionReasonInput?.value.trim();
|
||||
const note = resolutionNoteInput?.value.trim();
|
||||
const button = status === 'failed' ? markFailedBtn : cancelBatchBtn;
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = status === 'failed' ? 'Marking...' : 'Cancelling...';
|
||||
}
|
||||
try {
|
||||
if (status === 'failed') {
|
||||
await api.markSettlementBatchFailed(activeBatchId, { reason, note });
|
||||
} else {
|
||||
await api.cancelSettlementBatch(activeBatchId, { reason, note });
|
||||
}
|
||||
await loadBatches();
|
||||
await openDrawer(activeBatchId);
|
||||
} catch (error) {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
button.textContent = status === 'failed' ? 'Mark Failed' : 'Cancel Batch';
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
markFailedBtn?.addEventListener('click', () => resolveBatch('failed'));
|
||||
cancelBatchBtn?.addEventListener('click', () => resolveBatch('cancelled'));
|
||||
updateReferenceBtn?.addEventListener('click', async () => {
|
||||
if (!activeBatchId) return;
|
||||
if (referenceForm && !referenceForm.reportValidity()) return;
|
||||
const paidReference = referenceUpdateInput?.value.trim();
|
||||
const paidNote = noteUpdateInput?.value.trim();
|
||||
updateReferenceBtn.disabled = true;
|
||||
updateReferenceBtn.textContent = 'Updating...';
|
||||
try {
|
||||
await api.updateSettlementBatchReference(activeBatchId, {
|
||||
paid_reference: paidReference,
|
||||
paid_note: paidNote
|
||||
});
|
||||
await loadBatches();
|
||||
await openDrawer(activeBatchId);
|
||||
} catch (error) {
|
||||
updateReferenceBtn.disabled = false;
|
||||
updateReferenceBtn.textContent = 'Update Reference';
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
recordAdjustmentBtn?.addEventListener('click', async () => {
|
||||
if (!activeBatchId) return;
|
||||
if (adjustmentForm && !adjustmentForm.reportValidity()) return;
|
||||
const amount = Number(adjustmentAmountInput?.value || 0);
|
||||
recordAdjustmentBtn.disabled = true;
|
||||
recordAdjustmentBtn.textContent = 'Recording...';
|
||||
try {
|
||||
await api.recordSettlementBatchAdjustment(activeBatchId, {
|
||||
adjustment_type: adjustmentTypeInput?.value || 'credit',
|
||||
amount,
|
||||
reason: adjustmentReasonInput?.value.trim(),
|
||||
note: adjustmentNoteInput?.value.trim()
|
||||
});
|
||||
await loadBatches();
|
||||
await openDrawer(activeBatchId);
|
||||
} catch (error) {
|
||||
recordAdjustmentBtn.disabled = false;
|
||||
recordAdjustmentBtn.textContent = 'Record Adjustment';
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
reprocessBtn?.addEventListener('click', async () => {
|
||||
if (!activeBatchId) return;
|
||||
reprocessBtn.disabled = true;
|
||||
reprocessBtn.textContent = 'Reprocessing...';
|
||||
try {
|
||||
const result = await api.reprocessSettlementBatch(activeBatchId);
|
||||
await loadBatches();
|
||||
await openDrawer(result.new_batch?.id || activeBatchId);
|
||||
} catch (error) {
|
||||
reprocessBtn.disabled = false;
|
||||
reprocessBtn.textContent = 'Reprocess Batch';
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
downloadCsvBtn?.addEventListener('click', async () => {
|
||||
if (!activeBatchId) return;
|
||||
downloadCsvBtn.disabled = true;
|
||||
downloadCsvBtn.textContent = 'Downloading...';
|
||||
try {
|
||||
const token = api.getToken();
|
||||
const format = exportFormatSelect?.value || 'standard';
|
||||
const response = await fetch(`/admin/settlement-batches/${activeBatchId}/export.csv?format=${encodeURIComponent(format)}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`CSV export failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = format === 'bank_generic'
|
||||
? `${activeBatchCode || activeBatchId}-bank-generic-payout.csv`
|
||||
: `${activeBatchCode || activeBatchId}-payout-report.csv`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
downloadCsvBtn.disabled = false;
|
||||
downloadCsvBtn.textContent = 'Download CSV';
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
adminProfile = await api.applyPermissions();
|
||||
await loadBatches();
|
||||
})();
|
||||
})();
|
||||
</script>
|
||||
<!-- ui-nav -->
|
||||
<div id="__sb_nav" style="position:fixed;left:16px;bottom:16px;z-index:9999;background:#fff;border:1px solid #e2e8f0;padding:8px 10px;border-radius:8px;box-shadow:0 6px 24px rgba(15,23,42,0.12);font-family:Inter,Arial,sans-serif;font-size:12px;line-height:1.4">
|
||||
@ -490,4 +893,4 @@
|
||||
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
||||
</div>
|
||||
'
|
||||
</body></html>
|
||||
</body></html>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
const ADMIN_TOKEN_KEY = "admin_token";
|
||||
const ADMIN_PROFILE_KEY = "admin_profile";
|
||||
|
||||
function formatMoney(value) {
|
||||
const number = Number(value || 0);
|
||||
@ -90,11 +91,64 @@ async function adminFetch(path, options = {}) {
|
||||
return payload?.data !== undefined ? payload.data : payload;
|
||||
}
|
||||
|
||||
function hasPermission(profile, permission) {
|
||||
if (!permission) {
|
||||
return true;
|
||||
}
|
||||
const permissions = profile?.permissions;
|
||||
if (!permissions) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(permissions)) {
|
||||
return permissions.includes("*") || permissions.includes(permission);
|
||||
}
|
||||
if (permissions.admin === "*" || permissions.admin === true) {
|
||||
return true;
|
||||
}
|
||||
if (permissions[permission] === "*" || permissions[permission] === true) {
|
||||
return true;
|
||||
}
|
||||
const [domain, action] = permission.split(":");
|
||||
const domainValue = permissions[domain];
|
||||
if (domainValue === "*" || domainValue === true) {
|
||||
return true;
|
||||
}
|
||||
if (Array.isArray(domainValue)) {
|
||||
return domainValue.includes("*") || domainValue.includes(action);
|
||||
}
|
||||
if (domainValue && typeof domainValue === "object") {
|
||||
return domainValue[action] === "*" || domainValue[action] === true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getStoredProfile() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(ADMIN_PROFILE_KEY) || "null");
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyPermissionAttributes(profile) {
|
||||
document.querySelectorAll("[data-admin-permission]").forEach((el) => {
|
||||
const allowed = hasPermission(profile, el.getAttribute("data-admin-permission"));
|
||||
el.classList.toggle("hidden", !allowed);
|
||||
if ("disabled" in el) {
|
||||
el.disabled = !allowed;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.AdminUIAPI = {
|
||||
ADMIN_TOKEN_KEY,
|
||||
ADMIN_PROFILE_KEY,
|
||||
setToken: (token) => localStorage.setItem(ADMIN_TOKEN_KEY, token),
|
||||
getToken: () => localStorage.getItem(ADMIN_TOKEN_KEY),
|
||||
clearToken: () => localStorage.removeItem(ADMIN_TOKEN_KEY),
|
||||
clearToken: () => {
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY);
|
||||
localStorage.removeItem(ADMIN_PROFILE_KEY);
|
||||
},
|
||||
requireToken: () => {
|
||||
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
|
||||
if (!token) {
|
||||
@ -111,9 +165,27 @@ window.AdminUIAPI = {
|
||||
});
|
||||
if (data?.token) {
|
||||
window.AdminUIAPI.setToken(data.token);
|
||||
if (data.user) {
|
||||
localStorage.setItem(ADMIN_PROFILE_KEY, JSON.stringify(data.user));
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
getMe: async () => {
|
||||
const profile = await adminFetch("/admin/me");
|
||||
localStorage.setItem(ADMIN_PROFILE_KEY, JSON.stringify(profile));
|
||||
return profile;
|
||||
},
|
||||
getStoredProfile,
|
||||
hasPermission: (permission, profile) => hasPermission(profile || getStoredProfile(), permission),
|
||||
applyPermissions: async () => {
|
||||
let profile = getStoredProfile();
|
||||
if (!profile?.permissions) {
|
||||
profile = await window.AdminUIAPI.getMe();
|
||||
}
|
||||
applyPermissionAttributes(profile);
|
||||
return profile;
|
||||
},
|
||||
listMerchants: () => adminFetch("/admin/merchants"),
|
||||
listOutlets: (query) => adminFetch("/admin/outlets", { query }),
|
||||
getOutlet: (id) => adminFetch(`/admin/outlets/${id}`),
|
||||
@ -133,6 +205,11 @@ window.AdminUIAPI = {
|
||||
getTerminal: (id) => adminFetch(`/admin/terminals/${id}`),
|
||||
listDevices: (query) => adminFetch("/admin/devices", { query }),
|
||||
getDevice: (id) => adminFetch(`/admin/devices/${id}`),
|
||||
rotateDeviceCredential: (id) =>
|
||||
adminFetch(`/admin/devices/${id}/credentials/rotate`, {
|
||||
method: "POST",
|
||||
body: {}
|
||||
}),
|
||||
getDeviceHeartbeats: (id, query) =>
|
||||
adminFetch(`/admin/devices/${id}/heartbeats`, { query }),
|
||||
getDeviceConfig: (id) => adminFetch(`/admin/devices/${id}/config`),
|
||||
@ -143,7 +220,87 @@ window.AdminUIAPI = {
|
||||
body: payload || {}
|
||||
}),
|
||||
listTransactions: (query) => adminFetch("/admin/transactions", { query }),
|
||||
getDynamicQrExpiryScheduler: () => adminFetch("/admin/transactions/expiry-scheduler"),
|
||||
createSettlementBatches: (payload) =>
|
||||
adminFetch("/admin/settlement-batches", {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
listSettlementBatches: (query) => adminFetch("/admin/settlement-batches", { query }),
|
||||
getSettlementBatch: (id) => adminFetch(`/admin/settlement-batches/${id}`),
|
||||
listSettlementAdjustments: (query) =>
|
||||
adminFetch("/admin/settlement-adjustments", { query }),
|
||||
createSettlementAdjustmentExportJob: (payload) =>
|
||||
adminFetch("/admin/exports/settlement-adjustments", {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
getExportJob: (id) => adminFetch(`/admin/exports/${id}`),
|
||||
listExportJobs: (query) => adminFetch("/admin/exports", { query }),
|
||||
downloadExportJob: async (id) => {
|
||||
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
|
||||
if (!token) {
|
||||
throw new Error("ADMIN_AUTH_MISSING");
|
||||
}
|
||||
const response = await fetch(`/admin/exports/${id}/download`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Export download failed: ${response.status}`);
|
||||
}
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
filename:
|
||||
response.headers.get("content-disposition")?.match(/filename="([^"]+)"/)?.[1] ||
|
||||
`${id}.csv`
|
||||
};
|
||||
},
|
||||
getSettlementReconciliationReport: (query) =>
|
||||
adminFetch("/admin/reconciliation/settlement-batches", { query }),
|
||||
markSettlementBatchPaid: (id, payload) =>
|
||||
adminFetch(`/admin/settlement-batches/${id}/mark-paid`, {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
updateSettlementBatchReference: (id, payload) =>
|
||||
adminFetch(`/admin/settlement-batches/${id}/reference`, {
|
||||
method: "PATCH",
|
||||
body: payload || {}
|
||||
}),
|
||||
recordSettlementBatchAdjustment: (id, payload) =>
|
||||
adminFetch(`/admin/settlement-batches/${id}/adjustments`, {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
approveSettlementAdjustment: (id, payload) =>
|
||||
adminFetch(`/admin/settlement-adjustments/${id}/approve`, {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
rejectSettlementAdjustment: (id, payload) =>
|
||||
adminFetch(`/admin/settlement-adjustments/${id}/reject`, {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
markSettlementBatchFailed: (id, payload) =>
|
||||
adminFetch(`/admin/settlement-batches/${id}/mark-failed`, {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
cancelSettlementBatch: (id, payload) =>
|
||||
adminFetch(`/admin/settlement-batches/${id}/cancel`, {
|
||||
method: "POST",
|
||||
body: payload || {}
|
||||
}),
|
||||
reprocessSettlementBatch: (id) =>
|
||||
adminFetch(`/admin/settlement-batches/${id}/reprocess`, {
|
||||
method: "POST",
|
||||
body: {}
|
||||
}),
|
||||
getDashboardSummary: () => adminFetch("/admin/dashboard/summary"),
|
||||
getMqttStatus: (query) => adminFetch("/admin/mqtt/status", { query }),
|
||||
listFailedNotifications: (query) => adminFetch("/admin/notifications/failed", { query }),
|
||||
listAuditLogs: (query) => adminFetch("/admin/audit-logs", { query }),
|
||||
formatMoney,
|
||||
formatDateTime
|
||||
};
|
||||
|
||||
142
ui/shared/merchant-api.js
Normal file
142
ui/shared/merchant-api.js
Normal file
@ -0,0 +1,142 @@
|
||||
const MERCHANT_TOKEN_KEY = "merchant_token";
|
||||
const MERCHANT_ID_KEY = "merchant_id";
|
||||
const MERCHANT_USER_KEY = "merchant_user";
|
||||
const MERCHANT_AUTH_MODE_KEY = "merchant_auth_mode";
|
||||
|
||||
function merchantFormatMoney(value) {
|
||||
const number = Number(value || 0);
|
||||
if (!Number.isFinite(number)) {
|
||||
return "Rp 0";
|
||||
}
|
||||
return new Intl.NumberFormat("id-ID", {
|
||||
style: "currency",
|
||||
currency: "IDR",
|
||||
maximumFractionDigits: 0
|
||||
}).format(number);
|
||||
}
|
||||
|
||||
function merchantFormatDateTime(value) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return new Intl.DateTimeFormat("en-GB", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function merchantBuildQuery(query) {
|
||||
const search = new URLSearchParams();
|
||||
Object.entries(query || {}).forEach(([key, val]) => {
|
||||
if (val !== undefined && val !== null && val !== "") {
|
||||
search.append(key, String(val));
|
||||
}
|
||||
});
|
||||
return search.toString();
|
||||
}
|
||||
|
||||
async function merchantFetch(path, options = {}) {
|
||||
const token = localStorage.getItem(MERCHANT_TOKEN_KEY);
|
||||
const merchantId = localStorage.getItem(MERCHANT_ID_KEY);
|
||||
const {
|
||||
method = "GET",
|
||||
query,
|
||||
body,
|
||||
headers: extraHeaders = {},
|
||||
auth = true
|
||||
} = options;
|
||||
const suffix = merchantBuildQuery(query || {});
|
||||
const headers = { ...extraHeaders };
|
||||
|
||||
if (auth) {
|
||||
if (!token) {
|
||||
throw new Error("MERCHANT_AUTH_MISSING");
|
||||
}
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
if (merchantId) {
|
||||
headers["X-Merchant-Id"] = merchantId;
|
||||
}
|
||||
}
|
||||
if (method !== "GET" && body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(suffix ? `${path}?${suffix}` : path, {
|
||||
method,
|
||||
headers,
|
||||
body: method === "GET" || body === undefined ? undefined : JSON.stringify(body)
|
||||
});
|
||||
const raw = await response.text();
|
||||
let payload;
|
||||
try {
|
||||
payload = raw ? JSON.parse(raw) : {};
|
||||
} catch (error) {
|
||||
payload = {};
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message || payload?.error || `Request failed with status ${response.status}`);
|
||||
}
|
||||
return payload?.data !== undefined ? payload.data : payload;
|
||||
}
|
||||
|
||||
window.MerchantUIAPI = {
|
||||
MERCHANT_TOKEN_KEY,
|
||||
MERCHANT_ID_KEY,
|
||||
setSession: ({ token, merchant, user, auth_mode }) => {
|
||||
localStorage.setItem(MERCHANT_TOKEN_KEY, token);
|
||||
localStorage.setItem(MERCHANT_ID_KEY, merchant.id);
|
||||
if (user) {
|
||||
localStorage.setItem(MERCHANT_USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
if (auth_mode) {
|
||||
localStorage.setItem(MERCHANT_AUTH_MODE_KEY, auth_mode);
|
||||
}
|
||||
},
|
||||
clearSession: () => {
|
||||
localStorage.removeItem(MERCHANT_TOKEN_KEY);
|
||||
localStorage.removeItem(MERCHANT_ID_KEY);
|
||||
localStorage.removeItem(MERCHANT_USER_KEY);
|
||||
localStorage.removeItem(MERCHANT_AUTH_MODE_KEY);
|
||||
},
|
||||
getSessionUser: () => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(MERCHANT_USER_KEY) || "null");
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
getAuthMode: () => localStorage.getItem(MERCHANT_AUTH_MODE_KEY),
|
||||
requireSession: () => {
|
||||
const token = localStorage.getItem(MERCHANT_TOKEN_KEY);
|
||||
const merchantId = localStorage.getItem(MERCHANT_ID_KEY);
|
||||
if (!token || !merchantId) {
|
||||
window.location.href = "/ui/merchant-login";
|
||||
throw new Error("MERCHANT_AUTH_MISSING");
|
||||
}
|
||||
return { token, merchantId };
|
||||
},
|
||||
login: async ({ username, password }) => {
|
||||
const data = await merchantFetch("/merchant/login", {
|
||||
method: "POST",
|
||||
auth: false,
|
||||
body: { username, password }
|
||||
});
|
||||
if (data?.token && data?.merchant) {
|
||||
window.MerchantUIAPI.setSession(data);
|
||||
}
|
||||
return data;
|
||||
},
|
||||
getProfile: async () => {
|
||||
const profile = await merchantFetch("/merchant/profile");
|
||||
return profile?.merchant || profile;
|
||||
},
|
||||
getSettlementSummary: () => merchantFetch("/merchant/settlement-summary"),
|
||||
listSettlementBatches: (query) => merchantFetch("/merchant/settlement-batches", { query }),
|
||||
getSettlementBatch: (id) => merchantFetch(`/merchant/settlement-batches/${id}`),
|
||||
formatMoney: merchantFormatMoney,
|
||||
formatDateTime: merchantFormatDateTime
|
||||
};
|
||||
@ -388,15 +388,15 @@
|
||||
const detailContent = document.getElementById("transaction-detail-content");
|
||||
|
||||
const normalizeText = (value) => String(value || "").toLowerCase().trim();
|
||||
const toDateValue = (value) => {
|
||||
const toDateBoundary = (value, endOfDay = false) => {
|
||||
if (!value) {
|
||||
return "";
|
||||
return null;
|
||||
}
|
||||
const date = new Date(value);
|
||||
const date = new Date(`${value}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
return null;
|
||||
}
|
||||
return date.toISOString().slice(0, 10);
|
||||
return date;
|
||||
};
|
||||
|
||||
const statusMeta = (status) => {
|
||||
@ -529,10 +529,11 @@
|
||||
const merchant = merchantFilter?.value || "";
|
||||
const outlet = outletFilter?.value || "";
|
||||
const terminal = terminalFilter?.value || "";
|
||||
const from = toDateValue(fromFilter?.value);
|
||||
const to = toDateValue(toFilter?.value);
|
||||
const from = toDateBoundary(fromFilter?.value);
|
||||
const to = toDateBoundary(toFilter?.value, true);
|
||||
|
||||
const filtered = transactions.filter((tx) => {
|
||||
const txTime = new Date(tx.created_at).getTime();
|
||||
const id = normalizeText(tx.transaction_code || tx.id || tx.code);
|
||||
const rrn = normalizeText(tx.partner_reference || tx.rrn || tx.reference || "");
|
||||
const merchantName = normalizeText(merchantMap.get(tx.merchant_id) || "");
|
||||
@ -551,8 +552,8 @@
|
||||
const matchesMerchant = !merchant || merchantId === merchant;
|
||||
const matchesOutlet = !outlet || tx.outlet_id === outlet;
|
||||
const matchesTerminal = !terminal || tx.terminal_id === terminal;
|
||||
const matchesFrom = !from || normalizeText(tx.created_at) >= from;
|
||||
const matchesTo = !to || normalizeText(tx.created_at) <= to;
|
||||
const matchesFrom = !from || txTime >= from.getTime();
|
||||
const matchesTo = !to || txTime <= to.getTime();
|
||||
return matchesSearch && matchesStatus && matchesMerchant && matchesOutlet && matchesTerminal && matchesFrom && matchesTo;
|
||||
});
|
||||
|
||||
@ -728,17 +729,15 @@
|
||||
try {
|
||||
api.requireToken();
|
||||
|
||||
const q = normalizeText(searchInput?.value);
|
||||
const status = normalizeText(statusFilter?.value);
|
||||
const merchant = merchantFilter?.value || "";
|
||||
const from = fromFilter?.value || "";
|
||||
const to = toFilter?.value || "";
|
||||
const from = toDateBoundary(fromFilter?.value);
|
||||
const to = toDateBoundary(toFilter?.value, true);
|
||||
const query = {
|
||||
q,
|
||||
status,
|
||||
merchant_id: merchant,
|
||||
from,
|
||||
to
|
||||
from: from ? from.toISOString() : "",
|
||||
to: to ? to.toISOString() : ""
|
||||
};
|
||||
|
||||
const [
|
||||
|
||||
Reference in New Issue
Block a user