Commit fb6e381b by tdgiang

update code

parent c40b1f40
......@@ -26,7 +26,7 @@ exports.createTransaction = function (req, res) {
return res.status(400).json({ code: "99", data: "MISSING_FIELDS" });
}
if (!/^[0-9]{9,12}$/.test(String(customerIdCard).trim())) {
if (!/^[0-9]{12}$/.test(String(customerIdCard).trim())) {
return res.status(400).json({ code: "99", data: "INVALID_ID_CARD" });
}
......@@ -443,6 +443,7 @@ exports.listTransactions = function (req, res) {
resultMsg: tx.resultMsg,
createdByUsername: tx.createdByUsername,
createdAt: moment(tx.createdAt).format("DD/MM/YYYY HH:mm"),
updatedAt: moment(tx.updatedAt).format("DD/MM/YYYY HH:mm"),
paymentUrl: config.epay.req_domain + "/admin/pay/" + tx.merTrxId,
};
});
......@@ -513,13 +514,14 @@ exports.exportTransactions = function (req, res) {
}
var header = [
"Mã giao dịch", "Thời gian", "Người tạo", "Khách hàng", "SĐT",
"Mã giao dịch", "Thời gian", "Thời gian giao dịch", "Người tạo", "Khách hàng", "SĐT",
"CMND/CCCD", "Số tiền", "Trạng thái", "Ghi chú",
];
var rows = list.map(function (tx) {
return [
tx.merTrxId,
moment(tx.createdAt).format("DD/MM/YYYY HH:mm"),
moment(tx.updatedAt).format("DD/MM/YYYY HH:mm"),
tx.createdByUsername || "",
tx.customerName,
tx.customerPhone,
......
......@@ -200,6 +200,32 @@
.btn-ghost:hover { border-color: var(--color-primary); background: var(--color-primary-soft); }
.btn-ghost:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.export-dialog {
border: none;
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 24px;
width: 100%;
max-width: 340px;
}
.export-dialog::backdrop { background: rgba(15, 23, 42, 0.45); }
.export-dialog h2 { font-size: 17px; margin: 0 0 4px; }
.export-dialog .subtitle { font-size: 13px; color: var(--color-text-muted); margin: 0 0 20px; }
.export-dialog .field { margin-bottom: 16px; }
.export-dialog .field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.export-dialog .field input[type="date"] {
width: 100%;
height: 40px;
padding: 0 12px;
font-size: 14px;
font-family: inherit;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
background: var(--color-surface);
}
.export-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
/* Stat strip */
.stats {
display: grid;
......@@ -489,10 +515,10 @@
<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-ghost" href="/admin/transactions/export?staff={{selectedStaff}}&status={{selectedStatus}}&fromDate={{selectedFromDate}}&toDate={{selectedToDate}}">
<button type="button" class="btn-ghost" id="openExportDialogBtn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Xuất Excel
</a>
</button>
<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
......@@ -500,6 +526,27 @@
</div>
</div>
<dialog id="exportDialog" class="export-dialog">
<form method="GET" action="/admin/transactions/export">
<h2>Xuất Excel</h2>
<p class="subtitle">Chọn khoảng thời gian giao dịch cần xuất, độc lập với bộ lọc đang xem</p>
<input type="hidden" name="staff" value="{{selectedStaff}}">
<input type="hidden" name="status" value="{{selectedStatus}}">
<div class="field">
<label for="exportFromDate">Từ ngày</label>
<input type="date" id="exportFromDate" name="fromDate" value="{{selectedFromDate}}">
</div>
<div class="field">
<label for="exportToDate">Đến ngày</label>
<input type="date" id="exportToDate" name="toDate" value="{{selectedToDate}}">
</div>
<div class="dialog-actions">
<button type="button" class="filter-clear" id="cancelExportDialogBtn">Hủy</button>
<button type="submit" class="btn-primary">Xuất Excel</button>
</div>
</form>
</dialog>
<div class="stats" id="statsStrip">
<div class="stat-card is-revenue">
<p class="stat-label">Sản lượng giao dịch hôm nay</p>
......@@ -560,6 +607,7 @@
<tr>
<th>Hành động</th>
<th>Thời gian</th>
<th>Thời gian giao dịch</th>
<th>Mã giao dịch</th>
<th>Người tạo</th>
<th>Khách hàng</th>
......@@ -580,6 +628,7 @@
</button>
</td>
<td class="cell-muted">{{tx.createdAt}}</td>
<td class="cell-muted">{{tx.updatedAt}}</td>
<td class="cell-mono">{{tx.merTrxIdShort}}</td>
<td class="cell-muted">{{tx.createdByUsername}}</td>
<td>{{tx.customerName}}</td>
......@@ -616,6 +665,18 @@
<script>
(function () {
var exportDialog = document.getElementById('exportDialog');
var openExportBtn = document.getElementById('openExportDialogBtn');
var cancelExportBtn = document.getElementById('cancelExportDialogBtn');
if (exportDialog && openExportBtn) {
openExportBtn.addEventListener('click', function () { exportDialog.showModal(); });
}
if (exportDialog && cancelExportBtn) {
cancelExportBtn.addEventListener('click', function () { exportDialog.close(); });
}
})();
(function () {
var rows = Array.prototype.slice.call(document.querySelectorAll('#txTableBody tr'));
if (!rows.length) return;
......
<!DOCTYPE html>
<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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;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;
--color-danger: #DC2626;
--color-danger-bg: #FEF2F2;
--color-danger-border: #FECACA;
--color-success: #15803D;
--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);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--color-bg);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
color: var(--color-text);
display: flex;
justify-content: center;
padding: 40px 20px;
}
.page {
width: 100%;
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;
gap: 6px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-muted);
text-decoration: none;
margin-bottom: 20px;
transition: color 150ms ease;
}
.breadcrumb:hover { color: var(--color-primary); }
.breadcrumb svg { flex-shrink: 0; }
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 32px;
}
h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 4px;
}
.subtitle {
font-size: 14px;
color: var(--color-text-muted);
margin: 0 0 28px;
}
.field { margin-bottom: 20px; }
label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.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;
padding: 0 14px;
font-size: 15px;
font-family: inherit;
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
outline: none;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
input::placeholder { color: #A0AEC0; }
input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12);
}
.amount-field { position: relative; }
.amount-field input { padding-right: 52px; }
.amount-suffix {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 13px;
font-weight: 600;
color: var(--color-text-muted);
pointer-events: none;
}
.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;
background: var(--color-primary);
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 600;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: background 150ms ease, transform 150ms ease;
}
#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;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 700ms linear infinite;
display: none;
}
#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); } }
#result { margin-top: 20px; }
.alert {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 16px;
border-radius: 8px;
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; }
.alert-success {
background: var(--color-success-bg);
border-color: var(--color-success-border);
color: #14532D;
}
.alert-error {
background: var(--color-danger-bg);
border-color: var(--color-danger-border);
color: #7F1D1D;
}
.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;
gap: 8px;
margin-top: 8px;
background: #fff;
border: 1px solid var(--color-success-border);
border-radius: 6px;
padding: 8px 10px;
}
.link-row input {
flex: 1;
height: auto;
border: none;
padding: 0;
font-size: 13px;
color: var(--color-text);
background: transparent;
}
.link-row input:focus { box-shadow: none; }
.copy-btn {
flex-shrink: 0;
height: 30px;
padding: 0 12px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--color-success);
background: #fff;
border: 1px solid var(--color-success-border);
border-radius: 6px;
cursor: pointer;
transition: background 150ms ease;
}
.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">
<span>Xin chào, <strong>{{username}}</strong></span>
{% 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">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
Lịch sử giao dịch
</a>
<div class="card">
<h1>Thêm mới giao dịch</h1>
<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" 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 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" 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" 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" 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 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" data-field="customerIdCard">
<label for="customerIdCard">Số CMND/CCCD</label>
<input type="text" id="customerIdCard" name="customerIdCard" placeholder="012345678901" autocomplete="off" inputmode="numeric" maxlength="12" required aria-describedby="customerIdCard-error">
<p class="field-error" id="customerIdCard-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ố CMND/CCCD phải từ 9 đến 12 chữ số
</p>
</div>
<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="10000" max="20000000" step="1" inputmode="numeric" required aria-describedby="amount-error amount-preview">
<span class="amount-suffix">VNĐ</span>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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;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;
--color-danger: #dc2626;
--color-danger-bg: #fef2f2;
--color-danger-border: #fecaca;
--color-success: #15803d;
--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);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--color-bg);
font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif;
color: var(--color-text);
display: flex;
justify-content: center;
padding: 40px 20px;
}
.page {
width: 100%;
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;
gap: 6px;
font-size: 14px;
font-weight: 500;
color: var(--color-text-muted);
text-decoration: none;
margin-bottom: 20px;
transition: color 150ms ease;
}
.breadcrumb:hover {
color: var(--color-primary);
}
.breadcrumb svg {
flex-shrink: 0;
}
.card {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-card);
padding: 32px;
}
h1 {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.01em;
margin: 0 0 4px;
}
.subtitle {
font-size: 14px;
color: var(--color-text-muted);
margin: 0 0 28px;
}
.field {
margin-bottom: 20px;
}
label {
display: block;
font-size: 13px;
font-weight: 600;
color: var(--color-text);
margin-bottom: 6px;
}
.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;
padding: 0 14px;
font-size: 15px;
font-family: inherit;
color: var(--color-text);
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
outline: none;
transition:
border-color 150ms ease,
box-shadow 150ms ease;
}
input::placeholder {
color: #a0aec0;
}
input:focus {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12);
}
.amount-field {
position: relative;
}
.amount-field input {
padding-right: 52px;
}
.amount-suffix {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
font-size: 13px;
font-weight: 600;
color: var(--color-text-muted);
pointer-events: none;
}
.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;
background: var(--color-primary);
color: #fff;
font-family: inherit;
font-size: 15px;
font-weight: 600;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition:
background 150ms ease,
transform 150ms ease;
}
#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;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: spin 700ms linear infinite;
display: none;
}
#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);
}
}
#result {
margin-top: 20px;
}
.alert {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 16px;
border-radius: 8px;
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;
}
.alert-success {
background: var(--color-success-bg);
border-color: var(--color-success-border);
color: #14532d;
}
.alert-error {
background: var(--color-danger-bg);
border-color: var(--color-danger-border);
color: #7f1d1d;
}
.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;
gap: 8px;
margin-top: 8px;
background: #fff;
border: 1px solid var(--color-success-border);
border-radius: 6px;
padding: 8px 10px;
}
.link-row input {
flex: 1;
height: auto;
border: none;
padding: 0;
font-size: 13px;
color: var(--color-text);
background: transparent;
}
.link-row input:focus {
box-shadow: none;
}
.copy-btn {
flex-shrink: 0;
height: 30px;
padding: 0 12px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: var(--color-success);
background: #fff;
border: 1px solid var(--color-success-border);
border-radius: 6px;
cursor: pointer;
transition: background 150ms ease;
}
.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">
<span>Xin chào, <strong>{{username}}</strong></span>
{% 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">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Lịch sử giao dịch
</a>
<div class="card">
<h1>Thêm mới giao dịch</h1>
<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" 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
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>
<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ừ 10,000 đến 20,000,000 VNĐ
</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="2000000">2,000,000</button>
<button type="button" class="chip" data-amount="5000000">5,000,000</button>
<button type="button" class="chip" data-amount="10000000">10,000,000</button>
<button type="button" class="chip" data-amount="15000000">15,000,000</button>
<button type="button" class="chip" data-amount="19000000">19,000,000</button>
<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"
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>
<button type="submit" id="submitBtn">
<span class="spinner"></span>
<span class="btn-label">Tạo giao dịch</span>
</button>
</form>
<div id="result" aria-live="polite"></div>
<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
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" data-field="customerIdCard">
<label for="customerIdCard">Số CMND</label>
<input
type="text"
id="customerIdCard"
name="customerIdCard"
placeholder="012345678901"
autocomplete="off"
inputmode="numeric"
maxlength="12"
minlength="12"
required
aria-describedby="customerIdCard-error"
/>
<p class="field-error" id="customerIdCard-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ố CMND phải đủ 12 chữ số
</p>
</div>
<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="10000"
max="20000000"
step="1"
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ừ 10,000 đến 20,000,000 VNĐ
</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="2000000">
2,000,000
</button>
<button type="button" class="chip" data-amount="5000000">
5,000,000
</button>
<button type="button" class="chip" data-amount="10000000">
10,000,000
</button>
<button type="button" class="chip" data-amount="15000000">
15,000,000
</button>
<button type="button" class="chip" data-amount="19000000">
19,000,000
</button>
</div>
</div>
<button type="submit" id="submitBtn">
<span class="spinner"></span>
<span class="btn-label">Tạo giao dịch</span>
</button>
</form>
<div id="result" aria-live="polite"></div>
</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>';
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'));
});
}
<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>';
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);
amountInput.addEventListener("input", updateAmountPreview);
document.querySelectorAll('.chip').forEach(function (chip) {
chip.addEventListener('click', function () {
amountInput.value = chip.getAttribute('data-amount');
updateAmountPreview();
setFieldValid('amount');
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 validateIdCard(value) {
return /^[0-9]{9,12}$/.test(value.trim());
}
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'); }
if (!validateIdCard(data.customerIdCard)) { setFieldInvalid('customerIdCard'); valid = false; } else { setFieldValid('customerIdCard'); }
var amountNum = parseInt(data.amount, 10);
if (!isFinite(amountNum) || amountNum < 10000 || amountNum > 20000000) { setFieldInvalid('amount'); valid = false; } else { setFieldValid('amount'); }
return valid;
}
['customerName', 'customerAddress', 'customerIdCard'].forEach(function (id) {
document.getElementById(id).addEventListener('input', function () {
setFieldValid(id);
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 validateIdCard(value) {
return /^[0-9]{12}$/.test(value.trim());
}
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");
}
if (!validateIdCard(data.customerIdCard)) {
setFieldInvalid("customerIdCard");
valid = false;
} else {
setFieldValid("customerIdCard");
}
var amountNum = parseInt(data.amount, 10);
if (!isFinite(amountNum) || amountNum < 10000 || amountNum > 20000000) {
setFieldInvalid("amount");
valid = false;
} else {
setFieldValid("amount");
}
return valid;
}
["customerName", "customerAddress", "customerIdCard"].forEach(
function (id) {
document.getElementById(id).addEventListener("input", function () {
setFieldValid(id);
});
},
);
phoneInput.addEventListener("input", function () {
setFieldValid("customerPhone");
});
});
phoneInput.addEventListener('input', function () {
setFieldValid('customerPhone');
});
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var data = {
customerName: form.customerName.value,
customerPhone: form.customerPhone.value,
customerAddress: form.customerAddress.value,
customerIdCard: form.customerIdCard.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.disabled = true;
fetch('/admin/transactions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
})
.then(function (r) { return r.json(); })
.then(function (res) {
if (res.data === 'LOGIN_REQUIRED') {
window.location.href = '/admin/login';
return;
}
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 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();
navigator.clipboard && navigator.clipboard.writeText(field.value);
this.textContent = 'Đã chép!';
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 =
'<div class="alert alert-error">' + iconAlert +
'<div><p class="alert-title">Không tạo được giao dịch</p>' + res.data + '</div></div>';
}
})
.catch(function () {
document.getElementById('result').innerHTML =
'<div class="alert alert-error">' + iconAlert +
'<div><p class="alert-title">Lỗi kết nối</p>Vui lòng thử lại.</div></div>';
form.addEventListener("submit", function (e) {
e.preventDefault();
var btn = document.getElementById("submitBtn");
var data = {
customerName: form.customerName.value,
customerPhone: form.customerPhone.value,
customerAddress: form.customerAddress.value,
customerIdCard: form.customerIdCard.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.disabled = true;
fetch("/admin/transactions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
})
.finally(function () {
btn.classList.remove('loading');
btn.disabled = false;
});
});
</script>
</body>
.then(function (r) {
return r.json();
})
.then(function (res) {
if (res.data === "LOGIN_REQUIRED") {
window.location.href = "/admin/login";
return;
}
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 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();
navigator.clipboard &&
navigator.clipboard.writeText(field.value);
this.textContent = "Đã chép!";
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 =
'<div class="alert alert-error">' +
iconAlert +
'<div><p class="alert-title">Không tạo được giao dịch</p>' +
res.data +
"</div></div>";
}
})
.catch(function () {
document.getElementById("result").innerHTML =
'<div class="alert alert-error">' +
iconAlert +
'<div><p class="alert-title">Lỗi kết nối</p>Vui lòng thử lại.</div></div>';
})
.finally(function () {
btn.classList.remove("loading");
btn.disabled = false;
});
});
</script>
</body>
</html>
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