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) {
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) {
AdminTransaction.find(filter)
.sort({ createdAt: -1 })
......@@ -222,6 +230,7 @@ exports.listTransactions = function (req, res) {
var transactions = list.map(function (tx) {
return {
merTrxId: tx.merTrxId,
merTrxIdShort: tx.merTrxId.split("_").pop(),
customerName: tx.customerName,
customerPhone: tx.customerPhone,
amount: tx.amount,
......@@ -243,6 +252,8 @@ exports.listTransactions = function (req, res) {
username: req.session.username,
staffOptions: staffOptions || [],
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;
// timing responses cannot tell "no such user" apart from "wrong password".
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) {
res.render("admin/login", { error: null });
};
......
......@@ -6,6 +6,8 @@ module.exports = function (app) {
var requireAdmin = require("../middlewares/requireAdmin");
var loginRateLimit = require("../middlewares/loginRateLimit");
app.route("/admin").get(adminAuth.index);
app.route("/admin/login")
.get(adminAuth.loginForm)
.post(loginRateLimit, adminAuth.login);
......
......@@ -6,13 +6,14 @@
<title>Lịch sử giao dịch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
:root {
--color-bg: #F1F5F9;
--color-surface: #FFFFFF;
--color-primary: #1E40AF;
--color-primary-hover: #1D4ED8;
--color-primary-soft: #EFF6FF;
--color-text: #0F172A;
--color-text-muted: #64748B;
--color-border: #E2E8F0;
......@@ -24,6 +25,7 @@
--pill-failed-bg: #FEE2E2;
--pill-failed-fg: #991B1B;
--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);
}
......@@ -39,17 +41,38 @@
}
.page {
max-width: 1080px;
max-width: 1120px;
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 {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 24px;
margin-bottom: 20px;
}
h1 {
......@@ -65,6 +88,76 @@
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 {
display: inline-flex;
align-items: center;
......@@ -76,12 +169,51 @@
font-size: 14px;
font-weight: 600;
text-decoration: none;
border-radius: 8px;
transition: background 150ms ease;
border-radius: var(--radius-sm);
transition: background 150ms ease, transform 150ms ease;
white-space: nowrap;
cursor: pointer;
}
.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 {
background: var(--color-surface);
......@@ -91,6 +223,57 @@
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 {
overflow-x: auto;
}
......@@ -125,8 +308,17 @@
}
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: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); }
......@@ -150,6 +342,7 @@
border-radius: 999px;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.pill::before {
......@@ -158,22 +351,49 @@
height: 6px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
.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-failed { background: var(--pill-failed-bg); color: var(--pill-failed-fg); }
.link-cell a {
color: var(--color-primary);
font-weight: 600;
text-decoration: none;
.action-cell {
display: flex;
gap: 6px;
}
.icon-btn {
display: inline-flex;
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;
}
.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); }
.link-cell a:hover { text-decoration: underline; }
.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 {
text-align: center;
......@@ -183,6 +403,7 @@
.empty-state svg { margin-bottom: 12px; opacity: 0.5; }
.empty-state p { margin: 0; font-size: 14px; }
.empty-state[hidden] { display: none; }
.pager {
display: flex;
......@@ -208,22 +429,37 @@
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) {
body { padding: 20px 12px; }
.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>
</head>
<body>
<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>
{% if isAdmin %}<a href="/admin/accounts" style="color:inherit;text-decoration:none;font-weight:500;">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>
<form method="POST" action="/admin/logout" style="display:inline;">
<button type="submit" style="all:unset;cursor:pointer;color:inherit;font:inherit;">Đăng xuất</button>
{% if isAdmin %}<a href="/admin/accounts">Quản lý tài khoản</a>{% endif %}
<a href="/admin/account/password">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout">
<button type="submit">Đăng xuất</button>
</form>
</div>
<div class="page-header">
......@@ -231,28 +467,69 @@
<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>
</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 %}
<form method="GET" action="/admin/transactions" style="display:flex;gap:8px;align-items:center;">
<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;">
<select name="staff" class="select-control" aria-label="Lọc theo nhân viên">
<option value="">Tất cả nhân viên</option>
{% for s in staffOptions %}
<option value="{{s}}" {% if s == selectedStaff %}selected{% endif %}>{{s}}</option>
{% endfor %}
</select>
</form>
{% endif %}
<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 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 %}
</form>
{% if transactions.length %}
<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>
{% endif %}
<div class="card">
{% 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">
<table>
<thead>
<tr>
<th>Hành động</th>
<th>Thời gian</th>
<th>Mã giao dịch</th>
<th>Người tạo</th>
......@@ -260,36 +537,39 @@
<th>SĐT</th>
<th style="text-align:right">Số tiền</th>
<th>Trạng thái</th>
<th>Ghi chú</th>
<th>Link thanh toán</th>
</tr>
</thead>
<tbody>
<tbody id="txTableBody">
{% 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-mono">{{tx.merTrxId}}</td>
<td class="cell-mono">{{tx.merTrxIdShort}}</td>
<td class="cell-muted">{{tx.createdByUsername}}</td>
<td>{{tx.customerName}}</td>
<td class="cell-mono">{{tx.customerPhone}}</td>
<td class="cell-amount">{{tx.amount}}</td>
<td class="cell-amount">{{tx.amount|currency}} ₫</td>
<td>
<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 %}
</span>
</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>
{% endfor %}
</tbody>
</table>
</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 %}
<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>
......@@ -299,10 +579,80 @@
</div>
<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>
{% 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>
<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>
</html>
......@@ -6,13 +6,14 @@
<title>Thêm mới giao dịch</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<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>
:root {
--color-bg: #F1F5F9;
--color-surface: #FFFFFF;
--color-primary: #1E40AF;
--color-primary-hover: #1D4ED8;
--color-primary-soft: #EFF6FF;
--color-text: #0F172A;
--color-text-muted: #64748B;
--color-border: #E2E8F0;
......@@ -23,6 +24,7 @@
--color-success-bg: #F0FDF4;
--color-success-border: #BBF7D0;
--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);
}
......@@ -41,9 +43,29 @@
.page {
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 {
display: inline-flex;
align-items: center;
......@@ -90,12 +112,20 @@
margin-bottom: 6px;
}
.field-hint {
font-size: 12px;
color: var(--color-text-muted);
.field-error {
font-size: 12.5px;
font-weight: 500;
color: var(--color-danger);
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 {
width: 100%;
height: 44px;
......@@ -105,7 +135,7 @@
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
border-radius: var(--radius-sm);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
......@@ -132,7 +162,41 @@
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%;
height: 46px;
margin-top: 8px;
......@@ -142,7 +206,7 @@
font-size: 15px;
font-weight: 600;
border: none;
border-radius: 8px;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
......@@ -151,9 +215,10 @@
transition: background 150ms ease, transform 150ms ease;
}
button[type="submit"]:hover { background: var(--color-primary-hover); }
button[type="submit"]:active { transform: scale(0.99); }
button[type="submit"]:disabled { opacity: 0.7; cursor: not-allowed; }
#submitBtn:hover { background: var(--color-primary-hover); }
#submitBtn:active { transform: scale(0.99); }
#submitBtn:disabled { opacity: 0.7; cursor: not-allowed; }
#submitBtn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.spinner {
width: 16px;
......@@ -165,10 +230,10 @@
display: none;
}
button[type="submit"].loading .spinner { display: inline-block; }
button[type="submit"].loading .btn-label::after { content: "Đang tạo…"; }
button[type="submit"].loading .btn-label { font-size: 0; }
button[type="submit"].loading .btn-label::after { font-size: 15px; }
#submitBtn.loading .spinner { display: inline-block; }
#submitBtn.loading .btn-label::after { content: "Đang tạo…"; }
#submitBtn.loading .btn-label { font-size: 0; }
#submitBtn.loading .btn-label::after { font-size: 15px; }
@keyframes spin { to { transform: rotate(360deg); } }
......@@ -183,6 +248,12 @@
border: 1px solid;
font-size: 14px;
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; }
......@@ -201,6 +272,20 @@
.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 {
display: flex;
align-items: center;
......@@ -240,21 +325,41 @@
}
.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) {
.spinner { animation: none; }
.alert { animation: none; }
* { transition: none !important; }
}
</style>
</head>
<body>
<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>
{% if isAdmin %}<a href="/admin/accounts" style="color:inherit;text-decoration:none;font-weight:500;">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>
<form method="POST" action="/admin/logout" style="display:inline;">
<button type="submit" style="all:unset;cursor:pointer;color:inherit;font:inherit;">Đăng xuất</button>
{% if isAdmin %}<a href="/admin/accounts">Quản lý tài khoản</a>{% endif %}
<a href="/admin/account/password">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout">
<button type="submit">Đăng xuất</button>
</form>
</div>
<a class="breadcrumb" href="/admin/transactions">
......@@ -267,24 +372,48 @@
<p class="subtitle">Nhập thông tin khách hàng để tạo link thanh toán</p>
<form id="txForm" novalidate>
<div class="field">
<div class="field" data-field="customerName">
<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 class="field">
<div class="field" data-field="customerPhone">
<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 class="field">
<div class="field" data-field="customerAddress">
<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 class="field">
<div class="field" data-field="amount">
<label for="amount">Số tiền thanh toán</label>
<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>
</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>
<button type="submit" id="submitBtn">
<span class="spinner"></span>
......@@ -292,16 +421,75 @@
</button>
</form>
<div id="result"></div>
<div id="result" aria-live="polite"></div>
</div>
</div>
<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 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();
var form = e.target;
var btn = document.getElementById('submitBtn');
var data = {
customerName: form.customerName.value,
......@@ -310,6 +498,12 @@
amount: form.amount.value
};
if (!validateForm(data)) {
var firstInvalid = form.querySelector('.is-invalid input');
if (firstInvalid) firstInvalid.focus();
return;
}
btn.classList.add('loading');
btn.disabled = true;
......@@ -326,13 +520,17 @@
}
var el = document.getElementById('result');
if (res.code === '00') {
var trxShort = String(res.data.merTrxId || '').split('_').pop();
el.innerHTML =
'<div class="alert alert-success">' + iconCheck +
'<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">' +
'<input type="text" readonly value="' + res.data.paymentUrl + '" id="paymentUrlField">' +
'<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 () {
var field = document.getElementById('paymentUrlField');
field.select();
......@@ -341,6 +539,12 @@
var self = this;
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();
} else {
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