Commit 4bdc896a by tdgiang

Add status filter and Excel (CSV) export to admin/transactions

Status filter (pending/success/failed) works alongside the existing
staff/date filters, all sharing one buildTransactionFilter() helper so
listing and export never drift apart.

Export is a new GET /admin/transactions/export route: same filters,
no pagination limit (exports everything matching, not just the current
page), CSV with a UTF-8 BOM so Excel on Windows renders Vietnamese
diacritics correctly instead of mangling them.
Co-Authored-By: 's avatarClaude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017KPzWwuTEeX2vXGXvyGn4q
parent 4f1f381e
......@@ -261,9 +261,10 @@ exports.epayIPN = function (req, res) {
});
};
exports.listTransactions = function (req, res) {
var page = parseInt(req.query.page) || 1;
var perPage = 20;
var TRANSACTION_STATUSES = ["pending", "success", "failed"];
// Shared by listTransactions and exportTransactions so the two never drift apart.
function buildTransactionFilter(req) {
var isAdmin = req.session.role === "admin";
var filter = {};
......@@ -273,6 +274,10 @@ exports.listTransactions = function (req, res) {
filter.createdByUsername = String(req.query.staff);
}
if (TRANSACTION_STATUSES.indexOf(req.query.status) !== -1) {
filter.status = req.query.status;
}
var fromDate = moment(String(req.query.fromDate || ""), "YYYY-MM-DD", true);
var toDate = moment(String(req.query.toDate || ""), "YYYY-MM-DD", true);
if (fromDate.isValid() || toDate.isValid()) {
......@@ -281,6 +286,22 @@ exports.listTransactions = function (req, res) {
if (toDate.isValid()) filter.createdAt.$lte = toDate.endOf("day").toDate();
}
return {
filter: filter,
isAdmin: isAdmin,
selectedStatus: TRANSACTION_STATUSES.indexOf(req.query.status) !== -1 ? req.query.status : "",
selectedFromDate: fromDate.isValid() ? fromDate.format("YYYY-MM-DD") : "",
selectedToDate: toDate.isValid() ? toDate.format("YYYY-MM-DD") : "",
};
}
exports.listTransactions = function (req, res) {
var page = parseInt(req.query.page) || 1;
var perPage = 20;
var built = buildTransactionFilter(req);
var filter = built.filter;
var isAdmin = built.isAdmin;
function render(staffOptions) {
AdminTransaction.find(filter)
.sort({ createdAt: -1 })
......@@ -316,8 +337,9 @@ exports.listTransactions = function (req, res) {
username: req.session.username,
staffOptions: staffOptions || [],
selectedStaff: req.query.staff || "",
selectedFromDate: fromDate.isValid() ? fromDate.format("YYYY-MM-DD") : "",
selectedToDate: toDate.isValid() ? toDate.format("YYYY-MM-DD") : "",
selectedStatus: built.selectedStatus,
selectedFromDate: built.selectedFromDate,
selectedToDate: built.selectedToDate,
});
});
}
......@@ -336,3 +358,61 @@ exports.listTransactions = function (req, res) {
render([]);
}
};
var STATUS_LABELS_VI = {
pending: "Chờ thanh toán",
success: "Thành công",
failed: "Thất bại",
};
function csvEscape(value) {
var str = value === null || typeof value === "undefined" ? "" : String(value);
if (/[",\n\r]/.test(str)) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
exports.exportTransactions = function (req, res) {
var built = buildTransactionFilter(req);
AdminTransaction.find(built.filter)
.sort({ createdAt: -1 })
.exec(function (err, list) {
if (err) {
console.error("exportTransactions: DB error:", err.message);
return res.status(500).send("Lỗi cơ sở dữ liệu");
}
var header = [
"Mã giao dịch", "Thời gian", "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"),
tx.createdByUsername || "",
tx.customerName,
tx.customerPhone,
tx.customerIdCard,
tx.amount,
STATUS_LABELS_VI[tx.status] || tx.status,
tx.resultMsg || "",
];
});
var csv = [header].concat(rows)
.map(function (row) { return row.map(csvEscape).join(","); })
.join("\r\n");
var filename = "giao-dich_" + moment().format("YYYYMMDD_HHmmss") + ".csv";
res.setHeader("Content-Type", "text/csv; charset=utf-8");
res.setHeader("Content-Disposition", 'attachment; filename="' + filename + '"');
// BOM so Excel on Windows detects UTF-8 and renders Vietnamese diacritics correctly
// instead of mangling them under its default locale codepage. Written via
// fromCharCode (not a literal character) so the byte sequence can't get mangled
// by an editor/encoding round-trip.
res.send(String.fromCharCode(0xfeff) + csv);
});
};
......@@ -29,6 +29,8 @@ module.exports = function (app) {
.get(requireLogin, admin.listTransactions)
.post(requireLogin, admin.createTransaction);
app.route("/admin/transactions/export").get(requireLogin, admin.exportTransactions);
app.route("/admin/transactions/new").get(requireLogin, admin.newTransactionForm);
// Helmet's frameguard sets X-Frame-Options: SAMEORIGIN globally (config/express.js),
......
......@@ -179,6 +179,27 @@
.btn-primary:active { transform: scale(0.98); }
.btn-primary:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.btn-ghost {
display: inline-flex;
align-items: center;
gap: 8px;
height: 40px;
padding: 0 16px;
background: var(--color-surface);
color: var(--color-primary);
font-size: 14px;
font-weight: 600;
text-decoration: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
transition: border-color 150ms ease, background 150ms ease;
white-space: nowrap;
cursor: pointer;
}
.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; }
/* Stat strip */
.stats {
display: grid;
......@@ -468,6 +489,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}}">
<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>
<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
......@@ -484,13 +509,19 @@
{% endfor %}
</select>
{% endif %}
<select name="status" class="select-control" aria-label="Lọc theo trạng thái">
<option value="">Tất cả trạng thái</option>
<option value="pending" {% if selectedStatus == "pending" %}selected{% endif %}>Chờ thanh toán</option>
<option value="success" {% if selectedStatus == "success" %}selected{% endif %}>Thành công</option>
<option value="failed" {% if selectedStatus == "failed" %}selected{% endif %}>Thất bại</option>
</select>
<div class="date-range">
<input type="date" name="fromDate" class="select-control" value="{{selectedFromDate}}" aria-label="Từ ngày">
<span class="date-sep">&ndash;</span>
<input type="date" name="toDate" class="select-control" value="{{selectedToDate}}" aria-label="Đến ngày">
</div>
<button type="submit" class="btn-filter">Lọc</button>
{% if selectedStaff or selectedFromDate or selectedToDate %}
{% if selectedStaff or selectedStatus or selectedFromDate or selectedToDate %}
<a class="filter-clear" href="/admin/transactions">Xóa lọc</a>
{% endif %}
</form>
......@@ -579,9 +610,9 @@
</div>
<div class="pager">
{% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}{% if selectedFromDate %}&fromDate={{selectedFromDate}}{% endif %}{% if selectedToDate %}&toDate={{selectedToDate}}{% endif %}">&laquo; Trang trước</a>{% endif %}
{% if hasPrev %}<a href="/admin/transactions?page={{prevPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}{% if selectedStatus %}&status={{selectedStatus}}{% endif %}{% if selectedFromDate %}&fromDate={{selectedFromDate}}{% endif %}{% if selectedToDate %}&toDate={{selectedToDate}}{% endif %}">&laquo; Trang trước</a>{% endif %}
<span>Trang {{page}}</span>
{% if hasNext %}<a href="/admin/transactions?page={{nextPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}{% if selectedFromDate %}&fromDate={{selectedFromDate}}{% endif %}{% if selectedToDate %}&toDate={{selectedToDate}}{% endif %}">Trang sau &raquo;</a>{% endif %}
{% if hasNext %}<a href="/admin/transactions?page={{nextPage}}{% if selectedStaff %}&staff={{selectedStaff}}{% endif %}{% if selectedStatus %}&status={{selectedStatus}}{% endif %}{% if selectedFromDate %}&fromDate={{selectedFromDate}}{% endif %}{% if selectedToDate %}&toDate={{selectedToDate}}{% endif %}">Trang sau &raquo;</a>{% endif %}
</div>
</div>
......
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