OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana

Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor,
admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS.

Kredit & lisensi (ISC):
- Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak
  atas base kode dan repo yang bermanfaat.
- Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder.
- Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
This commit is contained in:
oxpoe committed 2026-10-09 05:51:21 +07:00
commit eaf05f2811
280 files changed
+123975

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= t('admin.print_vouchers.title', 'Cetak Voucher Batch') %> #<%= batch.id %> — <%= company %></title>
<link rel="stylesheet" href="/css/admin.css">
<!-- no-admin-theme -->
<style>
@page {
size: A4 portrait;
margin: 6mm 8mm;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body {
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
color: #0f172a;
background: #f8fafc;
padding: 12px;
}
.tpl { width: 100%; }
.tpl .page-break { break-after: page; page-break-after: always; }
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.card {
border: 1.5px solid #334155;
border-radius: 8px;
padding: 8px 10px 24px 10px;
min-height: 112px;
position: relative;
overflow: hidden;
display: flex;
gap: 8px;
align-items: stretch;
background: #ffffff;
break-inside: avoid !important;
page-break-inside: avoid !important;
}
.card::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(135deg, rgba(59,130,246,.04), rgba(16,185,129,.03));
}
.card > * { position: relative; }
.left { flex: 1; min-width: 0; display: flex; flex-direction: column; justify-content: space-between; }
.right { width: 48%; display: flex; flex-direction: column; justify-content: center; align-items: center; }
.row { display: flex; justify-content: space-between; gap: 6px; align-items: flex-start; }
.co { font-weight: 900; font-size: 11px; letter-spacing: .2px; line-height: 1.1; color: #0f172a; text-transform: uppercase; }
.wa-btm { position: absolute; left: 50%; bottom: 4px; transform: translateX(-50%); font-size: 8.5px; color: #475569; white-space: nowrap; z-index: 4; font-weight: 500; }
.pill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 6px; border-radius: 4px; background: rgba(2,6,23,.06); font-size: 9.5px; color: #0f172a; white-space: nowrap; line-height: 1.1; font-weight: 600; }
.pills { display: flex; gap: 4px; align-items: center; margin-top: 4px; }
.price-pill { background: rgba(16,185,129,.15); border: 1px solid rgba(16,185,129,.4); color: #065f46; font-weight: 700; }
.right-meta { margin-top: 4px; font-size: 9.5px; font-weight: 600; color: #334155; }
.price-top { margin-bottom: 6px; font-size: 10.5px; }
.code { font-weight: 900; font-size: 24px; letter-spacing: 1.5px; text-align: center; margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; color: #0f172a; }
.foot { font-size: 9px; color: #475569; margin-top: 4px; line-height: 1.15; }
.muted { color: #64748b; }
/* Floating Controls */
.print-controls {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
gap: 10px;
z-index: 1000;
}
.btn-action {
background: #1e3a8a;
color: #ffffff;
border: none;
padding: 10px 18px;
border-radius: 8px;
font-weight: 700;
font-size: 12px;
cursor: pointer;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.2);
}
.btn-close {
background: #475569;
}
@media print {
body {
background: #ffffff !important;
padding: 0 !important;
}
.no-print, .print-controls {
display: none !important;
}
}
</style>
<!-- MWRAP (admin.css dipindah ke atas agar style halaman tidak ketimpa) -->
<style>
@media screen { body { padding: 0 !important; } .print-controls { display: none !important; } .tb-title, .tb-title i { color: var(--text) !important; } .sidebar { color: var(--text); } }
@media print {
.topbar, .sidebar, .bottom-nav, .hb-menu { display: none !important; }
.page { padding: 0 !important; margin: 0 !important; }
.mw { display: block !important; height: auto !important; overflow: visible !important; }
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'vouchers', company }) %>
<div class="mw">
<div class="topbar">
<div class="tb-title"><button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button><i class="bi bi-printer"></i> <%= t('admin.print_vouchers.title', 'Cetak Voucher Batch') %> #<%= batch.id %></div>
<div class="tb-right">
<a href="/admin/vouchers/batches/<%= batch.id %>/pdf" target="_blank" class="btn btn-p btn-sm"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
<a href="/admin/vouchers" class="btn btn-g btn-sm"><i class="bi bi-arrow-left"></i> <%= t('common.back', 'Kembali') %></a>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<%
function formatValidity(v) {
if (!v) return '-';
const s = String(v).trim();
const mDay = s.match(/^(\d+)\s*d$/i);
if (mDay) {
const n = Number(mDay[1]);
if ((lang || 'id') === 'en') return n + (n === 1 ? ' day' : ' days');
return n + ' ' + t('admin.print_vouchers.days', 'hari');
}
const mHour = s.match(/^(\d+)\s*h$/i);
if (mHour) {
return mHour[1] + ' Jam';
}
return s;
}
%>
<% if (templateError) { %>
<div class="no-print" style="padding:10px 12px;background:#fff7ed;border:1px solid #fb923c;margin:8px;border-radius:8px;font-size:12px">
<b>Template Print:</b> <%= templateError %>
</div>
<% } %>
<% if (style === 'template' && renderedHtml) { %>
<div class="tpl"><%- renderedHtml %></div>
<% } else { %>
<div class="grid">
<% vouchers.forEach(v => { %>
<div class="card">
<div class="left">
<div class="row">
<div class="co"><%= settings.company_header || company %></div>
</div>
<div class="pills">
<div class="pill"><i class="bi bi-wifi"></i> <%= batch.package_name || 'Voucher Hotspot' %></div>
</div>
<% if (v.code !== v.password) { %>
<div class="foot">
<div class="muted"><%= t('admin.print_vouchers.hint_user_pass', 'User & Pass') %></div>
</div>
<% } else { %>
<div class="foot">
<div class="muted">Login Hotspot</div>
</div>
<% } %>
</div>
<div class="right">
<div class="pill price-pill price-top">Rp <%= Number(batch.price || 0).toLocaleString('id-ID') %></div>
<% if (v.code === v.password) { %>
<div class="code"><%= v.code %></div>
<div class="right-meta"><i class="bi bi-hourglass-split"></i> <%= formatValidity(batch.validity) %></div>
<% } else { %>
<div class="code" style="font-size:15px; letter-spacing:1px">U: <%= v.code %></div>
<div class="code" style="font-size:15px; letter-spacing:1px; margin-top:2px">P: <%= v.password %></div>
<div class="right-meta"><i class="bi bi-hourglass-split"></i> <%= formatValidity(batch.validity) %></div>
<% } %>
</div>
<div class="wa-btm">CS/WA: <%= (settings.whatsapp_admin_numbers && settings.whatsapp_admin_numbers.length > 0) ? '+' + settings.whatsapp_admin_numbers[0] : (settings.company_phone || '-') %></div>
</div>
<% }) %>
</div>
<% } %>
<div class="print-controls">
<button class="btn-action" onclick="window.print()"><i class="bi bi-printer-fill"></i> Cetak Voucher</button>
<button class="btn-action btn-close" onclick="window.close()"><i class="bi bi-x-lg"></i> <%= t('common.close','Tutup') %></button>
</div>
<script>
window.onload = function() {
setTimeout(() => {
if (!window.location.search.includes('noprint')) {
window.print();
}
}, 400);
}
</script>
</div>
</div>
</body>
</html>