Commit 798b419d by tdgiang

Add date-range filter, stats strip, search, and form validation to admin transactions UI

Adds GET /admin index redirect, createdAt date-range filtering on the
transactions list with a stat strip (total/success/pending/revenue) and
client-side search, plus inline validation and quick amount chips on the
new-transaction form.
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KPzWwuTEeX2vXGXvyGn4q
parent de551724
...@@ -209,6 +209,14 @@ exports.listTransactions = function (req, res) { ...@@ -209,6 +209,14 @@ exports.listTransactions = function (req, res) {
filter.createdByUsername = String(req.query.staff); filter.createdByUsername = String(req.query.staff);
} }
var fromDate = moment(String(req.query.fromDate || ""), "YYYY-MM-DD", true);
var toDate = moment(String(req.query.toDate || ""), "YYYY-MM-DD", true);
if (fromDate.isValid() || toDate.isValid()) {
filter.createdAt = {};
if (fromDate.isValid()) filter.createdAt.$gte = fromDate.startOf("day").toDate();
if (toDate.isValid()) filter.createdAt.$lte = toDate.endOf("day").toDate();
}
function render(staffOptions) { function render(staffOptions) {
AdminTransaction.find(filter) AdminTransaction.find(filter)
.sort({ createdAt: -1 }) .sort({ createdAt: -1 })
...@@ -222,6 +230,7 @@ exports.listTransactions = function (req, res) { ...@@ -222,6 +230,7 @@ exports.listTransactions = function (req, res) {
var transactions = list.map(function (tx) { var transactions = list.map(function (tx) {
return { return {
merTrxId: tx.merTrxId, merTrxId: tx.merTrxId,
merTrxIdShort: tx.merTrxId.split("_").pop(),
customerName: tx.customerName, customerName: tx.customerName,
customerPhone: tx.customerPhone, customerPhone: tx.customerPhone,
amount: tx.amount, amount: tx.amount,
...@@ -243,6 +252,8 @@ exports.listTransactions = function (req, res) { ...@@ -243,6 +252,8 @@ exports.listTransactions = function (req, res) {
username: req.session.username, username: req.session.username,
staffOptions: staffOptions || [], staffOptions: staffOptions || [],
selectedStaff: req.query.staff || "", selectedStaff: req.query.staff || "",
selectedFromDate: fromDate.isValid() ? fromDate.format("YYYY-MM-DD") : "",
selectedToDate: toDate.isValid() ? toDate.format("YYYY-MM-DD") : "",
}); });
}); });
} }
......
...@@ -10,6 +10,13 @@ var MIN_PASSWORD_LENGTH = 8; ...@@ -10,6 +10,13 @@ var MIN_PASSWORD_LENGTH = 8;
// timing responses cannot tell "no such user" apart from "wrong password". // timing responses cannot tell "no such user" apart from "wrong password".
var DUMMY_HASH = "$2b$10$v5huvbowopyU7O11oK7ykeCMpvpEJCt4.y9cv8kZ.7liCwdYEY8sS"; var DUMMY_HASH = "$2b$10$v5huvbowopyU7O11oK7ykeCMpvpEJCt4.y9cv8kZ.7liCwdYEY8sS";
exports.index = function (req, res) {
if (req.session && req.session.userId) {
return res.redirect("/admin/transactions");
}
return res.redirect("/admin/login");
};
exports.loginForm = function (req, res) { exports.loginForm = function (req, res) {
res.render("admin/login", { error: null }); res.render("admin/login", { error: null });
}; };
......
...@@ -6,6 +6,8 @@ module.exports = function (app) { ...@@ -6,6 +6,8 @@ module.exports = function (app) {
var requireAdmin = require("../middlewares/requireAdmin"); var requireAdmin = require("../middlewares/requireAdmin");
var loginRateLimit = require("../middlewares/loginRateLimit"); var loginRateLimit = require("../middlewares/loginRateLimit");
app.route("/admin").get(adminAuth.index);
app.route("/admin/login") app.route("/admin/login")
.get(adminAuth.loginForm) .get(adminAuth.loginForm)
.post(loginRateLimit, adminAuth.login); .post(loginRateLimit, adminAuth.login);
......
...@@ -6,13 +6,14 @@ ...@@ -6,13 +6,14 @@
<title>Lịch sử giao dịch</title> <title>Lịch sử giao dịch</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--color-bg: #F1F5F9; --color-bg: #F1F5F9;
--color-surface: #FFFFFF; --color-surface: #FFFFFF;
--color-primary: #1E40AF; --color-primary: #1E40AF;
--color-primary-hover: #1D4ED8; --color-primary-hover: #1D4ED8;
--color-primary-soft: #EFF6FF;
--color-text: #0F172A; --color-text: #0F172A;
--color-text-muted: #64748B; --color-text-muted: #64748B;
--color-border: #E2E8F0; --color-border: #E2E8F0;
...@@ -24,6 +25,7 @@ ...@@ -24,6 +25,7 @@
--pill-failed-bg: #FEE2E2; --pill-failed-bg: #FEE2E2;
--pill-failed-fg: #991B1B; --pill-failed-fg: #991B1B;
--radius: 10px; --radius: 10px;
--radius-sm: 8px;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06); --shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
} }
...@@ -39,17 +41,38 @@ ...@@ -39,17 +41,38 @@
} }
.page { .page {
max-width: 1080px; max-width: 1120px;
margin: 0 auto; margin: 0 auto;
} }
.nav {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--color-text-muted);
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.nav a {
color: inherit;
text-decoration: none;
font-weight: 500;
cursor: pointer;
}
.nav a:hover { color: var(--color-primary); }
.nav form { display: inline; }
.page-header { .page-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap; flex-wrap: wrap;
gap: 16px; gap: 16px;
margin-bottom: 24px; margin-bottom: 20px;
} }
h1 { h1 {
...@@ -65,6 +88,76 @@ ...@@ -65,6 +88,76 @@
margin: 4px 0 0; margin: 4px 0 0;
} }
.header-actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.select-control {
height: 40px;
padding: 0 12px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-family: inherit;
font-size: 13px;
color: var(--color-text);
background: var(--color-surface);
cursor: pointer;
transition: border-color 150ms ease;
}
.select-control:hover { border-color: #CBD5E1; }
.select-control:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 1px; }
.filter-bar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.date-range {
display: flex;
align-items: center;
gap: 8px;
}
.date-range input[type="date"] { width: 150px; }
.date-sep {
font-size: 13px;
color: var(--color-text-muted);
}
.btn-filter {
height: 40px;
padding: 0 16px;
font-family: inherit;
font-size: 13.5px;
font-weight: 600;
color: #fff;
background: var(--color-primary);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
transition: background 150ms ease;
}
.btn-filter:hover { background: var(--color-primary-hover); }
.btn-filter:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.filter-clear {
font-size: 13px;
font-weight: 500;
color: var(--color-text-muted);
text-decoration: none;
}
.filter-clear:hover { color: var(--color-primary); text-decoration: underline; }
.btn-primary { .btn-primary {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
...@@ -76,12 +169,51 @@ ...@@ -76,12 +169,51 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
text-decoration: none; text-decoration: none;
border-radius: 8px; border-radius: var(--radius-sm);
transition: background 150ms ease; transition: background 150ms ease, transform 150ms ease;
white-space: nowrap; white-space: nowrap;
cursor: pointer;
} }
.btn-primary:hover { background: var(--color-primary-hover); } .btn-primary:hover { background: var(--color-primary-hover); }
.btn-primary:active { transform: scale(0.98); }
.btn-primary:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
/* Stat strip */
.stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 14px 16px;
box-shadow: var(--shadow-card);
}
.stat-label {
font-size: 11.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin: 0 0 6px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
.stat-card.is-success .stat-value { color: var(--pill-success-fg); }
.stat-card.is-pending .stat-value { color: var(--pill-pending-fg); }
.stat-card.is-revenue .stat-value { color: var(--color-primary); }
.card { .card {
background: var(--color-surface); background: var(--color-surface);
...@@ -91,6 +223,57 @@ ...@@ -91,6 +223,57 @@
overflow: hidden; overflow: hidden;
} }
.toolbar {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--color-border);
}
.search-field {
position: relative;
flex: 1;
max-width: 340px;
}
.search-field svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none;
}
.search-field input {
width: 100%;
height: 38px;
padding: 0 12px 0 36px;
font-size: 13.5px;
font-family: inherit;
color: var(--color-text);
background: var(--color-header-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 150ms ease, background 150ms ease;
}
.search-field input::placeholder { color: #94A3B8; }
.search-field input:focus {
background: var(--color-surface);
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12);
}
.toolbar-count {
font-size: 12.5px;
color: var(--color-text-muted);
white-space: nowrap;
}
.table-scroll { .table-scroll {
overflow-x: auto; overflow-x: auto;
} }
...@@ -125,8 +308,17 @@ ...@@ -125,8 +308,17 @@
} }
tbody tr:last-child td { border-bottom: none; } tbody tr:last-child td { border-bottom: none; }
tbody tr { transition: background 120ms ease; } tbody tr { transition: background 120ms ease; animation: rowIn 240ms ease both; }
tbody tr:hover { background: #F8FAFC; } tbody tr:hover { background: #F8FAFC; }
tbody tr:nth-child(n+2) { animation-delay: 20ms; }
tbody tr:nth-child(n+3) { animation-delay: 40ms; }
tbody tr:nth-child(n+4) { animation-delay: 60ms; }
tbody tr:nth-child(n+5) { animation-delay: 80ms; }
@keyframes rowIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.cell-muted { color: var(--color-text-muted); } .cell-muted { color: var(--color-text-muted); }
...@@ -150,6 +342,7 @@ ...@@ -150,6 +342,7 @@
border-radius: 999px; border-radius: 999px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
white-space: nowrap;
} }
.pill::before { .pill::before {
...@@ -158,22 +351,49 @@ ...@@ -158,22 +351,49 @@
height: 6px; height: 6px;
border-radius: 50%; border-radius: 50%;
background: currentColor; background: currentColor;
flex-shrink: 0;
} }
.pill-pending { background: var(--pill-pending-bg); color: var(--pill-pending-fg); } .pill-pending { background: var(--pill-pending-bg); color: var(--pill-pending-fg); }
.pill-success { background: var(--pill-success-bg); color: var(--pill-success-fg); } .pill-success { background: var(--pill-success-bg); color: var(--pill-success-fg); }
.pill-failed { background: var(--pill-failed-bg); color: var(--pill-failed-fg); } .pill-failed { background: var(--pill-failed-bg); color: var(--pill-failed-fg); }
.link-cell a { .action-cell {
color: var(--color-primary); display: flex;
font-weight: 600; gap: 6px;
text-decoration: none; }
.icon-btn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
text-decoration: none;
transition: border-color 150ms ease, background 150ms ease, color 150ms ease;
} }
.link-cell a:hover { text-decoration: underline; } .icon-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.icon-btn.icon-btn-open {
color: var(--color-primary);
border-color: var(--color-primary-soft);
background: var(--color-primary-soft);
}
.icon-btn.icon-btn-open:hover { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.icon-btn.icon-btn-copy {
color: var(--pill-success-fg);
border-color: var(--pill-success-bg);
background: var(--pill-success-bg);
}
.icon-btn.icon-btn-copy:hover { background: var(--pill-success-fg); color: #fff; border-color: var(--pill-success-fg); }
.icon-btn.is-copied { border-color: var(--pill-success-fg); background: var(--pill-success-fg); color: #fff; }
.empty-state { .empty-state {
text-align: center; text-align: center;
...@@ -183,6 +403,7 @@ ...@@ -183,6 +403,7 @@
.empty-state svg { margin-bottom: 12px; opacity: 0.5; } .empty-state svg { margin-bottom: 12px; opacity: 0.5; }
.empty-state p { margin: 0; font-size: 14px; } .empty-state p { margin: 0; font-size: 14px; }
.empty-state[hidden] { display: none; }
.pager { .pager {
display: flex; display: flex;
...@@ -208,22 +429,37 @@ ...@@ -208,22 +429,37 @@
transition: border-color 150ms ease, background 150ms ease; transition: border-color 150ms ease, background 150ms ease;
} }
.pager a:hover { border-color: var(--color-primary); background: #EFF6FF; } .pager a:hover { border-color: var(--color-primary); background: var(--color-primary-soft); }
.pager a:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
@media (max-width: 900px) {
.stats { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 640px) { @media (max-width: 640px) {
body { padding: 20px 12px; } body { padding: 20px 12px; }
.page-header { align-items: flex-start; } .page-header { align-items: flex-start; }
.stats { grid-template-columns: repeat(2, 1fr); gap: 8px; }
.stat-card { padding: 10px 12px; }
.stat-value { font-size: 18px; }
.toolbar { flex-wrap: wrap; }
.search-field { max-width: none; width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
tbody tr { animation: none; }
* { transition: none !important; }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<div class="nav" style="display:flex;gap:16px;font-size:13px;color:var(--color-text-muted);margin-bottom:16px;"> <div class="nav">
<span>Xin chào, <strong>{{username}}</strong></span> <span>Xin chào, <strong>{{username}}</strong></span>
{% if isAdmin %}<a href="/admin/accounts" style="color:inherit;text-decoration:none;font-weight:500;">Quản lý tài khoản</a>{% endif %} {% if isAdmin %}<a href="/admin/accounts">Quản lý tài khoản</a>{% endif %}
<a href="/admin/account/password" style="color:inherit;text-decoration:none;font-weight:500;">Đổi mật khẩu</a> <a href="/admin/account/password">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout" style="display:inline;"> <form method="POST" action="/admin/logout">
<button type="submit" style="all:unset;cursor:pointer;color:inherit;font:inherit;">Đăng xuất</button> <button type="submit">Đăng xuất</button>
</form> </form>
</div> </div>
<div class="page-header"> <div class="page-header">
...@@ -231,28 +467,69 @@ ...@@ -231,28 +467,69 @@
<h1>Lịch sử giao dịch</h1> <h1>Lịch sử giao dịch</h1>
<p class="subtitle">Danh sách giao dịch đã tạo và trạng thái thanh toán</p> <p class="subtitle">Danh sách giao dịch đã tạo và trạng thái thanh toán</p>
</div> </div>
<div class="header-actions">
<a class="btn-primary" href="/admin/transactions/new">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Thêm mới giao dịch
</a>
</div>
</div>
<form class="filter-bar" method="GET" action="/admin/transactions">
{% if isAdmin %} {% if isAdmin %}
<form method="GET" action="/admin/transactions" style="display:flex;gap:8px;align-items:center;"> <select name="staff" class="select-control" aria-label="Lọc theo nhân viên">
<select name="staff" onchange="this.form.submit()" style="height:40px;padding:0 10px;border:1px solid var(--color-border);border-radius:8px;font-family:inherit;font-size:13px;"> <option value="">Tất cả nhân viên</option>
<option value="">Tất cả nhân viên</option> {% for s in staffOptions %}
{% for s in staffOptions %} <option value="{{s}}" {% if s == selectedStaff %}selected{% endif %}>{{s}}</option>
<option value="{{s}}" {% if s == selectedStaff %}selected{% endif %}>{{s}}</option> {% endfor %}
{% endfor %} </select>
</select> {% endif %}
</form> <div class="date-range">
<input type="date" name="fromDate" class="select-control" value="{{selectedFromDate}}" aria-label="Từ ngày">
<span class="date-sep">&ndash;</span>
<input type="date" name="toDate" class="select-control" value="{{selectedToDate}}" aria-label="Đến ngày">
</div>
<button type="submit" class="btn-filter">Lọc</button>
{% if selectedStaff or selectedFromDate or selectedToDate %}
<a class="filter-clear" href="/admin/transactions">Xóa lọc</a>
{% endif %} {% endif %}
<a class="btn-primary" href="/admin/transactions/new"> </form>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Thêm mới giao dịch {% if transactions.length %}
</a> <div class="stats" id="statsStrip">
<div class="stat-card">
<p class="stat-label">Tổng số GD</p>
<p class="stat-value" data-stat="total">0</p>
</div>
<div class="stat-card is-success">
<p class="stat-label">Thành công</p>
<p class="stat-value" data-stat="success">0</p>
</div>
<div class="stat-card is-pending">
<p class="stat-label">Chờ thanh toán</p>
<p class="stat-value" data-stat="pending">0</p>
</div>
<div class="stat-card is-revenue">
<p class="stat-label">Tổng tiền thành công</p>
<p class="stat-value" data-stat="revenue">0 ₫</p>
</div>
</div> </div>
{% endif %}
<div class="card"> <div class="card">
{% if transactions.length %} {% if transactions.length %}
<div class="toolbar">
<div class="search-field">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<input type="text" id="searchInput" placeholder="Tìm theo mã giao dịch, tên khách hàng, SĐT..." aria-label="Tìm kiếm giao dịch">
</div>
<span class="toolbar-count" id="visibleCount"></span>
</div>
<div class="table-scroll"> <div class="table-scroll">
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Hành động</th>
<th>Thời gian</th> <th>Thời gian</th>
<th>Mã giao dịch</th> <th>Mã giao dịch</th>
<th>Người tạo</th> <th>Người tạo</th>
...@@ -260,36 +537,39 @@ ...@@ -260,36 +537,39 @@
<th>SĐT</th> <th>SĐT</th>
<th style="text-align:right">Số tiền</th> <th style="text-align:right">Số tiền</th>
<th>Trạng thái</th> <th>Trạng thái</th>
<th>Ghi chú</th>
<th>Link thanh toán</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody id="txTableBody">
{% for tx in transactions %} {% for tx in transactions %}
<tr> <tr data-status="{{tx.status}}" data-amount="{{tx.amount}}" data-search="{{tx.merTrxId}} {{tx.customerName}} {{tx.customerPhone}}">
<td class="action-cell">
<a href="{{tx.paymentUrl}}" target="_blank" rel="noopener" class="icon-btn icon-btn-open" title="Mở link thanh toán" aria-label="Mở link thanh toán">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
<button type="button" class="icon-btn icon-btn-copy copy-link-btn" data-url="{{tx.paymentUrl}}" title="Sao chép link thanh toán" aria-label="Sao chép link thanh toán">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
</button>
</td>
<td class="cell-muted">{{tx.createdAt}}</td> <td class="cell-muted">{{tx.createdAt}}</td>
<td class="cell-mono">{{tx.merTrxId}}</td> <td class="cell-mono">{{tx.merTrxIdShort}}</td>
<td class="cell-muted">{{tx.createdByUsername}}</td> <td class="cell-muted">{{tx.createdByUsername}}</td>
<td>{{tx.customerName}}</td> <td>{{tx.customerName}}</td>
<td class="cell-mono">{{tx.customerPhone}}</td> <td class="cell-mono">{{tx.customerPhone}}</td>
<td class="cell-amount">{{tx.amount}}</td> <td class="cell-amount">{{tx.amount|currency}} ₫</td>
<td> <td>
<span class="pill pill-{{tx.status}}"> <span class="pill pill-{{tx.status}}">
{% if tx.status == "success" %}Thành công{% elseif tx.status == "failed" %}Thất bại{% else %}Chờ thanh toán{% endif %} {% if tx.status == "success" %}Thành công{% elseif tx.status == "failed" %}Thất bại{% else %}Chờ thanh toán{% endif %}
</span> </span>
</td> </td>
<td class="cell-muted">{{tx.resultMsg}}</td>
<td class="link-cell">
<a href="{{tx.paymentUrl}}" target="_blank" rel="noopener">
Mở link
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
</a>
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="empty-state" id="noSearchResults" hidden>
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<p>Không tìm thấy giao dịch phù hợp</p>
</div>
{% else %} {% else %}
<div class="empty-state"> <div class="empty-state">
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/></svg> <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><line x1="3" y1="10" x2="21" y2="10"/></svg>
...@@ -299,10 +579,80 @@ ...@@ -299,10 +579,80 @@
</div> </div>
<div class="pager"> <div class="pager">
{% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}">&laquo; Trang trước</a>{% endif %} {% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}{% if selectedFromDate %}&fromDate={{selectedFromDate}}{% endif %}{% if selectedToDate %}&toDate={{selectedToDate}}{% endif %}">&laquo; Trang trước</a>{% endif %}
<span>Trang {{page}}</span> <span>Trang {{page}}</span>
{% if hasNext %}<a href="/admin/transactions?page={{nextPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}">Trang sau &raquo;</a>{% endif %} {% if hasNext %}<a href="/admin/transactions?page={{nextPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}{% if selectedFromDate %}&fromDate={{selectedFromDate}}{% endif %}{% if selectedToDate %}&toDate={{selectedToDate}}{% endif %}">Trang sau &raquo;</a>{% endif %}
</div> </div>
</div> </div>
<script>
(function () {
var rows = Array.prototype.slice.call(document.querySelectorAll('#txTableBody tr'));
if (!rows.length) return;
var counts = { total: rows.length, success: 0, pending: 0 };
var successRevenue = 0;
rows.forEach(function (row) {
var status = row.getAttribute('data-status');
if (status === 'success') {
counts.success++;
successRevenue += parseInt(row.getAttribute('data-amount'), 10) || 0;
} else if (status !== 'failed') {
counts.pending++;
}
});
Object.keys(counts).forEach(function (key) {
var el = document.querySelector('[data-stat="' + key + '"]');
if (el) el.textContent = counts[key].toLocaleString('vi-VN');
});
var revenueEl = document.querySelector('[data-stat="revenue"]');
if (revenueEl) revenueEl.textContent = successRevenue.toLocaleString('vi-VN') + ' ₫';
var searchInput = document.getElementById('searchInput');
var visibleCountEl = document.getElementById('visibleCount');
var noResultsEl = document.getElementById('noSearchResults');
function updateVisibleCount(n) {
if (!visibleCountEl) return;
visibleCountEl.textContent = n === rows.length ? rows.length + ' giao dịch' : n + ' / ' + rows.length + ' giao dịch';
}
updateVisibleCount(rows.length);
if (searchInput) {
searchInput.addEventListener('input', function () {
var term = searchInput.value.trim().toLowerCase();
var visible = 0;
rows.forEach(function (row) {
var haystack = (row.getAttribute('data-search') || '').toLowerCase();
var match = haystack.indexOf(term) !== -1;
row.style.display = match ? '' : 'none';
if (match) visible++;
});
updateVisibleCount(visible);
if (noResultsEl) noResultsEl.hidden = visible !== 0;
});
}
var copyIcon = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
var checkIcon = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
document.querySelectorAll('.copy-link-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
var url = btn.getAttribute('data-url');
if (navigator.clipboard) navigator.clipboard.writeText(url);
btn.classList.add('is-copied');
btn.innerHTML = checkIcon;
btn.setAttribute('title', 'Đã chép link');
btn.setAttribute('aria-label', 'Đã chép link');
setTimeout(function () {
btn.classList.remove('is-copied');
btn.innerHTML = copyIcon;
btn.setAttribute('title', 'Sao chép link thanh toán');
btn.setAttribute('aria-label', 'Sao chép link thanh toán');
}, 1200);
});
});
})();
</script>
</body> </body>
</html> </html>
...@@ -6,13 +6,14 @@ ...@@ -6,13 +6,14 @@
<title>Thêm mới giao dịch</title> <title>Thêm mới giao dịch</title>
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style> <style>
:root { :root {
--color-bg: #F1F5F9; --color-bg: #F1F5F9;
--color-surface: #FFFFFF; --color-surface: #FFFFFF;
--color-primary: #1E40AF; --color-primary: #1E40AF;
--color-primary-hover: #1D4ED8; --color-primary-hover: #1D4ED8;
--color-primary-soft: #EFF6FF;
--color-text: #0F172A; --color-text: #0F172A;
--color-text-muted: #64748B; --color-text-muted: #64748B;
--color-border: #E2E8F0; --color-border: #E2E8F0;
...@@ -23,6 +24,7 @@ ...@@ -23,6 +24,7 @@
--color-success-bg: #F0FDF4; --color-success-bg: #F0FDF4;
--color-success-border: #BBF7D0; --color-success-border: #BBF7D0;
--radius: 10px; --radius: 10px;
--radius-sm: 8px;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06); --shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
} }
...@@ -41,9 +43,29 @@ ...@@ -41,9 +43,29 @@
.page { .page {
width: 100%; width: 100%;
max-width: 480px; max-width: 520px;
} }
.nav {
display: flex;
gap: 16px;
font-size: 13px;
color: var(--color-text-muted);
margin-bottom: 12px;
flex-wrap: wrap;
align-items: center;
}
.nav a {
color: inherit;
text-decoration: none;
font-weight: 500;
cursor: pointer;
}
.nav a:hover { color: var(--color-primary); }
.nav form { display: inline; }
.breadcrumb { .breadcrumb {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
...@@ -90,12 +112,20 @@ ...@@ -90,12 +112,20 @@
margin-bottom: 6px; margin-bottom: 6px;
} }
.field-hint { .field-error {
font-size: 12px; font-size: 12.5px;
color: var(--color-text-muted); font-weight: 500;
color: var(--color-danger);
margin-top: 6px; margin-top: 6px;
display: none;
align-items: center;
gap: 5px;
} }
.field.is-invalid .field-error { display: flex; }
.field.is-invalid input { border-color: var(--color-danger); }
.field.is-invalid input:focus { box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12); }
input { input {
width: 100%; width: 100%;
height: 44px; height: 44px;
...@@ -105,7 +135,7 @@ ...@@ -105,7 +135,7 @@
color: var(--color-text); color: var(--color-text);
background: var(--color-surface); background: var(--color-surface);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: var(--radius-sm);
outline: none; outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease; transition: border-color 150ms ease, box-shadow 150ms ease;
} }
...@@ -132,7 +162,41 @@ ...@@ -132,7 +162,41 @@
pointer-events: none; pointer-events: none;
} }
button[type="submit"] { .amount-preview {
font-size: 13px;
font-weight: 600;
color: var(--color-primary);
margin-top: 6px;
min-height: 18px;
font-variant-numeric: tabular-nums;
}
.amount-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.chip {
height: 30px;
padding: 0 12px;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: var(--color-primary);
background: var(--color-primary-soft);
border: 1px solid transparent;
border-radius: 999px;
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease;
}
.chip:hover { border-color: var(--color-primary); }
.chip.is-active { background: var(--color-primary); color: #fff; }
.chip:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
#submitBtn {
width: 100%; width: 100%;
height: 46px; height: 46px;
margin-top: 8px; margin-top: 8px;
...@@ -142,7 +206,7 @@ ...@@ -142,7 +206,7 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
border: none; border: none;
border-radius: 8px; border-radius: var(--radius-sm);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -151,9 +215,10 @@ ...@@ -151,9 +215,10 @@
transition: background 150ms ease, transform 150ms ease; transition: background 150ms ease, transform 150ms ease;
} }
button[type="submit"]:hover { background: var(--color-primary-hover); } #submitBtn:hover { background: var(--color-primary-hover); }
button[type="submit"]:active { transform: scale(0.99); } #submitBtn:active { transform: scale(0.99); }
button[type="submit"]:disabled { opacity: 0.7; cursor: not-allowed; } #submitBtn:disabled { opacity: 0.7; cursor: not-allowed; }
#submitBtn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.spinner { .spinner {
width: 16px; width: 16px;
...@@ -165,10 +230,10 @@ ...@@ -165,10 +230,10 @@
display: none; display: none;
} }
button[type="submit"].loading .spinner { display: inline-block; } #submitBtn.loading .spinner { display: inline-block; }
button[type="submit"].loading .btn-label::after { content: "Đang tạo…"; } #submitBtn.loading .btn-label::after { content: "Đang tạo…"; }
button[type="submit"].loading .btn-label { font-size: 0; } #submitBtn.loading .btn-label { font-size: 0; }
button[type="submit"].loading .btn-label::after { font-size: 15px; } #submitBtn.loading .btn-label::after { font-size: 15px; }
@keyframes spin { to { transform: rotate(360deg); } } @keyframes spin { to { transform: rotate(360deg); } }
...@@ -183,6 +248,12 @@ ...@@ -183,6 +248,12 @@
border: 1px solid; border: 1px solid;
font-size: 14px; font-size: 14px;
line-height: 1.5; line-height: 1.5;
animation: alertIn 220ms ease both;
}
@keyframes alertIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
} }
.alert svg { flex-shrink: 0; margin-top: 1px; } .alert svg { flex-shrink: 0; margin-top: 1px; }
...@@ -201,6 +272,20 @@ ...@@ -201,6 +272,20 @@
.alert-title { font-weight: 600; margin: 0 0 6px; } .alert-title { font-weight: 600; margin: 0 0 6px; }
.trx-badge {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: #14532D;
background: rgba(21, 128, 61, 0.1);
border-radius: 6px;
padding: 3px 8px;
margin-bottom: 10px;
font-variant-numeric: tabular-nums;
}
.link-row { .link-row {
display: flex; display: flex;
align-items: center; align-items: center;
...@@ -240,21 +325,41 @@ ...@@ -240,21 +325,41 @@
} }
.copy-btn:hover { background: var(--color-success-bg); } .copy-btn:hover { background: var(--color-success-bg); }
.copy-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.result-actions { margin-top: 10px; }
.btn-ghost {
height: 34px;
padding: 0 12px;
font-family: inherit;
font-size: 12.5px;
font-weight: 600;
color: var(--color-primary);
background: transparent;
border: 1px solid var(--color-border);
border-radius: 6px;
cursor: pointer;
transition: border-color 150ms ease, background 150ms ease;
}
.btn-ghost:hover { border-color: var(--color-primary); background: var(--color-primary-soft); }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.spinner { animation: none; } .spinner { animation: none; }
.alert { animation: none; }
* { transition: none !important; } * { transition: none !important; }
} }
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<div class="nav" style="display:flex;gap:16px;font-size:13px;color:var(--color-text-muted);margin-bottom:12px;"> <div class="nav">
<span>Xin chào, <strong>{{username}}</strong></span> <span>Xin chào, <strong>{{username}}</strong></span>
{% if isAdmin %}<a href="/admin/accounts" style="color:inherit;text-decoration:none;font-weight:500;">Quản lý tài khoản</a>{% endif %} {% if isAdmin %}<a href="/admin/accounts">Quản lý tài khoản</a>{% endif %}
<a href="/admin/account/password" style="color:inherit;text-decoration:none;font-weight:500;">Đổi mật khẩu</a> <a href="/admin/account/password">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout" style="display:inline;"> <form method="POST" action="/admin/logout">
<button type="submit" style="all:unset;cursor:pointer;color:inherit;font:inherit;">Đăng xuất</button> <button type="submit">Đăng xuất</button>
</form> </form>
</div> </div>
<a class="breadcrumb" href="/admin/transactions"> <a class="breadcrumb" href="/admin/transactions">
...@@ -267,24 +372,48 @@ ...@@ -267,24 +372,48 @@
<p class="subtitle">Nhập thông tin khách hàng để tạo link thanh toán</p> <p class="subtitle">Nhập thông tin khách hàng để tạo link thanh toán</p>
<form id="txForm" novalidate> <form id="txForm" novalidate>
<div class="field"> <div class="field" data-field="customerName">
<label for="customerName">Tên khách hàng</label> <label for="customerName">Tên khách hàng</label>
<input type="text" id="customerName" name="customerName" placeholder="Nguyễn Văn A" autocomplete="name" required> <input type="text" id="customerName" name="customerName" placeholder="Nguyễn Văn A" autocomplete="name" required aria-describedby="customerName-error">
<p class="field-error" id="customerName-error" role="alert">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Vui lòng nhập tên khách hàng
</p>
</div> </div>
<div class="field"> <div class="field" data-field="customerPhone">
<label for="customerPhone">Số điện thoại</label> <label for="customerPhone">Số điện thoại</label>
<input type="tel" id="customerPhone" name="customerPhone" placeholder="09xx xxx xxx" autocomplete="tel" required> <input type="tel" id="customerPhone" name="customerPhone" placeholder="09xx xxx xxx" autocomplete="tel" inputmode="numeric" required aria-describedby="customerPhone-error">
<p class="field-error" id="customerPhone-error" role="alert">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Số điện thoại không hợp lệ (VD: 0901234567)
</p>
</div> </div>
<div class="field"> <div class="field" data-field="customerAddress">
<label for="customerAddress">Địa chỉ</label> <label for="customerAddress">Địa chỉ</label>
<input type="text" id="customerAddress" name="customerAddress" placeholder="Số nhà, đường, phường/xã" autocomplete="street-address" required> <input type="text" id="customerAddress" name="customerAddress" placeholder="Số nhà, đường, phường/xã" autocomplete="street-address" required aria-describedby="customerAddress-error">
<p class="field-error" id="customerAddress-error" role="alert">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Vui lòng nhập địa chỉ
</p>
</div> </div>
<div class="field"> <div class="field" data-field="amount">
<label for="amount">Số tiền thanh toán</label> <label for="amount">Số tiền thanh toán</label>
<div class="amount-field"> <div class="amount-field">
<input type="number" id="amount" name="amount" placeholder="0" min="1000" step="1000" inputmode="numeric" required> <input type="number" id="amount" name="amount" placeholder="0" min="1000" step="1000" inputmode="numeric" required aria-describedby="amount-error amount-preview">
<span class="amount-suffix">VNĐ</span> <span class="amount-suffix">VNĐ</span>
</div> </div>
<p class="field-error" id="amount-error" role="alert">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Số tiền phải từ 1,000 VNĐ trở lên
</p>
<p class="amount-preview" id="amount-preview" aria-live="polite"></p>
<div class="amount-presets" role="group" aria-label="Chọn nhanh số tiền">
<button type="button" class="chip" data-amount="100000">100,000</button>
<button type="button" class="chip" data-amount="200000">200,000</button>
<button type="button" class="chip" data-amount="500000">500,000</button>
<button type="button" class="chip" data-amount="1000000">1,000,000</button>
<button type="button" class="chip" data-amount="2000000">2,000,000</button>
</div>
</div> </div>
<button type="submit" id="submitBtn"> <button type="submit" id="submitBtn">
<span class="spinner"></span> <span class="spinner"></span>
...@@ -292,16 +421,75 @@ ...@@ -292,16 +421,75 @@
</button> </button>
</form> </form>
<div id="result"></div> <div id="result" aria-live="polite"></div>
</div> </div>
</div> </div>
<script> <script>
var iconCheck = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>'; var iconCheck = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>';
var iconAlert = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'; var iconAlert = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
document.getElementById('txForm').addEventListener('submit', function (e) { var form = document.getElementById('txForm');
var amountInput = document.getElementById('amount');
var amountPreview = document.getElementById('amount-preview');
var phoneInput = document.getElementById('customerPhone');
function formatVnd(n) {
return n.toLocaleString('vi-VN');
}
function updateAmountPreview() {
var val = parseInt(amountInput.value, 10);
amountPreview.textContent = (isFinite(val) && val > 0) ? formatVnd(val) + ' ₫' : '';
document.querySelectorAll('.chip').forEach(function (chip) {
chip.classList.toggle('is-active', String(val) === chip.getAttribute('data-amount'));
});
}
amountInput.addEventListener('input', updateAmountPreview);
document.querySelectorAll('.chip').forEach(function (chip) {
chip.addEventListener('click', function () {
amountInput.value = chip.getAttribute('data-amount');
updateAmountPreview();
setFieldValid('amount');
});
});
function setFieldInvalid(name) {
var field = form.querySelector('[data-field="' + name + '"]');
if (field) field.classList.add('is-invalid');
}
function setFieldValid(name) {
var field = form.querySelector('[data-field="' + name + '"]');
if (field) field.classList.remove('is-invalid');
}
function validatePhone(value) {
return /^(0|\+84)[0-9]{9}$/.test(value.replace(/[\s.-]/g, ''));
}
function validateForm(data) {
var valid = true;
if (!data.customerName.trim()) { setFieldInvalid('customerName'); valid = false; } else { setFieldValid('customerName'); }
if (!validatePhone(data.customerPhone)) { setFieldInvalid('customerPhone'); valid = false; } else { setFieldValid('customerPhone'); }
if (!data.customerAddress.trim()) { setFieldInvalid('customerAddress'); valid = false; } else { setFieldValid('customerAddress'); }
var amountNum = parseInt(data.amount, 10);
if (!isFinite(amountNum) || amountNum < 1000) { setFieldInvalid('amount'); valid = false; } else { setFieldValid('amount'); }
return valid;
}
['customerName', 'customerAddress'].forEach(function (id) {
document.getElementById(id).addEventListener('input', function () {
setFieldValid(id);
});
});
phoneInput.addEventListener('input', function () {
setFieldValid('customerPhone');
});
form.addEventListener('submit', function (e) {
e.preventDefault(); e.preventDefault();
var form = e.target;
var btn = document.getElementById('submitBtn'); var btn = document.getElementById('submitBtn');
var data = { var data = {
customerName: form.customerName.value, customerName: form.customerName.value,
...@@ -310,6 +498,12 @@ ...@@ -310,6 +498,12 @@
amount: form.amount.value amount: form.amount.value
}; };
if (!validateForm(data)) {
var firstInvalid = form.querySelector('.is-invalid input');
if (firstInvalid) firstInvalid.focus();
return;
}
btn.classList.add('loading'); btn.classList.add('loading');
btn.disabled = true; btn.disabled = true;
...@@ -326,13 +520,17 @@ ...@@ -326,13 +520,17 @@
} }
var el = document.getElementById('result'); var el = document.getElementById('result');
if (res.code === '00') { if (res.code === '00') {
var trxShort = String(res.data.merTrxId || '').split('_').pop();
el.innerHTML = el.innerHTML =
'<div class="alert alert-success">' + iconCheck + '<div class="alert alert-success">' + iconCheck +
'<div><p class="alert-title">Đã tạo giao dịch thành công</p>' + '<div><p class="alert-title">Đã tạo giao dịch thành công</p>' +
(trxShort ? '<span class="trx-badge">#' + trxShort + '</span>' : '') +
'<div class="link-row">' + '<div class="link-row">' +
'<input type="text" readonly value="' + res.data.paymentUrl + '" id="paymentUrlField">' + '<input type="text" readonly value="' + res.data.paymentUrl + '" id="paymentUrlField">' +
'<button type="button" class="copy-btn" id="copyBtn">Sao chép</button>' + '<button type="button" class="copy-btn" id="copyBtn">Sao chép</button>' +
'</div></div></div>'; '</div>' +
'<div class="result-actions"><button type="button" class="btn-ghost" id="newTxBtn">Tạo giao dịch khác</button></div>' +
'</div></div>';
document.getElementById('copyBtn').addEventListener('click', function () { document.getElementById('copyBtn').addEventListener('click', function () {
var field = document.getElementById('paymentUrlField'); var field = document.getElementById('paymentUrlField');
field.select(); field.select();
...@@ -341,6 +539,12 @@ ...@@ -341,6 +539,12 @@
var self = this; var self = this;
setTimeout(function () { self.textContent = 'Sao chép'; }, 1500); setTimeout(function () { self.textContent = 'Sao chép'; }, 1500);
}); });
document.getElementById('newTxBtn').addEventListener('click', function () {
el.innerHTML = '';
amountPreview.textContent = '';
document.querySelectorAll('.chip').forEach(function (c) { c.classList.remove('is-active'); });
document.getElementById('customerName').focus();
});
form.reset(); form.reset();
} else { } else {
el.innerHTML = el.innerHTML =
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment