858 lines
42 KiB
HTML
858 lines
42 KiB
HTML
<!DOCTYPE html>
|
|
|
|
<html class="light" lang="en"><head>
|
|
<meta charset="utf-8"/>
|
|
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
|
<title>Transactions | Soundbox Ops</title>
|
|
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&family=JetBrains+Mono&display=swap" rel="stylesheet"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
|
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
|
|
<style>
|
|
.material-symbols-outlined {
|
|
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
|
}
|
|
body {
|
|
font-family: 'Inter', sans-serif;
|
|
background-color: #F8FAFC;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar {
|
|
width: 6px;
|
|
height: 6px;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
}
|
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
|
background: #E2E8F0;
|
|
border-radius: 10px;
|
|
}
|
|
.mono-text {
|
|
font-family: 'JetBrains Mono', monospace;
|
|
}
|
|
</style>
|
|
<script id="tailwind-config">
|
|
tailwind.config = {
|
|
darkMode: "class",
|
|
theme: {
|
|
extend: {
|
|
"colors": {
|
|
"slate-200": "#E2E8F0",
|
|
"primary-fixed-dim": "#b4c5ff",
|
|
"on-tertiary-fixed": "#360f00",
|
|
"on-tertiary": "#ffffff",
|
|
"on-secondary": "#ffffff",
|
|
"surface-container": "#ededf9",
|
|
"on-background": "#191b23",
|
|
"error-container": "#ffdad6",
|
|
"surface-container-high": "#e7e7f3",
|
|
"on-primary": "#ffffff",
|
|
"background": "#F8FAFC",
|
|
"surface-container-low": "#f3f3fe",
|
|
"info": "#0EA5E9",
|
|
"on-surface-variant": "#434655",
|
|
"primary-fixed": "#dbe1ff",
|
|
"on-error": "#ffffff",
|
|
"on-secondary-fixed-variant": "#38485d",
|
|
"on-primary-container": "#eeefff",
|
|
"error": "#ba1a1a",
|
|
"on-secondary-container": "#54647a",
|
|
"tertiary-fixed-dim": "#ffb596",
|
|
"surface-variant": "#e1e2ed",
|
|
"tertiary": "#943700",
|
|
"surface": "#faf8ff",
|
|
"tertiary-container": "#bc4800",
|
|
"secondary-fixed": "#d3e4fe",
|
|
"primary": "#004ac6",
|
|
"on-error-container": "#93000a",
|
|
"on-surface": "#191b23",
|
|
"on-primary-fixed": "#00174b",
|
|
"slate-900": "#0F172A",
|
|
"on-tertiary-container": "#ffede6",
|
|
"outline-variant": "#c3c6d7",
|
|
"on-secondary-fixed": "#0b1c30",
|
|
"on-primary-fixed-variant": "#003ea8",
|
|
"secondary-container": "#d0e1fb",
|
|
"secondary": "#505f76",
|
|
"inverse-on-surface": "#f0f0fb",
|
|
"danger": "#DC2626",
|
|
"surface-container-lowest": "#ffffff",
|
|
"secondary-fixed-dim": "#b7c8e1",
|
|
"surface-dim": "#d9d9e5",
|
|
"slate-100": "#F1F5F9",
|
|
"surface-bright": "#faf8ff",
|
|
"on-tertiary-fixed-variant": "#7d2d00",
|
|
"success": "#16A34A",
|
|
"primary-container": "#2563eb",
|
|
"surface-tint": "#0053db",
|
|
"inverse-primary": "#b4c5ff",
|
|
"slate-700": "#334155",
|
|
"inverse-surface": "#2e3039",
|
|
"tertiary-fixed": "#ffdbcd",
|
|
"surface-container-highest": "#e1e2ed",
|
|
"warning": "#F59E0B",
|
|
"outline": "#737686",
|
|
"slate-500": "#64748B"
|
|
},
|
|
"borderRadius": {
|
|
"DEFAULT": "0.125rem",
|
|
"lg": "0.25rem",
|
|
"xl": "0.5rem",
|
|
"full": "0.75rem"
|
|
},
|
|
"spacing": {
|
|
"page-padding": "24px",
|
|
"topbar-height": "72px",
|
|
"row-height": "52px",
|
|
"gutter": "24px",
|
|
"card-padding": "20px"
|
|
},
|
|
"fontFamily": {
|
|
"display-lg": ["Plus Jakarta Sans"],
|
|
"label-md": ["Inter"],
|
|
"metric-lg": ["Inter"],
|
|
"headline-md": ["Plus Jakarta Sans"],
|
|
"body-md": ["Inter"],
|
|
"body-lg": ["Inter"],
|
|
"headline-lg": ["Plus Jakarta Sans"],
|
|
"metric-sm": ["Inter"]
|
|
},
|
|
"fontSize": {
|
|
"display-lg": ["36px", {"lineHeight": "44px", "letterSpacing": "-0.02em", "fontWeight": "600"}],
|
|
"label-md": ["12px", {"lineHeight": "16px", "letterSpacing": "0.01em", "fontWeight": "500"}],
|
|
"metric-lg": ["32px", {"lineHeight": "40px", "fontWeight": "600"}],
|
|
"headline-md": ["20px", {"lineHeight": "28px", "fontWeight": "600"}],
|
|
"body-md": ["14px", {"lineHeight": "20px", "fontWeight": "400"}],
|
|
"body-lg": ["16px", {"lineHeight": "24px", "fontWeight": "400"}],
|
|
"headline-lg": ["28px", {"lineHeight": "36px", "fontWeight": "600"}],
|
|
"metric-sm": ["14px", {"lineHeight": "20px", "fontWeight": "600"}]
|
|
}
|
|
},
|
|
},
|
|
}
|
|
</script>
|
|
</head>
|
|
<body class="bg-background text-on-surface">
|
|
<!-- SideNavBar -->
|
|
<aside class="w-64 h-full fixed left-0 top-0 flex flex-col py-6 px-4 gap-2 bg-surface-container-lowest dark:bg-slate-900 border-r border-slate-200 dark:border-slate-700 z-50">
|
|
<div class="px-4 mb-8">
|
|
<h1 class="font-headline-md text-headline-md font-bold text-primary dark:text-primary-fixed">Soundbox Ops</h1>
|
|
<p class="font-body-md text-body-md text-on-surface-variant">Admin Console</p>
|
|
</div>
|
|
<nav class="flex-1 space-y-1">
|
|
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant dark:text-slate-400 font-body-md text-body-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors rounded-lg" href="#">
|
|
<span class="material-symbols-outlined" data-icon="dashboard">dashboard</span>
|
|
Overview
|
|
</a>
|
|
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant dark:text-slate-400 font-body-md text-body-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors rounded-lg" href="#">
|
|
<span class="material-symbols-outlined" data-icon="storefront">storefront</span>
|
|
Merchant Management
|
|
</a>
|
|
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant dark:text-slate-400 font-body-md text-body-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors rounded-lg" href="#">
|
|
<span class="material-symbols-outlined" data-icon="speaker_group">speaker_group</span>
|
|
Device Registry
|
|
</a>
|
|
<a class="flex items-center gap-3 px-4 py-3 bg-secondary-container dark:bg-secondary text-on-secondary-container dark:text-on-secondary font-bold rounded-lg font-body-md text-body-md" href="#">
|
|
<span class="material-symbols-outlined" data-icon="receipt_long">receipt_long</span>
|
|
Transactions
|
|
</a>
|
|
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant dark:text-slate-400 font-body-md text-body-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors rounded-lg" href="#">
|
|
<span class="material-symbols-outlined" data-icon="account_balance">account_balance</span>
|
|
Ledger & Settlement
|
|
</a>
|
|
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant dark:text-slate-400 font-body-md text-body-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors rounded-lg" href="#">
|
|
<span class="material-symbols-outlined" data-icon="history_edu">history_edu</span>
|
|
Audit Control
|
|
</a>
|
|
</nav>
|
|
<div class="mt-auto pt-6 border-t border-slate-100">
|
|
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant dark:text-slate-400 font-body-md text-body-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors rounded-lg" href="#">
|
|
<span class="material-symbols-outlined" data-icon="settings">settings</span>
|
|
Settings
|
|
</a>
|
|
<a class="flex items-center gap-3 px-4 py-3 text-on-surface-variant dark:text-slate-400 font-body-md text-body-md hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors rounded-lg" href="#">
|
|
<span class="material-symbols-outlined" data-icon="help">help</span>
|
|
Support
|
|
</a>
|
|
</div>
|
|
</aside>
|
|
<!-- TopNavBar -->
|
|
<header class="fixed top-0 right-0 h-[72px] flex justify-between items-center w-[calc(100%-256px)] ml-64 px-page-padding bg-surface-container-lowest dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700 z-40">
|
|
<div class="flex items-center gap-6">
|
|
<div class="relative w-96">
|
|
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-on-surface-variant text-[20px]" data-icon="search">search</span>
|
|
<input id="transaction-search-input" class="w-full bg-slate-100 border-none rounded-xl py-2 pl-10 pr-4 text-body-md focus:ring-2 focus:ring-primary/20" placeholder="Search TxID, Merchant, or RRN..." type="text"/>
|
|
</div>
|
|
<div class="hidden md:flex items-center gap-6">
|
|
<a class="text-primary dark:text-primary-fixed border-b-2 border-primary h-[72px] flex items-center font-body-md" href="#">Dashboard</a>
|
|
<a class="text-on-surface-variant dark:text-slate-400 hover:text-primary h-[72px] flex items-center font-body-md" href="#">System Health</a>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-4">
|
|
<button class="p-2 text-on-surface-variant hover:bg-slate-100 rounded-full transition-colors 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 border-2 border-white"></span>
|
|
</button>
|
|
<button class="p-2 text-on-surface-variant hover:bg-slate-100 rounded-full transition-colors">
|
|
<span class="material-symbols-outlined" data-icon="calendar_today">calendar_today</span>
|
|
</button>
|
|
<div class="h-8 w-[1px] bg-slate-200 mx-2"></div>
|
|
<div class="flex items-center gap-3">
|
|
<div class="text-right">
|
|
<p class="font-label-md text-label-md font-bold">Admin User</p>
|
|
<p class="font-label-md text-[10px] text-slate-500 uppercase tracking-wider">Super Admin</p>
|
|
</div>
|
|
<img alt="Administrator Profile" class="w-10 h-10 rounded-full bg-slate-100" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDSeJvTDs1TfYLIgdJ99lOXHT5MY8X9SROFFT_ZKrdyO71EDMx1uVpWWLSdzowrHAbCMudUvLgfWEXLTF554Zm4jU_9PUPfPHUfEgp7sOGPDLWT_nlc2MQWH5CuyWmIpmtnQr6CBb8pL7491sl7kx1fZteImOaTsRYroTGvHLzuUH6BDseXkEq10bJw9YhHKQLpQiy3jTo_pMVRnxI1lwYXOShYCmA9uh9LQv4KArnlqQmJEHpBRghfePXKC6JHWnre2hxKUc0Wyow"/>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
<!-- Main Content Canvas -->
|
|
<main class="ml-64 pt-[72px] min-h-screen p-page-padding">
|
|
<!-- Summary Bar (KPI Cards) -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-gutter mb-8">
|
|
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl">
|
|
<p class="font-label-md text-label-md text-on-surface-variant mb-1 uppercase tracking-tight">Total Volume (24h)</p>
|
|
<div class="flex items-end justify-between">
|
|
<h3 class="font-metric-lg text-metric-lg">Rp 1.42B</h3>
|
|
<span class="font-metric-sm text-metric-sm text-success flex items-center mb-1">
|
|
<span class="material-symbols-outlined text-[16px]" data-icon="trending_up">trending_up</span>
|
|
+12.4%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl">
|
|
<p class="font-label-md text-label-md text-on-surface-variant mb-1 uppercase tracking-tight">Success Rate</p>
|
|
<div class="flex items-end justify-between">
|
|
<h3 class="font-metric-lg text-metric-lg">99.92%</h3>
|
|
<span class="font-metric-sm text-metric-sm text-success flex items-center mb-1">
|
|
<span class="material-symbols-outlined text-[16px]" data-icon="check_circle">check_circle</span>
|
|
Stable
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl">
|
|
<p class="font-label-md text-label-md text-on-surface-variant mb-1 uppercase tracking-tight">Pending Settlements</p>
|
|
<div class="flex items-end justify-between">
|
|
<h3 class="font-metric-lg text-metric-lg">142</h3>
|
|
<span class="font-metric-sm text-metric-sm text-warning flex items-center mb-1">
|
|
<span class="material-symbols-outlined text-[16px]" data-icon="schedule">schedule</span>
|
|
-5.2%
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="bg-surface-container-lowest border border-slate-200 p-card-padding rounded-xl">
|
|
<p class="font-label-md text-label-md text-on-surface-variant mb-1 uppercase tracking-tight">Active QRIS Soundboxes</p>
|
|
<div class="flex items-end justify-between">
|
|
<h3 class="font-metric-lg text-metric-lg">1,894</h3>
|
|
<span class="font-metric-sm text-metric-sm text-info flex items-center mb-1">
|
|
<span class="material-symbols-outlined text-[16px]" data-icon="sensors">sensors</span>
|
|
98% Online
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- Filters & Tools Bar -->
|
|
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl p-4 mb-6 flex flex-wrap items-center gap-4">
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-label-md text-label-md text-slate-500">Status:</span>
|
|
<select id="transaction-status-filter" class="bg-slate-50 border-slate-200 rounded-lg text-body-md py-1.5 focus:ring-primary/20">
|
|
<option value="">All Statuses</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="paid">Paid</option>
|
|
<option value="failed">Failed</option>
|
|
<option value="awaiting_payment">Awaiting Payment</option>
|
|
<option value="expired">Expired</option>
|
|
<option value="reversed">Reversed</option>
|
|
<option value="initiated">Initiated</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-label-md text-label-md text-slate-500">Merchant:</span>
|
|
<select id="transaction-merchant-filter" class="bg-slate-50 border-slate-200 rounded-lg text-body-md py-1.5 focus:ring-primary/20">
|
|
<option value="">All Merchants</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-label-md text-label-md text-slate-500">Outlet:</span>
|
|
<select id="transaction-outlet-filter" class="bg-slate-50 border-slate-200 rounded-lg text-body-md py-1.5 focus:ring-primary/20">
|
|
<option value="">All Outlets</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-label-md text-label-md text-slate-500">Terminal:</span>
|
|
<select id="transaction-terminal-filter" class="bg-slate-50 border-slate-200 rounded-lg text-body-md py-1.5 focus:ring-primary/20">
|
|
<option value="">All Terminals</option>
|
|
</select>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-label-md text-label-md text-slate-500">Range:</span>
|
|
<input id="transaction-from-filter" class="bg-slate-50 border border-slate-200 rounded-lg text-body-md py-1.5 px-3 focus:ring-primary/20" type="date"/>
|
|
<span class="font-label-md text-label-md text-slate-500">to</span>
|
|
<input id="transaction-to-filter" class="bg-slate-50 border border-slate-200 rounded-lg text-body-md py-1.5 px-3 focus:ring-primary/20" type="date"/>
|
|
</div>
|
|
<div class="ml-auto flex items-center gap-2">
|
|
<button id="transaction-clear-filter" class="px-4 py-2 border border-slate-200 rounded-lg text-body-md font-bold flex items-center gap-2 hover:bg-slate-50 transition-colors">Clear</button>
|
|
<button class="px-4 py-2 border border-slate-200 rounded-lg text-body-md font-bold flex items-center gap-2 hover:bg-slate-50 transition-colors">
|
|
<span class="material-symbols-outlined text-[18px]" data-icon="download">download</span>
|
|
Export CSV
|
|
</button>
|
|
<button class="px-4 py-2 bg-primary text-on-primary rounded-lg text-body-md font-bold flex items-center gap-2 hover:opacity-90 transition-opacity">
|
|
<span class="material-symbols-outlined text-[18px]" data-icon="add">add</span>
|
|
New Transaction
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<!-- Transactions Table -->
|
|
<div class="bg-surface-container-lowest border border-slate-200 rounded-xl overflow-hidden">
|
|
<div class="overflow-x-auto custom-scrollbar">
|
|
<table class="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr class="bg-slate-50 border-b border-slate-200">
|
|
<th class="px-6 py-4 font-label-md text-label-md text-on-surface-variant font-bold uppercase tracking-wider">Timestamp</th>
|
|
<th class="px-6 py-4 font-label-md text-label-md text-on-surface-variant font-bold uppercase tracking-wider">Transaction ID</th>
|
|
<th class="px-6 py-4 font-label-md text-label-md text-on-surface-variant font-bold uppercase tracking-wider">Merchant Name</th>
|
|
<th class="px-6 py-4 font-label-md text-label-md text-on-surface-variant font-bold uppercase tracking-wider">Amount</th>
|
|
<th class="px-6 py-4 font-label-md text-label-md text-on-surface-variant font-bold uppercase tracking-wider text-right">Fee</th>
|
|
<th class="px-6 py-4 font-label-md text-label-md text-on-surface-variant font-bold uppercase tracking-wider text-right">Net</th>
|
|
<th class="px-6 py-4 font-label-md text-label-md text-on-surface-variant font-bold uppercase tracking-wider text-center">Status</th>
|
|
<th class="px-6 py-4"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="transaction-table-body" class="divide-y divide-slate-100">
|
|
<tr>
|
|
<td id="transaction-table-empty" colspan="8" class="px-6 py-10 text-center text-slate-500">Loading transactions...</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<!-- Pagination -->
|
|
<div class="px-6 py-4 bg-slate-50 border-t border-slate-200 flex items-center justify-between">
|
|
<p id="transaction-pagination-label" class="text-body-md text-on-surface-variant">Showing <span class="font-bold text-on-surface">1 - 10</span> of 1,284 transactions</p>
|
|
<div class="flex gap-2">
|
|
<button class="px-3 py-1.5 border border-slate-200 rounded-lg bg-white text-slate-400 hover:text-on-surface transition-colors cursor-not-allowed">
|
|
<span class="material-symbols-outlined text-[18px]" data-icon="chevron_left">chevron_left</span>
|
|
</button>
|
|
<button class="px-3 py-1.5 border border-slate-200 rounded-lg bg-white text-on-surface hover:bg-slate-50 transition-colors">
|
|
1
|
|
</button>
|
|
<button class="px-3 py-1.5 border border-primary bg-primary-container/10 text-primary font-bold rounded-lg">
|
|
2
|
|
</button>
|
|
<button class="px-3 py-1.5 border border-slate-200 rounded-lg bg-white text-on-surface hover:bg-slate-50 transition-colors">
|
|
3
|
|
</button>
|
|
<button class="px-3 py-1.5 border border-slate-200 rounded-lg bg-white text-on-surface hover:bg-slate-50 transition-colors">
|
|
<span class="material-symbols-outlined text-[18px]" data-icon="chevron_right">chevron_right</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
<!-- Detail Drawer Overlay -->
|
|
<div class="fixed inset-0 bg-slate-900/40 backdrop-blur-sm z-[60] opacity-0 pointer-events-none transition-opacity duration-300" id="transaction-detail-overlay"></div>
|
|
<!-- Transaction Detail Drawer -->
|
|
<aside class="fixed top-0 right-0 h-full w-[480px] bg-surface-container-lowest z-[70] translate-x-full transition-transform duration-300 shadow-2xl overflow-y-auto custom-scrollbar" id="transaction-detail-drawer">
|
|
<div class="p-6 border-b border-slate-200 flex items-center justify-between sticky top-0 bg-surface-container-lowest z-10">
|
|
<div>
|
|
<h2 class="font-headline-md text-headline-md">Transaction Details</h2>
|
|
<p id="transaction-detail-subtitle" class="text-body-md text-on-surface-variant">-</p>
|
|
</div>
|
|
<button class="p-2 hover:bg-slate-100 rounded-full transition-colors" id="transaction-detail-close">
|
|
<span class="material-symbols-outlined" data-icon="close">close</span>
|
|
</button>
|
|
</div>
|
|
<div class="p-6 space-y-8" id="transaction-detail-content"></div>
|
|
</aside>
|
|
<script src="/ui/shared/admin-api.js"></script>
|
|
<script>
|
|
(function () {
|
|
const api = window.AdminUIAPI;
|
|
if (!api) {
|
|
return;
|
|
}
|
|
|
|
const tableBody = document.getElementById("transaction-table-body");
|
|
const searchInput = document.getElementById("transaction-search-input");
|
|
const statusFilter = document.getElementById("transaction-status-filter");
|
|
const merchantFilter = document.getElementById("transaction-merchant-filter");
|
|
const outletFilter = document.getElementById("transaction-outlet-filter");
|
|
const terminalFilter = document.getElementById("transaction-terminal-filter");
|
|
const fromFilter = document.getElementById("transaction-from-filter");
|
|
const toFilter = document.getElementById("transaction-to-filter");
|
|
const clearFilter = document.getElementById("transaction-clear-filter");
|
|
const paginationLabel = document.getElementById("transaction-pagination-label");
|
|
const overlay = document.getElementById("transaction-detail-overlay");
|
|
const drawer = document.getElementById("transaction-detail-drawer");
|
|
const closeButton = document.getElementById("transaction-detail-close");
|
|
const subtitle = document.getElementById("transaction-detail-subtitle");
|
|
const detailContent = document.getElementById("transaction-detail-content");
|
|
|
|
const normalizeText = (value) => String(value || "").toLowerCase().trim();
|
|
const toDateValue = (value) => {
|
|
if (!value) {
|
|
return "";
|
|
}
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) {
|
|
return value;
|
|
}
|
|
return date.toISOString().slice(0, 10);
|
|
};
|
|
|
|
const statusMeta = (status) => {
|
|
const value = normalizeText(status);
|
|
if (value === "paid" || value === "success" || value === "settled") {
|
|
return {
|
|
label: "Paid",
|
|
className: "bg-success/10 text-success text-[11px] font-bold uppercase flex items-center gap-1",
|
|
dot: "bg-success"
|
|
};
|
|
}
|
|
if (value === "pending" || value === "awaiting_payment") {
|
|
return {
|
|
label: "Pending",
|
|
className: "bg-warning/10 text-warning text-[11px] font-bold uppercase flex items-center gap-1",
|
|
dot: "bg-warning"
|
|
};
|
|
}
|
|
if (value === "failed" || value === "expired" || value === "reversed") {
|
|
return {
|
|
label: value.charAt(0).toUpperCase() + value.slice(1),
|
|
className: "bg-danger/10 text-danger text-[11px] font-bold uppercase flex items-center gap-1",
|
|
dot: "bg-danger"
|
|
};
|
|
}
|
|
return {
|
|
label: value ? value.charAt(0).toUpperCase() + value.slice(1) : "Unknown",
|
|
className: "bg-slate-100 text-slate-500 text-[11px] font-bold uppercase flex items-center gap-1",
|
|
dot: "bg-slate-400"
|
|
};
|
|
};
|
|
|
|
let transactions = [];
|
|
let merchants = [];
|
|
let outlets = [];
|
|
let terminals = [];
|
|
const merchantMap = new Map();
|
|
const outletMap = new Map();
|
|
const terminalMap = new Map();
|
|
let searchTimer;
|
|
|
|
const renderRows = (items) => {
|
|
if (!tableBody) {
|
|
return;
|
|
}
|
|
|
|
if (!items.length) {
|
|
tableBody.innerHTML = '<tr><td colspan="8" class="px-6 py-10 text-center text-slate-500">No transactions found.</td></tr>';
|
|
return;
|
|
}
|
|
|
|
tableBody.innerHTML = items
|
|
.map((tx) => {
|
|
const id = tx.transaction_code || tx.id || tx.code || "TRX";
|
|
const created = api.formatDateTime(tx.created_at);
|
|
const statusText = normalizeText(tx.status);
|
|
const status = statusMeta(tx.status);
|
|
const merchantName = merchantMap.get(tx.merchant_id) || "Unknown";
|
|
const amount = api.formatMoney(tx.amount);
|
|
const feeRaw = tx.fee || tx.fee_amount || tx.mdr_amount;
|
|
const netRaw = tx.net || tx.net_amount || tx.settled_amount;
|
|
const fee = Number.isFinite(Number(feeRaw))
|
|
? api.formatMoney(feeRaw)
|
|
: "-";
|
|
const net = Number.isFinite(Number(netRaw))
|
|
? api.formatMoney(netRaw)
|
|
: "-";
|
|
const rrn = tx.partner_reference || tx.rrn || tx.reference || "-";
|
|
|
|
return `
|
|
<tr class="hover:bg-slate-50 transition-colors group">
|
|
<td class="px-6 py-4">
|
|
<p class="font-body-md text-body-md font-bold">${created}</p>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<span class="mono-text text-body-md text-primary font-medium">${id}</span>
|
|
<p class="text-[11px] text-slate-400">RRN: ${rrn}</p>
|
|
</td>
|
|
<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">
|
|
<span class="material-symbols-outlined text-[18px] text-slate-500">receipt_long</span>
|
|
</div>
|
|
<div>
|
|
<p class="font-body-md text-body-md font-bold">${merchantName}</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<p class="font-body-md text-body-md font-bold">${amount}</p>
|
|
</td>
|
|
<td class="px-6 py-4 text-right">
|
|
<p class="font-body-md text-body-md text-slate-500">${fee}</p>
|
|
</td>
|
|
<td class="px-6 py-4 text-right">
|
|
<p class="font-body-md text-body-md font-bold text-on-surface">${net}</p>
|
|
</td>
|
|
<td class="px-6 py-4">
|
|
<div class="flex justify-center">
|
|
<span class="px-2.5 py-1 rounded-full ${status.className}">
|
|
<span class="w-1.5 h-1.5 rounded-full ${status.dot}"></span>
|
|
${status.label}
|
|
</span>
|
|
</div>
|
|
</td>
|
|
<td class="px-6 py-4 text-right">
|
|
<button class="p-2 text-slate-400 hover:text-primary transition-colors" data-action="open-transaction-detail" data-id="${id}">
|
|
<span class="material-symbols-outlined" data-icon="visibility">visibility</span>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
})
|
|
.join("");
|
|
|
|
tableBody.querySelectorAll("button[data-action='open-transaction-detail']").forEach((button) => {
|
|
button.addEventListener("click", (event) => {
|
|
const id = event.currentTarget.getAttribute("data-id");
|
|
const tx = transactions.find((item) => String(item.transaction_code || item.id || item.code) === id);
|
|
if (tx) {
|
|
openDrawer(tx);
|
|
}
|
|
});
|
|
});
|
|
};
|
|
|
|
const applyFilters = () => {
|
|
const q = normalizeText(searchInput?.value);
|
|
const status = normalizeText(statusFilter?.value);
|
|
const merchant = merchantFilter?.value || "";
|
|
const outlet = outletFilter?.value || "";
|
|
const terminal = terminalFilter?.value || "";
|
|
const from = toDateValue(fromFilter?.value);
|
|
const to = toDateValue(toFilter?.value);
|
|
|
|
const filtered = transactions.filter((tx) => {
|
|
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) || "");
|
|
const outletName = normalizeText(outletMap.get(tx.outlet_id)?.name || outletMap.get(tx.outlet_id)?.outlet_code || tx.outlet_id || "");
|
|
const terminalName = normalizeText(terminalMap.get(tx.terminal_id)?.terminal_code || terminalMap.get(tx.terminal_id)?.code || tx.terminal_id || tx.device_id || "");
|
|
const merchantId = tx.merchant_id || "";
|
|
|
|
const matchesSearch =
|
|
!q ||
|
|
id.includes(q) ||
|
|
rrn.includes(q) ||
|
|
merchantName.includes(q) ||
|
|
outletName.includes(q) ||
|
|
terminalName.includes(q);
|
|
const matchesStatus = !status || normalizeText(tx.status) === status;
|
|
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;
|
|
return matchesSearch && matchesStatus && matchesMerchant && matchesOutlet && matchesTerminal && matchesFrom && matchesTo;
|
|
});
|
|
|
|
renderRows(filtered);
|
|
|
|
if (paginationLabel) {
|
|
paginationLabel.innerHTML = `Showing <span class=\"font-bold text-on-surface\">1 - ${Math.min(filtered.length, 10)}</span> of <span class=\"font-bold text-on-surface\">${filtered.length}</span> transactions`;
|
|
}
|
|
};
|
|
|
|
const openDrawer = (tx) => {
|
|
if (!overlay || !drawer || !detailContent) {
|
|
return;
|
|
}
|
|
|
|
const id = tx.transaction_code || tx.id || "TXN";
|
|
const status = statusMeta(tx.status);
|
|
const statusLabel = status.label;
|
|
const merchantName = merchantMap.get(tx.merchant_id) || "Unknown";
|
|
const outlet = outletMap.get(tx.outlet_id) || {};
|
|
const terminal = terminalMap.get(tx.terminal_id) || {};
|
|
|
|
subtitle.textContent = id;
|
|
detailContent.innerHTML = `
|
|
<div class="bg-success/5 border border-success/20 rounded-xl p-6 text-center">
|
|
<div class="w-12 h-12 ${status.className.includes("text-success") ? "bg-success" : status.className.includes("text-warning") ? "bg-warning" : "bg-danger"} text-white rounded-full flex items-center justify-center mx-auto mb-3">
|
|
<span class="material-symbols-outlined text-[28px]" data-icon="check_circle" style="font-variation-settings: 'FILL' 1;">check_circle</span>
|
|
</div>
|
|
<h3 class="text-headline-md text-success mb-1">${api.formatMoney(tx.amount || 0)}</h3>
|
|
<p class="text-body-md font-bold text-success uppercase tracking-widest">${statusLabel}</p>
|
|
<p class="text-label-md text-slate-500 mt-2">Transaction Detail</p>
|
|
</div>
|
|
<div class="grid grid-cols-2 gap-y-6">
|
|
<div>
|
|
<p class="text-label-md text-slate-500 uppercase">Merchant</p>
|
|
<p class="text-body-md font-bold">${merchantName}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-label-md text-slate-500 uppercase">Merchant ID</p>
|
|
<p class="text-body-md font-bold mono-text">${tx.merchant_id || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-label-md text-slate-500 uppercase">Outlet</p>
|
|
<p class="text-body-md font-bold">${outlet.name || outlet.outlet_code || tx.outlet_id || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-label-md text-slate-500 uppercase">Terminal / Device</p>
|
|
<p class="text-body-md font-bold">${terminal.terminal_code || terminal.code || tx.terminal_id || tx.device_id || "-"}</p>
|
|
</div>
|
|
<div class="col-span-2">
|
|
<p class="text-label-md text-slate-500 uppercase">Retrieval Reference Number (RRN)</p>
|
|
<p class="text-body-md font-bold mono-text">${tx.partner_reference || tx.rrn || "-"}</p>
|
|
</div>
|
|
</div>
|
|
<div class="space-y-4">
|
|
<h4 class="font-bold text-body-md">Transaction Lifecycle</h4>
|
|
<div class="relative pl-8 space-y-6">
|
|
<div class="absolute left-3.5 top-2 bottom-2 w-[1px] bg-slate-200"></div>
|
|
<div class="relative">
|
|
<div class="absolute -left-8 w-7 h-7 bg-primary text-white rounded-full flex items-center justify-center border-4 border-white">
|
|
<span class="material-symbols-outlined text-[14px]" data-icon="payments">payments</span>
|
|
</div>
|
|
<div>
|
|
<p class="text-body-md font-bold">Created</p>
|
|
<p class="text-label-md text-slate-500">${api.formatDateTime(tx.created_at)}</p>
|
|
</div>
|
|
</div>
|
|
${tx.paid_at ? `
|
|
<div class="relative">
|
|
<div class="absolute -left-8 w-7 h-7 bg-success text-white rounded-full flex items-center justify-center border-4 border-white">
|
|
<span class="material-symbols-outlined text-[14px]" data-icon="account_balance_wallet">account_balance_wallet</span>
|
|
</div>
|
|
<div>
|
|
<p class="text-body-md font-bold">Paid</p>
|
|
<p class="text-label-md text-slate-500">${api.formatDateTime(tx.paid_at)}</p>
|
|
</div>
|
|
</div>
|
|
` : ""}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="flex items-center justify-between mb-2">
|
|
<h4 class="font-bold text-body-md">Audit Trail (Raw JSON)</h4>
|
|
</div>
|
|
<div class="bg-slate-900 rounded-lg p-4 text-slate-300 text-[12px] mono-text h-40 overflow-y-auto custom-scrollbar">
|
|
<pre>${JSON.stringify(tx, null, 2)}</pre>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
overlay.classList.remove("pointer-events-none", "opacity-0");
|
|
overlay.classList.add("opacity-100");
|
|
drawer.classList.remove("translate-x-full");
|
|
};
|
|
|
|
const closeDrawer = () => {
|
|
if (!overlay || !drawer) {
|
|
return;
|
|
}
|
|
drawer.classList.add("translate-x-full");
|
|
overlay.classList.remove("opacity-100");
|
|
overlay.classList.add("opacity-0", "pointer-events-none");
|
|
};
|
|
|
|
const renderMerchantFilter = () => {
|
|
if (!merchantFilter) {
|
|
return;
|
|
}
|
|
merchantFilter.innerHTML = '<option value="">All Merchants</option>';
|
|
Array.from(merchantMap.entries())
|
|
.map(([id, name]) => ({ id, name }))
|
|
.filter((item) => item.id)
|
|
.sort((a, b) => normalizeText(a.name).localeCompare(normalizeText(b.name)))
|
|
.forEach((item) => {
|
|
const option = document.createElement("option");
|
|
option.value = item.id;
|
|
option.textContent = item.name;
|
|
merchantFilter.appendChild(option);
|
|
});
|
|
};
|
|
|
|
const renderOutletFilter = () => {
|
|
if (!outletFilter) {
|
|
return;
|
|
}
|
|
|
|
const rows = [];
|
|
outlets.forEach((outlet) => {
|
|
const id = outlet.id;
|
|
if (!id) {
|
|
return;
|
|
}
|
|
rows.push({ id, name: outlet.name || outlet.outlet_code || id });
|
|
});
|
|
|
|
outletFilter.innerHTML = '<option value="">All Outlets</option>';
|
|
rows
|
|
.sort((a, b) => normalizeText(a.name).localeCompare(normalizeText(b.name)))
|
|
.forEach((item) => {
|
|
const option = document.createElement("option");
|
|
option.value = item.id;
|
|
option.textContent = item.name;
|
|
outletFilter.appendChild(option);
|
|
});
|
|
};
|
|
|
|
const renderTerminalFilter = () => {
|
|
if (!terminalFilter) {
|
|
return;
|
|
}
|
|
|
|
const rows = [];
|
|
terminals.forEach((terminal) => {
|
|
const id = terminal.id;
|
|
if (!id) {
|
|
return;
|
|
}
|
|
rows.push({ id, name: terminal.terminal_code || terminal.code || id });
|
|
});
|
|
|
|
terminalFilter.innerHTML = '<option value="">All Terminals</option>';
|
|
rows
|
|
.sort((a, b) => normalizeText(a.name).localeCompare(normalizeText(b.name)))
|
|
.forEach((item) => {
|
|
const option = document.createElement("option");
|
|
option.value = item.id;
|
|
option.textContent = item.name;
|
|
terminalFilter.appendChild(option);
|
|
});
|
|
};
|
|
|
|
const refresh = async () => {
|
|
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 query = {
|
|
q,
|
|
status,
|
|
merchant_id: merchant,
|
|
from,
|
|
to
|
|
};
|
|
|
|
const [
|
|
txRows,
|
|
merchantRows,
|
|
outletRows,
|
|
terminalRows
|
|
] = await Promise.all([
|
|
api.listTransactions(query),
|
|
api.listMerchants(),
|
|
api.listOutlets(),
|
|
api.listTerminals()
|
|
]);
|
|
|
|
transactions = Array.isArray(txRows) ? txRows : [];
|
|
merchants = Array.isArray(merchantRows) ? merchantRows : [];
|
|
outlets = Array.isArray(outletRows) ? outletRows : [];
|
|
terminals = Array.isArray(terminalRows) ? terminalRows : [];
|
|
|
|
merchantMap.clear();
|
|
outletMap.clear();
|
|
terminalMap.clear();
|
|
|
|
merchants.forEach((merchant) => {
|
|
const id = merchant.id || merchant.merchant_id || merchant.merchant_code;
|
|
const name = merchant.legal_name || merchant.brand_name || merchant.company_name || merchant.name || id || "Unknown";
|
|
if (id) {
|
|
merchantMap.set(id, name);
|
|
}
|
|
});
|
|
|
|
outlets.forEach((outlet) => {
|
|
const id = outlet.id;
|
|
if (id) {
|
|
outletMap.set(id, outlet);
|
|
}
|
|
});
|
|
|
|
terminals.forEach((terminal) => {
|
|
const id = terminal.id;
|
|
if (id) {
|
|
terminalMap.set(id, terminal);
|
|
}
|
|
});
|
|
|
|
renderMerchantFilter();
|
|
renderOutletFilter();
|
|
renderTerminalFilter();
|
|
applyFilters();
|
|
} catch (error) {
|
|
console.error("[transaction-history] failed loading", error);
|
|
if (tableBody) {
|
|
tableBody.innerHTML = '<tr><td colspan="8" class="px-6 py-6 text-center text-danger">Unable to load transactions</td></tr>';
|
|
}
|
|
}
|
|
};
|
|
|
|
const onFilterChange = () => {
|
|
if (searchTimer) {
|
|
clearTimeout(searchTimer);
|
|
}
|
|
searchTimer = setTimeout(refresh, 220);
|
|
};
|
|
|
|
clearFilter?.addEventListener("click", () => {
|
|
if (searchInput) {
|
|
searchInput.value = "";
|
|
}
|
|
if (statusFilter) {
|
|
statusFilter.value = "";
|
|
}
|
|
if (merchantFilter) {
|
|
merchantFilter.value = "";
|
|
}
|
|
if (outletFilter) {
|
|
outletFilter.value = "";
|
|
}
|
|
if (terminalFilter) {
|
|
terminalFilter.value = "";
|
|
}
|
|
if (fromFilter) {
|
|
fromFilter.value = "";
|
|
}
|
|
if (toFilter) {
|
|
toFilter.value = "";
|
|
}
|
|
refresh();
|
|
});
|
|
|
|
searchInput?.addEventListener("input", onFilterChange);
|
|
statusFilter?.addEventListener("change", onFilterChange);
|
|
merchantFilter?.addEventListener("change", onFilterChange);
|
|
outletFilter?.addEventListener("change", onFilterChange);
|
|
terminalFilter?.addEventListener("change", onFilterChange);
|
|
fromFilter?.addEventListener("change", onFilterChange);
|
|
toFilter?.addEventListener("change", onFilterChange);
|
|
overlay?.addEventListener("click", closeDrawer);
|
|
closeButton?.addEventListener("click", closeDrawer);
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") {
|
|
closeDrawer();
|
|
}
|
|
});
|
|
|
|
refresh();
|
|
})();
|
|
</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">
|
|
<a href="/ui" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">UI Catalog</a>
|
|
<a href="/ui/hub" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Hub</a>
|
|
<a href="/ui/admin-login" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Admin Login</a>
|
|
<a href="/ui/merchant-login" style="margin-right:8px;color:#2563eb;text-decoration:none;font-weight:600">Merchant Login</a>
|
|
<a href="/ui/admin-dashboard-overview" style="margin-right:0;color:#2563eb;text-decoration:none;font-weight:600">Dashboard</a>
|
|
</div>
|
|
</body></html>
|