Commit a965ca8f by tdgiang

update code

parent 0e366893
......@@ -283,17 +283,25 @@ exports.epayIPN = function (req, res) {
};
var TRANSACTION_STATUSES = ["pending", "success", "failed"];
var DEFAULT_FEE_RATE = 2.2;
// Shared by listTransactions and exportTransactions so the two never drift apart.
function buildTransactionFilter(req) {
// Staff scoping shared by the transaction table filter and the report cards:
// staff sees only their own transactions, admin sees everyone unless a specific staff is picked.
function buildStaffScopeFilter(req) {
var isAdmin = req.session.role === "admin";
var filter = {};
if (!isAdmin) {
filter.createdByUsername = req.session.username;
} else if (req.query.staff) {
filter.createdByUsername = String(req.query.staff);
}
return filter;
}
// Shared by listTransactions and exportTransactions so the two never drift apart.
function buildTransactionFilter(req) {
var isAdmin = req.session.role === "admin";
var filter = buildStaffScopeFilter(req);
if (TRANSACTION_STATUSES.indexOf(req.query.status) !== -1) {
filter.status = req.query.status;
......@@ -316,6 +324,97 @@ function buildTransactionFilter(req) {
};
}
function sumSuccessAmount(match) {
return AdminTransaction.aggregate([
{ $match: match },
{ $group: { _id: null, total: { $sum: "$amount" } } },
])
.exec()
.then(function (rows) {
return rows.length ? rows[0].total : 0;
});
}
// Bank không xử lý sao kê Thứ 7/CN: ngày thường lấy sản lượng thành công của hôm qua,
// riêng Thứ 2 gộp sản lượng thành công của Thứ 6 + Thứ 7 + CN tuần trước; Thứ 7/CN trả về null (=> 0).
function settlementSourceRange() {
var dayOfWeek = moment().day(); // 0=CN, 1=T2 ... 6=T7
if (dayOfWeek === 0 || dayOfWeek === 6) {
return null;
}
if (dayOfWeek === 1) {
return {
start: moment().subtract(3, "days").startOf("day").toDate(),
end: moment().subtract(1, "days").endOf("day").toDate(),
};
}
return {
start: moment().subtract(1, "days").startOf("day").toDate(),
end: moment().subtract(1, "days").endOf("day").toDate(),
};
}
// Số tiền thanh toán = sản lượng thành công (ngày nguồn) - phí riêng của từng nhân viên,
// cộng dồn theo phạm vi (1 nhân viên nếu đang lọc, hoặc toàn bộ nếu admin không lọc ai).
function computeSettlementAmount(scopeFilter) {
var range = settlementSourceRange();
if (!range) {
return Promise.resolve(0);
}
var match = Object.assign({}, scopeFilter, {
status: "success",
createdAt: { $gte: range.start, $lte: range.end },
});
return AdminTransaction.aggregate([
{ $match: match },
{ $group: { _id: "$createdByUsername", total: { $sum: "$amount" } } },
])
.exec()
.then(function (rows) {
if (!rows.length) return 0;
var usernames = rows.map(function (r) { return r._id; }).filter(Boolean);
return AdminUser.find({ username: { $in: usernames } }, "username feeRate")
.exec()
.then(function (users) {
var feeByUsername = {};
users.forEach(function (u) {
feeByUsername[u.username] = u.feeRate;
});
return rows.reduce(function (sum, r) {
var fee = feeByUsername.hasOwnProperty(r._id) ? feeByUsername[r._id] : DEFAULT_FEE_RATE;
return sum + r.total * (1 - fee / 100);
}, 0);
});
});
}
function computeReportStats(req, callback) {
var scopeFilter = buildStaffScopeFilter(req);
var todayStart = moment().startOf("day").toDate();
var todayEnd = moment().endOf("day").toDate();
var monthStart = moment().startOf("month").toDate();
Promise.all([
sumSuccessAmount(Object.assign({}, scopeFilter, { status: "success", createdAt: { $gte: todayStart, $lte: todayEnd } })),
AdminTransaction.countDocuments(Object.assign({}, scopeFilter, { createdAt: { $gte: todayStart, $lte: todayEnd } })).exec(),
sumSuccessAmount(Object.assign({}, scopeFilter, { status: "success", createdAt: { $gte: monthStart, $lte: todayEnd } })),
computeSettlementAmount(scopeFilter),
])
.then(function (results) {
callback(null, {
todayVolume: results[0],
todayCount: results[1],
monthVolume: results[2],
settlementAmount: Math.round(results[3]),
});
})
.catch(function (err) {
callback(err);
});
}
exports.listTransactions = function (req, res) {
var page = parseInt(req.query.page) || 1;
var perPage = 20;
......@@ -347,6 +446,12 @@ exports.listTransactions = function (req, res) {
paymentUrl: config.epay.req_domain + "/admin/pay/" + tx.merTrxId,
};
});
computeReportStats(req, function (statsErr, reportStats) {
if (statsErr) {
console.error("listTransactions: report stats error:", statsErr.message);
reportStats = { todayVolume: 0, todayCount: 0, monthVolume: 0, settlementAmount: 0 };
}
res.render("admin/transactions-list", {
transactions: transactions,
page: page,
......@@ -361,6 +466,8 @@ exports.listTransactions = function (req, res) {
selectedStatus: built.selectedStatus,
selectedFromDate: built.selectedFromDate,
selectedToDate: built.selectedToDate,
reportStats: reportStats,
});
});
});
}
......
......@@ -82,6 +82,7 @@ exports.accountsList = function (req, res) {
error: req.query.error || null,
created: req.query.created === "1",
reset: req.query.reset === "1",
feeUpdated: req.query.feeUpdated === "1",
});
});
};
......@@ -204,6 +205,30 @@ exports.resetAccountPassword = function (req, res) {
});
};
exports.updateAccountFee = function (req, res) {
var feeRate = parseFloat(req.body.feeRate);
if (isNaN(feeRate) || feeRate < 0 || feeRate > 100) {
return res.redirect("/admin/accounts?error=INVALID_FEE");
}
AdminUser.findById(req.params.id, function (err, user) {
if (err) {
console.error("updateAccountFee: DB error:", err.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
if (!user) {
return res.status(404).send("Không tìm thấy tài khoản");
}
user.feeRate = feeRate;
user.save(function (saveErr) {
if (saveErr) {
console.error("updateAccountFee: DB error saving:", saveErr.message);
return res.redirect("/admin/accounts?error=DB_ERROR");
}
return res.redirect("/admin/accounts?feeUpdated=1");
});
});
};
exports.changePasswordForm = function (req, res) {
res.render("admin/change-password", { error: null, success: false });
};
......
......@@ -8,6 +8,7 @@ var AdminUserSchema = new Schema(
passwordHash: { type: String, required: true },
role: { type: String, enum: ["admin", "staff"], default: "staff" },
active: { type: Boolean, default: true },
feeRate: { type: Number, default: 2.2 }, // % phí thanh toán áp lên sản lượng giao dịch thành công
},
{ timestamps: true }
);
......
......@@ -20,6 +20,7 @@ module.exports = function (app) {
app.route("/admin/accounts/:id/toggle").post(requireLogin, requireAdmin, adminAuth.toggleAccount);
app.route("/admin/accounts/:id/reset-password").post(requireLogin, requireAdmin, adminAuth.resetAccountPassword);
app.route("/admin/accounts/:id/fee").post(requireLogin, requireAdmin, adminAuth.updateAccountFee);
app.route("/admin/account/password")
.get(requireLogin, adminAuth.changePasswordForm)
......
......@@ -97,6 +97,8 @@
{% if created %}<div class="alert alert-success">Đã tạo tài khoản thành công.</div>{% endif %}
{% if reset %}<div class="alert alert-success">Đã đặt lại mật khẩu.</div>{% endif %}
{% if feeUpdated %}<div class="alert alert-success">Đã cập nhật phí thanh toán.</div>{% endif %}
{% if error == "INVALID_FEE" %}<div class="alert alert-error">Phí thanh toán không hợp lệ (0 - 100).</div>{% endif %}
{% if error == "MISSING_FIELDS" %}<div class="alert alert-error">Vui lòng nhập đủ thông tin.</div>{% endif %}
{% if error == "DUPLICATE_USERNAME" %}<div class="alert alert-error">Tên đăng nhập đã tồn tại.</div>{% endif %}
{% if error == "DB_ERROR" %}<div class="alert alert-error">Lỗi cơ sở dữ liệu, vui lòng thử lại.</div>{% endif %}
......@@ -136,6 +138,7 @@
<th>Tên đăng nhập</th>
<th>Vai trò</th>
<th>Trạng thái</th>
<th>Phí (%)</th>
<th>Thao tác</th>
</tr>
</thead>
......@@ -146,15 +149,21 @@
<td><span class="pill pill-{{u.role}}">{% if u.role == "admin" %}Admin{% else %}Nhân viên{% endif %}</span></td>
<td><span class="pill pill-{% if u.active %}active{% else %}locked{% endif %}">{% if u.active %}Hoạt động{% else %}Đã khoá{% endif %}</span></td>
<td>
<form method="POST" action="/admin/accounts/{{u.id}}/fee" style="display:flex;gap:6px;align-items:center;">
<input type="number" name="feeRate" value="{{u.feeRate}}" min="0" max="100" step="0.1" style="width:70px;" required>
<button type="submit" class="secondary">Lưu</button>
</form>
</td>
<td>
<div class="row-actions">
{% if u.username == currentUsername %}
<span style="font-size:12.5px;color:var(--color-text-muted);">Tài khoản của bạn</span>
{% else %}
<form method="POST" action="/admin/accounts/{{u._id}}/toggle">
<form method="POST" action="/admin/accounts/{{u.id}}/toggle">
<button type="submit" class="secondary">{% if u.active %}Khoá{% else %}Mở khoá{% endif %}</button>
</form>
{% endif %}
<form method="POST" action="/admin/accounts/{{u._id}}/reset-password">
<form method="POST" action="/admin/accounts/{{u.id}}/reset-password">
<input type="password" name="newPassword" placeholder="Mật khẩu mới" required minlength="8">
<button type="submit" class="secondary">Đặt lại</button>
</form>
......
......@@ -500,6 +500,25 @@
</div>
</div>
<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>
<p class="stat-value">{{reportStats.todayVolume|currency}} ₫</p>
</div>
<div class="stat-card">
<p class="stat-label">Tổng số giao dịch hôm nay</p>
<p class="stat-value">{{reportStats.todayCount}}</p>
</div>
<div class="stat-card is-success">
<p class="stat-label">Số tiền thanh toán</p>
<p class="stat-value">{{reportStats.settlementAmount|currency}} ₫</p>
</div>
<div class="stat-card is-revenue">
<p class="stat-label">Tổng sản lượng tháng này</p>
<p class="stat-value">{{reportStats.monthVolume|currency}} ₫</p>
</div>
</div>
<form class="filter-bar" method="GET" action="/admin/transactions">
{% if isAdmin %}
<select name="staff" class="select-control" aria-label="Lọc theo nhân viên">
......@@ -526,27 +545,6 @@
{% endif %}
</form>
{% if transactions.length %}
<div class="stats" id="statsStrip">
<div class="stat-card">
<p class="stat-label">Tổng số GD</p>
<p class="stat-value" data-stat="total">0</p>
</div>
<div class="stat-card is-success">
<p class="stat-label">Thành công</p>
<p class="stat-value" data-stat="success">0</p>
</div>
<div class="stat-card is-pending">
<p class="stat-label">Chờ thanh toán</p>
<p class="stat-value" data-stat="pending">0</p>
</div>
<div class="stat-card is-revenue">
<p class="stat-label">Tổng tiền thành công</p>
<p class="stat-value" data-stat="revenue">0 ₫</p>
</div>
</div>
{% endif %}
<div class="card">
{% if transactions.length %}
<div class="toolbar">
......@@ -621,24 +619,6 @@
var rows = Array.prototype.slice.call(document.querySelectorAll('#txTableBody tr'));
if (!rows.length) return;
var counts = { total: rows.length, success: 0, pending: 0 };
var successRevenue = 0;
rows.forEach(function (row) {
var status = row.getAttribute('data-status');
if (status === 'success') {
counts.success++;
successRevenue += parseInt(row.getAttribute('data-amount'), 10) || 0;
} else if (status !== 'failed') {
counts.pending++;
}
});
Object.keys(counts).forEach(function (key) {
var el = document.querySelector('[data-stat="' + key + '"]');
if (el) el.textContent = counts[key].toLocaleString('vi-VN');
});
var revenueEl = document.querySelector('[data-stat="revenue"]');
if (revenueEl) revenueEl.textContent = successRevenue.toLocaleString('vi-VN') + ' ₫';
var searchInput = document.getElementById('searchInput');
var visibleCountEl = document.getElementById('visibleCount');
var noResultsEl = document.getElementById('noSearchResults');
......
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