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
<style> href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap"
:root { rel="stylesheet"
--color-bg: #F1F5F9; />
--color-surface: #FFFFFF; <style>
--color-primary: #1E40AF; :root {
--color-primary-hover: #1D4ED8; --color-bg: #f1f5f9;
--color-primary-soft: #EFF6FF; --color-surface: #ffffff;
--color-text: #0F172A; --color-primary: #1e40af;
--color-text-muted: #64748B; --color-primary-hover: #1d4ed8;
--color-border: #E2E8F0; --color-primary-soft: #eff6ff;
--color-danger: #DC2626; --color-text: #0f172a;
--color-danger-bg: #FEF2F2; --color-text-muted: #64748b;
--color-danger-border: #FECACA; --color-border: #e2e8f0;
--color-success: #15803D; --color-danger: #dc2626;
--color-success-bg: #F0FDF4; --color-danger-bg: #fef2f2;
--color-success-border: #BBF7D0; --color-danger-border: #fecaca;
--radius: 10px; --color-success: #15803d;
--radius-sm: 8px; --color-success-bg: #f0fdf4;
--shadow-card: 0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06); --color-success-border: #bbf7d0;
} --radius: 10px;
--radius-sm: 8px;
* { box-sizing: border-box; } --shadow-card:
0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.06);
body { }
margin: 0;
min-height: 100vh; * {
background: var(--color-bg); box-sizing: border-box;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; }
color: var(--color-text);
display: flex; body {
justify-content: center; margin: 0;
padding: 40px 20px; min-height: 100vh;
} background: var(--color-bg);
font-family:
.page { "Inter",
width: 100%; -apple-system,
max-width: 520px; BlinkMacSystemFont,
} "Segoe UI",
Roboto,
.nav { Helvetica,
display: flex; Arial,
gap: 16px; sans-serif;
font-size: 13px; color: var(--color-text);
color: var(--color-text-muted); display: flex;
margin-bottom: 12px; justify-content: center;
flex-wrap: wrap; padding: 40px 20px;
align-items: center; }
}
.page {
.nav a { width: 100%;
color: inherit; max-width: 520px;
text-decoration: none; }
font-weight: 500;
cursor: pointer; .nav {
} display: flex;
gap: 16px;
.nav a:hover { color: var(--color-primary); } font-size: 13px;
.nav form { display: inline; } color: var(--color-text-muted);
margin-bottom: 12px;
.breadcrumb { flex-wrap: wrap;
display: inline-flex; align-items: center;
align-items: center; }
gap: 6px;
font-size: 14px; .nav a {
font-weight: 500; color: inherit;
color: var(--color-text-muted); text-decoration: none;
text-decoration: none; font-weight: 500;
margin-bottom: 20px; cursor: pointer;
transition: color 150ms ease; }
}
.nav a:hover {
.breadcrumb:hover { color: var(--color-primary); } color: var(--color-primary);
.breadcrumb svg { flex-shrink: 0; } }
.nav form {
.card { display: inline;
background: var(--color-surface); }
border: 1px solid var(--color-border);
border-radius: var(--radius); .breadcrumb {
box-shadow: var(--shadow-card); display: inline-flex;
padding: 32px; align-items: center;
} gap: 6px;
font-size: 14px;
h1 { font-weight: 500;
font-size: 22px; color: var(--color-text-muted);
font-weight: 700; text-decoration: none;
letter-spacing: -0.01em; margin-bottom: 20px;
margin: 0 0 4px; transition: color 150ms ease;
} }
.subtitle { .breadcrumb:hover {
font-size: 14px; color: var(--color-primary);
color: var(--color-text-muted); }
margin: 0 0 28px; .breadcrumb svg {
} flex-shrink: 0;
}
.field { margin-bottom: 20px; }
.card {
label { background: var(--color-surface);
display: block; border: 1px solid var(--color-border);
font-size: 13px; border-radius: var(--radius);
font-weight: 600; box-shadow: var(--shadow-card);
color: var(--color-text); padding: 32px;
margin-bottom: 6px; }
}
h1 {
.field-error { font-size: 22px;
font-size: 12.5px; font-weight: 700;
font-weight: 500; letter-spacing: -0.01em;
color: var(--color-danger); margin: 0 0 4px;
margin-top: 6px; }
display: none;
align-items: center; .subtitle {
gap: 5px; font-size: 14px;
} color: var(--color-text-muted);
margin: 0 0 28px;
.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); } .field {
margin-bottom: 20px;
input { }
width: 100%;
height: 44px; label {
padding: 0 14px; display: block;
font-size: 15px; font-size: 13px;
font-family: inherit; font-weight: 600;
color: var(--color-text); color: var(--color-text);
background: var(--color-surface); margin-bottom: 6px;
border: 1px solid var(--color-border); }
border-radius: var(--radius-sm);
outline: none; .field-error {
transition: border-color 150ms ease, box-shadow 150ms ease; font-size: 12.5px;
} font-weight: 500;
color: var(--color-danger);
input::placeholder { color: #A0AEC0; } margin-top: 6px;
display: none;
input:focus { align-items: center;
border-color: var(--color-primary); gap: 5px;
box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12); }
}
.field.is-invalid .field-error {
.amount-field { position: relative; } display: flex;
}
.amount-field input { padding-right: 52px; } .field.is-invalid input {
border-color: var(--color-danger);
.amount-suffix { }
position: absolute; .field.is-invalid input:focus {
right: 14px; box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.12);
top: 50%; }
transform: translateY(-50%);
font-size: 13px; input {
font-weight: 600; width: 100%;
color: var(--color-text-muted); height: 44px;
pointer-events: none; padding: 0 14px;
} font-size: 15px;
font-family: inherit;
.amount-preview { color: var(--color-text);
font-size: 13px; background: var(--color-surface);
font-weight: 600; border: 1px solid var(--color-border);
color: var(--color-primary); border-radius: var(--radius-sm);
margin-top: 6px; outline: none;
min-height: 18px; transition:
font-variant-numeric: tabular-nums; border-color 150ms ease,
} box-shadow 150ms ease;
}
.amount-presets {
display: flex; input::placeholder {
flex-wrap: wrap; color: #a0aec0;
gap: 8px; }
margin-top: 10px;
} input:focus {
border-color: var(--color-primary);
.chip { box-shadow: 0 0 0 3px rgba(30, 64, 175, 0.12);
height: 30px; }
padding: 0 12px;
font-family: inherit; .amount-field {
font-size: 12.5px; position: relative;
font-weight: 600; }
color: var(--color-primary);
background: var(--color-primary-soft); .amount-field input {
border: 1px solid transparent; padding-right: 52px;
border-radius: 999px; }
cursor: pointer;
transition: background 150ms ease, border-color 150ms ease; .amount-suffix {
} position: absolute;
right: 14px;
.chip:hover { border-color: var(--color-primary); } top: 50%;
.chip.is-active { background: var(--color-primary); color: #fff; } transform: translateY(-50%);
.chip:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } font-size: 13px;
font-weight: 600;
#submitBtn { color: var(--color-text-muted);
width: 100%; pointer-events: none;
height: 46px; }
margin-top: 8px;
background: var(--color-primary); .amount-preview {
color: #fff; font-size: 13px;
font-family: inherit; font-weight: 600;
font-size: 15px; color: var(--color-primary);
font-weight: 600; margin-top: 6px;
border: none; min-height: 18px;
border-radius: var(--radius-sm); font-variant-numeric: tabular-nums;
cursor: pointer; }
display: flex;
align-items: center; .amount-presets {
justify-content: center; display: flex;
gap: 8px; flex-wrap: wrap;
transition: background 150ms ease, transform 150ms ease; gap: 8px;
} margin-top: 10px;
}
#submitBtn:hover { background: var(--color-primary-hover); }
#submitBtn:active { transform: scale(0.99); } .chip {
#submitBtn:disabled { opacity: 0.7; cursor: not-allowed; } height: 30px;
#submitBtn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } padding: 0 12px;
font-family: inherit;
.spinner { font-size: 12.5px;
width: 16px; font-weight: 600;
height: 16px; color: var(--color-primary);
border: 2px solid rgba(255, 255, 255, 0.4); background: var(--color-primary-soft);
border-top-color: #fff; border: 1px solid transparent;
border-radius: 50%; border-radius: 999px;
animation: spin 700ms linear infinite; cursor: pointer;
display: none; transition:
} background 150ms ease,
border-color 150ms ease;
#submitBtn.loading .spinner { display: inline-block; } }
#submitBtn.loading .btn-label::after { content: "Đang tạo…"; }
#submitBtn.loading .btn-label { font-size: 0; } .chip:hover {
#submitBtn.loading .btn-label::after { font-size: 15px; } border-color: var(--color-primary);
}
@keyframes spin { to { transform: rotate(360deg); } } .chip.is-active {
background: var(--color-primary);
#result { margin-top: 20px; } color: #fff;
}
.alert { .chip:focus-visible {
display: flex; outline: 2px solid var(--color-primary);
gap: 12px; outline-offset: 2px;
align-items: flex-start; }
padding: 16px;
border-radius: 8px; #submitBtn {
border: 1px solid; width: 100%;
font-size: 14px; height: 46px;
line-height: 1.5; margin-top: 8px;
animation: alertIn 220ms ease both; background: var(--color-primary);
} color: #fff;
font-family: inherit;
@keyframes alertIn { font-size: 15px;
from { opacity: 0; transform: translateY(4px); } font-weight: 600;
to { opacity: 1; transform: translateY(0); } border: none;
} border-radius: var(--radius-sm);
cursor: pointer;
.alert svg { flex-shrink: 0; margin-top: 1px; } display: flex;
align-items: center;
.alert-success { justify-content: center;
background: var(--color-success-bg); gap: 8px;
border-color: var(--color-success-border); transition:
color: #14532D; background 150ms ease,
} transform 150ms ease;
}
.alert-error {
background: var(--color-danger-bg); #submitBtn:hover {
border-color: var(--color-danger-border); background: var(--color-primary-hover);
color: #7F1D1D; }
} #submitBtn:active {
transform: scale(0.99);
.alert-title { font-weight: 600; margin: 0 0 6px; } }
#submitBtn:disabled {
.trx-badge { opacity: 0.7;
display: inline-flex; cursor: not-allowed;
align-items: center; }
gap: 6px; #submitBtn:focus-visible {
font-size: 12px; outline: 2px solid var(--color-primary);
font-weight: 600; outline-offset: 2px;
color: #14532D; }
background: rgba(21, 128, 61, 0.1);
border-radius: 6px; .spinner {
padding: 3px 8px; width: 16px;
margin-bottom: 10px; height: 16px;
font-variant-numeric: tabular-nums; border: 2px solid rgba(255, 255, 255, 0.4);
} border-top-color: #fff;
border-radius: 50%;
.link-row { animation: spin 700ms linear infinite;
display: flex; display: none;
align-items: center; }
gap: 8px;
margin-top: 8px; #submitBtn.loading .spinner {
background: #fff; display: inline-block;
border: 1px solid var(--color-success-border); }
border-radius: 6px; #submitBtn.loading .btn-label::after {
padding: 8px 10px; content: "Đang tạo…";
} }
#submitBtn.loading .btn-label {
.link-row input { font-size: 0;
flex: 1; }
height: auto; #submitBtn.loading .btn-label::after {
border: none; font-size: 15px;
padding: 0; }
font-size: 13px;
color: var(--color-text); @keyframes spin {
background: transparent; to {
} transform: rotate(360deg);
}
.link-row input:focus { box-shadow: none; } }
.copy-btn { #result {
flex-shrink: 0; margin-top: 20px;
height: 30px; }
padding: 0 12px;
font-family: inherit; .alert {
font-size: 12px; display: flex;
font-weight: 600; gap: 12px;
color: var(--color-success); align-items: flex-start;
background: #fff; padding: 16px;
border: 1px solid var(--color-success-border); border-radius: 8px;
border-radius: 6px; border: 1px solid;
cursor: pointer; font-size: 14px;
transition: background 150ms ease; line-height: 1.5;
} animation: alertIn 220ms ease both;
}
.copy-btn:hover { background: var(--color-success-bg); }
.copy-btn:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; } @keyframes alertIn {
from {
.result-actions { margin-top: 10px; } opacity: 0;
transform: translateY(4px);
.btn-ghost { }
height: 34px; to {
padding: 0 12px; opacity: 1;
font-family: inherit; transform: translateY(0);
font-size: 12.5px; }
font-weight: 600; }
color: var(--color-primary);
background: transparent; .alert svg {
border: 1px solid var(--color-border); flex-shrink: 0;
border-radius: 6px; margin-top: 1px;
cursor: pointer; }
transition: border-color 150ms ease, background 150ms ease;
} .alert-success {
background: var(--color-success-bg);
.btn-ghost:hover { border-color: var(--color-primary); background: var(--color-primary-soft); } border-color: var(--color-success-border);
color: #14532d;
@media (prefers-reduced-motion: reduce) { }
.spinner { animation: none; }
.alert { animation: none; } .alert-error {
* { transition: none !important; } background: var(--color-danger-bg);
} border-color: var(--color-danger-border);
</style> color: #7f1d1d;
</head> }
<body>
<div class="page"> .alert-title {
<div class="nav"> font-weight: 600;
<span>Xin chào, <strong>{{username}}</strong></span> margin: 0 0 6px;
{% 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"> .trx-badge {
<button type="submit">Đăng xuất</button> display: inline-flex;
</form> align-items: center;
</div> gap: 6px;
<a class="breadcrumb" href="/admin/transactions"> font-size: 12px;
<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> font-weight: 600;
Lịch sử giao dịch color: #14532d;
</a> background: rgba(21, 128, 61, 0.1);
border-radius: 6px;
<div class="card"> padding: 3px 8px;
<h1>Thêm mới giao dịch</h1> margin-bottom: 10px;
<p class="subtitle">Nhập thông tin khách hàng để tạo link thanh toán</p> font-variant-numeric: tabular-nums;
}
<form id="txForm" novalidate>
<div class="field" data-field="customerName"> .link-row {
<label for="customerName">Tên khách hàng</label> display: flex;
<input type="text" id="customerName" name="customerName" placeholder="Nguyễn Văn A" autocomplete="name" required aria-describedby="customerName-error"> align-items: center;
<p class="field-error" id="customerName-error" role="alert"> gap: 8px;
<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> margin-top: 8px;
Vui lòng nhập tên khách hàng background: #fff;
</p> border: 1px solid var(--color-success-border);
</div> border-radius: 6px;
<div class="field" data-field="customerPhone"> padding: 8px 10px;
<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"> .link-row input {
<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> flex: 1;
Số điện thoại không hợp lệ (VD: 0901234567) height: auto;
</p> border: none;
</div> padding: 0;
<div class="field" data-field="customerAddress"> font-size: 13px;
<label for="customerAddress">Địa chỉ</label> color: var(--color-text);
<input type="text" id="customerAddress" name="customerAddress" placeholder="Số nhà, đường, phường/xã" autocomplete="street-address" required aria-describedby="customerAddress-error"> background: transparent;
<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ỉ .link-row input:focus {
</p> box-shadow: none;
</div> }
<div class="field" data-field="customerIdCard">
<label for="customerIdCard">Số CMND/CCCD</label> .copy-btn {
<input type="text" id="customerIdCard" name="customerIdCard" placeholder="012345678901" autocomplete="off" inputmode="numeric" maxlength="12" required aria-describedby="customerIdCard-error"> flex-shrink: 0;
<p class="field-error" id="customerIdCard-error" role="alert"> height: 30px;
<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> padding: 0 12px;
Số CMND/CCCD phải từ 9 đến 12 chữ số font-family: inherit;
</p> font-size: 12px;
</div> font-weight: 600;
<div class="field" data-field="amount"> color: var(--color-success);
<label for="amount">Số tiền thanh toán</label> background: #fff;
<div class="amount-field"> border: 1px solid var(--color-success-border);
<input type="number" id="amount" name="amount" placeholder="0" min="10000" max="20000000" step="1" inputmode="numeric" required aria-describedby="amount-error amount-preview"> border-radius: 6px;
<span class="amount-suffix">VNĐ</span> 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>
<p class="field-error" id="amount-error" role="alert"> <div class="field" data-field="customerPhone">
<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> <label for="customerPhone">Số điện thoại</label>
Số tiền phải từ 10,000 đến 20,000,000 VNĐ <input
</p> type="tel"
<p class="amount-preview" id="amount-preview" aria-live="polite"></p> id="customerPhone"
<div class="amount-presets" role="group" aria-label="Chọn nhanh số tiền"> name="customerPhone"
<button type="button" class="chip" data-amount="2000000">2,000,000</button> placeholder="09xx xxx xxx"
<button type="button" class="chip" data-amount="5000000">5,000,000</button> autocomplete="tel"
<button type="button" class="chip" data-amount="10000000">10,000,000</button> inputmode="numeric"
<button type="button" class="chip" data-amount="15000000">15,000,000</button> required
<button type="button" class="chip" data-amount="19000000">19,000,000</button> aria-describedby="customerPhone-error"
/>
<p class="field-error" id="customerPhone-error" role="alert">
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
Số điện thoại không hợp lệ (VD: 0901234567)
</p>
</div> </div>
</div> <div class="field" data-field="customerAddress">
<button type="submit" id="submitBtn"> <label for="customerAddress">Địa chỉ</label>
<span class="spinner"></span> <input
<span class="btn-label">Tạo giao dịch</span> type="text"
</button> id="customerAddress"
</form> name="customerAddress"
placeholder="Số nhà, đường, phường/xã"
<div id="result" aria-live="polite"></div> 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>
</div> <script>
<script> var iconCheck =
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>'; '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>';
var iconAlert = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>'; var iconAlert =
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>';
var form = document.getElementById('txForm');
var amountInput = document.getElementById('amount'); var form = document.getElementById("txForm");
var amountPreview = document.getElementById('amount-preview'); var amountInput = document.getElementById("amount");
var phoneInput = document.getElementById('customerPhone'); var amountPreview = document.getElementById("amount-preview");
var phoneInput = document.getElementById("customerPhone");
function formatVnd(n) {
return n.toLocaleString('vi-VN'); function formatVnd(n) {
} return n.toLocaleString("vi-VN");
}
function updateAmountPreview() {
var val = parseInt(amountInput.value, 10); function updateAmountPreview() {
amountPreview.textContent = (isFinite(val) && val > 0) ? formatVnd(val) + ' ₫' : ''; var val = parseInt(amountInput.value, 10);
document.querySelectorAll('.chip').forEach(function (chip) { amountPreview.textContent =
chip.classList.toggle('is-active', String(val) === chip.getAttribute('data-amount')); 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) { 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]{12}$/.test(value.trim());
return /^[0-9]{9,12}$/.test(value.trim()); }
}
function validateForm(data) {
function validateForm(data) { var valid = true;
var valid = true; if (!data.customerName.trim()) {
if (!data.customerName.trim()) { setFieldInvalid('customerName'); valid = false; } else { setFieldValid('customerName'); } setFieldInvalid("customerName");
if (!validatePhone(data.customerPhone)) { setFieldInvalid('customerPhone'); valid = false; } else { setFieldValid('customerPhone'); } valid = false;
if (!data.customerAddress.trim()) { setFieldInvalid('customerAddress'); valid = false; } else { setFieldValid('customerAddress'); } } else {
if (!validateIdCard(data.customerIdCard)) { setFieldInvalid('customerIdCard'); valid = false; } else { setFieldValid('customerIdCard'); } setFieldValid("customerName");
var amountNum = parseInt(data.amount, 10); }
if (!isFinite(amountNum) || amountNum < 10000 || amountNum > 20000000) { setFieldInvalid('amount'); valid = false; } else { setFieldValid('amount'); } if (!validatePhone(data.customerPhone)) {
return valid; setFieldInvalid("customerPhone");
} valid = false;
} else {
['customerName', 'customerAddress', 'customerIdCard'].forEach(function (id) { setFieldValid("customerPhone");
document.getElementById(id).addEventListener('input', function () { }
setFieldValid(id); 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 () { form.addEventListener("submit", function (e) {
setFieldValid('customerPhone'); e.preventDefault();
}); var btn = document.getElementById("submitBtn");
var data = {
form.addEventListener('submit', function (e) { customerName: form.customerName.value,
e.preventDefault(); customerPhone: form.customerPhone.value,
var btn = document.getElementById('submitBtn'); customerAddress: form.customerAddress.value,
var data = { customerIdCard: form.customerIdCard.value,
customerName: form.customerName.value, amount: form.amount.value,
customerPhone: form.customerPhone.value, };
customerAddress: form.customerAddress.value,
customerIdCard: form.customerIdCard.value, if (!validateForm(data)) {
amount: form.amount.value var firstInvalid = form.querySelector(".is-invalid input");
}; if (firstInvalid) firstInvalid.focus();
return;
if (!validateForm(data)) { }
var firstInvalid = form.querySelector('.is-invalid input');
if (firstInvalid) firstInvalid.focus(); btn.classList.add("loading");
return; btn.disabled = true;
}
fetch("/admin/transactions", {
btn.classList.add('loading'); method: "POST",
btn.disabled = true; headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
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>';
}) })
.finally(function () { .then(function (r) {
btn.classList.remove('loading'); return r.json();
btn.disabled = false; })
}); .then(function (res) {
}); if (res.data === "LOGIN_REQUIRED") {
</script> window.location.href = "/admin/login";
</body> 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> </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