fix(qr-custom): center QR, timer berhenti, refund saat lunas, regenerate expired
All checks were successful
CI / test (push) Successful in 1m44s
All checks were successful
CI / test (push) Successful in 1m44s
- link publik: QR center, nominal/QR/timer disembunyikan saat expired - reject/refund untuk paid - regenerate QR expired (kode unik baru) di detail & tabel - versi 0.6.4
This commit is contained in:
@@ -2,6 +2,15 @@
|
||||
|
||||
Format mengikuti [Keep a Changelog](https://keepachangelog.com/) dan [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.6.4] — 2026-11-06 — "qr-custom-ux"
|
||||
|
||||
### Diperbaiki
|
||||
- QR pada link publik kini **center**.
|
||||
- Timer hanya tampil saat QR masih aktif; berhenti/disembunyikan ketika LUNAS, ditolak, atau kedaluwarsa.
|
||||
- **Reject / Refund** kini tersedia juga untuk pembayaran yang sudah LUNAS.
|
||||
- QR kedaluwarsa: **nominal dan gambar QR disembunyikan** di link publik maupun detail dashboard.
|
||||
- Tombol **Regenerate** untuk membuat QR baru + kode unik baru dari QR yang kedaluwarsa (juga tersedia di tabel histori).
|
||||
|
||||
## [0.6.3] — 2026-11-06 — "webhook-secret-auto"
|
||||
|
||||
### Diperbaiki
|
||||
|
||||
@@ -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.3_webhook--secret-ADFF2F" alt="Versi 0.6.3 (webhook-secret-auto)">
|
||||
<img src="https://img.shields.io/badge/versi-0.6.4_qr--ux-ADFF2F" alt="Versi 0.6.4 (qr-custom-ux)">
|
||||
<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>
|
||||
|
||||
@@ -2116,7 +2116,11 @@ app.get('/custom-qr/:token', (req, res) => {
|
||||
const company = settings.company_header || 'OPEN-ISP';
|
||||
const statusLabels = { pending: 'Menunggu Pembayaran', paid: 'LUNAS', expired: 'Kedaluwarsa', warning: 'Perlu Verifikasi Admin', rejected: 'Ditolak / Refund' };
|
||||
const status = String(row.status || 'pending');
|
||||
const showQr = status !== 'paid' && status !== 'rejected';
|
||||
const expiresMs = row.expires_at ? new Date(row.expires_at).getTime() : 0;
|
||||
const expiredNow = status === 'expired' || (status === 'pending' && expiresMs && expiresMs < Date.now());
|
||||
const showAmount = !expiredNow;
|
||||
const showQr = !expiredNow && status !== 'paid' && status !== 'rejected';
|
||||
const showTimer = status === 'pending' && !expiredNow;
|
||||
const amountText = 'Rp ' + Number(row.amount_unique || 0).toLocaleString('id-ID');
|
||||
const baseAmountText = 'Rp ' + Number(row.amount || 0).toLocaleString('id-ID');
|
||||
const html = `<!doctype html><html lang="id"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
@@ -2126,7 +2130,7 @@ body{margin:0;background:#0b1220;color:#e5e7eb;font-family:system-ui,Segoe UI,Ar
|
||||
.card{max-width:460px;width:100%;background:#0f172a;border:1px solid rgba(148,163,184,.2);border-radius:16px;padding:22px;text-align:center}
|
||||
h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
|
||||
.amt{font-size:26px;font-weight:800;color:#ADFF2F;margin:12px 0 2px}.base{font-size:12px;color:#64748b}
|
||||
.qr{background:#fff;padding:12px;border-radius:14px;display:inline-block;margin:14px 0}.qr img{display:block;width:240px;height:240px}
|
||||
.qr{background:#fff;padding:12px;border-radius:14px;display:block;width:max-content;margin:14px auto}.qr img{display:block;width:240px;height:240px}
|
||||
.badge{display:inline-block;padding:5px 10px;border-radius:999px;font-size:12px;font-weight:700;margin-top:6px}
|
||||
.pending{background:rgba(245,158,11,.15);color:#fbbf24}.paid{background:rgba(34,197,94,.15);color:#4ade80}
|
||||
.expired{background:rgba(239,68,68,.15);color:#f87171}.warning{background:rgba(249,115,22,.15);color:#fb923c}.rejected{background:rgba(148,163,184,.15);color:#cbd5e1}
|
||||
@@ -2134,20 +2138,22 @@ h1{font-size:19px;margin:0 0 4px}p{margin:6px 0;color:#94a3b8;font-size:13px}
|
||||
</style></head><body><div class="card">
|
||||
<div style="font-size:12px;letter-spacing:1px;color:#64748b;text-transform:uppercase">${cqEscape(row.category || 'Pembayaran')}</div>
|
||||
<h1>${cqEscape(row.title)}</h1>
|
||||
<div class="amt">${amountText}</div>
|
||||
<div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div>
|
||||
<div class="badge ${cqEscape(status)}" id="statusBadge">${cqEscape(statusLabels[status] || status)}</div>
|
||||
${showAmount
|
||||
? `<div class="amt">${amountText}</div><div class="base">Nominal dasar ${baseAmountText} + kode unik ${Number(row.unique_code || 0)}</div>`
|
||||
: `<div class="amt" style="font-size:18px;color:#f87171">QR Kedaluwarsa</div><div class="base">Nominal dan QR disembunyikan. Minta admin membuat QR baru.</div>`}
|
||||
<div class="badge ${cqEscape(expiredNow ? 'expired' : status)}" id="statusBadge">${cqEscape(expiredNow ? 'Kedaluwarsa' : (statusLabels[status] || status))}</div>
|
||||
${showQr ? `<div class="qr"><img src="/custom-qr/${cqEscape(row.share_token)}/qr.png?amount=${Number(row.amount_unique || 0)}" alt="QRIS"></div>` : ''}
|
||||
<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div>
|
||||
${showTimer ? `<div class="timer" id="timer" data-expires="${cqEscape(row.expires_at || '')}"></div>` : ''}
|
||||
${row.note ? `<div class="note">${cqEscape(row.note)}</div>` : ''}
|
||||
<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div>
|
||||
${showAmount ? `<div class="note">Bayar dengan nominal <b>persis</b> seperti di atas agar pembayaran otomatis tercocokkan. QR berlaku ${require('./services/customQrService').DURATION_MINUTES} menit.</div>` : ''}
|
||||
<div class="note">${cqEscape(company)}</div>
|
||||
</div>
|
||||
<script>
|
||||
(function(){
|
||||
var el=document.getElementById('timer'), exp=el&&el.getAttribute('data-expires');
|
||||
function tick(){
|
||||
if(!el||!exp) return;
|
||||
if(!el) return;
|
||||
if(!exp) return;
|
||||
var d=new Date(exp).getTime()-Date.now();
|
||||
if(d<=0){ el.textContent='Batas 20 menit telah lewat — pembayaran tetap dicek admin.'; return; }
|
||||
var m=Math.floor(d/60000), s=Math.floor((d%60000)/1000);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "open-isp",
|
||||
"version": "0.6.3",
|
||||
"version": "0.6.4",
|
||||
"description": "OPEN-ISP \u2014 sistem billing & manajemen ISP open-source (PWA pelanggan, multi-OLT, RADIUS, WhatsApp, QRIS)",
|
||||
"main": "app-customer.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -4062,6 +4062,17 @@ router.post('/custom-qr/:id/manual-paid', requireAdminSession, requireSidebarMen
|
||||
res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||
});
|
||||
|
||||
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'));
|
||||
req.session._msg = { type: 'success', text: `QR baru dibuat: Rp ${Number(row.amount_unique).toLocaleString('id-ID')} (kode ${row.unique_code}).` };
|
||||
return res.redirect('/admin/custom-qr?id=' + row.id);
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: e.message };
|
||||
return res.redirect('/admin/custom-qr?id=' + Number(req.params.id));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/custom-qr/:id/accept', requireAdminSession, requireSidebarMenuAccess('custom_qr'), express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
require('../services/customQrService').acceptWarning(req.params.id, resolvePaidByName(req, 'Admin'), req.body.note);
|
||||
|
||||
@@ -118,6 +118,20 @@ function acceptWarning(id, actor, note) {
|
||||
return getById(id);
|
||||
}
|
||||
|
||||
function regenerate(id, actor) {
|
||||
const old = getById(id);
|
||||
if (!old) throw new Error('Data tidak ditemukan.');
|
||||
if (old.status === 'pending' && !isExpired(old)) throw new Error('QR masih aktif. Tunggu kedaluwarsa atau gunakan QR yang ada.');
|
||||
const row = create({
|
||||
title: old.title,
|
||||
category: old.category,
|
||||
amount: old.amount,
|
||||
note: old.note,
|
||||
createdBy: actor
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
function reject(id, actor, note) {
|
||||
const row = getById(id);
|
||||
if (!row) throw new Error('Data tidak ditemukan.');
|
||||
@@ -181,6 +195,7 @@ module.exports = {
|
||||
candidatesByAmount,
|
||||
markManualPaid,
|
||||
acceptWarning,
|
||||
regenerate,
|
||||
reject,
|
||||
markPaidFromWebhook,
|
||||
markWarningFromWebhook,
|
||||
|
||||
@@ -1 +1 @@
|
||||
0.6.3
|
||||
0.6.4
|
||||
@@ -54,12 +54,15 @@
|
||||
</div>
|
||||
|
||||
<% if (detail) { %>
|
||||
<% const cqExpired = detail.status === 'expired' || (detail.status === 'pending' && detail.expires_at && new Date(detail.expires_at).getTime() < Date.now()); %>
|
||||
<div class="card mb16">
|
||||
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></h6></div>
|
||||
<div class="card-hd"><h6><i class="bi bi-qr-code"></i> <%= detail.title %><% if (!cqExpired) { %> — Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %><% } %></h6></div>
|
||||
<div class="card-bd" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:18px">
|
||||
<div style="text-align:center">
|
||||
<% if (detail.status === 'paid' || detail.status === 'rejected') { %>
|
||||
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">QR tidak aktif — status: <b><%= detail.status %></b></div>
|
||||
<% if (detail.status === 'paid' || detail.status === 'rejected' || cqExpired) { %>
|
||||
<div style="padding:24px;border:1px dashed var(--border);border-radius:12px;color:var(--muted)">
|
||||
<% if (cqExpired) { %>QR kedaluwarsa — nominal & QR disembunyikan.<% } else { %>QR tidak aktif — status: <b><%= detail.status %></b><% } %>
|
||||
</div>
|
||||
<% } else { %>
|
||||
<img src="/custom-qr/<%= detail.share_token %>/qr.png" alt="QRIS" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
|
||||
<div id="cqTimer" data-expires="<%= detail.expires_at || '' %>" style="margin-top:8px;font-size:13px;color:#fbbf24"></div>
|
||||
@@ -67,9 +70,11 @@
|
||||
</div>
|
||||
<div style="font-size:13px;line-height:1.8">
|
||||
<div><b>Status:</b> <%= detail.status %></div>
|
||||
<% if (!cqExpired) { %>
|
||||
<div><b>Nominal dasar:</b> Rp <%= Number(detail.amount||0).toLocaleString('id-ID') %></div>
|
||||
<div><b>Kode unik:</b> <%= detail.unique_code %></div>
|
||||
<div><b>Nominal bayar:</b> Rp <%= Number(detail.amount_unique||0).toLocaleString('id-ID') %></div>
|
||||
<% } %>
|
||||
<div><b>Kategori:</b> <%= detail.category || '-' %></div>
|
||||
<div><b>Dibuat:</b> <%= detail.created_at %></div>
|
||||
<div><b>Kedaluwarsa:</b> <%= detail.expires_at || '-' %></div>
|
||||
@@ -93,12 +98,17 @@
|
||||
<button class="btn btn-p btn-sm"><i class="bi bi-check2"></i> Selesaikan</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (detail.status !== 'paid' && detail.status !== 'rejected') { %>
|
||||
<% if (detail.status !== 'rejected') { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/reject" onsubmit="return confirm('Tolak / refund pembayaran ini?')">
|
||||
<input type="hidden" name="note" value="Refund dari panel admin">
|
||||
<button class="btn btn-g btn-sm"><i class="bi bi-x-circle"></i> Reject / Refund</button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (cqExpired) { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= detail.id %>/regenerate" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
|
||||
<button class="btn btn-p btn-sm"><i class="bi bi-arrow-repeat"></i> Regenerate QR</button>
|
||||
</form>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -131,6 +141,11 @@
|
||||
<button class="btn btn-g btn-sm" title="Manual pay"><i class="bi bi-check2-circle"></i></button>
|
||||
</form>
|
||||
<% } %>
|
||||
<% if (r.status === 'expired') { %>
|
||||
<form method="POST" action="/admin/custom-qr/<%= r.id %>/regenerate" style="display:inline" onsubmit="return confirm('Buat QR baru dengan kode unik baru?')">
|
||||
<button class="btn btn-g btn-sm" title="Regenerate"><i class="bi bi-arrow-repeat"></i></button>
|
||||
</form>
|
||||
<% } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% }); %>
|
||||
|
||||
Reference in New Issue
Block a user