feat(qr-custom): filter tanggal + pilih baris & hapus terpilih
All checks were successful
CI / test (push) Successful in 1m37s

- filter from/to pada list
- checkbox pilih semua, batch delete dengan password admin
- reset histori tetap
- versi 0.6.6
This commit is contained in:
oxpoe
2026-11-06 05:11:50 +07:00
parent a2888810ad
commit 0f7affc498
7 changed files with 73 additions and 8 deletions

View File

@@ -2,6 +2,13 @@
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
## [0.6.6] — 2026-11-06 — "qr-custom-bulk"
### Ditambahkan
- **Filter tanggal** (dari/sampai) pada histori QR-Custom Payment.
- **Pilih semua / per baris** + tombol **Hapus Terpilih** (batch remove) dengan password admin.
- **Reset Histori** tetap tersedia untuk menghapus semua data.
## [0.6.5] — 2026-11-06 — "qr-custom-delete"
### Ditambahkan

View File

@@ -11,7 +11,7 @@
<p align="center">
<img src="https://img.shields.io/badge/lisensi-ISC-ADFF2F" alt="Lisensi: ISC">
<img src="https://img.shields.io/badge/versi-0.6.5_qr--delete-ADFF2F" alt="Versi 0.6.5 (qr-custom-delete)">
<img src="https://img.shields.io/badge/versi-0.6.6_qr--bulk-ADFF2F" alt="Versi 0.6.6 (qr-custom-bulk)">
<img src="https://img.shields.io/badge/Node.js-%E2%89%A520-339933" alt="Node.js >= 20">
<img src="https://img.shields.io/badge/docker-ready-2496ED?logo=docker&logoColor=white" alt="Docker">
<a href="https://demo-openisp.oxpoe.xyz"><img src="https://img.shields.io/badge/demo-live-ADFF2F?logo=googlechrome&logoColor=black" alt="Demo"></a>

View File

