Commit fb6e381b by tdgiang

update code

parent c40b1f40
...@@ -26,7 +26,7 @@ exports.createTransaction = function (req, res) { ...@@ -26,7 +26,7 @@ exports.createTransaction = function (req, res) {
return res.status(400).json({ code: "99", data: "MISSING_FIELDS" }); 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" }); return res.status(400).json({ code: "99", data: "INVALID_ID_CARD" });
} }
...@@ -443,6 +443,7 @@ exports.listTransactions = function (req, res) { ...@@ -443,6 +443,7 @@ exports.listTransactions = function (req, res) {
resultMsg: tx.resultMsg, resultMsg: tx.resultMsg,
createdByUsername: tx.createdByUsername, createdByUsername: tx.createdByUsername,
createdAt: moment(tx.createdAt).format("DD/MM/YYYY HH:mm"), 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, paymentUrl: config.epay.req_domain + "/admin/pay/" + tx.merTrxId,
}; };
}); });
...@@ -513,13 +514,14 @@ exports.exportTransactions = function (req, res) { ...@@ -513,13 +514,14 @@ exports.exportTransactions = function (req, res) {
} }
var header = [ 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ú", "CMND/CCCD", "Số tiền", "Trạng thái", "Ghi chú",
]; ];
var rows = list.map(function (tx) { var rows = list.map(function (tx) {
return [ return [
tx.merTrxId, tx.merTrxId,
moment(tx.createdAt).format("DD/MM/YYYY HH:mm"), moment(tx.createdAt).format("DD/MM/YYYY HH:mm"),
moment(tx.updatedAt).format("DD/MM/YYYY HH:mm"),
tx.createdByUsername || "", tx.createdByUsername || "",
tx.customerName, tx.customerName,
tx.customerPhone, tx.customerPhone,
......
...@@ -200,6 +200,32 @@ ...@@ -200,6 +200,32 @@
.btn-ghost:hover { border-color: var(--color-primary); background: var(--color-primary-soft); } .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; } .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 */ /* Stat strip */
.stats { .stats {
display: grid; display: grid;
...@@ -489,10 +515,10 @@ ...@@ -489,10 +515,10 @@
<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"> <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> <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 Xuất Excel
</a> </button>
<a class="btn-primary" href="/admin/transactions/new"> <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> <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 Thêm mới giao dịch
...@@ -500,6 +526,27 @@ ...@@ -500,6 +526,27 @@
</div> </div>
</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="stats" id="statsStrip">
<div class="stat-card is-revenue"> <div class="stat-card is-revenue">
<p class="stat-label">Sản lượng giao dịch hôm nay</p> <p class="stat-label">Sản lượng giao dịch hôm nay</p>
...@@ -560,6 +607,7 @@ ...@@ -560,6 +607,7 @@
<tr> <tr>
<th>Hành động</th> <th>Hành động</th>
<th>Thời gian</th> <th>Thời gian</th>
<th>Thời gian giao dịch</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>
<th>Khách hàng</th> <th>Khách hàng</th>
...@@ -580,6 +628,7 @@ ...@@ -580,6 +628,7 @@
</button> </button>
</td> </td>
<td class="cell-muted">{{tx.createdAt}}</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-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>
...@@ -616,6 +665,18 @@ ...@@ -616,6 +665,18 @@
<script> <script>
(function () { (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')); var rows = Array.prototype.slice.call(document.querySelectorAll('#txTableBody tr'));
if (!rows.length) return; if (!rows.length) return;
......
<!DOCTYPE html> <!doctype html>
<html lang="vi"> <html lang="vi">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1" />
<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;800&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-primary-soft: #eff6ff;
--color-text: #0F172A; --color-text: #0f172a;
--color-text-muted: #64748B; --color-text-muted: #64748b;
--color-border: #E2E8F0; --color-border: #e2e8f0;
--color-danger: #DC2626; --color-danger: #dc2626;
--color-danger-bg: #FEF2F2; --color-danger-bg: #fef2f2;
--color-danger-border: #FECACA; --color-danger-border: #fecaca;
--color-success: #15803D; --color-success: #15803d;
--color-success-bg: #F0FDF4; --color-success-bg: #f0fdf4;
--color-success-border: #BBF7D0; --color-success-border: #bbf7d0;
--radius: 10px; --radius: 10px;
--radius-sm: 8px; --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);
} }
* { box-sizing: border-box; } * {
box-sizing: border-box;
}
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
background: var(--color-bg); background: var(--color-bg);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; font-family:
"Inter",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
Helvetica,
Arial,
sans-serif;
color: var(--color-text); color: var(--color-text);
display: flex; display: flex;
justify-content: center; justify-content: center;
...@@ -63,8 +77,12 @@ ...@@ -63,8 +77,12 @@
cursor: pointer; cursor: pointer;
} }
.nav a:hover { color: var(--color-primary); } .nav a:hover {
.nav form { display: inline; } color: var(--color-primary);
}
.nav form {
display: inline;
}
.breadcrumb { .breadcrumb {
display: inline-flex; display: inline-flex;
...@@ -78,8 +96,12 @@ ...@@ -78,8 +96,12 @@
transition: color 150ms ease; transition: color 150ms ease;
} }
.breadcrumb:hover { color: var(--color-primary); } .breadcrumb:hover {
.breadcrumb svg { flex-shrink: 0; } color: var(--color-primary);
}
.breadcrumb svg {
flex-shrink: 0;
}
.card { .card {
background: var(--color-surface); background: var(--color-surface);
...@@ -102,7 +124,9 @@ ...@@ -102,7 +124,9 @@
margin: 0 0 28px; margin: 0 0 28px;
} }
.field { margin-bottom: 20px; } .field {
margin-bottom: 20px;
}
label { label {
display: block; display: block;
...@@ -122,9 +146,15 @@ ...@@ -122,9 +146,15 @@
gap: 5px; gap: 5px;
} }
.field.is-invalid .field-error { display: flex; } .field.is-invalid .field-error {
.field.is-invalid input { border-color: var(--color-danger); } display: flex;
.field.is-invalid input:focus { box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12); } }
.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%;
...@@ -137,19 +167,27 @@ ...@@ -137,19 +167,27 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); 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;
} }
input::placeholder { color: #A0AEC0; } input::placeholder {
color: #a0aec0;
}
input:focus { input:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12); box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12);
} }
.amount-field { position: relative; } .amount-field {
position: relative;
}
.amount-field input { padding-right: 52px; } .amount-field input {
padding-right: 52px;
}
.amount-suffix { .amount-suffix {
position: absolute; position: absolute;
...@@ -189,12 +227,22 @@ ...@@ -189,12 +227,22 @@
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 999px; border-radius: 999px;
cursor: pointer; cursor: pointer;
transition: background 150ms ease, border-color 150ms ease; transition:
background 150ms ease,
border-color 150ms ease;
} }
.chip:hover { border-color: var(--color-primary); } .chip:hover {
.chip.is-active { background: var(--color-primary); color: #fff; } border-color: var(--color-primary);
.chip:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } }
.chip.is-active {
background: var(--color-primary);
color: #fff;
}
.chip:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
#submitBtn { #submitBtn {
width: 100%; width: 100%;
...@@ -212,13 +260,25 @@ ...@@ -212,13 +260,25 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
transition: background 150ms ease, transform 150ms ease; transition:
background 150ms ease,
transform 150ms ease;
} }
#submitBtn:hover { background: var(--color-primary-hover); } #submitBtn:hover {
#submitBtn:active { transform: scale(0.99); } background: var(--color-primary-hover);
#submitBtn:disabled { opacity: 0.7; cursor: not-allowed; } }
#submitBtn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } #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 { .spinner {
width: 16px; width: 16px;
...@@ -230,14 +290,28 @@ ...@@ -230,14 +290,28 @@
display: none; display: none;
} }
#submitBtn.loading .spinner { display: inline-block; } #submitBtn.loading .spinner {
#submitBtn.loading .btn-label::after { content: "Đang tạo…"; } display: inline-block;
#submitBtn.loading .btn-label { font-size: 0; } }
#submitBtn.loading .btn-label::after { font-size: 15px; } #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); } } @keyframes spin {
to {
transform: rotate(360deg);
}
}
#result { margin-top: 20px; } #result {
margin-top: 20px;
}
.alert { .alert {
display: flex; display: flex;
...@@ -252,25 +326,37 @@ ...@@ -252,25 +326,37 @@
} }
@keyframes alertIn { @keyframes alertIn {
from { opacity: 0; transform: translateY(4px); } from {
to { opacity: 1; transform: translateY(0); } 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;
}
.alert-success { .alert-success {
background: var(--color-success-bg); background: var(--color-success-bg);
border-color: var(--color-success-border); border-color: var(--color-success-border);
color: #14532D; color: #14532d;
} }
.alert-error { .alert-error {
background: var(--color-danger-bg); background: var(--color-danger-bg);
border-color: var(--color-danger-border); border-color: var(--color-danger-border);
color: #7F1D1D; color: #7f1d1d;
} }
.alert-title { font-weight: 600; margin: 0 0 6px; } .alert-title {
font-weight: 600;
margin: 0 0 6px;
}
.trx-badge { .trx-badge {
display: inline-flex; display: inline-flex;
...@@ -278,7 +364,7 @@ ...@@ -278,7 +364,7 @@
gap: 6px; gap: 6px;
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
color: #14532D; color: #14532d;
background: rgba(21, 128, 61, 0.1); background: rgba(21, 128, 61, 0.1);
border-radius: 6px; border-radius: 6px;
padding: 3px 8px; padding: 3px 8px;
...@@ -307,7 +393,9 @@ ...@@ -307,7 +393,9 @@
background: transparent; background: transparent;
} }
.link-row input:focus { box-shadow: none; } .link-row input:focus {
box-shadow: none;
}
.copy-btn { .copy-btn {
flex-shrink: 0; flex-shrink: 0;
...@@ -324,10 +412,17 @@ ...@@ -324,10 +412,17 @@
transition: background 150ms ease; transition: background 150ms ease;
} }
.copy-btn:hover { background: var(--color-success-bg); } .copy-btn:hover {
.copy-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } background: var(--color-success-bg);
}
.copy-btn:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.result-actions { margin-top: 10px; } .result-actions {
margin-top: 10px;
}
.btn-ghost { .btn-ghost {
height: 34px; height: 34px;
...@@ -340,30 +435,53 @@ ...@@ -340,30 +435,53 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: border-color 150ms ease, background 150ms ease; transition:
border-color 150ms ease,
background 150ms ease;
} }
.btn-ghost:hover { border-color: var(--color-primary); background: var(--color-primary-soft); } .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 {
.alert { animation: none; } animation: none;
* { transition: none !important; } }
.alert {
animation: none;
}
* {
transition: none !important;
}
} }
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <div class="page">
<div class="nav"> <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">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">Đổi mật khẩu</a> <a href="/admin/account/password">Đổi mật khẩu</a>
<form method="POST" action="/admin/logout"> <form method="POST" action="/admin/logout">
<button type="submit">Đă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">
<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> <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 Lịch sử giao dịch
</a> </a>
...@@ -374,53 +492,183 @@ ...@@ -374,53 +492,183 @@
<form id="txForm" novalidate> <form id="txForm" novalidate>
<div class="field" data-field="customerName"> <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 aria-describedby="customerName-error"> <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"> <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> <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 Vui lòng nhập tên khách hàng
</p> </p>
</div> </div>
<div class="field" data-field="customerPhone"> <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" inputmode="numeric" required aria-describedby="customerPhone-error"> <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"> <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> <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) Số điện thoại không hợp lệ (VD: 0901234567)
</p> </p>
</div> </div>
<div class="field" data-field="customerAddress"> <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 aria-describedby="customerAddress-error"> <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"> <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> <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ỉ Vui lòng nhập địa chỉ
</p> </p>
</div> </div>
<div class="field" data-field="customerIdCard"> <div class="field" data-field="customerIdCard">
<label for="customerIdCard">Số CMND/CCCD</label> <label for="customerIdCard">Số CMND</label>
<input type="text" id="customerIdCard" name="customerIdCard" placeholder="012345678901" autocomplete="off" inputmode="numeric" maxlength="12" required aria-describedby="customerIdCard-error"> <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"> <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> <svg
Số CMND/CCCD phải từ 9 đến 12 chữ số 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> </p>
</div> </div>
<div class="field" data-field="amount"> <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="10000" max="20000000" step="1" inputmode="numeric" required aria-describedby="amount-error amount-preview"> <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> <span class="amount-suffix">VNĐ</span>
</div> </div>
<p class="field-error" id="amount-error" role="alert"> <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> <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Đ Số tiền phải từ 10,000 đến 20,000,000 VNĐ
</p> </p>
<p class="amount-preview" id="amount-preview" aria-live="polite"></p> <p
<div class="amount-presets" role="group" aria-label="Chọn nhanh số tiền"> class="amount-preview"
<button type="button" class="chip" data-amount="2000000">2,000,000</button> id="amount-preview"
<button type="button" class="chip" data-amount="5000000">5,000,000</button> aria-live="polite"
<button type="button" class="chip" data-amount="10000000">10,000,000</button> ></p>
<button type="button" class="chip" data-amount="15000000">15,000,000</button> <div
<button type="button" class="chip" data-amount="19000000">19,000,000</button> 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>
</div> </div>
<button type="submit" id="submitBtn"> <button type="submit" id="submitBtn">
...@@ -433,149 +681,204 @@ ...@@ -433,149 +681,204 @@
</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 =
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>'; '<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 form = document.getElementById("txForm");
var amountInput = document.getElementById('amount'); var amountInput = document.getElementById("amount");
var amountPreview = document.getElementById('amount-preview'); var amountPreview = document.getElementById("amount-preview");
var phoneInput = document.getElementById('customerPhone'); var phoneInput = document.getElementById("customerPhone");
function formatVnd(n) { function formatVnd(n) {
return n.toLocaleString('vi-VN'); return n.toLocaleString("vi-VN");
} }
function updateAmountPreview() { function updateAmountPreview() {
var val = parseInt(amountInput.value, 10); var val = parseInt(amountInput.value, 10);
amountPreview.textContent = (isFinite(val) && val > 0) ? formatVnd(val) + ' ₫' : ''; amountPreview.textContent =
document.querySelectorAll('.chip').forEach(function (chip) { isFinite(val) && val > 0 ? formatVnd(val) + " ₫" : "";
chip.classList.toggle('is-active', String(val) === chip.getAttribute('data-amount')); 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) { document.querySelectorAll(".chip").forEach(function (chip) {
chip.addEventListener('click', function () { chip.addEventListener("click", function () {
amountInput.value = chip.getAttribute('data-amount'); amountInput.value = chip.getAttribute("data-amount");
updateAmountPreview(); updateAmountPreview();
setFieldValid('amount'); setFieldValid("amount");
}); });
}); });
function setFieldInvalid(name) { function setFieldInvalid(name) {
var field = form.querySelector('[data-field="' + name + '"]'); var field = form.querySelector('[data-field="' + name + '"]');
if (field) field.classList.add('is-invalid'); if (field) field.classList.add("is-invalid");
} }
function setFieldValid(name) { function setFieldValid(name) {
var field = form.querySelector('[data-field="' + name + '"]'); var field = form.querySelector('[data-field="' + name + '"]');
if (field) field.classList.remove('is-invalid'); if (field) field.classList.remove("is-invalid");
} }
function validatePhone(value) { function validatePhone(value) {
return /^(0|\+84)[0-9]{9}$/.test(value.replace(/[\s.-]/g, '')); return /^(0|\+84)[0-9]{9}$/.test(value.replace(/[\s.-]/g, ""));
} }
function validateIdCard(value) { function validateIdCard(value) {
return /^[0-9]{9,12}$/.test(value.trim()); return /^[0-9]{12}$/.test(value.trim());
} }
function validateForm(data) { function validateForm(data) {
var valid = true; var valid = true;
if (!data.customerName.trim()) { setFieldInvalid('customerName'); valid = false; } else { setFieldValid('customerName'); } if (!data.customerName.trim()) {
if (!validatePhone(data.customerPhone)) { setFieldInvalid('customerPhone'); valid = false; } else { setFieldValid('customerPhone'); } setFieldInvalid("customerName");
if (!data.customerAddress.trim()) { setFieldInvalid('customerAddress'); valid = false; } else { setFieldValid('customerAddress'); } valid = false;
if (!validateIdCard(data.customerIdCard)) { setFieldInvalid('customerIdCard'); valid = false; } else { setFieldValid('customerIdCard'); } } 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); var amountNum = parseInt(data.amount, 10);
if (!isFinite(amountNum) || amountNum < 10000 || amountNum > 20000000) { setFieldInvalid('amount'); valid = false; } else { setFieldValid('amount'); } if (!isFinite(amountNum) || amountNum < 10000 || amountNum > 20000000) {
setFieldInvalid("amount");
valid = false;
} else {
setFieldValid("amount");
}
return valid; return valid;
} }
['customerName', 'customerAddress', 'customerIdCard'].forEach(function (id) { ["customerName", "customerAddress", "customerIdCard"].forEach(
document.getElementById(id).addEventListener('input', function () { function (id) {
document.getElementById(id).addEventListener("input", function () {
setFieldValid(id); setFieldValid(id);
}); });
}); },
phoneInput.addEventListener('input', function () { );
setFieldValid('customerPhone'); phoneInput.addEventListener("input", function () {
setFieldValid("customerPhone");
}); });
form.addEventListener('submit', function (e) { form.addEventListener("submit", function (e) {
e.preventDefault(); e.preventDefault();
var btn = document.getElementById('submitBtn'); var btn = document.getElementById("submitBtn");
var data = { var data = {
customerName: form.customerName.value, customerName: form.customerName.value,
customerPhone: form.customerPhone.value, customerPhone: form.customerPhone.value,
customerAddress: form.customerAddress.value, customerAddress: form.customerAddress.value,
customerIdCard: form.customerIdCard.value, customerIdCard: form.customerIdCard.value,
amount: form.amount.value amount: form.amount.value,
}; };
if (!validateForm(data)) { if (!validateForm(data)) {
var firstInvalid = form.querySelector('.is-invalid input'); var firstInvalid = form.querySelector(".is-invalid input");
if (firstInvalid) firstInvalid.focus(); if (firstInvalid) firstInvalid.focus();
return; return;
} }
btn.classList.add('loading'); btn.classList.add("loading");
btn.disabled = true; btn.disabled = true;
fetch('/admin/transactions', { fetch("/admin/transactions", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(data) body: JSON.stringify(data),
})
.then(function (r) {
return r.json();
}) })
.then(function (r) { return r.json(); })
.then(function (res) { .then(function (res) {
if (res.data === 'LOGIN_REQUIRED') { if (res.data === "LOGIN_REQUIRED") {
window.location.href = '/admin/login'; window.location.href = "/admin/login";
return; return;
} }
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(); 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>' : '') + (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 class="result-actions"><button type="button" class="btn-ghost" id="newTxBtn">Tạo giao dịch khác</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>'; "</div></div>";
document.getElementById('copyBtn').addEventListener('click', function () { document
var field = document.getElementById('paymentUrlField'); .getElementById("copyBtn")
.addEventListener("click", function () {
var field = document.getElementById("paymentUrlField");
field.select(); field.select();
navigator.clipboard && navigator.clipboard.writeText(field.value); navigator.clipboard &&
this.textContent = 'Đã chép!'; navigator.clipboard.writeText(field.value);
this.textContent = "Đã chép!";
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('newTxBtn').addEventListener('click', function () { document.getElementById("customerName").focus();
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 =
'<div class="alert alert-error">' + iconAlert + '<div class="alert alert-error">' +
'<div><p class="alert-title">Không tạo được giao dịch</p>' + res.data + '</div></div>'; iconAlert +
'<div><p class="alert-title">Không tạo được giao dịch</p>' +
res.data +
"</div></div>";
} }
}) })
.catch(function () { .catch(function () {
document.getElementById('result').innerHTML = document.getElementById("result").innerHTML =
'<div class="alert alert-error">' + iconAlert + '<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>'; '<div><p class="alert-title">Lỗi kết nối</p>Vui lòng thử lại.</div></div>';
}) })
.finally(function () { .finally(function () {
btn.classList.remove('loading'); btn.classList.remove("loading");
btn.disabled = false; btn.disabled = false;
}); });
}); });
</script> </script>
</body> </body>
</html> </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