@@ -1,6 +1,6 @@
{
"name": "open-isp",
"version": "0.6.5",
"version": "0.6.6",
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
"main": "app-customer.js",
"scripts": {

View File

@@ -4019,6 +4019,8 @@ router.get('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_q
let detail = null;
const detailId = Number(req.query.id) || 0;
if (detailId > 0) { try { detail = svc.getById(detailId); } catch (e) {} }
const filterFrom = String(req.query.from || '').trim();
const filterTo = String(req.query.to || '').trim();
const settings = getSettings();
const baseUrl = String(settings.public_base_url || ((req.headers['x-forwarded-proto'] || req.protocol) + '://' + req.get('host'))).replace(/\/+$/, '');
res.render('admin/custom_qr', {
@@ -4026,7 +4028,8 @@ router.get('/custom-qr', requireAdminSession, requireSidebarMenuAccess('custom_q
company: company(),
activePage: 'custom_qr',
msg: flashMsg(req),
rows: svc.list(150),
rows: svc.list(200, { from: filterFrom, to: filterTo }),
filterFrom, filterTo,
stats: svc.stats(),
detail,
baseUrl,
@@ -4086,6 +4089,19 @@ router.post('/custom-qr/reset', requireAdminSession, restrictToAdmin, requireSid
res.redirect('/admin/custom-qr');
});
router.post('/custom-qr/delete-bulk', requireAdminSession, restrictToAdmin, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
if (!_checkAdminPassword(req)) throw new Error('Password admin salah.');
const ids = Array.isArray(req.body.ids) ? req.body.ids : [req.body.ids];
const n = require('../services/customQrService').removeMany(ids);
req.session._msg = { type: 'success', text: `${n} QR custom dihapus.` };
} catch (e) { req.session._msg = { type: 'error', text: e.message }; }
const q = [];
if (req.body.from) q.push('from=' + encodeURIComponent(req.body.from));
if (req.body.to) q.push('to=' + encodeURIComponent(req.body.to));
res.redirect('/admin/custom-qr' + (q.length ? ('?' + q.join('&')) : ''));
});
router.post('/custom-qr/:id/regenerate', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
try {
const row = require('../services/customQrService').regenerate(req.params.id, resolvePaidByName(req, 'Admin'));

View File

@@ -75,9 +75,17 @@ function getByToken(token) {
return db.prepare('SELECT * FROM custom_qr_payments WHERE share_token=?').get(String(token || ''));
}
function list(limit = 100) {
function list(limit = 100, opts = {}) {
sweepExpired();
return db.prepare('SELECT * FROM custom_qr_payments ORDER BY id DESC LIMIT ?').all(Math.max(1, Math.min(500, Number(limit) || 100)));
const where = [];
const params = [];
if (opts.from) { where.push('date(created_at) >= date(?)'); params.push(String(opts.from)); }
if (opts.to) { where.push('date(created_at) <= date(?)'); params.push(String(opts.to)); }
const sql = 'SELECT * FROM custom_qr_payments' +
(where.length ? (' WHERE ' + where.join(' AND ')) : '') +
' ORDER BY id DESC LIMIT ?';
params.push(Math.max(1, Math.min(500, Number(limit) || 100)));
return db.prepare(sql).all(...params);
}
function stats() {
@@ -123,6 +131,16 @@ function remove(id) {
return r.changes || 0;
}
function removeMany(ids) {
const list = (Array.isArray(ids) ? ids : [ids])
.map(function (x) { return Number(x); })
.filter(function (n) { return Number.isFinite(n) && n > 0; });
if (!list.length) return 0;
const marks = list.map(function () { return '?'; }).join(',');
const r = db.prepare('DELETE FROM custom_qr_payments WHERE id IN (' + marks + ')').run(...list);
return r.changes || 0;
}
function clearAll() {
const r = db.prepare('DELETE FROM custom_qr_payments').run();
return r.changes || 0;
@@ -207,6 +225,7 @@ module.exports = {
acceptWarning,
regenerate,
remove,
removeMany,
clearAll,
reject,
markPaidFromWebhook,

View File

@@ -1 +1 @@
0.6.5
0.6.6

View File

@@ -127,15 +127,34 @@
<button class="btn btn-g btn-sm"><i class="bi bi-trash3"></i> Reset Histori</button>
</form>
</div>
<div class="card-bd" style="border-bottom:1px solid var(--border)">
<form method="GET" action="/admin/custom-qr" style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-end">
<div class="fg"><label class="fl">Dari tanggal</label><input class="fc" type="date" name="from" value="<%= filterFrom || '' %>"></div>
<div class="fg"><label class="fl">Sampai tanggal</label><input class="fc" type="date" name="to" value="<%= filterTo || '' %>"></div>
<button class="btn btn-p btn-sm" type="submit"><i class="bi bi-funnel"></i> Filter</button>
<a class="btn btn-g btn-sm" href="/admin/custom-qr"><i class="bi bi-x-circle"></i> Reset Filter</a>
</form>
</div>
<form id="cqBulkForm" method="POST" action="/admin/custom-qr/delete-bulk"></form>
<div class="card-bd" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center;border-bottom:1px solid var(--border)">
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer">
<input type="checkbox" id="cqAll" onclick="cqToggleAll(this)"> Pilih semua
</label>
<input form="cqBulkForm" class="fc" type="password" name="password" required placeholder="Password admin" style="max-width:180px">
<input form="cqBulkForm" type="hidden" name="from" value="<%= filterFrom || '' %>">
<input form="cqBulkForm" type="hidden" name="to" value="<%= filterTo || '' %>">
<button form="cqBulkForm" class="btn btn-g btn-sm" onclick="return confirm('Hapus QR yang dipilih?')"><i class="bi bi-trash"></i> Hapus Terpilih</button>
</div>
<div class="card-bd" style="overflow:auto">
<table class="table" style="width:100%;font-size:12.5px">
<thead><tr><th>Judul</th><th>Kategori</th><th>Nominal</th><th>Status</th><th>Dibuat</th><th>Kedaluwarsa</th><th>Aksi</th></tr></thead>
<thead><tr><th style="width:30px"></th><th>Judul</th><th>Kategori</th><th>Nominal</th><th>Status</th><th>Dibuat</th><th>Kedaluwarsa</th><th>Aksi</th></tr></thead>
<tbody>
<% if (!rows || rows.length === 0) { %>
<tr><td colspan="7" class="muted" style="text-align:center;padding:20px">Belum ada QR custom.</td></tr>
<tr><td colspan="8" class="muted" style="text-align:center;padding:20px">Belum ada QR custom.</td></tr>
<% } %>
<% rows.forEach(function(r) { %>
<tr>
<td><input type="checkbox" class="cq-check" form="cqBulkForm" name="ids" value="<%= r.id %>"></td>
<td><b><%= r.title %></b><% if (r.note) { %><div class="muted" style="font-size:11px"><%= r.note %></div><% } %></td>
<td><%= r.category || '-' %></td>
<td>Rp <%= Number(r.amount_unique||0).toLocaleString('id-ID') %></td>
@@ -166,6 +185,10 @@
</div>
</div>
<script>
function cqToggleAll(el) {
var on = !!(el && el.checked);
document.querySelectorAll('.cq-check').forEach(function (c) { c.checked = on; });
}
function copyCqLink() {
var el = document.getElementById('cqLink');
if (!el) return;