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

+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.pages.about.meta_title', 'Tentang Kami') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/pages.css?v=3">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="Beranda">
<img class="bmark" src="<%= brandLogo %>" alt="<%= company %>"><span class="bname"><%= brandName %></span>
</a>
<div class="header-actions" style="margin-left:auto">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill"></i></button>
<a class="lbtn lbtn-ghost lbtn-sm" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-box-arrow-in-right"></i> <%= isLoggedIn ? 'Dashboard' : 'Masuk' %></a>
</div>
</div>
</header>
<main class="container">
<section class="page-hero">
<h1><%= t('customer.pages.about.hero_title_prefix', 'Tentang') %> <%= company %></h1>
<p><%= t('customer.pages.about.hero_subtitle', 'Solusi internet cepat dan terjangkau untuk masyarakat.') %></p>
</section>
<div class="prose">
<p class="lead"><%- String(t('customer.pages.about.p1', '<strong>{company}</strong> adalah penyedia layanan internet (ISP) yang berfokus pada penyediaan akses broadband berkualitas tinggi untuk perumahan dan bisnis kecil.')).replace('{company}', company) %></p>
<p><%= t('customer.pages.about.p2', 'Kami percaya bahwa di era digital saat ini, akses internet yang stabil dan cepat bukan lagi kemewahan, melainkan kebutuhan dasar untuk mendukung pendidikan, ekonomi kreatif, dan komunikasi tanpa batas.') %></p>
<div class="prose-grid">
<div class="pcard"><i class="bi bi-rocket-takeoff"></i><h5><%= t('customer.pages.about.stats.speed_title', 'Kecepatan Tinggi') %></h5><p><%= t('customer.pages.about.stats.speed_desc', 'Jaringan serat optik untuk pengalaman internet tanpa hambatan.') %></p></div>
<div class="pcard"><i class="bi bi-shield-check"></i><h5><%= t('customer.pages.about.stats.stable_title', 'Stabil & Aman') %></h5><p><%= t('customer.pages.about.stats.stable_desc', 'Infrastruktur yang dirancang untuk keandalan maksimal 24/7.') %></p></div>
<div class="pcard"><i class="bi bi-headset"></i><h5><%= t('customer.pages.about.stats.support_title', 'Dukungan Lokal') %></h5><p><%= t('customer.pages.about.stats.support_desc', 'Tim teknis yang siap membantu Anda kapan saja.') %></p></div>
</div>
<h4><i class="bi bi-eye"></i> <%= t('customer.pages.about.vision_title', 'Visi Kami') %></h4>
<p><%= t('customer.pages.about.vision_body', 'Menjadi mitra konektivitas digital terpercaya yang mampu menjangkau hingga pelosok, memberikan dampak positif bagi perkembangan ekonomi dan sosial masyarakat.') %></p>
<h4><i class="bi bi-bullseye"></i> <%= t('customer.pages.about.mission_title', 'Misi Kami') %></h4>
<ul>
<li><%= t('customer.pages.about.mission_1', 'Menyediakan layanan internet dengan harga yang kompetitif namun berkualitas premium.') %></li>
<li><%= t('customer.pages.about.mission_2', 'Terus berinovasi dalam infrastruktur jaringan untuk mengikuti perkembangan teknologi.') %></li>
<li><%= t('customer.pages.about.mission_3', 'Memberikan layanan pelanggan yang responsif dan solutif.') %></li>
</ul>
<div style="text-align:center;margin-top:30px">
<a class="lbtn lbtn-primary" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-arrow-left"></i> <%= t('common.back_to', 'Kembali ke') %> <%= isLoggedIn ? t('customer.dashboard_title', 'Dashboard Pelanggan') : t('common.portal', 'Portal') %></a>
</div>
</div>
<div class="page-foot">&copy; <%= new Date().getFullYear() %> <%= company %> • <%= t('customer.pages.about.footer_tagline', 'Menghubungkan Anda ke Masa Depan') %></div>
</main>
<script>
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); try{localStorage.setItem('landing-theme',t);}catch(e){} }
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
<%- include('partials/customer_bottom_nav', { activeNav: 'none' }) %>
</body>
</html>
+1092
View File
File diff suppressed because it is too large. Load diff
+106
View File
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="<%= (typeof lang !== 'undefined' ? lang : 'id') %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.acsdev.title', 'Detail Perangkat') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.cards-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 24px; }
.card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
.card-header { padding: 14px 18px; border-bottom: 1px solid var(--border); font-weight: 700; color: var(--muted); }
.info-row { display: flex; justify-content: space-between; padding: 8px 18px; border-bottom: 1px solid rgba(51, 65, 85, 0.1); }
.status-banner { padding: 12px 20px; border-radius: 10px; margin-bottom: 20px; font-weight: 700; border: 1px solid var(--border); }
.status-online { background: rgba(16, 185, 129, 0.1); color: var(--success); }
.status-offline { background: rgba(239, 68, 68, 0.1); color: var(--danger); }
.client-card { background: var(--sub-bg); border: 1px solid var(--border); border-radius: 10px; padding: 14px; margin-bottom: 10px; }
.client-header { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
.client-icon { width: 32px; height: 32px; border-radius: 8px; display: flex; align-items: center; justify-content: center; background: rgba(59,130,246,0.1); color: var(--primary); }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'acs_pro', 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-hdd-network"></i>
<%= t('admin.acsdev.title', 'Detail Perangkat') %>: <%= device.serialNumber || device.sn || device.phone || '-' %>
</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<a href="/admin/acs?acs=<%= encodeURIComponent(acsId || 'all') %>" class="btn btn-g btn-sm" title="Kembali">
<i class="bi bi-arrow-left"></i>
</a>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<div class="status-banner <%= isOnline ? 'status-online' : 'status-offline' %>">
<i class="bi bi-circle-fill" style="font-size:10px"></i>
<%= isOnline ? t('admin.acsdev.online', 'Perangkat Online') : t('admin.acsdev.offline', 'Perangkat Offline') %>
— <%= t('admin.acsdev.last_inform', 'Inform Terakhir') %>: <%= device.lastInform ? new Date(device.lastInform).toLocaleString('id-ID') : t('admin.acsdev.never', 'Belum pernah') %>
</div>
<div class="cards-grid">
<div class="card">
<div class="card-header"><i class="bi bi-info-circle"></i> <%= t('admin.acsdev.device_info', 'Info Perangkat') %></div>
<div class="info-row"><span><%= t('admin.acsdev.tag', 'Tag/ID') %></span><span style="font-weight:800;"><%= device.phone || device.id || '-' %></span></div>
<div class="info-row"><span><%= t('admin.acsdev.model', 'Model') %></span><span><%= device.model || '-' %></span></div>
<div class="info-row"><span><%= t('admin.acsdev.serial', 'Serial Number') %></span><code style="color:var(--info)"><%= device.serialNumber || device.sn || '-' %></code></div>
<div class="info-row"><span><%= t('admin.acsdev.swver', 'Versi Software') %></span><span><%= device.softwareVersion || '-' %></span></div>
</div>
<div class="card">
<div class="card-header"><i class="bi bi-diagram-3"></i> <%= t('admin.acsdev.net_info', 'Info Jaringan') %></div>
<div class="info-row"><span><%= t('admin.acsdev.pppoe_user', 'PPPoE User') %></span><span><%= device.pppoeUsername || device.pppoeUser || '-' %></span></div>
<div class="info-row"><span><%= t('admin.acsdev.ip', 'IP Address') %></span><code><%= device.pppoeIP || device.ip || '-' %></code></div>
<div class="info-row"><span><%= t('admin.acsdev.rxpower', 'RX Power') %></span><span class="badge" style="background:var(--primary); color:white;"><%= device.rxPower || '-' %> dBm</span></div>
<div class="info-row"><span><%= t('admin.acsdev.ssid', 'SSID') %></span><span><%= device.ssid || '-' %></span></div>
<div class="info-row"><span><%= t('admin.acsdev.uptime', 'Uptime') %></span><span><%= device.uptime || '-' %></span></div>
<div class="info-row"><span><%= t('admin.acsdev.pppoe_uptime', 'PPPoE Uptime') %></span><span><%= device.pppoeUptime || '-' %></span></div>
</div>
</div>
<div class="card" style="margin-bottom:24px;">
<div class="card-header" style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
<div><i class="bi bi-people"></i> <%= t('admin.acsdev.clients', 'Klien Terhubung (Live)') %></div>
<span class="badge bm"><%= (clients && clients.length) ? clients.length : 0 %></span>
</div>
<div style="padding:18px 18px 10px 18px;">
<% if (clients && clients.length) { %>
<% clients.forEach((client) => {
const iface = String(client.iface || '');
const isWiFi = iface.toLowerCase().includes('wifi') || iface.toLowerCase().includes('wlan') || iface.toLowerCase().includes('802.11');
const isOn = String(client.status || '').toLowerCase() === 'online';
%>
<div class="client-card">
<div class="client-header">
<div class="client-icon"><i class="bi bi-<%= isWiFi ? 'wifi' : 'ethernet' %>"></i></div>
<div style="flex:1;">
<div style="font-weight:800;"><%= client.hostname || '-' %></div>
<div style="font-size:11px; color:var(--muted);"><%= client.ip || '-' %> | <%= client.mac || '-' %></div>
</div>
<% if (typeof client.rssi === 'number') { %>
<div class="badge" style="background:var(--primary); color:white;"><%= client.rssi %> dBm</div>
<% } %>
<span class="badge <%= isOn ? 'bs' : 'bm' %>"><%= client.status || '-' %></span>
</div>
<div style="font-size:11px;color:var(--muted)"><%= t('admin.acsdev.iface', 'Interface') %>: <%= client.iface || '-' %></div>
</div>
<% }) %>
<% } else { %>
<div style="text-align:center; color:var(--muted); padding: 8px 0;">
<i class="bi bi-info-circle"></i> <%= t('admin.acsdev.no_clients', 'Tidak ada klien terhubung.') %>
</div>
<% } %>
</div>
</div>
<footer style="margin-top: 30px; padding: 20px; text-align: center; color: var(--muted); font-size: 12px; border-top: 1px solid var(--border);">
&copy; <%= new Date().getFullYear() %> <%= (typeof settings !== 'undefined' ? settings.company_header : 'OPEN-ISP') %> - <%= t('admin.acsdev.genieacs', 'GenieACS Pro') %>
</footer>
</div>
</div>
</body>
</html>
+149
View File
@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Manajemen Area | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'areas', 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-geo-alt"></i>
<span>Manajemen Area / Wilayah</span>
</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<button class="btn btn-p btn-sm" onclick="openModal('addAreaModal')"><i class="bi bi-plus-lg"></i> Tambah Area Baru</button>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='error'?'d':'s' %>" data-flash="1"><%= msg.text %></div>
<% } %>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-geo-alt-fill"></i> Daftar Master Area / Wilayah (<%= areas.length %>)</h6>
</div>
<div class="hint" style="padding:12px 16px 0 16px;font-size:12px;color:var(--muted)">
Area digunakan untuk mengelompokkan lokasi pelanggan dan wilayah tugas kolektor. Kolektor yang memiliki area tugas tertentu hanya akan mengelola pelanggan di area tersebut.
</div>
<div class="tbl-wrap" style="padding-top:12px">
<table class="dtbl">
<thead>
<tr>
<th>#</th>
<th>Nama Area / Wilayah</th>
<th>Keterangan / Deskripsi</th>
<th>Jumlah Pelanggan</th>
<th>Jumlah Kolektor</th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (!areas || areas.length === 0) { %>
<tr><td colspan="6" class="text-center text-muted" style="padding:24px">Belum ada data area. Klik "Tambah Area Baru" untuk membuat area.</td></tr>
<% } else { %>
<% areas.forEach((a, idx) => { %>
<tr>
<td><%= idx + 1 %></td>
<td>
<strong style="color:var(--text);font-size:14px"><i class="bi bi-geo-alt text-primary me-1"></i><%= a.name %></strong>
</td>
<td><span class="text-muted"><%= a.description || '-' %></span></td>
<td>
<span class="badge bs" style="font-size:12px"><i class="bi bi-people me-1"></i><%= a.customer_count || 0 %> <%= t('common.customer','Pelanggan') %></span>
</td>
<td>
<span class="badge bm" style="font-size:12px"><i class="bi bi-person-badge me-1"></i><%= a.collector_count || 0 %> <%= t('collector.role','Kolektor') %></span>
</td>
<td style="white-space:nowrap">
<form id="adel-<%= a.id %>" action="/admin/areas/<%= a.id %>/delete" method="POST" style="display:none" onsubmit="return confirm('Hapus area &quot;<%= a.name %>&quot;?')"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick='editArea(<%- JSON.stringify(a).split('<').join('\\u003c') %>)'><i class="bi bi-pencil"></i> Edit Area</button>
<div class="act-sep"></div>
<button type="submit" form="adel-<%= a.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Area</button>
</div>
</div>
</td>
</tr>
<% }) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Tambah Area -->
<div class="mo" id="addAreaModal">
<div class="mb">
<form action="/admin/areas" method="POST">
<div class="mh"><span class="mt"><i class="bi bi-geo-alt-fill"></i> Tambah Area Baru</span><button type="button" class="mc" onclick="closeModal('addAreaModal')">&times;</button></div>
<div class="mbody">
<div class="fg">
<label class="fl">Nama Area / Wilayah *</label>
<input type="text" name="name" class="fc" placeholder="Contoh: Kelurahan Sukamaju, Blok C1-C10" required>
</div>
<div class="fg">
<label class="fl"><%= t('common.note_optional2','Keterangan / Catatan (Opsional)') %></label>
<textarea name="description" class="fc" rows="3" placeholder="Contoh: Wilayah jangkauan ODP-01 s/d ODP-05"></textarea>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addAreaModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> Simpan Area</button>
</div>
</form>
</div>
</div>
<!-- Modal Edit Area -->
<div class="mo" id="editAreaModal">
<div class="mb">
<form id="editAreaForm" method="POST">
<div class="mh"><span class="mt"><i class="bi bi-pencil-square"></i> Edit Data Area</span><button type="button" class="mc" onclick="closeModal('editAreaModal')">&times;</button></div>
<div class="mbody">
<div class="fg">
<label class="fl">Nama Area / Wilayah *</label>
<input type="text" name="name" id="e-area-name" class="fc" required>
</div>
<div class="fg">
<label class="fl"><%= t('common.note_optional2','Keterangan / Catatan (Opsional)') %></label>
<textarea name="description" id="e-area-desc" class="fc" rows="3"></textarea>
</div>
<div class="hint" style="font-size:11px;color:var(--muted)">
<i class="bi bi-info-circle"></i> Mengubah nama area akan otomatis memperbarui nama area pada semua pelanggan dan kolektor terkait.
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('editAreaModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> <%= t('tech.mon.save','Simpan Perubahan') %></button>
</div>
</form>
</div>
</div>
<script>
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
function editArea(a){
document.getElementById('editAreaForm').action = '/admin/areas/' + a.id + '/update';
document.getElementById('e-area-name').value = a.name || '';
document.getElementById('e-area-desc').value = a.description || '';
openModal('editAreaModal');
}
</script>
</body>
</html>
+442
View File
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.type-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.type-technician { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
.type-admin { background: rgba(168, 85, 247, 0.15); color: #a855f7; }
.type-cashier { background: rgba(34, 197, 94, 0.15); color: #22c55e; }
.type-collector { background: rgba(249, 115, 22, 0.15); color: #f97316; }
.time-info {
font-size: 12px;
color: var(--muted);
margin-top: 4px;
}
.location-link {
color: var(--primary);
text-decoration: none;
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.location-link:hover {
text-decoration: underline;
}
.late-badge {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
margin-left: 8px;
}
.filter-row {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 20px;
}
.date-input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg2);
color: var(--text);
font-size: 13px;
}
.photo-thumb {
width: 52px;
height: 52px;
object-fit: cover;
border-radius: 8px;
border: 1px solid var(--border);
display: block;
}
.photo-stack {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.photo-item {
text-decoration: none;
color: var(--muted);
font-size: 11px;
text-align: center;
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'attendance', company }) %>
<div class="mw">
<!-- TOPBAR -->
<div class="topbar">
<div class="tb-title">
<button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button>
<i class="bi bi-calendar-check"></i> <%= title %>
</div>
<div class="tb-right">
<span class="adm-badge">
<i class="bi bi-person-fill"></i>
<%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'success' ? 's' : 'd' %>" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<!-- Filter & Export -->
<div class="filter-row">
<input type="date" id="filterDate" class="date-input" value="<%= selectedDate %>">
<button class="btn btn-p btn-sm" onclick="filterByDate()">
<i class="bi bi-funnel"></i> Filter
</button>
<button class="btn btn-s btn-sm" onclick="exportData()">
<i class="bi bi-file-earmark-excel"></i> Export Excel
</button>
</div>
<!-- Statistics Grid -->
<%
const techStats = stats.find(s => s.employee_type === 'technician') || {};
const adminStats = stats.find(s => s.employee_type === 'admin') || {};
const cashierStats = stats.find(s => s.employee_type === 'cashier') || {};
const collectorStats = stats.find(s => s.employee_type === 'collector') || {};
const totalAll = (techStats.total || 0) + (adminStats.total || 0) + (cashierStats.total || 0) + (collectorStats.total || 0);
const checkedInAll = (techStats.checked_in || 0) + (adminStats.checked_in || 0) + (cashierStats.checked_in || 0) + (collectorStats.checked_in || 0);
const checkedOutAll = (techStats.checked_out || 0) + (adminStats.checked_out || 0) + (cashierStats.checked_out || 0) + (collectorStats.checked_out || 0);
%>
<div class="stat-grid" style="grid-template-columns:repeat(4,1fr)">
<div class="sc p">
<i class="bi bi-people sc-icon"></i>
<div class="sc-label">Total Karyawan</div>
<div class="sc-val"><%= totalAll %></div>
<div class="sc-sub">Hari ini</div>
</div>
<div class="sc s">
<i class="bi bi-check-circle sc-icon"></i>
<div class="sc-label">Sedang Bekerja</div>
<div class="sc-val"><%= checkedInAll %></div>
<div class="sc-sub">Check-in</div>
</div>
<div class="sc p">
<i class="bi bi-door-open sc-icon"></i>
<div class="sc-label">Sudah Pulang</div>
<div class="sc-val"><%= checkedOutAll %></div>
<div class="sc-sub">Check-out</div>
</div>
<div class="sc d">
<i class="bi bi-clock-history sc-icon"></i>
<div class="sc-label">Terlambat</div>
<div class="sc-val"><%= lateCheckIns.length %></div>
<div class="sc-sub">Setelah 08:30</div>
</div>
</div>
<!-- Breakdown by Type -->
<div class="stat-grid" style="grid-template-columns:repeat(4,1fr);margin-top:16px">
<div class="sc" style="border-left:3px solid #3b82f6">
<div class="sc-label"><i class="bi bi-person-workspace"></i> Teknisi</div>
<div class="sc-val"><%= techStats.total || 0 %></div>
<div class="sc-sub">
<span style="color:#22c55e"><%= techStats.checked_in || 0 %> aktif</span> •
<span style="color:#3b82f6"><%= techStats.checked_out || 0 %> selesai</span>
</div>
</div>
<div class="sc" style="border-left:3px solid #a855f7">
<div class="sc-label"><i class="bi bi-person-badge"></i> Admin</div>
<div class="sc-val"><%= adminStats.total || 0 %></div>
<div class="sc-sub">
<span style="color:#22c55e"><%= adminStats.checked_in || 0 %> aktif</span> •
<span style="color:#3b82f6"><%= adminStats.checked_out || 0 %> selesai</span>
</div>
</div>
<div class="sc" style="border-left:3px solid #22c55e">
<div class="sc-label"><i class="bi bi-cash-coin"></i> Kasir</div>
<div class="sc-val"><%= cashierStats.total || 0 %></div>
<div class="sc-sub">
<span style="color:#22c55e"><%= cashierStats.checked_in || 0 %> aktif</span> •
<span style="color:#3b82f6"><%= cashierStats.checked_out || 0 %> selesai</span>
</div>
</div>
<div class="sc" style="border-left:3px solid #f97316">
<div class="sc-label"><i class="bi bi-person-walking"></i> <%= t('collector.role','Kolektor') %></div>
<div class="sc-val"><%= collectorStats.total || 0 %></div>
<div class="sc-sub">
<span style="color:#22c55e"><%= collectorStats.checked_in || 0 %> aktif</span> •
<span style="color:#3b82f6"><%= collectorStats.checked_out || 0 %> selesai</span>
</div>
</div>
</div>
<!-- Late Check-ins Alert -->
<% if (lateCheckIns.length > 0) { %>
<div class="card" style="margin-top:20px;border-left:3px solid #ef4444">
<div class="card-hd">
<h6><i class="bi bi-exclamation-triangle"></i> Terlambat Check-in (<%= lateCheckIns.length %>)</h6>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('pwa.field.name','Nama') %></th>
<th><%= t('common.type','Tipe') %></th>
<th>Waktu Check-in</th>
<th>Terlambat</th>
</tr>
</thead>
<tbody>
<% lateCheckIns.forEach(att => {
const checkInTime = parseDateInTimezone(att.check_in_time);
const targetTime = new Date(checkInTime);
targetTime.setHours(8, 30, 0, 0);
const lateMinutes = Math.floor((checkInTime - targetTime) / 1000 / 60);
%>
<tr>
<td class="fw6"><%= att.employee_name %></td>
<td><span class="type-badge type-<%= att.employee_type %>"><%= att.employee_type %></span></td>
<td><%= formatTimeLocal(att.check_in_time) %></td>
<td><span class="late-badge"><%= lateMinutes %> menit</span></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<% } %>
<!-- Not Checked Out Alert -->
<% if (notCheckedOut.length > 0) { %>
<div class="card" style="margin-top:20px;border-left:3px solid #f59e0b">
<div class="card-hd">
<h6><i class="bi bi-bell"></i> Belum Check-out (<%= notCheckedOut.length %>)</h6>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('pwa.field.name','Nama') %></th>
<th><%= t('common.type','Tipe') %></th>
<th>Check-in</th>
<th>Durasi</th>
</tr>
</thead>
<tbody>
<% notCheckedOut.forEach(att => {
const checkInTime = parseDateInTimezone(att.check_in_time);
const now = new Date();
const durationMinutes = Math.floor((now - checkInTime) / 1000 / 60);
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
%>
<tr>
<td class="fw6"><%= att.employee_name %></td>
<td><span class="type-badge type-<%= att.employee_type %>"><%= att.employee_type %></span></td>
<td><%= formatTimeLocal(att.check_in_time) %></td>
<td><%= hours %> jam <%= minutes %> menit</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<% } %>
<!-- All Attendance Records -->
<div class="card" style="margin-top:20px">
<div class="card-hd">
<h6><i class="bi bi-calendar-check"></i> Semua Absensi - <%= parseDateInTimezone(selectedDate).toLocaleDateString('id-ID', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }) %></h6>
</div>
<% if (attendances.length > 0) { %>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('pwa.field.name','Nama') %></th>
<th><%= t('common.type','Tipe') %></th>
<th>Check-in</th>
<th>Check-out</th>
<th>Durasi</th>
<th><%= t('common.status','Status') %></th>
<th>Lokasi</th>
<th>Foto</th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% attendances.forEach(att => { %>
<tr>
<td class="fw6"><%= att.employee_name %></td>
<td><span class="type-badge type-<%= att.employee_type %>"><%= att.employee_type %></span></td>
<td>
<%= formatTimeLocal(att.check_in_time) %>
<% if (att.check_in_note) { %>
<div class="time-info"><i class="bi bi-chat-dots-fill"></i> <%= att.check_in_note %></div>
<% } %>
</td>
<td>
<% if (att.check_out_time) { %>
<%= formatTimeLocal(att.check_out_time) %>
<% if (att.check_out_note) { %>
<div class="time-info"><i class="bi bi-chat-dots-fill"></i> <%= att.check_out_note %></div>
<% } %>
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td>
<% if (att.work_duration_minutes > 0) { %>
<%= Math.floor(att.work_duration_minutes / 60) %>j <%= att.work_duration_minutes % 60 %>m
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td>
<% if (att.status === 'checked_in') { %>
<span class="badge badge-s"><%= t('common.active','Aktif') %></span>
<% } else { %>
<span class="badge badge-p">Selesai</span>
<% } %>
</td>
<td>
<% if (att.check_in_lat && att.check_in_lng) { %>
<a href="https://www.google.com/maps?q=<%= att.check_in_lat %>,<%= att.check_in_lng %>"
target="_blank" class="location-link">
<i class="bi bi-geo-alt-fill"></i> Lihat Map
</a>
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td>
<% if (att.check_in_photo || att.check_out_photo) { %>
<div class="photo-stack">
<% if (att.check_in_photo) { %>
<a href="<%= att.check_in_photo %>" target="_blank" class="photo-item">
<img src="<%= att.check_in_photo %>" alt="Foto check-in" class="photo-thumb">
In
</a>
<% } %>
<% if (att.check_out_photo) { %>
<a href="<%= att.check_out_photo %>" target="_blank" class="photo-item">
<img src="<%= att.check_out_photo %>" alt="Foto check-out" class="photo-thumb">
Out
</a>
<% } %>
</div>
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td>
<div style="display:flex;gap:6px">
<button class="btn btn-g btn-sm" data-id="<%= att.id %>" onclick="editAttendance(this.dataset.id)">
<i class="bi bi-pencil"></i>
</button>
<form method="POST" action="/admin/attendance/<%= att.id %>/delete" style="display:inline"
onsubmit="return confirm('Yakin hapus absensi ini?')">
<button type="submit" class="btn btn-d btn-sm">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } else { %>
<div class="card-bd" style="text-align:center;padding:60px 20px">
<div style="font-size:64px;margin-bottom:16px;opacity:0.3"><i class="bi bi-inbox"></i></div>
<h3 style="color:var(--muted);font-weight:500">Tidak ada data absensi</h3>
<p style="color:var(--muted);font-size:14px">Belum ada karyawan yang melakukan absensi pada tanggal ini.</p>
</div>
<% } %>
</div>
</div>
</div>
<script>
function filterByDate() {
const date = document.getElementById('filterDate').value;
if (date) {
window.location.href = '/admin/attendance?date=' + date;
}
}
function exportData() {
const date = document.getElementById('filterDate').value;
const today = new Date().toISOString().split('T')[0];
const startDate = prompt('Tanggal Mulai (YYYY-MM-DD):', date || today);
if (!startDate) return;
const endDate = prompt('Tanggal Akhir (YYYY-MM-DD):', date || today);
if (!endDate) return;
window.location.href = `/admin/attendance/export?startDate=${startDate}&endDate=${endDate}`;
}
function editAttendance(id) {
const checkIn = prompt('Waktu Check-in (YYYY-MM-DD HH:MM:SS):');
if (!checkIn) return;
const checkOut = prompt('Waktu Check-out (YYYY-MM-DD HH:MM:SS) - kosongkan jika belum:');
const checkInNote = prompt('Catatan Check-in:') || '';
const checkOutNote = prompt('Catatan Check-out:') || '';
fetch(`/admin/attendance/${id}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
check_in_time: checkIn,
check_out_time: checkOut || null,
check_in_note: checkInNote,
check_out_note: checkOutNote
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert(' ' + data.message);
window.location.reload();
} else {
alert(' ' + data.message);
}
})
.catch(err => {
alert('Gagal update: ' + err.message);
});
}
// Auto-refresh every 5 minutes
setTimeout(() => {
window.location.reload();
}, 5 * 60 * 1000);
</script>
</body>
</html>
+140
View File
@@ -0,0 +1,140 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.log-details { font-family: monospace; font-size: 11px; background: rgba(0,0,0,0.1); padding: 8px; border-radius: 4px; white-space: pre-wrap; word-break: break-all; max-height: 200px; overflow-y: auto; }
.badge-action { padding: 3px 8px; border-radius: 4px; font-weight: 600; font-size: 10px; }
.badge-CREATE { background: var(--sdim); color: var(--success); }
.badge-UPDATE { background: var(--wdim); color: var(--warning); }
.badge-DELETE { background: var(--ddim); color: var(--danger); }
.badge-LOGIN { background: var(--pdim); color: var(--primary); }
.badge-LOGOUT { background: var(--bg3); color: var(--muted); }
.stat-grid-4 { grid-template-columns: repeat(4, 1fr); }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'audit_logs', 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-shield-lock"></i>
<span><%= title %></span>
</div>
<div class="tb-right">
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<div class="stat-grid stat-grid-4">
<div class="sc p">
<div class="sc-label"><%= t('admin.audit.total', 'Total Log') %></div>
<div class="sc-val"><%= stats.total %></div>
</div>
<div class="sc s">
<div class="sc-label"><%= t('admin.audit.recent24', 'Aktivitas 24 Jam') %></div>
<div class="sc-val"><%= stats.recent_24h %></div>
</div>
<div class="sc w">
<div class="sc-label"><%= t('admin.audit.updates', 'Update Data') %></div>
<div class="sc-val"><%= stats.by_action.find(a => a.action === 'UPDATE')?.count || 0 %></div>
</div>
<div class="sc d">
<div class="sc-label"><%= t('admin.audit.deletes', 'Penghapusan') %></div>
<div class="sc-val"><%= stats.by_action.find(a => a.action === 'DELETE')?.count || 0 %></div>
</div>
</div>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-columns-reverse"></i> <%= t('admin.audit.table_title', 'Log Aktivitas Sistem') %></h6>
<div class="flex gap">
<form class="flex gap" method="GET">
<select class="fs fs-sm" name="action" onchange="this.form.submit()">
<option value=""><%= t('admin.audit.all_actions', '-- Semua Aksi --') %></option>
<% stats.by_action.forEach(a => { %>
<option value="<%= a.action %>" <%= filters.action === a.action ? 'selected' : '' %>><%= a.action %></option>
<% }) %>
</select>
<select class="fs fs-sm" name="entity_type" onchange="this.form.submit()">
<option value=""><%= t('admin.audit.all_modules', '-- Semua Modul --') %></option>
<% stats.by_entity_type.forEach(e => { %>
<option value="<%= e.entity_type %>" <%= filters.entity_type === e.entity_type ? 'selected' : '' %>><%= e.entity_type %></option>
<% }) %>
</select>
</form>
</div>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('admin.audit.th_time', 'Waktu') %></th>
<th><%= t('admin.audit.th_action', 'Aksi') %></th>
<th><%= t('admin.audit.th_module', 'Modul') %></th>
<th><%= t('admin.audit.th_entity', 'ID Entity') %></th>
<th><%= t('admin.audit.th_actor', 'Pelaku') %></th>
<th><%= t('admin.audit.th_detail', 'Detail') %></th>
<th><%= t('admin.audit.th_ip', 'IP / Device') %></th>
</tr>
</thead>
<tbody>
<% logs.forEach(log => { %>
<tr>
<td class="text-muted" style="font-size:11px">
<%= typeof formatDateLocal === 'function' ? formatDateLocal(log.created_at) : (log.created_at ? new Date(log.created_at).toLocaleString('id-ID') : '-') %>
</td>
<td>
<span class="badge-action badge-<%= log.action %>"><%= log.action %></span>
</td>
<td><span class="badge bm"><%= log.entity_type %></span></td>
<td class="text-muted"><%= log.entity_id || '-' %></td>
<td>
<div class="fw6"><%= log.actor_name %></div>
<div class="text-muted" style="font-size:10px"><%= log.actor_type %> (ID: <%= log.actor_id %>)</div>
</td>
<td>
<% if (log.details) { %>
<button class="btn btn-g btn-sm" onclick="toggleDetails('det-<%= log.id %>')"><%= t('admin.audit.view_detail', 'Lihat Detail') %></button>
<div id="det-<%= log.id %>" class="log-details mt-2" style="display:none">
<%= JSON.stringify(log.details, null, 2) %>
</div>
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td class="text-muted" style="font-size:10px">
<div><%= log.ip_address || '-' %></div>
<div style="max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="<%= log.user_agent %>">
<%= log.user_agent || '-' %>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function toggleDetails(id) {
const el = document.getElementById(id);
el.style.display = el.style.display === 'none' ? 'block' : 'none';
}
function toggleSidebar() {
var sb = document.getElementById('sidebar') || document.querySelector('.sidebar');
var ov = document.getElementById('sidebarOverlay');
if (sb) sb.classList.toggle('open');
if (ov) ov.classList.toggle('show');
}
</script>
</body>
</html>
+425
View File
@@ -0,0 +1,425 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.automation.title', 'Otomatisasi') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.au-tabs{display:flex;gap:8px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:10px;overflow-x:auto;-webkit-overflow-scrolling:touch;white-space:nowrap}
.au-tab{padding:8px 14px;border-radius:8px;cursor:pointer;color:var(--muted);font-weight:700;flex-shrink:0;transition:all .2s}
.au-tab:hover{background:rgba(255,255,255,.06);color:var(--text)}
.au-tab.active{background:var(--primary);color:#0A0D14}
.au-panel{display:none}
.au-panel.active{display:block}
.au-savebar{position:sticky;bottom:0;z-index:5;background:var(--bg2);border:1px solid var(--border);border-top:1px solid var(--border);padding:12px;display:flex;justify-content:flex-end;gap:10px;border-radius:12px;margin-top:6px}
.au-map td{padding:8px 10px;vertical-align:middle}
.ew-badge{display:inline-block;border-radius:999px;padding:2px 10px;font-size:11px;font-weight:800;white-space:nowrap}
.ew-green{background:rgba(34,197,94,.16);color:#22c55e}
.ew-amber{background:rgba(245,158,11,.18);color:#f59e0b}
.ew-gray{background:rgba(148,163,184,.16);color:#94a3b8}
.ew-svc{background:rgba(173,255,47,.12);color:#ADFF2F;border-radius:6px;padding:1px 8px;font-size:11.5px;font-weight:700}
@media(max-width:768px){.au-tab{padding:6px 10px;font-size:12px}}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'automation', 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-robot"></i> <%= t('admin.automation.title', 'Otomatisasi') %></div>
<div class="tb-right">
<a href="/admin/whatsapp/templates" class="btn btn-g btn-sm"><i class="bi bi-chat-left-quote"></i> <%= t('admin.automation.edit_templates', 'Template Pesan') %></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">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1"><i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %></div>
<% } %>
<div class="alert alert-s" style="margin-bottom:14px">
<i class="bi bi-info-circle"></i> <%= t('admin.automation.intro', 'Semua pengaturan otomatis (pengingat tagihan, tunggakan/isolir, auto-lunas) beserta pemilihan template ada di halaman ini.') %>
</div>
<div class="au-tabs">
<div class="au-tab active" data-tab="reminder" onclick="auTab('reminder')"><i class="bi bi-alarm"></i> <%= t('admin.automation.tab_reminder', 'Pengingat Tagihan') %></div>
<div class="au-tab" data-tab="manual" onclick="auTab('manual')"><i class="bi bi-send"></i> <%= t('admin.automation.tab_manual', 'Kirim Manual') %></div>
<div class="au-tab" data-tab="arrears" onclick="auTab('arrears')"><i class="bi bi-person-x"></i> <%= t('admin.automation.tab_arrears', 'Tunggakan &amp; Isolir') %></div>
<div class="au-tab" data-tab="lunas" onclick="auTab('lunas')"><i class="bi bi-check2-circle"></i> <%= t('admin.automation.tab_lunas', 'Auto-Lunas') %></div>
<div class="au-tab" data-tab="riwayat" onclick="auTab('riwayat')"><i class="bi bi-clock-history"></i> <%= t('admin.automation.tab_history', 'Riwayat') %></div>
<div class="au-tab" data-tab="templates" onclick="auTab('templates')"><i class="bi bi-chat-left-quote"></i> <%= t('admin.automation.tab_templates', 'Template &amp; Preview') %></div>
</div>
<form method="POST" action="/admin/automation">
<!-- ===================== PENINGAT TAGIHAN ===================== -->
<div class="au-panel active" id="au-reminder">
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-alarm"></i> <%= t('admin.automation.reminder_title', 'Pengingat Tagihan Otomatis') %></h6></div>
<div class="card-bd">
<div class="fg" style="width:100%">
<label class="fl"><%= t('admin.automation.allow_billing', 'Kirim Tagihan via WhatsApp (Global)') %></label>
<label style="display:flex;align-items:center;gap:10px;font-weight:800;color:var(--text)">
<input type="checkbox" name="billing_enabled" value="1" <%= getSetting('whatsapp_billing_to_customer_enabled', true) ? 'checked' : '' %>>
<%= t('admin.automation.allow_billing_hint', 'Izinkan pengiriman tagihan ke pelanggan (semua fitur)') %>
</label>
</div>
<div class="fg" style="width:100%;margin-top:14px">
<label class="fl"><%= t('admin.automation.cron_enable', 'Pengingat Otomatis (Jadwal / CRON)') %></label>
<label style="display:flex;align-items:center;gap:10px;font-weight:800;color:var(--text)">
<input type="checkbox" name="enabled" value="1" <%= getSetting('whatsapp_auto_billing_enabled', false) ? 'checked' : '' %>>
<%= t('admin.automation.cron_enable_hint', 'Jalankan pengingat tagihan otomatis') %>
</label>
<div class="hint"><%= t('admin.automation.cron_hint', 'Dikirim pada hari ke-H sebelum tanggal isolir. Contoh: 3,1 = H-3 dan H-1.') %></div>
</div>
<div class="form-row" style="margin-top:12px">
<div class="fg">
<label class="fl"><%= t('admin.automation.days_before', 'Hari pengingat (H sebelum isolir)') %></label>
<input class="fc" name="reminder_days_before" value="<%= getSetting('reminder_days_before', '2') %>" placeholder="2">
</div>
<div class="fg" style="flex:2">
<label class="fl"><i class="bi bi-chat-left-quote"></i> <%= t('admin.automation.auto_template', 'Template Pengingat Otomatis') %></label>
<select class="fs" id="au_tpl" name="auto_billing_template_key" onchange="auLoadTemplate()">
<% Object.keys(billingTemplates||{}).forEach(function(k){ %>
<option value="<%= k %>" <%= (getSetting('auto_billing_template_key','whatsapp_auto_billing_message')===k) ? 'selected' : '' %>><%= billingTemplates[k] %></option>
<% }); %>
</select>
<div class="hint"><%= t('admin.automation.auto_template_hint', 'Pilih "Tagihan QRIS" bila ingin lampiran gambar QR.') %></div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.automation.delay', 'Jeda Antar Pesan (detik)') %></label>
<input class="fc" type="number" name="delay" value="<%= getSetting('whatsapp_broadcast_delay', 2) %>" min="1" max="60">
</div>
</div>
<div class="fg" style="width:100%;margin-top:12px">
<label class="fl"><%= t('admin.automation.billing_message', 'Isi Pesan Pengingat') %> — <span id="au_msg_label" style="color:var(--primary)"><%= (billingTemplates||{})[selectedTplKey] || selectedTplKey %></span></label>
<textarea class="fc" id="au_msg" name="message" data-dirty="0" rows="8" required style="width:100%"><%= autoBillingMsg %></textarea>
<div class="hint"><i class="bi bi-info-circle"></i> <%= t('admin.automation.vars', 'Variabel') %>: <b>{{nama}}</b>, <b>{{tagihan}}</b>, <b>{{rincian}}</b>, <b>{{paket}}</b>, <b>{{link}}</b>. <%= t('admin.automation.msg_follow_hint', 'Isi ini otomatis mengikuti template yang dipilih di atas.') %></div>
</div>
</div>
</div>
</div>
<!-- ===================== TUNGGAKAN & ISOLIR ===================== -->
<div class="au-panel" id="au-arrears">
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-person-x"></i> <%= t('admin.automation.arrears_title', 'Tunggakan &amp; Isolir Otomatis') %></h6></div>
<div class="card-bd">
<div class="form-row">
<div class="fg">
<label class="fl"><i class="bi bi-person-x"></i> <%= t('admin.automation.auto_deactivate', 'Auto-Nonaktif Pelanggan Menunggak') %></label>
<select class="fs" name="auto_deactivate_enabled">
<option value="true" <%= (settings.auto_deactivate_enabled === undefined ? true : settings.auto_deactivate_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
<option value="false" <%= (settings.auto_deactivate_enabled === undefined ? true : settings.auto_deactivate_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
</select>
</div>
<div class="fg">
<label class="fl"><%= t('admin.automation.deactivate_months', 'Batas Tunggakan (bulan)') %></label>
<input class="fc" type="number" min="1" max="24" name="auto_deactivate_months" value="<%= settings.auto_deactivate_months || 3 %>">
<div class="hint"><%= t('admin.automation.deactivate_months_hint', 'Pelanggan dinonaktif (isolir) bila menunggak sebanyak ini (3 = 3x tagihan baru belum lunas).') %></div>
</div>
<div class="fg">
<label class="fl"><i class="bi bi-trash3"></i> <%= t('admin.automation.auto_delete', 'Auto-Hapus Pelanggan (lewat batas)') %></label>
<select class="fs" name="auto_delete_overdue_enabled">
<option value="true" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
<option value="false" <%= (settings.auto_delete_overdue_enabled === undefined ? true : settings.auto_delete_overdue_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
</select>
<div class="hint"><%= t('admin.automation.auto_delete_hint', 'Jika Aktif: pelanggan menunggak lebih dari batas (mis. bulan ke-4) dihapus otomatis — harus daftar ulang.') %></div>
</div>
<div class="fg">
<label class="fl"><i class="bi bi-exclamation-triangle"></i> <%= t('admin.automation.arrears_notice', 'Auto-Peringatan Tunggakan') %></label>
<select class="fs" name="arrears_notice_enabled">
<option value="true" <%= (settings.arrears_notice_enabled === undefined ? true : settings.arrears_notice_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
<option value="false" <%= (settings.arrears_notice_enabled === undefined ? true : settings.arrears_notice_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
</select>
<div class="hint"><%= t('admin.automation.arrears_notice_hint', 'Kirim WA peringatan saat menunggak mendekati batas. Template di tab Template &amp; Preview.') %></div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.automation.manual_action', 'Aksi Manual') %></label>
<button type="button" class="btn btn-w btn-sm" onclick="runAutoDeactivate()"><i class="bi bi-play-fill"></i> <%= t('admin.automation.run_now', 'Jalankan sekarang') %></button>
</div>
</div>
</div>
</div>
</div>
<!-- ===================== AUTO-LUNAS ===================== -->
<div class="au-panel" id="au-lunas">
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-check2-circle"></i> <%= t('admin.automation.lunas_title', 'Auto-Lunas (Tagihan Rp 0 / auto_isolate=0)') %></h6></div>
<div class="card-bd">
<div class="form-row">
<div class="fg">
<label class="fl"><i class="bi bi-check2-circle"></i> <%= t('admin.automation.lunas_notify', 'Notifikasi ke Pelanggan') %></label>
<select class="fs" name="autolunas_notify_enabled">
<option value="true" <%= (settings.autolunas_notify_enabled === undefined ? true : settings.autolunas_notify_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
<option value="false" <%= (settings.autolunas_notify_enabled === undefined ? true : settings.autolunas_notify_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
</select>
<div class="hint"><%= t('admin.automation.lunas_notify_hint', 'Kirim WA "Tagihan LUNAS" ke pelanggan saat tagihan Rp 0 / auto_isolate=0 dilunaskan otomatis.') %></div>
</div>
<div class="fg">
<label class="fl"><i class="bi bi-clipboard-data"></i> <%= t('admin.automation.lunas_recap', 'Rekap Bulanan ke Admin') %></label>
<select class="fs" name="autolunas_recap_enabled">
<option value="true" <%= (settings.autolunas_recap_enabled === undefined ? true : settings.autolunas_recap_enabled) ? 'selected' : '' %>><%= t('common.active', 'Aktif') %></option>
<option value="false" <%= (settings.autolunas_recap_enabled === undefined ? true : settings.autolunas_recap_enabled) ? '' : 'selected' %>><%= t('common.inactive', 'Nonaktif') %></option>
</select>
<div class="hint"><%= t('admin.automation.lunas_recap_hint', 'Rekap (jumlah + daftar mitra Rp 0) dikirim ke admin tiap tanggal 1.') %></div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.automation.view_recap', 'Lihat Rekap') %></label>
<a class="btn btn-g btn-sm" href="/admin/ewallet-logs"><i class="bi bi-list-check"></i> <%= t('admin.automation.open_recap', 'Buka Log &amp; Rekap') %></a>
</div>
</div>
</div>
</div>
</div>
<div class="au-savebar">
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> <%= t('common.save', 'Simpan') %></button>
</div>
</form>
<!-- ===================== KIRIM MANUAL ===================== -->
<div class="au-panel" id="au-manual">
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-send"></i> <%= t('admin.automation.manual_title', 'Kirim Tagihan Manual (berdasarkan tanggal isolir)') %></h6></div>
<div class="card-bd">
<div style="display:flex;gap:14px;flex-wrap:wrap">
<div class="fg"><label class="fl"><i class="bi bi-calendar-event"></i> <%= t('admin.automation.manual_isolate', 'Tanggal Isolir') %></label>
<select class="fs" id="as_isolate">
<option value="0"><%= t('admin.automation.all_dates', 'Semua') %></option>
<% (isolateDays||[]).forEach(function(d){ %><option value="<%= d %>">Tgl <%= d %></option><% }); %>
</select>
</div>
<div class="fg"><label class="fl"><i class="bi bi-chat-left-quote"></i> <%= t('admin.automation.manual_template', 'Template') %></label>
<select class="fs" id="as_tpl">
<% Object.keys(billingTemplates||{}).forEach(function(k){ %><option value="<%= k %>"><%= billingTemplates[k] %></option><% }); %>
</select>
</div>
<div class="fg"><label class="fl"><i class="bi bi-clock-history"></i> <%= t('admin.automation.manual_interval', 'Interval') %></label>
<select class="fs" id="as_mode" onchange="asToggle()">
<option value="static"><%= t('admin.automation.interval_static', 'Statis') %></option>
<option value="random"><%= t('admin.automation.interval_random', 'Random (anti-ban)') %></option>
</select>
</div>
<div class="fg" id="asStatic"><label class="fl"><%= t('admin.automation.delay_short', 'Jeda (detik)') %></label>
<input class="fc" type="number" id="as_val" value="15" min="3" max="600">
</div>
<div class="fg" id="asRandom" style="display:none"><label class="fl"><%= t('admin.automation.delay_random', 'Jeda acak (min–max detik)') %></label>
<div style="display:flex;gap:6px">
<input class="fc" type="number" id="as_min" value="15" min="3" max="600">
<input class="fc" type="number" id="as_max" value="40" min="3" max="900">
</div>
</div>
</div>
<div class="hint"><i class="bi bi-info-circle"></i> <%= t('admin.automation.manual_hint', 'Kirim ke pelanggan belum bayar pada tanggal isolir terpilih. Bisa dipakai meski pengingat otomatis OFF. Gunakan Random agar tidak dianggap spam.') %></div>
</div>
<div class="card-bd" style="border-top:1px solid var(--border);background:var(--bg2);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<div id="as_result" style="font-size:12px" class="text-muted"></div>
<div style="display:flex;gap:8px">
<button type="button" class="btn btn-g" onclick="asStop()"><i class="bi bi-stop-circle"></i> <%= t('admin.automation.stop', 'Hentikan') %></button>
<button type="button" class="btn btn-p" onclick="asStart(this)"><i class="bi bi-send"></i> <%= t('admin.automation.manual_send', 'Kirim Tagihan Sekarang') %></button>
</div>
</div>
<div class="card-bd" id="as_progress" style="display:none;border-top:1px solid var(--border)">
<div style="font-weight:700;font-size:13px" id="as_progress_text"></div>
<div style="height:8px;background:var(--bg);border-radius:999px;overflow:hidden;margin-top:8px"><div id="as_bar" style="height:100%;width:0;background:var(--primary);transition:width .3s"></div></div>
</div>
</div>
</div>
<!-- ===================== RIWAYAT ===================== -->
<div class="au-panel" id="au-riwayat">
<div class="card mb16">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<h6 style="margin:0"><i class="bi bi-clock-history"></i> <%= t('admin.automation.history_title', 'Riwayat Otomatisasi') %></h6>
<a class="btn btn-g btn-sm" href="/admin/automation"><i class="bi bi-arrow-clockwise"></i> <%= t('common.refresh', 'Refresh') %></a>
</div>
<div class="card-bd">
<div class="tbl-wrap">
<table class="dtbl">
<thead><tr>
<th><%= t('common.time', 'Waktu') %></th>
<th><%= t('admin.automation.col_type', 'Tipe') %></th>
<th><%= t('admin.automation.col_template', 'Template') %></th>
<th><%= t('admin.automation.col_isolate', 'Isolir') %></th>
<th><%= t('admin.automation.col_interval', 'Jeda') %></th>
<th><%= t('admin.automation.col_target', 'Target') %></th>
<th><%= t('admin.automation.col_sent', 'Kirim') %></th>
<th><%= t('admin.automation.col_failed', 'Gagal') %></th>
<th><%= t('admin.automation.col_status', 'Status') %></th>
</tr></thead>
<tbody>
<% (runs||[]).forEach(function(r){ %>
<tr>
<td class="muted" style="font-size:12px"><%= r.started_at || '-' %></td>
<td><span class="ew-svc"><%= r.type==='auto_reminder' ? t('admin.automation.type_auto','Otomatis') : t('admin.automation.type_manual','Manual') %></span></td>
<td class="mono" style="font-size:11px"><%= r.template_key || '-' %></td>
<td><%= r.isolate_day ? ('Tgl ' + r.isolate_day) : t('admin.automation.all_dates','Semua') %></td>
<td style="font-size:11.5px"><%= r.interval_label || '-' %></td>
<td><%= r.target||0 %></td>
<td style="color:var(--success);font-weight:800"><%= r.sent||0 %></td>
<td style="color:var(--danger);font-weight:800"><%= r.failed||0 %></td>
<td><span class="ew-badge <%= r.status==='done'?'ew-green':(r.status==='running'?'ew-amber':'ew-gray') %>"><%= r.status %></span></td>
</tr>
<% }); %>
<% if(!runs || !runs.length){ %><tr><td colspan="9" class="text-center text-muted"><%= t('admin.automation.no_history', 'Belum ada riwayat otomatisasi.') %></td></tr><% } %>
</tbody>
</table>
</div>
<div class="hint" style="margin-top:10px"><i class="bi bi-info-circle"></i> <%= t('admin.automation.history_hint', 'Riwayat 30 aktivitas terakhir (manual & otomatis): template, jumlah terkirim, gagal, dan status.') %></div>
</div>
</div>
</div>
<!-- ===================== TEMPLATE & PREVIEW ===================== -->
<div class="au-panel" id="au-templates">
<div class="card mb16">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<h6 style="margin:0"><i class="bi bi-chat-left-quote"></i> <%= t('admin.automation.mapping_title', 'Template yang Dipakai Tiap Otomatisasi') %></h6>
<div style="display:flex;gap:8px">
<a class="btn btn-g btn-sm" href="/admin/whatsapp/templates"><i class="bi bi-pencil-square"></i> <%= t('admin.automation.edit_templates', 'Edit Template') %></a>
<a class="btn btn-p btn-sm" href="/admin/whatsapp/preview"><i class="bi bi-eye"></i> <%= t('admin.automation.preview', 'Preview') %></a>
</div>
</div>
<div class="card-bd">
<div class="tbl-wrap">
<table class="dtbl au-map">
<thead><tr><th><%= t('admin.automation.col_event', 'Otomatisasi') %></th><th><%= t('admin.automation.col_template', 'Template') %></th><th style="width:150px"></th></tr></thead>
<tbody>
<tr>
<td><i class="bi bi-alarm"></i> <%= t('admin.automation.map_reminder', 'Pengingat tagihan (CRON)') %></td>
<td class="mono" style="font-size:11.5px"><%= getSetting('auto_billing_template_key', 'whatsapp_auto_billing_message') %></td>
<td><a class="btn btn-g btn-sm" href="/admin/whatsapp/templates"><i class="bi bi-pencil"></i> <%= t('common.edit','Edit') %></a></td>
</tr>
<tr>
<td><i class="bi bi-exclamation-triangle"></i> <%= t('admin.automation.map_arrears', 'Peringatan tunggakan') %></td>
<td class="mono" style="font-size:11.5px">whatsapp_arrears_warning_message</td>
<td><a class="btn btn-g btn-sm" href="/admin/whatsapp/templates"><i class="bi bi-pencil"></i> <%= t('common.edit','Edit') %></a></td>
</tr>
<tr>
<td><i class="bi bi-person-x"></i> <%= t('admin.automation.map_deactivated', 'Auto-Nonaktif pelanggan') %></td>
<td class="mono" style="font-size:11.5px">whatsapp_deactivated_message</td>
<td><a class="btn btn-g btn-sm" href="/admin/whatsapp/templates"><i class="bi bi-pencil"></i> <%= t('common.edit','Edit') %></a></td>
</tr>
<tr>
<td><i class="bi bi-trash3"></i> <%= t('admin.automation.map_deleted', 'Auto-Hapus pelanggan') %></td>
<td class="mono" style="font-size:11.5px">whatsapp_deleted_message</td>
<td><a class="btn btn-g btn-sm" href="/admin/whatsapp/templates"><i class="bi bi-pencil"></i> <%= t('common.edit','Edit') %></a></td>
</tr>
<tr>
<td><i class="bi bi-check2-circle"></i> <%= t('admin.automation.map_lunas', 'Auto-Lunas') %></td>
<td class="mono" style="font-size:11.5px">whatsapp_autolunas_message</td>
<td><a class="btn btn-g btn-sm" href="/admin/whatsapp/templates"><i class="bi bi-pencil"></i> <%= t('common.edit','Edit') %></a></td>
</tr>
<tr>
<td><i class="bi bi-qr-code"></i> <%= t('admin.automation.map_qris', 'Tagihan QRIS (lampiran QR)') %></td>
<td class="mono" style="font-size:11.5px">whatsapp_billing_qris_message</td>
<td><a class="btn btn-g btn-sm" href="/admin/whatsapp/templates"><i class="bi bi-pencil"></i> <%= t('common.edit','Edit') %></a></td>
</tr>
</tbody>
</table>
</div>
<div class="hint" style="margin-top:10px"><i class="bi bi-info-circle"></i> <%= t('admin.automation.mapping_hint', 'Ganti isi pesan di menu template, lalu cek hasilnya di Preview Pesan.') %></div>
</div>
</div>
</div>
</div>
</div>
<script>
function auTab(name){
document.querySelectorAll('.au-panel').forEach(function(p){ p.classList.toggle('active', p.id === 'au-' + name); });
document.querySelectorAll('.au-tab').forEach(function(t){ t.classList.toggle('active', t.getAttribute('data-tab') === name); });
}
async function runReminderNow(btn){
if (!confirm('Jalankan pengingat tagihan sekarang?')) return;
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menjalankan...';
try {
var r = await fetch('/admin/whatsapp/run-reminder-now', { method: 'POST' });
var j = await r.json().catch(function(){ return {}; });
alert(j.message || j.text || 'Selesai.');
} catch (e) { alert('Gagal: ' + e.message); }
finally { btn.disabled = false; btn.innerHTML = old; }
}
var auTplPrev = null;
document.addEventListener('DOMContentLoaded', function(){
var s = document.getElementById('au_tpl');
if (s) auTplPrev = s.value;
var ta = document.getElementById('au_msg');
if (ta) ta.addEventListener('input', function(){ ta.dataset.dirty = '1'; });
});
async function auLoadTemplate(){
var sel = document.getElementById('au_tpl');
var ta = document.getElementById('au_msg');
var lbl = document.getElementById('au_msg_label');
if (!sel || !ta) return;
if (auTplPrev === null) auTplPrev = sel.value;
if (sel.value === auTplPrev) return;
if (ta.dataset.dirty === '1' && !confirm('Muat template terpilih dan buang perubahan yang belum disimpan?')) {
sel.value = auTplPrev;
return;
}
try {
var r = await fetch('/admin/automation/template?key=' + encodeURIComponent(sel.value));
var j = await r.json();
ta.value = j.text || '';
ta.dataset.dirty = '0';
if (lbl) lbl.textContent = j.label || sel.value;
} catch (e) { /* diamkan */ }
auTplPrev = sel.value;
}
window.runAutoDeactivate = function () {
if (!confirm('Jalankan auto-nonaktif sekarang? Pelanggan yang menunggak sesuai batas akan diisolir.')) return;
fetch('/admin/customers/auto-deactivate-now', { method: 'POST' })
.then(function (r) { return r.json(); })
.then(function (d) { alert(d.message || 'Selesai'); if (d.processed > 0) location.reload(); })
.catch(function () { alert('Gagal menjalankan auto-nonaktif'); });
};
function asToggle(){
var m = (document.getElementById('as_mode') || {}).value;
document.getElementById('asStatic').style.display = m === 'static' ? '' : 'none';
document.getElementById('asRandom').style.display = m === 'random' ? '' : 'none';
}
async function asStart(btn){
if (!confirm('Kirim tagihan sekarang ke pelanggan belum bayar pada tanggal isolir terpilih?')) return;
var res = document.getElementById('as_result');
var old = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Memulai...';
if (res) { res.textContent = 'Memulai pengiriman…'; res.style.color = 'var(--muted)'; }
try {
var body = new URLSearchParams({
isolate_day: document.getElementById('as_isolate').value,
template_key: document.getElementById('as_tpl').value,
interval_mode: document.getElementById('as_mode').value,
interval_value: document.getElementById('as_val').value,
interval_min: document.getElementById('as_min').value,
interval_max: document.getElementById('as_max').value
});
var r = await fetch('/admin/automation/send-now', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: body.toString() });
var j = await r.json().catch(function(){ return {}; });
if (res) { res.textContent = j.ok ? (j.message || 'Berhasil.') : ('Gagal — ' + (j.message || 'Terjadi kesalahan.')); res.style.color = j.ok ? 'var(--success)' : 'var(--danger)'; }
if (j.ok) { document.getElementById('as_progress').style.display = 'block'; asPoll(); }
} catch (e) {
if (res) { res.textContent = 'Gagal — ' + e.message; res.style.color = 'var(--danger)'; }
} finally { btn.disabled = false; btn.innerHTML = old; }
}
async function asStop(){ try { await fetch('/admin/api/whatsapp/billing-send-stop', { method: 'POST' }); } catch (e) {} }
async function asPoll(){
try {
var r = await fetch('/admin/api/whatsapp/billing-send-status');
var s = await r.json();
if (!s || (!s.running && !s.total)) return;
document.getElementById('as_progress').style.display = 'block';
var pct = s.total ? Math.round(((s.sent + s.failed) / s.total) * 100) : 0;
document.getElementById('as_bar').style.width = pct + '%';
document.getElementById('as_progress_text').textContent = (s.running ? 'Mengirim…' : 'Selesai') + ' ' + s.sent + '/' + s.total + ' terkirim, ' + s.failed + ' gagal' + (s.last ? (' • terakhir: ' + s.last) : '') + (s.label ? (' • ' + s.label) : '');
} catch (e) {}
}
setInterval(function(){ if (document.getElementById('au-manual') && document.getElementById('au-manual').classList.contains('active')) asPoll(); }, 3000);
</script>
</body>
</html>
+242
View File
@@ -0,0 +1,242 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.backup-actions { display: flex; gap: 8px; }
.badge-database { background: var(--sdim); color: var(--success); }
.badge-settings { background: var(--wdim); color: var(--warning); }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: activePage || 'backup', company }) %>
<div class="mw">
<!-- TOPBAR -->
<div class="topbar">
<div class="tb-title">
<button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button>
<i class="bi bi-hdd-stack"></i>
<span id="page-title">Backup & Recovery</span>
</div>
<div class="tb-right">
<span class="adm-badge">
<i class="bi bi-person-fill"></i>
<%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<!-- CONTENT -->
<div class="page">
<!-- Alert Message -->
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'danger' ? 'd' : msg.type === 'success' ? 's' : msg.type === 'warning' ? 'w' : 'i' %> mb16" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-triangle' %>"></i>
<%= msg.text %>
</div>
<% } %>
<!-- Stats Grid -->
<div class="stat-grid">
<div class="sc p">
<i class="bi bi-files sc-icon"></i>
<div class="sc-label">Total Backup</div>
<div class="sc-val"><%= total %></div>
<div class="sc-sub">File tersimpan</div>
</div>
<div class="sc s">
<i class="bi bi-database sc-icon"></i>
<div class="sc-label">Database</div>
<div class="sc-val"><%= backups.filter(b => b.type === 'database').length %></div>
<div class="sc-sub">Backup database</div>
</div>
<div class="sc w">
<i class="bi bi-gear sc-icon"></i>
<div class="sc-label">Settings</div>
<div class="sc-val"><%= backups.filter(b => b.type === 'settings').length %></div>
<div class="sc-sub">Backup pengaturan</div>
</div>
<div class="sc i">
<i class="bi bi-hdd sc-icon"></i>
<div class="sc-label">Total Size</div>
<div class="sc-val"><%= (backups.reduce((sum, b) => sum + b.sizeKB, 0) / 1024).toFixed(1) %><span style="font-size:14px"> MB</span></div>
<div class="sc-sub">Kapasitas digunakan</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px; margin-bottom: 16px;">
<!-- Create Backup -->
<div class="card">
<div class="card-hd"><h6><i class="bi bi-plus-circle"></i> Buat Backup Baru</h6></div>
<div class="card-bd">
<form action="/admin/backup/create" method="POST">
<div class="fg">
<label class="fl">Tipe Backup</label>
<select class="fs" name="type">
<option value="all">Semua (Database + Settings)</option>
<option value="database">Database Saja</option>
<option value="settings">Settings Saja</option>
</select>
</div>
<button type="submit" class="btn btn-p w-100">
<i class="bi bi-cloud-upload"></i> Mulai Backup
</button>
</form>
</div>
</div>
<!-- Upload & Restore -->
<div class="card">
<div class="card-hd"><h6><i class="bi bi-upload"></i> Upload & Restore Backup</h6></div>
<div class="card-bd">
<form action="/admin/backup/upload-restore" method="POST" enctype="multipart/form-data" onsubmit="return confirm('PERINGATAN: Memulihkan file backup akan menimpa data/pengaturan aplikasi saat ini. Sistem akan membuat backup otomatis sebelum restore. Lanjutkan?')">
<div class="fg">
<label class="fl">Pilih File Backup (.db / .json)</label>
<input type="file" class="fc" name="backupFile" accept=".db,.sqlite,.json" required style="padding: 6px 10px;">
<small class="text-muted" style="font-size: 10px; display: block; margin-top: 4px;">File .db (database) atau .json (settings)</small>
</div>
<button type="submit" class="btn btn-s w-100">
<i class="bi bi-cloud-arrow-up"></i> Upload & Restore
</button>
</form>
</div>
</div>
<!-- Cleanup -->
<div class="card">
<div class="card-hd"><h6><i class="bi bi-trash"></i> Pembersihan Otomatis</h6></div>
<div class="card-bd">
<form action="/admin/backup/cleanup" method="POST">
<div class="fg">
<label class="fl">Retensi (Hari)</label>
<input type="number" class="fc" name="retentionDays" value="30" min="1" max="365">
<small class="text-muted" style="font-size: 10px; display: block; margin-top: 4px;">Backup lebih lama dari periode ini akan dihapus</small>
</div>
<button type="submit" class="btn btn-w w-100">
<i class="bi bi-stars"></i> Bersihkan Sekarang
</button>
</form>
</div>
</div>
</div>
<!-- Backup List -->
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-ul"></i> Riwayat Backup</h6>
</div>
<div class="card-bd">
<% if (backups.length === 0) { %>
<div class="text-center py-5">
<i class="bi bi-inbox text-muted" style="font-size: 40px; opacity: 0.3;"></i>
<p class="text-muted mt-2">Belum ada file backup tersedia</p>
</div>
<% } else { %>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>Nama File</th>
<th><%= t('common.type','Tipe') %></th>
<th class="num">Ukuran</th>
<th>Tanggal Dibuat</th>
<th class="text-right"><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% backups.forEach(backup => { %>
<tr>
<td>
<div class="flex ai-c gap">
<i class="bi bi-file-earmark-zip text-primary"></i>
<span><%= backup.fileName %></span>
</div>
</td>
<td>
<span class="badge <%= backup.type === 'database' ? 'bs' : 'bw' %>">
<%= backup.type === 'database' ? 'Database' : 'Settings' %>
</span>
</td>
<td class="num"><%= backup.sizeKB %> KB</td>
<td>
<% if (backup.createdDate) { %>
<%= backup.createdDate.toLocaleString('id-ID', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}) %>
<% } else { %>
-
<% } %>
</td>
<td style="white-space:nowrap">
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<a class="act-item primary" href="/admin/backup/download/<%= encodeURIComponent(backup.fileName) %>" download><i class="bi bi-download"></i> Download</a>
<button type="button" class="act-item success" onclick="restoreBackup('<%= backup.fileName %>', '<%= backup.type %>')"><i class="bi bi-arrow-counterclockwise"></i> Restore</button>
<div class="act-sep"></div>
<button type="button" class="act-item danger" onclick="deleteBackup('<%= backup.fileName %>')"><i class="bi bi-trash"></i> Hapus Backup</button>
</div>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
</div>
</div>
</div>
</div>
<script>
function restoreBackup(fileName, type) {
if (!confirm('Apakah Anda yakin ingin merestore backup ini? Data saat ini akan ditimpa.')) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/backup/restore';
form.innerHTML = `
<input type="hidden" name="fileName" value="${fileName}">
<input type="hidden" name="type" value="${type}">
`;
document.body.appendChild(form);
form.submit();
}
function deleteBackup(fileName) {
if (!confirm('Apakah Anda yakin ingin menghapus backup ini?')) {
return;
}
const form = document.createElement('form');
form.method = 'POST';
form.action = '/admin/backup/delete';
form.innerHTML = `
<input type="hidden" name="fileName" value="${fileName}">
`;
document.body.appendChild(form);
form.submit();
}
function toggleSidebar() {
var sb = document.getElementById('sidebar') || document.querySelector('.sidebar');
var ov = document.getElementById('sidebarOverlay');
if (sb) sb.classList.toggle('open');
if (ov) ov.classList.toggle('show');
}
</script>
</body>
</html>
+559
View File
@@ -0,0 +1,559 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.billing.title', 'Tagihan') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.filter-bar{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:14px 18px;margin-bottom:18px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.paid-row td{opacity:.7}
.void-row td{opacity:.5}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'billing', 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-receipt"></i> <%= t('admin.billing.manage_title', 'Manajemen Tagihan') %></div>
<div class="tb-right">
<button class="btn btn-p btn-sm" onclick="openModal('genModal')"><i class="bi bi-lightning-charge"></i> <%= t('admin.billing.generate', 'Generate Tagihan') %></button>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<!-- Summary Cards -->
<div class="stat-grid" style="grid-template-columns:repeat(4,1fr)">
<div class="sc p"><i class="bi bi-receipt sc-icon"></i><div class="sc-label"><%= t('admin.billing.summary.total', 'Total Tagihan') %></div><div class="sc-val"><%= summary.total.count||0 %></div><div class="sc-sub">Rp <%= (summary.total.total||0).toLocaleString('id-ID') %></div></div>
<div class="sc s"><i class="bi bi-check-circle sc-icon"></i><div class="sc-label"><%= t('admin.billing.summary.paid', 'Sudah Bayar') %></div><div class="sc-val"><%= summary.paid.count||0 %></div><div class="sc-sub">Rp <%= (summary.paid.total||0).toLocaleString('id-ID') %></div></div>
<div class="sc d"><i class="bi bi-hourglass sc-icon"></i><div class="sc-label"><%= t('admin.billing.summary.unpaid', 'Belum Bayar') %></div><div class="sc-val"><%= summary.unpaid.count||0 %></div><div class="sc-sub">Rp <%= (summary.unpaid.total||0).toLocaleString('id-ID') %></div></div>
<div class="sc w"><i class="bi bi-percent sc-icon"></i><div class="sc-label"><%= t('admin.billing.summary.collected_pct', '% Terkumpul') %></div><div class="sc-val"><%= summary.total.count>0 ? Math.round((summary.paid.count/summary.total.count)*100) : 0 %>%</div></div>
</div>
<!-- Filter -->
<form method="GET" action="/admin/billing" class="filter-bar">
<div class="sinput" style="max-width:340px">
<i class="bi bi-search"></i>
<input class="fc" name="search" placeholder="<%= t('admin.billing.search_placeholder', 'Cari nama / telepon...') %>" value="<%= search %>">
</div>
<select class="fs" name="month" style="width:130px">
<option value=""><%= t('admin.billing.all_months', 'Semua Bulan') %></option>
<% const monthNames = (lang === 'en')
? ['January','February','March','April','May','June','July','August','September','October','November','December']
: ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
for(let m=1;m<=12;m++){%>
<option value="<%= m %>" <%= filterMonth==m?'selected':'' %>><%= monthNames[m-1] %></option>
<% } %>
</select>
<select class="fs" name="year" style="width:100px">
<% for(let y=new Date().getFullYear(); y>=2023; y--){ %>
<option value="<%= y %>" <%= filterYear==y?'selected':'' %>><%= y %></option>
<% } %>
</select>
<select class="fs" name="status" style="width:130px">
<option value="all" <%= filterStatus==='all'?'selected':'' %>><%= t('admin.billing.all_status', 'Semua Status') %></option>
<option value="unpaid" <%= filterStatus==='unpaid'?'selected':'' %>><%= t('admin.billing.unpaid', 'Belum Bayar') %></option>
<option value="paid" <%= filterStatus==='paid'?'selected':'' %>><%= t('admin.billing.paid', 'Sudah Bayar') %></option>
<option value="void" <%= filterStatus==='void'?'selected':'' %>><%= t('admin.bill.st_void','Hangus') %></option>
</select>
<select class="fs" name="package_id" style="width:170px">
<option value="">Semua Paket</option>
<% (typeof packages !== 'undefined' ? packages : []).forEach(p => { %><option value="<%= p.id %>" <%= String(filterPackage)===String(p.id)?'selected':'' %>><%= p.name %></option><% }) %>
</select>
<select class="fs" name="area" style="width:150px">
<option value="">Semua Area</option>
<% (typeof areas !== 'undefined' ? areas : []).forEach(a => { %><option value="<%= a %>" <%= filterArea===a?'selected':'' %>><%= a %></option><% }) %>
</select>
<select class="fs" name="router_id" style="width:150px">
<option value="">Semua Router</option>
<% (typeof routers !== 'undefined' ? routers : []).forEach(r => { %><option value="<%= r.id %>" <%= String(filterRouter)===String(r.id)?'selected':'' %>><%= r.name %></option><% }) %>
</select>
<select class="fs" name="collector_id" style="width:160px">
<option value="">Semua Kolektor</option>
<% (typeof collectors !== 'undefined' ? collectors : []).forEach(k => { %><option value="<%= k.id %>" <%= String(filterCollector)===String(k.id)?'selected':'' %>><%= k.name %></option><% }) %>
</select>
<button class="btn btn-g btn-sm" type="submit"><i class="bi bi-funnel"></i> <%= t('common.filter', 'Filter') %></button>
<a href="/admin/billing" class="btn btn-g btn-sm"><i class="bi bi-x"></i> <%= t('common.reset', 'Reset') %></a>
</form>
<!-- Print Header (Only visible on paper print) -->
<div class="print-only" style="margin-bottom:14px;border-bottom:2px solid #0f172a;padding-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h2 style="font-size:16pt;font-weight:800;text-transform:uppercase;margin:0"><%= company %></h2>
<div style="font-size:10pt;color:#334155"><%= t('admin.billing.manage_title', 'Rekapitulasi Tagihan Pelanggan') %> <%= t('admin.bill.period_label','• Periode:') %> <%= filterMonth ? (monthNames[filterMonth-1] + ' ' + filterYear) : ('Tahun ' + filterYear) %></div>
</div>
<div style="text-align:right;font-size:8.5pt;color:#475569">
<div><%= t('admin.cust.printed_at','Dicetak:') %> <%= getNowLocal() %></div>
<div>Total Tagihan: <strong><%= invoices.length %> Transaksi</strong></div>
</div>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-hd">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;width:100%">
<h6 style="margin:0"><i class="bi bi-list-ul"></i> <%= t('admin.billing.list', 'Daftar Tagihan') %> (<%= invoices.length %>)</h6>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<button type="button" class="btn btn-g btn-sm" onclick="window.print()" title="<%= t('admin.bill.tt_print_recap','Cetak Rekap Tagihan') %>"><i class="bi bi-printer"></i> <%= t('admin.bill.btn_print_recap','Cetak Rekap') %></button>
<span class="text-muted small" id="bulkCount">0 dipilih</span>
<button type="button" class="btn btn-s btn-sm" id="bulkPayBtn" disabled><i class="bi bi-check-lg"></i> <%= t('admin.bill.btn_mass_pay','Bayar Massal') %></button>
<button type="button" class="btn btn-d btn-sm" id="bulkDeleteBtn" disabled><i class="bi bi-trash"></i> <%= t('admin.bill.btn_mass_delete','Hapus Massal') %></button>
</div>
</div>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th style="width:34px"><input type="checkbox" id="inv_all" style="width:16px;height:16px"></th>
<th>#</th>
<th><%= t('admin.billing.table.customer', 'Pelanggan') %></th>
<th><%= t('admin.billing.table.period', 'Periode') %></th>
<th><%= t('admin.billing.table.package', 'Paket') %></th>
<th class="num"><%= t('admin.billing.table.amount', 'Jumlah') %></th>
<th><%= t('admin.billing.table.status', 'Status') %></th>
<th><%= t('admin.billing.table.paid_by', 'Dibayar Oleh') %></th>
<th><%= t('admin.billing.table.paid_time', 'Waktu Bayar') %></th>
<th><%= t('admin.billing.table.actions', 'Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (invoices.length===0) { %>
<tr><td colspan="10" class="text-center text-muted" style="padding:32px"><%= t('admin.billing.empty', 'Tidak ada tagihan. Generate tagihan bulanan terlebih dahulu.') %></td></tr>
<% } %>
<% invoices.forEach((inv,i)=>{ %>
<tr class="<%= inv.status==='void'?'void-row':(inv.status==='paid'?'paid-row':'') %>">
<td>
<input
type="checkbox"
class="inv_sel"
style="width:16px;height:16px"
data-id="<%= inv.id %>"
data-status="<%= String(inv.status || '') %>"
data-customer-id="<%= inv.customer_id %>"
data-customer-name="<%= String(inv.customer_name || '').replace(/\"/g, '&quot;') %>"
data-period-month="<%= inv.period_month %>"
data-period-year="<%= inv.period_year %>"
data-amount="<%= Number(inv.amount || 0) %>"
data-package="<%= String(inv.package_name || '-').replace(/\"/g, '&quot;') %>"
>
</td>
<td class="text-muted"><%= i+1 %></td>
<td>
<div class="fw6"><%= inv.customer_name %></div>
<div style="font-size:11px;color:var(--muted)"><%= inv.customer_phone||inv.genieacs_tag||'' %></div>
</td>
<td>
<% const mns = (lang === 'en')
? ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
: ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des']; %>
<span class="badge bm"><%= mns[inv.period_month-1] %> <%= inv.period_year %></span>
<% if (inv.status === 'unpaid' && inv.isolate_day) { %><div style="font-size:10px;color:var(--muted);margin-top:4px">Jatuh tempo tgl <%= inv.isolate_day %></div><% } %>
</td>
<td style="font-size:12px;color:var(--muted)"><%= inv.package_name||'-' %></td>
<td class="num fw6" style="color:var(--success)">
<% if (Number(inv.discount_amount||0) > 0) { %>
<div style="font-size:10px;color:var(--muted);text-decoration:line-through">Rp <%= Number(inv.amount_before_discount||0).toLocaleString('id-ID') %></div>
<% } %>
<div>Rp <%= inv.amount.toLocaleString('id-ID') %></div>
<% if (Number(inv.discount_amount||0) > 0) { %>
<div style="font-size:10px;color:var(--danger);font-weight:700">Diskon <%= inv.discount_type==='percent' ? (inv.discount_value+'%') : '' %> -Rp <%= Number(inv.discount_amount).toLocaleString('id-ID') %></div>
<% } %>
<% if (inv.status === 'unpaid' && inv.qris_amount_unique) { %>
<div style="font-size:11px;color:var(--muted);margin-top:4px">
Nominal unik: <span class="mono" style="font-weight:600;color:var(--primary)">Rp <%= Number(inv.qris_amount_unique || inv.amount || 0).toLocaleString('id-ID') %></span>
<% if (inv.qris_unique_code) { %>
<span class="badge bm" style="margin-left:4px">Kode <%= String(inv.qris_unique_code).padStart(3, "0") %></span>
<% } %>
</div>
<% } %>
</td>
<td>
<% if(inv.status==='paid'){%><span class="badge bs"><i class="bi bi-check-circle-fill" style="font-size:9px"></i> <%= t('admin.billing.status.paid', 'Lunas') %></span>
<%}else if(inv.status==='void'){%><span class="badge bd" title="<%= inv.void_reason||'' %>"><i class="bi bi-x-octagon-fill" style="font-size:9px"></i> <%= t('admin.bill.st_void','Hangus') %></span>
<%}else{%><span class="badge bd"><i class="bi bi-clock-fill" style="font-size:9px"></i> <%= t('admin.billing.status.unpaid', 'Belum Bayar') %></span><%}%>
</td>
<td style="font-size:12px">
<div><%= inv.paid_by_name||'-' %></div>
<% if (inv.notes) { %>
<% const _pf = (String(inv.notes).match(/\/(?:admin\/media\/payment-proof|uploads\/payment_proofs)\/([A-Za-z0-9._-]+)/) || [])[1]; %>
<% const _nc = String(inv.notes).replace(/Bukti bayar:\s*\S+/gi, '').replace(/AUTO-QRIS:\s*cocok nominal unik Rp\s*([0-9.]+)[^\n]*/gi, 'QRIS otomatis tercocokkan Rp $1').replace(/https?:\/\/\S*\/uploads\/payment_proofs\/\S+/gi, '').replace(/[ \t]{2,}/g, ' ').replace(/\n{2,}/g, '\n').trim(); %>
<% if (_nc) { %><div style="font-size:10px;color:var(--muted)"><%= _nc %></div><% } %>
<% if (_pf) { %><a class="btn btn-g btn-icon btn-sm" style="margin-top:4px" title="<%= t('admin.bill.tt_view_proof','Lihat Bukti Bayar') %>" target="_blank" href="/admin/media/payment-proof/<%= _pf %>"><i class="bi bi-image"></i> Lihat Bukti</a><% } %>
<% } %>
</td>
<td style="font-size:11px;color:var(--muted)"><%= inv.paid_at ? new Date(inv.paid_at).toLocaleString('id-ID',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}) : '-' %></td>
<td style="white-space:nowrap">
<% if(inv.status==='unpaid'){%>
<form id="qa-<%= inv.id %>" method="POST" action="/admin/billing/<%= inv.id %>/qris-assign" style="display:none"></form>
<% if (inv.qris_amount_unique) { %>
<form id="qr-<%= inv.id %>" method="POST" action="/admin/billing/<%= inv.id %>/qris-assign?force=1" style="display:none" onsubmit="return confirm('Ganti Kode Unik tagihan ini?')"></form>
<form id="qc-<%= inv.id %>" method="POST" action="/admin/billing/<%= inv.id %>/qris-clear" style="display:none" onsubmit="return confirm('Hapus Kode Unik tagihan ini?')"></form>
<% } %>
<form id="uni-<%= inv.id %>" method="POST" action="/admin/customers/<%= inv.customer_id %>/unisolate" style="display:none" class="needs-confirm" data-confirm-key="confirmUnisolate"></form>
<form id="wa-<%= inv.id %>" method="POST" action="/admin/billing/<%= inv.id %>/whatsapp" style="display:none"></form>
<%}else{%>
<form id="upa-<%= inv.id %>" method="POST" action="/admin/billing/<%= inv.id %>/unpay" style="display:none" class="needs-confirm" data-confirm-key="confirmUnpay"></form>
<%}%>
<form id="iso-<%= inv.id %>" method="POST" action="/admin/customers/<%= inv.customer_id %>/isolate" style="display:none" class="needs-confirm" data-confirm-key="confirmIsolateManual"></form>
<form id="delb-<%= inv.id %>" method="POST" action="/admin/billing/<%= inv.id %>/delete" style="display:none" class="needs-confirm" data-confirm-key="confirmDelete"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<% if(inv.status==='unpaid'){%>
<button type="button" class="act-item disc-btn" data-id="<%= inv.id %>" data-name="<%= String(inv.customer_name||'').replace(/'/g,'&#39;') %>" data-amount="<%= Number(inv.amount||0) %>" data-period="<%= mns[inv.period_month-1] %> <%= inv.period_year %>"><i class="bi bi-percent"></i> <%= t('admin.bill.btn_discount','Beri Diskon') %></button>
<button type="button" class="act-item success pay-btn" data-id="<%= inv.id %>" data-customer-id="<%= inv.customer_id %>" data-name="<%= inv.customer_name.replace(/'/g, '&#39;') %>" data-period="<%= mns[inv.period_month-1] %> <%= inv.period_year %>"><i class="bi bi-check-lg"></i> Tandai Lunas</button>
<button type="submit" form="qa-<%= inv.id %>" class="act-item"><i class="bi bi-qr-code"></i> <%= t('admin.bill.btn_gen_unique','Buat Kode Unik QRIS') %></button>
<% if (inv.qris_amount_unique) { %>
<button type="submit" form="qr-<%= inv.id %>" class="act-item warn"><i class="bi bi-arrow-repeat"></i> Regenerate Kode Unik</button>
<button type="submit" form="qc-<%= inv.id %>" class="act-item danger"><i class="bi bi-x-lg"></i> <%= t('admin.bill.btn_del_unique','Hapus Kode Unik') %></button>
<% } %>
<button type="submit" form="uni-<%= inv.id %>" class="act-item primary"><i class="bi bi-wifi"></i> <%= t('admin.bill.btn_unisolate','Buka Isolir') %></button>
<button type="submit" form="wa-<%= inv.id %>" class="act-item"><i class="bi bi-whatsapp"></i> Kirim Tagihan WA</button>
<%}else{%>
<a class="act-item" href="/admin/billing/<%= inv.id %>/print" target="_blank"><i class="bi bi-printer"></i> <%= t('admin.bill.btn_print_a4','Cetak Faktur (A4)') %></a>
<a class="act-item" href="/admin/billing/<%= inv.id %>/print-thermal" target="_blank"><i class="bi bi-receipt"></i> <%= t('admin.bill.btn_print_thermal','Cetak Struk Thermal') %></a>
<a class="act-item" href="/admin/billing/<%= inv.id %>/pdf" target="_blank"><i class="bi bi-file-earmark-pdf"></i> <%= t('admin.bill.btn_invoice_pdf','Invoice PDF') %></a>
<% if (inv.customer_phone) { %>
<button type="button" class="act-item" onclick="sendInvoicePdfWa('<%= inv.id %>', this)"><i class="bi bi-file-earmark-arrow-up"></i> Kirim Invoice PDF (WA)</button>
<% } %>
<button type="submit" form="upa-<%= inv.id %>" class="act-item warn"><i class="bi bi-arrow-counterclockwise"></i> <%= t('admin.bill.btn_revert','Batalkan Lunas') %></button>
<%}%>
<div class="act-sep"></div>
<button type="submit" form="iso-<%= inv.id %>" class="act-item warn"><i class="bi bi-wifi-off"></i> Isolir Manual</button>
<button type="submit" form="delb-<%= inv.id %>" class="act-item danger"><i class="bi bi-trash"></i> <%= t('admin.bill.btn_del_bill','Hapus Tagihan') %></button>
</div>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<form id="bulkDeleteForm" method="POST" action="/admin/billing/delete-bulk" style="display:none"></form>
</div>
</div>
<!-- Modal Generate -->
<div class="mo" id="genModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-lightning-charge"></i> <%= t('admin.billing.generate_monthly', 'Generate Tagihan Bulanan') %></span><button class="mc" onclick="closeModal('genModal')">&times;</button></div>
<form method="POST" action="/admin/billing/generate">
<div class="mbody">
<div class="alert alert-i"><i class="bi bi-info-circle"></i> <%= t('admin.billing.generate_hint', 'Tagihan akan dibuat untuk semua pelanggan aktif yang belum memiliki tagihan di periode yang dipilih.') %></div>
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('admin.billing.month', 'Bulan') %></label>
<select class="fs" name="month" required>
<% const mns2 = (lang === 'en')
? ['January','February','March','April','May','June','July','August','September','October','November','December']
: ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
for(let m=1;m<=12;m++){%>
<option value="<%= m %>" <%= m===new Date().getMonth()+1?'selected':'' %>><%= mns2[m-1] %></option>
<% } %>
</select>
</div>
<div class="fg">
<label class="fl"><%= t('admin.billing.year', 'Tahun') %></label>
<select class="fs" name="year" required>
<% for(let y=new Date().getFullYear(); y>=2023; y--){ %>
<option value="<%= y %>"><%= y %></option>
<% } %>
</select>
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('genModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-lightning-charge"></i> <%= t('admin.billing.generate', 'Generate Tagihan') %></button>
</div>
</form>
</div>
</div>
<!-- Modal Diskon -->
<div class="mo" id="discModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-percent"></i> <%= t('admin.bill.btn_discount','Beri Diskon') %></span><button class="mc" onclick="closeModal('discModal')">&times;</button></div>
<form method="POST" id="discModalForm">
<div class="mbody">
<p class="text-muted" id="discModalDesc" style="margin-bottom:14px;font-size:13px"></p>
<div class="form-row">
<div class="fg">
<label class="fl">Tipe Diskon</label>
<select class="fs" name="discount_type" id="dmType">
<option value="percent">Persen (%)</option>
<option value="amount">Nominal (Rp)</option>
</select>
</div>
<div class="fg">
<label class="fl">Nilai</label>
<input class="fc" type="number" name="discount_value" id="dmValue" min="1" step="1" required>
</div>
</div>
<div class="fg"><label class="fl"><%= t('admin.bill.note_optional','Catatan (opsional)') %></label><input class="fc" name="note" placeholder="<%= t('admin.bill.ph_note','mis. kompensasi gangguan') %>"></div>
<div class="hint"><i class="bi bi-info-circle"></i> Persen dihitung dari harga dasar (sebelum PPN/USO). Kode unik QRIS akan digenerate ulang.</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('discModal')">Batal</button>
<button type="submit" class="btn btn-w"><i class="bi bi-percent"></i> Terapkan Diskon</button>
</div>
</form>
</div>
</div>
<!-- Modal Tandai Bayar -->
<div class="mo" id="payModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-cash-coin"></i> <%= t('admin.billing.actions.mark_paid', 'Tandai Lunas') %></span><button class="mc" onclick="closeModal('payModal')">&times;</button></div>
<form method="POST" id="payForm">
<div class="mbody">
<p class="text-muted" id="payDesc" style="margin-bottom:14px;font-size:13px"></p>
<div id="multiPaySection" style="display:none; margin-bottom: 20px; background: rgba(255,255,255,0.03); padding: 12px; border-radius: 8px; border: 1px solid var(--border);">
<label class="fl" style="margin-bottom:8px"><%= t('admin.billing.choose_invoice', 'Pilih Tagihan yang akan dilunasi:') %></label>
<div id="unpaidList" style="display:flex; flex-direction:column; gap:8px">
<!-- Dynamic list of checkboxes -->
</div>
</div>
<% const payByDefault = (typeof session !== 'undefined' && session.isCashier)
? (`Kasir ${(session.cashierName || '').trim()}` + ((session.cashierUsername || '').trim() ? ` (@${String(session.cashierUsername).trim()})` : '')).trim()
: 'Admin'; %>
<div class="fg"><label class="fl">Metode Bayar</label><select class="fs" name="payment_method"><option value="Tunai">Tunai (Cash)</option><option value="Transfer Bank">Transfer Bank</option><option value="QRIS">QRIS</option><option value="E-Wallet">E-Wallet</option><option value="Lainnya">Lainnya</option></select></div>
<div class="fg"><label class="fl"><%= t('admin.billing.received_by', 'Diterima Oleh') %></label><input class="fc" name="paid_by_name" placeholder="<%= t('admin.billing.received_by_placeholder', 'Nama petugas / teknisi') %>" value="<%= payByDefault || 'Admin' %>"></div>
<div class="fg"><label class="fl"><%= t('admin.billing.notes_optional', 'Catatan (opsional)') %></label><textarea class="fc" name="notes" placeholder="<%= t('admin.billing.notes_placeholder', 'Transfer BRI, tunai, dll...') %>"></textarea></div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('payModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-s"><i class="bi bi-check-lg"></i> <%= t('admin.billing.confirm_paid', 'Konfirmasi Lunas') %></button>
</div>
</form>
</div>
</div>
<script>
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
document.querySelectorAll('.mo').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show')}));
const LANG = "<%= lang || 'id' %>";
const MONTHS = (LANG === "en")
? ['','Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
: ['','Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
const I18N = {
customerPrefix: "<%= t('admin.billing.js.customer_prefix', 'Pelanggan:') %>",
loadingOther: "<%= t('admin.billing.loading_other_invoices', 'Loading tagihan lainnya...') %>",
periodPrefix: "<%= t('admin.billing.table.period', 'Periode') %>",
packageFallback: "<%= t('admin.billing.js.package_fallback', 'Internet') %>",
confirmUnisolate: "<%= t('admin.billing.confirm.unisolate_without_payment', 'Buka isolir pelanggan ini tanpa bayar?') %>",
confirmUnpay: "<%= t('admin.billing.confirm.unpay', 'Batalkan status lunas?') %>",
confirmIsolateManual: "<%= t('admin.billing.confirm.isolate_manual', 'Isolir pelanggan ini secara manual?') %>",
confirmDelete: "<%= t('admin.billing.confirm.delete', 'Hapus tagihan ini?') %>"
};
document.addEventListener('submit', function(e) {
const form = e.target;
if (!(form instanceof HTMLFormElement)) return;
if (!form.classList.contains('needs-confirm')) return;
const key = String(form.dataset.confirmKey || '');
const msg = I18N[key] || 'Lanjutkan?';
if (!confirm(msg)) e.preventDefault();
});
// Handle "Tandai Lunas" via data-* attributes (avoid EJS/HTML quote conflict)
document.addEventListener('click', async function(e) {
const btn = e.target.closest('.pay-btn');
if (!btn) return;
const id = btn.dataset.id;
const customerId = btn.dataset.customerId;
const name = btn.dataset.name;
const period = btn.dataset.period;
const form = document.getElementById('payForm');
const multiSection = document.getElementById('multiPaySection');
const unpaidList = document.getElementById('unpaidList');
const payDesc = document.getElementById('payDesc');
payDesc.textContent = I18N.customerPrefix + ' ' + name;
unpaidList.innerHTML = '<div class="text-muted small">' + I18N.loadingOther + '</div>';
multiSection.style.display = 'block';
openModal('payModal');
try {
const res = await fetch('/admin/api/billing/unpaid/' + customerId);
const invoices = await res.json();
unpaidList.innerHTML = '';
if (invoices.length > 0) {
invoices.forEach(inv => {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.alignItems = 'center';
div.style.gap = '10px';
const isCurrent = inv.id == id;
div.innerHTML = `
<input type="checkbox" name="invoice_ids" value="${inv.id}" id="inv_${inv.id}" ${isCurrent ? 'checked' : ''} style="width:16px; height:16px">
<label for="inv_${inv.id}" style="font-size:13px; cursor:pointer">
${I18N.periodPrefix} ${(MONTHS[inv.period_month] || '')} ${inv.period_year}
<span class="text-muted">(${inv.package_name || I18N.packageFallback})</span> -
<strong style="color:var(--success)">Rp ${inv.amount.toLocaleString('id-ID')}</strong>
</label>
`;
unpaidList.appendChild(div);
});
form.action = '/admin/billing/pay-bulk';
} else {
multiSection.style.display = 'none';
form.action = '/admin/billing/' + id + '/pay';
}
} catch (err) {
console.error('Failed to load unpaid invoices:', err);
multiSection.style.display = 'none';
form.action = '/admin/billing/' + id + '/pay';
}
});
function getSelectedInvoices() {
return Array.from(document.querySelectorAll('.inv_sel:checked')).map(cb => cb.dataset || {});
}
function updateBulkUi() {
const list = getSelectedInvoices();
const countEl = document.getElementById('bulkCount');
const payBtn = document.getElementById('bulkPayBtn');
const delBtn = document.getElementById('bulkDeleteBtn');
if (countEl) countEl.textContent = `${list.length} dipilih`;
if (payBtn) payBtn.disabled = list.length === 0;
if (delBtn) delBtn.disabled = list.length === 0;
}
document.addEventListener('change', function(e) {
const all = document.getElementById('inv_all');
if (e.target && e.target.id === 'inv_all') {
const checked = Boolean(all && all.checked);
document.querySelectorAll('.inv_sel').forEach(cb => { cb.checked = checked; });
updateBulkUi();
return;
}
if (e.target && e.target.classList && e.target.classList.contains('inv_sel')) {
updateBulkUi();
}
});
document.getElementById('bulkPayBtn')?.addEventListener('click', function() {
const selected = getSelectedInvoices();
const unpaid = selected.filter(x => String(x.status || '').toLowerCase() === 'unpaid');
if (unpaid.length === 0) return alert('Pilih minimal 1 tagihan BELUM BAYAR.');
const form = document.getElementById('payForm');
const multiSection = document.getElementById('multiPaySection');
const unpaidList = document.getElementById('unpaidList');
const payDesc = document.getElementById('payDesc');
if (payDesc) payDesc.textContent = `Bayar massal: ${unpaid.length} tagihan`;
if (unpaidList) unpaidList.innerHTML = '';
if (multiSection) multiSection.style.display = 'block';
for (const inv of unpaid) {
const div = document.createElement('div');
div.style.display = 'flex';
div.style.alignItems = 'center';
div.style.gap = '10px';
const id = String(inv.id || '').trim();
const customerName = String(inv.customerName || '').trim();
const pkg = String(inv.package || '').trim();
const period = `${(MONTHS[Number(inv.periodMonth || 0)] || '')} ${String(inv.periodYear || '')}`.trim();
const amt = Number(inv.amount || 0) || 0;
div.innerHTML = `
<input type="checkbox" name="invoice_ids" value="${id}" id="bulk_inv_${id}" checked style="width:16px; height:16px">
<label for="bulk_inv_${id}" style="font-size:13px; cursor:pointer">
${customerName ? `<strong>${customerName}</strong> • ` : ''}${I18N.periodPrefix} ${period}
<span class="text-muted">(${pkg || I18N.packageFallback})</span> -
<strong style="color:var(--success)">Rp ${amt.toLocaleString('id-ID')}</strong>
</label>
`;
unpaidList.appendChild(div);
}
if (form) form.action = '/admin/billing/pay-bulk';
openModal('payModal');
});
document.getElementById('bulkDeleteBtn')?.addEventListener('click', function() {
const selected = getSelectedInvoices();
if (selected.length === 0) return;
if (!confirm(`Hapus ${selected.length} tagihan terpilih?`)) return;
const f = document.getElementById('bulkDeleteForm');
if (!f) return;
f.innerHTML = '';
for (const inv of selected) {
const id = String(inv.id || '').trim();
if (!id) continue;
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'invoice_ids';
input.value = id;
f.appendChild(input);
}
f.submit();
});
async function sendInvoicePdfWa(invoiceId, btn) {
if (!btn) return;
const oldHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i>';
try {
const res = await fetch(`/admin/billing/${invoiceId}/send-pdf-wa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
alert(' ' + data.message);
} else {
alert(' ' + data.message);
}
} catch (err) {
alert(' Error: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = oldHtml;
}
}
document.querySelectorAll('.disc-btn').forEach(function(btn){
btn.addEventListener('click', function(){
var id = btn.getAttribute('data-id');
var name = btn.getAttribute('data-name');
var amount = btn.getAttribute('data-amount');
var period = btn.getAttribute('data-period');
var f = document.getElementById('discModalForm');
if (!f) return;
f.action = '/admin/billing/' + id + '/discount';
document.getElementById('discModalDesc').textContent = name + ' — ' + period + ' (Rp ' + Number(amount || 0).toLocaleString('id-ID') + ')';
var v = document.getElementById('dmValue'); if (v) v.value = '';
openModal('discModal');
});
});
updateBulkUi();
setTimeout(()=>document.querySelectorAll('.alert').forEach(a=>a.style.display='none'),5000);
</script>
</body>
</html>
+277
View File
@@ -0,0 +1,277 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.broadcast.title', 'Broadcast WhatsApp') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.wa-tabs { display: flex; gap: 8px; margin-bottom: 14px; border-bottom: 1px solid var(--border); padding-bottom: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; white-space: nowrap; }
.wa-tab { padding: 8px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; flex-shrink: 0; }
.wa-tab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.wa-tab.active { background: var(--primary); color: white; }
@media (max-width: 768px) {
.wa-tab { padding: 6px 10px; font-size: 12px; }
.form-row, .grid-2 { grid-template-columns: 1fr !important; }
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: activePage || 'broadcast', 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-megaphone"></i> <%= t('admin.broadcast.title', 'Broadcast WhatsApp') %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% const waTab = 'broadcast'; %>
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="wa-tabs">
<a href="/admin/whatsapp" class="wa-tab <%= waTab==='status' ? 'active' : '' %>"><i class="bi bi-phone"></i> <%= t('admin.wa.tab_status','Status WhatsApp') %></a>
<a href="/admin/whatsapp/broadcast" class="wa-tab <%= waTab==='broadcast' ? 'active' : '' %>"><i class="bi bi-megaphone"></i> <%= t('admin.wa.tab_broadcast','Broadcast & Pengingat') %></a>
<a href="/admin/whatsapp/templates" class="wa-tab <%= waTab==='templates' ? 'active' : '' %>"><i class="bi bi-chat-left-quote"></i> <%= t('admin.wa.tab_templates','Template Pesan') %></a>
<a href="/admin/whatsapp/preview" class="wa-tab <%= waTab==='preview' ? 'active' : '' %>"><i class="bi bi-eye"></i> <%= t('admin.wa.tab_preview','Preview Pesan') %></a>
<a href="/admin/whatsapp/live-chat" class="wa-tab <%= waTab==='livechat' ? 'active' : '' %>"><i class="bi bi-chat-dots-fill"></i> <%= t('admin.wa.tab_livechat','Live Chat') %></a>
</div>
<div class="card">
<div class="card-hd" style="display: flex; justify-content: space-between; align-items: center;">
<h6><i class="bi bi-send-fill"></i> <%= t('admin.broadcast.send_mass', 'Kirim Pesan Massal') %></h6>
<div id="broadcastCounter" class="badge bp" style="display: none;">
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
<%= t('admin.broadcast.sending', 'Mengirim:') %> <span id="sentCount">0</span>/<span id="totalCount">0</span>
(<span id="failedCount" class="text-danger ms-1">0</span> <%= t('admin.broadcast.failed', 'Gagal') %>)
</div>
</div>
<form id="broadcastForm" method="POST" action="/admin/whatsapp/broadcast" data-confirm="<%= t('admin.broadcast.confirm_send', 'Kirim pesan broadcast sekarang? Pastikan target dan pesan sudah benar.') %>">
<div class="card-bd">
<div class="alert alert-i" style="margin-bottom: 14px; background: rgba(99,102,241,0.12); border: 1px solid rgba(99,102,241,0.3);">
<div style="font-weight: 800; margin-bottom: 4px; display: flex; align-items: center; gap: 6px; color: var(--primary);">
<i class="bi bi-shield-check" style="font-size: 16px;"></i> Anti-Banned Aktif (Spintax + Jeda Mengetik)
</div>
<div style="font-size: 12px; line-height: 1.5; color: var(--text);">
• <strong>Variasi Kata Spintax:</strong> Gunakan <code>{Halo|Selamat Pagi|Yth.}</code> agar isi pesan otomatis diacak & tidak dianggap spam.<br>
• <strong>Jeda Mengetik Manusiawi:</strong> Sebelum dikirim, sistem menunggu sejenak (menyesuaikan panjang pesan) meniru tempo menulis manusia.<br>
• <strong>Jeda Acak & Batching:</strong> Dikirim dengan jeda acak dinamis dan jeda istirahat otomatis antar batch.
</div>
</div>
<div class="form-row" style="margin-bottom: 15px;">
<div class="fg">
<label class="fl"><%= t('admin.broadcast.target', 'Target Penerima') %></label>
<select class="fs" name="target" id="targetSelect" required style="width: 100%;">
<option value="all"><%= t('admin.broadcast.target_all', 'Semua Pelanggan (Aktif & Isolir)') %></option>
<option value="active"><%= t('admin.broadcast.target_active', 'Hanya Pelanggan Aktif') %></option>
<option value="suspended"><%= t('admin.broadcast.target_suspended', 'Hanya Pelanggan Terisolir') %></option>
<option value="unpaid"><%= t('admin.broadcast.target_unpaid', 'Pelanggan yang Punya Tunggakan Tagihan') %></option>
</select>
</div>
<div class="fg">
<label class="fl"><%= t('admin.broadcast.delay', 'Jeda Antar Pesan (Detik)') %></label>
<input class="fc" type="number" name="delay" value="<%= getSetting('whatsapp_broadcast_delay', 2) %>" min="1" max="60">
</div>
</div>
<div class="alert alert-d alert-permanent" style="margin-bottom: 20px; border: 1px solid rgba(248,81,73,0.4);">
<i class="bi bi-exclamation-triangle-fill"></i> <strong><%= t('admin.broadcast.warning_title', 'PERINGATAN PENTING:') %></strong> <%= t('admin.broadcast.warning_body', 'Penggunaan fitur broadcast massal berisiko menyebabkan akun WhatsApp Anda DIBLOKIR (BANNED) oleh pihak Meta jika dianggap melanggar ketentuan spam. Pastikan pesan relevan bagi pelanggan. Developer aplikasi tidak bertanggung jawab atas segala risiko pemblokiran akun.') %>
</div>
<div class="form-row">
<div class="fg" style="width: 100%;">
<label class="fl"><%= t('admin.broadcast.message', 'Isi Pesan WhatsApp') %></label>
<textarea class="fc" name="message" id="messageBox" rows="8" required style="width: 100%;"></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;">
<i class="bi bi-info-circle"></i> <%= t('admin.broadcast.variables', 'Variabel:') %> <b>{{nama}}</b>, <b>{{tagihan}}</b>, <b>{{rincian}}</b>, <b>{{paket}}</b>, <b>{{link}}</b> (<%= t('admin.broadcast.portal_link', 'Link Portal') %>).
<br>
<i class="bi bi-chat-text"></i> <%= t('admin.broadcast.formatting', 'Gunakan *Tebal*, _Miring_, atau ~Coret~ sesuai standar WhatsApp.') %>
</div>
</div>
</div>
</div>
<div class="card-bd" style="border-top: 1px solid var(--border); background: var(--bg2); display: flex; justify-content: flex-end;">
<button type="submit" class="btn btn-p"><i class="bi bi-send"></i> <%= t('admin.broadcast.start', 'Mulai Broadcast') %></button>
</div>
</form>
</div>
<div class="card mb16">
<div class="card-bd">
<div class="hint" style="margin:0;padding:12px;border:1px dashed var(--border);border-radius:10px;background:var(--bg2)">
<i class="bi bi-robot"></i> <strong>Pengingat otomatis dipindah:</strong> pengaturan jadwal, pemilihan template &amp; isi pesan pengingat tagihan kini di menu <a href="/admin/automation" style="color:var(--primary);font-weight:800">Automation &rarr;</a>
</div>
</div>
</div>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-send-check"></i> Kirim Tagihan Sekarang (Manual — bila Auto OFF)</h6></div>
<form method="POST" action="/admin/whatsapp/send-billing-now" onsubmit="return confirm('Kirim tagihan sekarang ke pelanggan belum bayar?');">
<div class="card-bd">
<div style="display:flex;gap:14px;flex-wrap:wrap">
<div class="fg"><label class="fl"><i class="bi bi-calendar-event"></i> Tanggal Isolir</label>
<select class="fs" name="isolate_day">
<option value="0"><%= t('collector.filter_all','Semua') %></option>
<% (isolateDays||[]).forEach(function(d){ %><option value="<%= d %>">Tgl <%= d %></option><% }); %>
</select>
</div>
<div class="fg"><label class="fl"><i class="bi bi-chat-left-quote"></i> Template</label>
<select class="fs" name="template_key">
<% Object.keys(billingTemplates||{}).forEach(function(k){ %><option value="<%= k %>"><%= billingTemplates[k] %></option><% }); %>
</select>
</div>
<div class="fg"><label class="fl"><i class="bi bi-clock-history"></i> Interval</label>
<select class="fs" name="interval_mode" id="bsMode" onchange="bsToggle()">
<option value="static">Statis</option>
<option value="random">Random (anti-ban)</option>
</select>
</div>
<div class="fg" id="bsStatic"><label class="fl">Jeda (detik)</label>
<input class="fc" type="number" name="interval_value" value="15" min="3" max="600">
</div>
<div class="fg" id="bsRandom" style="display:none"><label class="fl">Jeda acak (min–max detik)</label>
<div style="display:flex;gap:6px">
<input class="fc" type="number" name="interval_min" value="15" min="3" max="600">
<input class="fc" type="number" name="interval_max" value="40" min="3" max="900">
</div>
</div>
</div>
<div style="font-size:11px;color:var(--muted);margin-top:6px"><i class="bi bi-info-circle"></i> Kirim ke pelanggan <b>belum bayar</b> pada tanggal isolir terpilih. Gunakan <b>Random</b> agar tidak dianggap spam/di-ban.</div>
</div>
<div class="card-bd" style="border-top:1px solid var(--border);background:var(--bg2);display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<div id="bsResult" style="font-size:12px" class="text-muted"></div>
<div style="display:flex;gap:8px">
<button type="button" class="btn btn-g" onclick="bsStop()"><i class="bi bi-stop-circle"></i> Hentikan</button>
<button type="submit" class="btn btn-p"><i class="bi bi-send"></i> Kirim Tagihan Sekarang</button>
</div>
</div>
</form>
<div class="card-bd" id="bsProgress" style="display:none;border-top:1px solid var(--border)">
<div style="font-weight:700;font-size:13px" id="bsProgressText"></div>
<div style="height:8px;background:var(--bg);border-radius:999px;overflow:hidden;margin-top:8px"><div id="bsBar" style="height:100%;width:0;background:var(--primary);transition:width .3s"></div></div>
</div>
</div>
</div>
</div>
<script>
const I18N = {
confirmChangeTarget: "<%= t('admin.broadcast.confirm_change_target', 'Ganti target penerima? Pesan yang sudah Anda ketik akan ditimpa dengan template baru.') %>",
sendingText: "<%= t('admin.broadcast.sending_btn', 'Sedang Mengirim...') %>",
donePrefix: "<%= t('admin.broadcast.done_prefix', 'Selesai:') %>",
sentSuffix: "<%= t('admin.broadcast.sent_suffix', 'dikirim') %>",
startBtn: "<%= t('admin.broadcast.start', 'Mulai Broadcast') %>"
};
const templates = {
all: `Yth. Pelanggan {{nama}},\n\nBersama ini kami informasikan bahwa akan ada pemeliharaan jaringan rutin pada:\nHari/Tanggal: ...\nPukul: ...\n\nSelama proses tersebut, layanan internet (Paket {{paket}}) akan terganggu sementara waktu. Kami memohon maaf atas ketidaknyamanan ini.\n\nCek status layanan di: {{link}}\n\nTerima kasih.\nSalam,\nAdmin <%= company %>`,
active: `Yth. Pelanggan Setia {{nama}},\n\nTerima kasih telah mempercayakan kebutuhan internet Anda kepada kami. Nikmati terus layanan terbaik Paket {{paket}} tanpa hambatan.\n\nKelola akun Anda di: {{link}}\n\nSalam,\nAdmin <%= company %>`,
suspended: `Yth. Pelanggan {{nama}},\n\nMohon maaf, layanan internet Anda saat ini sedang dinonaktifkan (Terisolir). \n\nTotal tunggakan Anda: *Rp {{tagihan}}* (Periode: {{rincian}}).\n\nSilakan bayar melalui portal kami: {{link}}\n\nSalam,\nAdmin <%= company %>`,
unpaid: `Yth. Pelanggan {{nama}},\n\nBerdasarkan data sistem kami, Anda memiliki tagihan internet yang *BELUM LUNAS*.\n\n📦 *Paket:* {{paket}}\n💰 *Total Tagihan:* Rp {{tagihan}}\n📅 *Periode:* {{rincian}}\n\nMohon segera melakukan pembayaran melalui portal pelanggan: {{link}}\n\nTerima kasih atas kerja samanya.\nSalam,\nAdmin <%= company %>`
};
const targetSelect = document.getElementById('targetSelect');
const messageBox = document.getElementById('messageBox');
// Set template awal
messageBox.value = templates[targetSelect.value];
// Ganti template saat dropdown berubah
targetSelect.addEventListener('change', function() {
if (confirm(I18N.confirmChangeTarget)) {
messageBox.value = templates[this.value];
} else {
// Revert dropdown if canceled
for(let i=0; i<this.options.length; i++) {
if(templates[this.options[i].value] === messageBox.value) {
this.selectedIndex = i;
break;
}
}
}
});
setTimeout(()=>document.querySelectorAll('.alert:not(.alert-permanent)').forEach(a=>a.style.display='none'),5000);
// Polling Broadcast Status
const broadcastCounter = document.getElementById('broadcastCounter');
const sentCountEl = document.getElementById('sentCount');
const totalCountEl = document.getElementById('totalCount');
const failedCountEl = document.getElementById('failedCount');
const submitBtn = document.querySelector('button[type="submit"]');
const broadcastForm = document.getElementById('broadcastForm');
if (broadcastForm) {
broadcastForm.addEventListener('submit', function (e) {
const msg = String(broadcastForm.dataset.confirm || '').trim();
if (msg && !confirm(msg)) e.preventDefault();
});
}
async function checkBroadcastStatus() {
try {
const res = await fetch('/admin/api/whatsapp/broadcast-status');
const status = await res.json();
if (status.active) {
broadcastCounter.style.display = 'inline-flex';
sentCountEl.textContent = status.sent;
totalCountEl.textContent = status.total;
failedCountEl.textContent = status.failed;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span> ' + I18N.sendingText;
} else {
if (status.total > 0 && status.sent + status.failed === status.total) {
// Just finished
broadcastCounter.className = 'badge bs';
broadcastCounter.innerHTML = `<i class="bi bi-check-circle-fill me-1"></i> ${I18N.donePrefix} ${status.sent}/${status.total} ${I18N.sentSuffix}`;
submitBtn.disabled = false;
submitBtn.innerHTML = '<i class="bi bi-send"></i> ' + I18N.startBtn;
} else {
broadcastCounter.style.display = 'none';
}
}
} catch (e) { console.error('Status check failed', e); }
}
// Jalankan pengingat tagihan otomatis sekarang (manual)
async function runReminderNow(btn) {
const out = document.getElementById('runReminderResult');
const old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Menjalankan...'; }
if (out) { out.textContent = 'Menjalankan pengingat…'; out.style.color = 'var(--muted)'; }
try {
const r = await fetch('/admin/whatsapp/run-reminder-now', { method: 'POST' });
const j = await r.json().catch(function () { return {}; });
if (out) { out.textContent = j.ok ? (j.message || 'Berhasil.') : ('Gagal — ' + (j.message || 'Terjadi kesalahan.')); out.style.color = j.ok ? 'var(--success)' : 'var(--danger)'; }
} catch (e) {
if (out) { out.textContent = 'Gagal — ' + e.message; out.style.color = 'var(--danger)'; }
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = old; }
}
}
// Start polling
setInterval(checkBroadcastStatus, 2000);
checkBroadcastStatus();
function bsToggle(){ var m=(document.getElementById('bsMode')||{}).value; document.getElementById('bsStatic').style.display = m==='static'?'':'none'; document.getElementById('bsRandom').style.display = m==='random'?'':'none'; }
async function bsStop(){ try{ await fetch('/admin/api/whatsapp/billing-send-stop',{method:'POST'}); }catch(e){} }
async function bsPoll(){ try{ const r=await fetch('/admin/api/whatsapp/billing-send-status'); const s=await r.json(); const box=document.getElementById('bsProgress'); if(!s||(!s.running&&!s.total)) return; box.style.display='block'; const pct = s.total? Math.round(((s.sent+s.failed)/s.total)*100):0; document.getElementById('bsBar').style.width=pct+'%'; document.getElementById('bsProgressText').textContent=(s.running?'Mengirim…':'Selesai')+' '+s.sent+'/'+s.total+' terkirim, '+s.failed+' gagal'+(s.last?(' • terakhir: '+s.last):'')+(s.label?(' • '+s.label):''); }catch(e){} }
setInterval(bsPoll, 3000); bsPoll();
</script>
</body>
</html>
+742
View File
@@ -0,0 +1,742 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.attendance-card {
background: var(--bg2);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
border: 1px solid var(--border);
}
.status-badge {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
}
.status-checked-in {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.status-checked-out {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.status-not-checked-in {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid var(--border);
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
color: var(--muted);
font-weight: 500;
}
.info-value {
color: var(--text);
font-weight: 600;
}
.note-input {
width: 100%;
padding: 12px;
border: 1px solid var(--border);
border-radius: 8px;
margin-top: 12px;
font-size: 14px;
background: var(--bg3);
color: var(--text);
}
.location-info {
font-size: 12px;
color: var(--muted);
margin-top: 8px;
padding: 8px;
background: var(--bg3);
border-radius: 4px;
}
.photo-help {
font-size: 12px;
color: var(--muted);
margin-top: 6px;
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'cashier_attendance', 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-calendar-check"></i> <%= title %>
</div>
<div class="tb-right">
<span class="adm-badge">
<i class="bi bi-person-fill"></i> Kasir
</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'success' ? 's' : 'd' %>" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<!-- Today's Status -->
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-calendar-day"></i> Status Hari Ini</h6>
</div>
<div class="card-bd">
<% if (todayAttendance) { %>
<% if (todayAttendance.status === 'checked_in') { %>
<span class="status-badge status-checked-in"><i class="bi bi-check-lg"></i> Sudah Check-in</span>
<% } else { %>
<span class="status-badge status-checked-out"><i class="bi bi-check-lg"></i> Sudah Check-out</span>
<% } %>
<div class="info-row">
<span class="info-label">Check-in</span>
<span class="info-value"><%= formatDateLocal(todayAttendance.check_in_time) %></span>
</div>
<% if (todayAttendance.check_out_time) { %>
<div class="info-row">
<span class="info-label">Check-out</span>
<span class="info-value"><%= formatDateLocal(todayAttendance.check_out_time) %></span>
</div>
<div class="info-row">
<span class="info-label">Durasi Kerja</span>
<span class="info-value">
<%= Math.floor(todayAttendance.work_duration_minutes / 60) %> jam
<%= todayAttendance.work_duration_minutes % 60 %> menit
</span>
</div>
<% } %>
<% if (todayAttendance.check_in_note) { %>
<div class="info-row">
<span class="info-label">Catatan Check-in</span>
<span class="info-value"><%= todayAttendance.check_in_note %></span>
</div>
<% } %>
<% if (todayAttendance.check_out_note) { %>
<div class="info-row">
<span class="info-label">Catatan Check-out</span>
<span class="info-value"><%= todayAttendance.check_out_note %></span>
</div>
<% } %>
<% if (todayAttendance.check_in_photo || todayAttendance.check_out_photo) { %>
<div class="info-row" style="align-items:flex-start">
<span class="info-label">Foto Absensi</span>
<span class="info-value" style="display:flex;gap:10px;flex-wrap:wrap;justify-content:flex-end">
<% if (todayAttendance.check_in_photo) { %>
<a href="<%= todayAttendance.check_in_photo %>" target="_blank" style="text-decoration:none;color:var(--text);font-size:12px;text-align:center">
<img src="<%= todayAttendance.check_in_photo %>" alt="Foto check-in" style="width:72px;height:72px;object-fit:cover;border-radius:10px;display:block;margin-bottom:4px">
Check-in
</a>
<% } %>
<% if (todayAttendance.check_out_photo) { %>
<a href="<%= todayAttendance.check_out_photo %>" target="_blank" style="text-decoration:none;color:var(--text);font-size:12px;text-align:center">
<img src="<%= todayAttendance.check_out_photo %>" alt="Foto check-out" style="width:72px;height:72px;object-fit:cover;border-radius:10px;display:block;margin-bottom:4px">
Check-out
</a>
<% } %>
</span>
</div>
<% } %>
<% if (todayAttendance.status === 'checked_in') { %>
<textarea id="checkoutNote" class="note-input" placeholder="Catatan check-out (opsional)"></textarea>
<input id="checkoutPhoto" type="file" accept="image/*" capture="user" style="display: none;" onchange="updatePhotoInfo('checkoutPhoto', 'checkoutPhotoInfo', 'checkoutPhotoPreviewWrap', 'checkoutPhotoPreview')">
<div style="text-align: center; margin: 16px 0;">
<button type="button" onclick="document.getElementById('checkoutPhoto').click()" class="btn btn-p d-inline-flex align-items-center justify-content-center" style="width: 64px; height: 64px; border-radius: 50%; font-size: 24px; box-shadow: 0 4px 12px rgba(168, 85, 247, 0.4); border: none; transition: transform 0.2s;" onmousedown="this.style.transform='scale(0.92)'" onmouseup="this.style.transform='scale(1)'">
<i class="bi bi-camera-fill"></i>
</button>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.tap_camera','Ketuk tombol kamera untuk mengambil foto selfie') %></div>
</div>
<div id="checkoutPhotoInfo" class="photo-help text-center">Belum ada foto diambil. Foto wajib untuk check-out.</div>
<div style="text-align: center; margin-top: 4px;">
<div id="checkoutPhotoStatus" class="photo-help" style="display:inline-block;padding:4px 10px;border-radius:999px;background:rgba(239,68,68,0.18);color:#fecaca"><%= t('collector.selfie_required','Foto wajib') %></div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;justify-content:center">
<button type="button" onclick="clearSelectedPhoto('checkoutPhoto')" class="btn btn-d btn-sm" id="checkoutPhotoClearBtn" style="display: none; border-radius: 8px; font-size: 11px;"><%= t('collector.delete_photo','Hapus Foto') %></button>
</div>
<div id="checkoutPhotoPreviewWrap" style="display:none;margin-top:8px">
<div class="photo-help" style="margin-bottom:6px">Preview foto final yang akan dikirim:</div>
<img id="checkoutPhotoPreview" alt="Preview foto check-out" style="width:100%;max-width:240px;border-radius:12px;border:1px solid var(--border);object-fit:cover">
</div>
<button id="checkoutSubmitBtn" class="btn btn-d btn-sm" style="width:100%;margin-top:12px;opacity:0.55;cursor:not-allowed" onclick="checkout(event)" disabled>
<i class="bi bi-door-open"></i> Check-out Sekarang
</button>
<% } %>
<% } else { %>
<span class="status-badge status-not-checked-in"><i class="bi bi-x-lg"></i> Belum Check-in</span>
<p style="color: var(--muted); margin: 16px 0;">Silakan lakukan check-in untuk memulai hari kerja Anda.</p>
<textarea id="checkinNote" class="note-input" placeholder="Catatan check-in (opsional)"></textarea>
<input id="checkinPhoto" type="file" accept="image/*" capture="user" style="display: none;" onchange="updatePhotoInfo('checkinPhoto', 'checkinPhotoInfo', 'checkinPhotoPreviewWrap', 'checkinPhotoPreview')">
<div style="text-align: center; margin: 16px 0;">
<button type="button" onclick="document.getElementById('checkinPhoto').click()" class="btn btn-s d-inline-flex align-items-center justify-content-center" style="width: 64px; height: 64px; border-radius: 50%; font-size: 24px; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); border: none; transition: transform 0.2s;" onmousedown="this.style.transform='scale(0.92)'" onmouseup="this.style.transform='scale(1)'">
<i class="bi bi-camera-fill"></i>
</button>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.tap_camera','Ketuk tombol kamera untuk mengambil foto selfie') %></div>
</div>
<div id="checkinPhotoInfo" class="photo-help text-center">Belum ada foto diambil. Foto wajib untuk check-in.</div>
<div style="text-align: center; margin-top: 4px;">
<div id="checkinPhotoStatus" class="photo-help" style="display:inline-block;padding:4px 10px;border-radius:999px;background:rgba(239,68,68,0.18);color:#fecaca"><%= t('collector.selfie_required','Foto wajib') %></div>
</div>
<div style="display:flex;gap:8px;margin-top:8px;justify-content:center">
<button type="button" onclick="clearSelectedPhoto('checkinPhoto')" class="btn btn-d btn-sm" id="checkinPhotoClearBtn" style="display: none; border-radius: 8px; font-size: 11px;"><%= t('collector.delete_photo','Hapus Foto') %></button>
</div>
<div id="checkinPhotoPreviewWrap" style="display:none;margin-top:8px">
<div class="photo-help" style="margin-bottom:6px">Preview foto final yang akan dikirim:</div>
<img id="checkinPhotoPreview" alt="Preview foto check-in" style="width:100%;max-width:240px;border-radius:12px;border:1px solid var(--border);object-fit:cover">
</div>
<button id="checkinSubmitBtn" class="btn btn-s btn-sm" style="width:100%;margin-top:12px;opacity:0.55;cursor:not-allowed" onclick="checkin(event)" disabled>
<i class="bi bi-check-circle"></i> Check-in Sekarang
</button>
<% } %>
<div id="locationInfo" class="location-info" style="display: none;">
<i class="bi bi-geo-alt-fill"></i> Mengambil lokasi GPS...
</div>
</div>
</div>
<!-- Monthly Summary -->
<div class="stat-grid" style="grid-template-columns:repeat(4,1fr)">
<div class="sc p">
<i class="bi bi-calendar-month sc-icon"></i>
<div class="sc-label">Total Hari</div>
<div class="sc-val"><%= summary.total_days || 0 %></div>
<div class="sc-sub">Bulan ini</div>
</div>
<div class="sc s">
<i class="bi bi-check-circle sc-icon"></i>
<div class="sc-label">Hari Lengkap</div>
<div class="sc-val"><%= summary.completed_days || 0 %></div>
<div class="sc-sub">Check-in & out</div>
</div>
<div class="sc p">
<i class="bi bi-clock sc-icon"></i>
<div class="sc-label">Total Jam</div>
<div class="sc-val">
<%= summary.total_minutes ? Math.floor(summary.total_minutes / 60) : 0 %>
</div>
<div class="sc-sub">Jam kerja</div>
</div>
<div class="sc s">
<i class="bi bi-graph-up sc-icon"></i>
<div class="sc-label">Rata-rata</div>
<div class="sc-val">
<%= summary.avg_minutes ? Math.floor(summary.avg_minutes / 60) : 0 %>
</div>
<div class="sc-sub">Jam/hari</div>
</div>
</div>
<!-- Recent History -->
<div class="card" style="margin-top:20px">
<div class="card-hd">
<h6><i class="bi bi-clock-history"></i> Riwayat Terakhir</h6>
</div>
<% if (history && history.length > 0) { %>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('common.date','Tanggal') %></th>
<th>Check-in</th>
<th>Check-out</th>
<th>Durasi</th>
<th><%= t('common.status','Status') %></th>
</tr>
</thead>
<tbody>
<% history.forEach(item => { %>
<tr>
<td class="fw6">
<%= parseDateInTimezone(item.check_in_time).toLocaleDateString('id-ID', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric'
}) %>
</td>
<td>
<%= formatTimeLocal(item.check_in_time) %>
</td>
<td>
<%= item.check_out_time ? formatTimeLocal(item.check_out_time) : '-' %>
</td>
<td>
<% if (item.work_duration_minutes > 0) { %>
<%= Math.floor(item.work_duration_minutes / 60) %>j <%= item.work_duration_minutes % 60 %>m
<% } else { %>
<span class="text-muted">-</span>
<% } %>
</td>
<td>
<% if (item.status === 'checked_in') { %>
<span class="badge badge-w">Belum selesai</span>
<% } else { %>
<span class="badge badge-s">Selesai</span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } else { %>
<div class="card-bd" style="text-align:center;padding:40px 20px">
<p style="color:var(--muted)">Belum ada riwayat absensi</p>
</div>
<% } %>
</div>
</div>
</div>
<script>
let currentLocation = null;
const processedAttendancePhotos = {};
const photoFieldConfig = {
checkinPhoto: { infoId: 'checkinPhotoInfo', previewWrapId: 'checkinPhotoPreviewWrap', previewImgId: 'checkinPhotoPreview', statusId: 'checkinPhotoStatus', submitBtnId: 'checkinSubmitBtn' },
checkoutPhoto: { infoId: 'checkoutPhotoInfo', previewWrapId: 'checkoutPhotoPreviewWrap', previewImgId: 'checkoutPhotoPreview', statusId: 'checkoutPhotoStatus', submitBtnId: 'checkoutSubmitBtn' }
};
function setPhotoStatus(statusId, text, mode = 'idle') {
const el = document.getElementById(statusId);
if (!el) return;
const palette = {
idle: ['rgba(239, 68, 68, 0.18)', '#fecaca'],
waiting: ['rgba(245, 158, 11, 0.18)', '#fde68a'],
processing: ['rgba(59, 130, 246, 0.18)', '#bfdbfe'],
ready: ['rgba(16, 185, 129, 0.18)', '#bbf7d0'],
error: ['rgba(239, 68, 68, 0.18)', '#fecaca']
};
const [bg, color] = palette[mode] || palette.idle;
el.style.background = bg;
el.style.color = color;
el.textContent = text;
}
function setSubmitButtonState(buttonId, enabled) {
const button = document.getElementById(buttonId);
if (!button) return;
button.disabled = !enabled;
button.style.opacity = enabled ? '1' : '0.55';
button.style.cursor = enabled ? 'pointer' : 'not-allowed';
}
function resetPhotoPreview(previewWrapId, previewImgId) {
const previewWrap = document.getElementById(previewWrapId);
const previewImg = document.getElementById(previewImgId);
if (previewImg && previewImg.dataset.objectUrl) {
URL.revokeObjectURL(previewImg.dataset.objectUrl);
delete previewImg.dataset.objectUrl;
}
if (previewImg) previewImg.removeAttribute('src');
if (previewWrap) previewWrap.style.display = 'none';
}
function setPhotoPreview(previewWrapId, previewImgId, file) {
const previewWrap = document.getElementById(previewWrapId);
const previewImg = document.getElementById(previewImgId);
if (!previewWrap || !previewImg) return;
resetPhotoPreview(previewWrapId, previewImgId);
const objectUrl = URL.createObjectURL(file);
previewImg.src = objectUrl;
previewImg.dataset.objectUrl = objectUrl;
previewWrap.style.display = 'block';
}
function updatePhotoInfo(inputId, infoId, previewWrapId, previewImgId) {
const input = document.getElementById(inputId);
const info = document.getElementById(infoId);
const config = getPhotoState(inputId);
if (!input || !info) return;
const file = input.files && input.files[0];
delete processedAttendancePhotos[inputId];
if (!file) {
info.textContent = 'Belum ada foto dipilih.';
resetPhotoPreview(previewWrapId, previewImgId);
if (config) setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
if (config) setSubmitButtonState(config.submitBtnId, false);
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'none';
return;
}
info.textContent = `Foto dipilih: ${file.name} (${Math.round(file.size / 1024)} KB)`;
if (config) setPhotoStatus(config.statusId, 'Menyiapkan preview', 'processing');
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'inline-block';
refreshPhotoPreview(inputId).catch((error) => {
info.textContent = error.message;
resetPhotoPreview(previewWrapId, previewImgId);
if (config) setPhotoStatus(config.statusId, 'Preview gagal', 'error');
});
}
function getPhotoState(inputId) {
return photoFieldConfig[inputId];
}
function clearSelectedPhoto(inputId) {
const config = getPhotoState(inputId);
const input = document.getElementById(inputId);
if (!config || !input) return;
input.value = '';
delete processedAttendancePhotos[inputId];
const info = document.getElementById(config.infoId);
if (info) info.textContent = 'Belum ada foto dipilih. Foto wajib untuk absensi.';
resetPhotoPreview(config.previewWrapId, config.previewImgId);
setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
setSubmitButtonState(config.submitBtnId, false);
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'none';
}
async function refreshPhotoPreview(inputId, force = false) {
const config = getPhotoState(inputId);
const input = document.getElementById(inputId);
const info = config ? document.getElementById(config.infoId) : null;
const file = input && input.files && input.files[0];
if (!config || !info) return null;
if (!file) {
info.textContent = 'Belum ada foto dipilih. Foto wajib untuk absensi.';
resetPhotoPreview(config.previewWrapId, config.previewImgId);
delete processedAttendancePhotos[inputId];
setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
setSubmitButtonState(config.submitBtnId, false);
return null;
}
if (!currentLocation) {
info.textContent = `Foto dipilih: ${file.name} (${Math.round(file.size / 1024)} KB). Preview menunggu lokasi GPS.`;
resetPhotoPreview(config.previewWrapId, config.previewImgId);
setPhotoStatus(config.statusId, 'Menunggu GPS', 'waiting');
setSubmitButtonState(config.submitBtnId, false);
return null;
}
const cacheKey = [file.name, file.size, file.lastModified, currentLocation.lat, currentLocation.lng].join('|');
const cached = processedAttendancePhotos[inputId];
if (!force && cached && cached.cacheKey === cacheKey && cached.file) {
setPhotoPreview(config.previewWrapId, config.previewImgId, cached.file);
info.textContent = `Preview siap: ${cached.file.name} (${Math.round(cached.file.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Preview siap dikirim', 'ready');
setSubmitButtonState(config.submitBtnId, true);
return cached.file;
}
info.textContent = 'Memproses foto untuk preview...';
setPhotoStatus(config.statusId, 'Memproses foto', 'processing');
setSubmitButtonState(config.submitBtnId, false);
const processedFile = await processAttendancePhotoFile(file, currentLocation);
processedAttendancePhotos[inputId] = { cacheKey, file: processedFile };
setPhotoPreview(config.previewWrapId, config.previewImgId, processedFile);
info.textContent = `Preview siap: ${processedFile.name} (${Math.round(processedFile.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Preview siap dikirim', 'ready');
setSubmitButtonState(config.submitBtnId, true);
return processedFile;
}
function refreshAllPhotoPreviews() {
Object.keys(photoFieldConfig).forEach((inputId) => {
refreshPhotoPreview(inputId, true).catch(() => {});
});
}
function buildAttendanceFormData(location, note, photoInputId) {
const formData = new FormData();
formData.append('lat', location.lat);
formData.append('lng', location.lng);
formData.append('note', note);
return getProcessedAttendancePhoto(photoInputId, location).then((processedPhoto) => {
if (processedPhoto) {
formData.append('photo', processedPhoto);
}
return formData;
});
}
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Gagal membaca foto yang dipilih'));
};
img.src = objectUrl;
});
}
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
const fallback = () => {
try {
const dataUrl = canvas.toDataURL(type, quality);
const parts = dataUrl.split(',');
if (parts.length < 2) throw new Error('Gagal memproses foto absensi');
const mime = (parts[0].match(/data:(.*?);base64/) || [])[1] || type || 'image/jpeg';
const binary = atob(parts[1]);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
resolve(new Blob([bytes], { type: mime }));
} catch (e) {
reject(new Error('Gagal memproses foto absensi'));
}
};
if (typeof canvas.toBlob !== 'function') return fallback();
canvas.toBlob((blob) => {
if (blob) return resolve(blob);
fallback();
}, type, quality);
});
}
function buildWatermarkLines(location) {
const timestamp = new Date().toLocaleString('id-ID', {
timeZone: '<%= settings.timezone %>',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return [
'<%= settings.companyName %>'.toUpperCase(),
'KASIR PORTAL - ABSENSI',
`WAKTU: ${timestamp}`,
`KOORDINAT: ${location.lat.toFixed(6)}, ${location.lng.toFixed(6)}`
];
}
async function processAttendancePhotoFile(file, location) {
const img = await loadImageFromFile(file);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('Gagal memproses foto absensi (Canvas Context)');
const maxDim = 1200;
let { width, height } = img;
if (width > maxDim || height > maxDim) {
if (width > height) {
height = Math.round((height * maxDim) / width);
width = maxDim;
} else {
width = Math.round((width * maxDim) / height);
height = maxDim;
}
}
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
const pad = Math.round(width * 0.03);
const fontSize = Math.max(12, Math.round(width * 0.024));
ctx.font = `bold ${fontSize}px sans-serif`;
const lines = buildWatermarkLines(location);
let maxTextWidth = 0;
lines.forEach((line) => {
const w = ctx.measureText(line).width;
if (w > maxTextWidth) maxTextWidth = w;
});
const boxW = maxTextWidth + pad * 2;
const boxH = lines.length * fontSize * 1.45 + pad * 1.5;
const boxX = pad;
const boxY = height - boxH - pad;
ctx.fillStyle = 'rgba(0, 0, 0, 0.45)';
ctx.beginPath();
ctx.roundRect(boxX, boxY, boxW, boxH, fontSize * 0.5);
ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
lines.forEach((line, idx) => {
ctx.fillText(line, boxX + pad, boxY + pad + idx * fontSize * 1.45);
});
const blob = await canvasToBlob(canvas, 'image/jpeg', 0.82);
return new File([blob], file.name || 'attendance.jpg', { type: 'image/jpeg', lastModified: Date.now() });
}
function getProcessedAttendancePhoto(photoInputId, location) {
const input = document.getElementById(photoInputId);
const file = input && input.files && input.files[0];
if (!file) return Promise.resolve(null);
const config = getPhotoState(photoInputId);
const cached = processedAttendancePhotos[photoInputId];
const cacheKey = [file.name, file.size, file.lastModified, location.lat, location.lng].join('|');
if (cached && cached.cacheKey === cacheKey && cached.file) {
return Promise.resolve(cached.file);
}
return processAttendancePhotoFile(file, location).then((processedFile) => {
processedAttendancePhotos[photoInputId] = { cacheKey, file: processedFile };
return processedFile;
});
}
function checkin(event) {
event.preventDefault();
if (!currentLocation) {
alert('Lokasi GPS belum tersedia. Silakan izinkan akses lokasi.');
return;
}
const note = document.getElementById('checkinNote').value;
const submitBtn = document.getElementById('checkinSubmitBtn');
if (submitBtn) submitBtn.disabled = true;
buildAttendanceFormData(currentLocation, note, 'checkinPhoto')
.then((formData) => {
return fetch('/admin/cashiers/attendance/checkin', {
method: 'POST',
body: formData
});
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.message || 'Gagal melakukan check-in');
if (submitBtn) submitBtn.disabled = false;
}
})
.catch((err) => {
alert('Terjadi kesalahan koneksi saat check-in');
console.error(err);
if (submitBtn) submitBtn.disabled = false;
});
}
function checkout(event) {
event.preventDefault();
if (!currentLocation) {
alert('Lokasi GPS belum tersedia. Silakan izinkan akses lokasi.');
return;
}
const note = document.getElementById('checkoutNote').value;
const submitBtn = document.getElementById('checkoutSubmitBtn');
if (submitBtn) submitBtn.disabled = true;
buildAttendanceFormData(currentLocation, note, 'checkoutPhoto')
.then((formData) => {
return fetch('/admin/cashiers/attendance/checkout', {
method: 'POST',
body: formData
});
})
.then(res => res.json())
.then(data => {
if (data.success) {
window.location.reload();
} else {
alert(data.message || 'Gagal melakukan check-out');
if (submitBtn) submitBtn.disabled = false;
}
})
.catch((err) => {
alert('Terjadi kesalahan koneksi saat check-out');
console.error(err);
if (submitBtn) submitBtn.disabled = false;
});
}
// Request GPS Location
window.addEventListener('DOMContentLoaded', () => {
const locInfo = document.getElementById('locationInfo');
if (locInfo) locInfo.style.display = 'block';
if (navigator.geolocation) {
navigator.geolocation.watchPosition(
(position) => {
currentLocation = {
lat: position.coords.latitude,
lng: position.coords.longitude
};
if (locInfo) {
locInfo.className = 'location-info';
locInfo.innerHTML = `<i class="bi bi-geo-alt-fill"></i> GPS Aktif: ${currentLocation.lat.toFixed(6)}, ${currentLocation.lng.toFixed(6)}`;
}
refreshAllPhotoPreviews();
},
(error) => {
let msg = 'Gagal mengakses GPS.';
if (error.code === error.PERMISSION_DENIED) {
msg = 'Akses GPS ditolak. Silakan izinkan lokasi di pengaturan browser Anda.';
}
if (locInfo) {
locInfo.style.background = 'rgba(239, 68, 68, 0.15)';
locInfo.style.color = '#ef4444';
locInfo.innerHTML = `<i class="bi bi-exclamation-triangle-fill"></i> ${msg}`;
}
},
{ enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
);
} else {
if (locInfo) {
locInfo.style.background = 'rgba(239, 68, 68, 0.15)';
locInfo.style.color = '#ef4444';
locInfo.innerHTML = '<i class="bi bi-exclamation-triangle-fill"></i> Browser Anda tidak mendukung GPS Geolocation.';
}
}
});
</script>
</body>
</html>
+108
View File
@@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Laporan Kasir | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'cashiers_reports', 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-journal-text"></i>
<span>Laporan Kasir</span>
</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/billing" class="btn btn-g btn-sm"><i class="bi bi-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='error'?'d':'s' %>" data-flash="1"><%= msg.text %></div>
<% } %>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-funnel"></i> Filter</h6>
</div>
<div class="card-bd" style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
<% if (cashiers && cashiers.length > 1) { %>
<select class="fs" style="min-width:240px" onchange="location='/admin/cashiers/reports?cashierId='+encodeURIComponent(this.value)">
<option value="">Semua Kasir</option>
<% (cashiers || []).forEach(c => { %>
<option value="<%= c.id %>" <%= cashierId && Number(cashierId)===Number(c.id) ? 'selected' : '' %>><%= c.name %> (@<%= c.username %>)</option>
<% }) %>
</select>
<% } else if (cashiers && cashiers.length === 1) { %>
<span class="badge bm"><%= cashiers[0].name %><%= cashiers[0].username ? (' (@' + cashiers[0].username + ')') : '' %></span>
<% } %>
<span class="badge bm"><%= Number(summary?.count || 0) %> transaksi</span>
<span class="badge bs">Rp <%= Number(summary?.total || 0).toLocaleString('id-ID') %></span>
<span class="badge bm">Tagihan: <%= Number(summary?.invoice_count || 0) %> (Rp <%= Number(summary?.invoice_total || 0).toLocaleString('id-ID') %>)</span>
<span class="badge bm">Deposit Agent: <%= Number(summary?.topup_count || 0) %> (Rp <%= Number(summary?.topup_total || 0).toLocaleString('id-ID') %>)</span>
</div>
</div>
<div class="card" style="margin-top:16px">
<div class="card-hd">
<h6><i class="bi bi-list-ul"></i> Riwayat Transaksi</h6>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('collector.col_time','Waktu') %></th>
<th>Kasir</th>
<th>Jenis</th>
<th>Detail</th>
<th class="num"><%= t('collector.col_amount','Nominal') %></th>
<th>Catatan</th>
<th class="num">Ref</th>
</tr>
</thead>
<tbody>
<% if (!rows || rows.length === 0) { %>
<tr><td colspan="7" class="text-center text-muted" style="padding:24px">Belum ada transaksi kasir.</td></tr>
<% } else { %>
<% rows.forEach(r => { %>
<tr>
<td style="font-size:11px;color:var(--muted)"><%= r.at ? new Date(r.at).toLocaleString((lang || 'id') === 'en' ? 'en-US' : 'id-ID',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '-' %></td>
<td class="fw6"><%= r.actor_name || '-' %></td>
<td>
<% if (r.kind === 'agent_topup') { %>
<span class="badge bs">DEPOSIT</span>
<% } else { %>
<span class="badge bm">TAGIHAN</span>
<% } %>
</td>
<td style="font-size:12px">
<% if (r.kind === 'agent_topup') { %>
<div class="fw6"><%= r.agent_name || '-' %> <span class="text-muted" style="font-size:11px"><%= r.agent_username ? '(@' + r.agent_username + ')' : '' %></span></div>
<% } else { %>
<div class="fw6"><%= r.customer_name || '-' %></div>
<div style="font-size:11px;color:var(--muted)">
<%= r.customer_phone || '' %><%= r.period_month && r.period_year ? (' • ' + r.period_month + '/' + r.period_year) : '' %><%= r.package_name ? (' • ' + r.package_name) : '' %>
</div>
<% } %>
</td>
<td class="num fw6" style="color:var(--success)">Rp <%= Number(r.amount||0).toLocaleString('id-ID') %></td>
<td style="font-size:11px;color:var(--muted)"><%= r.notes ? r.notes : '-' %></td>
<td class="num"><code>#<%= r.ref_id %></code></td>
</tr>
<% }) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
+148
View File
@@ -0,0 +1,148 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.nav.cashiers', 'Kasir') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'cashiers', 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-person-vcard"></i>
<span><%= t('admin.nav.cashiers', 'Kasir') %></span>
</div>
<div class="tb-right">
<span class="adm-badge">
<i class="bi bi-person-fill"></i>
<%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
</span>
<button class="btn btn-p btn-sm" onclick="openModal('addCashierModal')"><i class="bi bi-plus-lg"></i> <%= t('admin.cashiers.add', 'Tambah Kasir') %></button>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='error'?'d':'s' %>" data-flash="1"><%= msg.text %></div>
<% } %>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-ul"></i> <%= t('admin.cashiers.list', 'Daftar Petugas Kasir') %></h6>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('admin.cashiers.table.name', 'Nama') %></th>
<th><%= t('admin.cashiers.table.username', 'Username') %></th>
<th><%= t('admin.cashiers.table.password', 'Password') %></th>
<th><%= t('admin.cashiers.table.phone', 'No. HP') %></th>
<th><%= t('admin.cashiers.table.status', 'Status') %></th>
<th><%= t('admin.cashiers.table.actions', 'Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (cashiers.length === 0) { %>
<tr><td colspan="6" class="text-center text-muted" style="padding:20px"><%= t('admin.cashiers.empty', 'Belum ada data kasir.') %></td></tr>
<% } else { %>
<% cashiers.forEach(c => { %>
<tr>
<td><strong><%= c.name %></strong></td>
<td><code><%= c.username %></code></td>
<td><span class="text-muted"><%= c.password %></span></td>
<td><%= c.phone || '-' %></td>
<td>
<span class="badge <%= c.is_active ? 'bs' : 'bd' %>">
<%= c.is_active ? t('common.active', 'Aktif') : t('common.inactive', 'Nonaktif') %>
</span>
</td>
<td style="white-space:nowrap">
<form id="kdel-<%= c.id %>" action="/admin/cashiers/<%= c.id %>/delete" method="POST" style="display:none" onsubmit="return confirm(I18N.confirmDeleteCashier)"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick='editCashier(<%- JSON.stringify(c).split('<').join('\\u003c') %>)'><i class="bi bi-pencil"></i> Edit Kasir</button>
<div class="act-sep"></div>
<button type="submit" form="kdel-<%= c.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Kasir</button>
</div>
</div>
</td>
</tr>
<% }) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Tambah -->
<div class="mo" id="addCashierModal">
<div class="mb">
<form action="/admin/cashiers" method="POST">
<div class="mh"><span class="mt"><%= t('admin.cashiers.modal.add_title', 'Tambah Kasir Baru') %></span><button type="button" class="mc" onclick="closeModal('addCashierModal')">&times;</button></div>
<div class="mbody">
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.full_name', 'Nama Lengkap') %></label><input type="text" name="name" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.login_username', 'Username Login') %></label><input type="text" name="username" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.password', 'Password') %></label><input type="text" name="password" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.phone', 'No. HP') %></label><input type="text" name="phone" class="fc" placeholder="<%= t('admin.cashiers.form.phone_placeholder', 'Contoh: 08123456789') %>"></div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addCashierModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><%= t('common.save', 'Simpan') %></button>
</div>
</form>
</div>
</div>
<!-- Modal Edit -->
<div class="mo" id="editCashierModal">
<div class="mb">
<form id="editForm" method="POST">
<div class="mh"><span class="mt"><%= t('admin.cashiers.modal.edit_title', 'Edit Data Kasir') %></span><button type="button" class="mc" onclick="closeModal('editCashierModal')">&times;</button></div>
<div class="mbody">
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.full_name', 'Nama Lengkap') %></label><input type="text" name="name" id="e-name" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.login_username', 'Username Login') %></label><input type="text" name="username" id="e-username" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.password', 'Password') %></label><input type="text" name="password" id="e-password" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.cashiers.form.phone', 'No. HP') %></label><input type="text" name="phone" id="e-phone" class="fc"></div>
<div class="fg">
<label class="fl"><%= t('admin.cashiers.form.account_status', 'Status Akun') %></label>
<select name="is_active" id="e-status" class="fs">
<option value="1"><%= t('common.active', 'Aktif') %></option>
<option value="0"><%= t('common.inactive', 'Nonaktif') %></option>
</select>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('editCashierModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><%= t('admin.cashiers.save_changes', 'Simpan Perubahan') %></button>
</div>
</form>
</div>
</div>
<script>
const I18N = {
confirmDeleteCashier: "<%= t('admin.cashiers.confirm_delete', 'Hapus kasir ini?') %>"
};
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function editCashier(c) {
document.getElementById('editForm').action = '/admin/cashiers/' + c.id + '/update';
document.getElementById('e-name').value = c.name;
document.getElementById('e-username').value = c.username;
document.getElementById('e-password').value = c.password;
document.getElementById('e-phone').value = c.phone || '';
document.getElementById('e-status').value = c.is_active;
openModal('editCashierModal');
}
</script>
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Approval Pembayaran Kolektor | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'collector_payments', 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-check2-square"></i>
<span>Approval Pembayaran Kolektor</span>
</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/collector-payments?status=pending" class="btn btn-g btn-sm <%= status==='pending'?'btn-p':'' %>">Pending</a>
<a href="/admin/collector-payments?status=approved" class="btn btn-g btn-sm <%= status==='approved'?'btn-p':'' %>">Approved</a>
<a href="/admin/collector-payments?status=rejected" class="btn btn-g btn-sm <%= status==='rejected'?'btn-p':'' %>">Rejected</a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='error'?'d':'s' %>" data-flash="1"><%= msg.text %></div>
<% } %>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-ul"></i> Daftar Request (<%= (rows || []).length %>)</h6>
</div>
<div class="hint" style="padding:12px 16px 0 16px;font-size:12px;color:var(--muted)">
Label proses membantu membedakan request yang memang menunggu approval, request yang otomatis langsung lunas oleh kolektor, dan request yang sudah diproses admin atau kasir.
</div>
<div style="padding:12px 16px 0 16px;display:flex;gap:10px;flex-wrap:wrap">
<select id="processFilter" class="fs" style="max-width:220px">
<option value="all">Semua Proses</option>
<option value="pending">Butuh Approval</option>
<option value="auto">Auto Kolektor</option>
<option value="admin">Disetujui Admin</option>
<option value="cashier">Disetujui Kasir</option>
<option value="rejected">Ditolak Manual</option>
<option value="manual">Diproses Manual</option>
</select>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('collector.col_time','Waktu') %></th>
<th><%= t('collector.role','Kolektor') %></th>
<th><%= t('common.customer','Pelanggan') %></th>
<th>Tagihan</th>
<th class="num"><%= t('collector.col_amount','Nominal') %></th>
<th>Proses</th>
<th><%= t('common.status','Status') %></th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (!rows || rows.length === 0) { %>
<tr><td colspan="8" class="text-center text-muted" style="padding:24px">Tidak ada request.</td></tr>
<% } else { %>
<% rows.forEach(r => { %>
<%
const decidedRole = String(r.decided_by_role || '').toLowerCase();
const processLabel =
r.status === 'pending' ? 'Butuh Approval' :
decidedRole === 'system' ? 'Auto Kolektor' :
decidedRole === 'cashier' ? 'Disetujui Kasir' :
decidedRole === 'admin' ? 'Disetujui Admin' :
r.status === 'rejected' ? 'Ditolak Manual' :
'Diproses Manual';
const processBadge =
r.status === 'pending' ? 'bm' :
decidedRole === 'system' ? 'bs' :
decidedRole === 'cashier' || decidedRole === 'admin' ? 'bp' :
r.status === 'rejected' ? 'bd' :
'bm';
%>
<tr data-process="<%= r.status === 'pending' ? 'pending' : (decidedRole === 'system' ? 'auto' : (decidedRole === 'admin' ? 'admin' : (decidedRole === 'cashier' ? 'cashier' : (r.status === 'rejected' ? 'rejected' : 'manual')))) %>">
<td style="font-size:11px;color:var(--muted)"><%= r.created_at ? new Date(r.created_at).toLocaleString('id-ID',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '-' %></td>
<td class="fw6"><%= r.collector_name %> <span class="text-muted" style="font-size:11px"><%= r.collector_username ? '(@'+r.collector_username+')' : '' %></span></td>
<td>
<div class="fw6"><%= r.customer_name %></div>
<div style="font-size:11px;color:var(--muted)"><%= r.customer_phone || '' %></div>
</td>
<td style="font-size:12px">
<div>Invoice <code>#<%= r.invoice_id %></code> • <span class="badge bm"><%= r.period_month %>/<%= r.period_year %></span></div>
<div style="font-size:11px;color:var(--muted)"><%= r.invoice_status %> • Rp <%= Number(r.invoice_amount||0).toLocaleString('id-ID') %></div>
<% if (r.customer_address) { %><div style="font-size:11px;color:var(--muted)"><%= r.customer_address %></div><% } %>
<% if (r.lat && r.lng) { %><div><a target="_blank" href="https://www.google.com/maps?q=<%= encodeURIComponent(r.lat + ',' + r.lng) %>">Map</a></div><% } %>
</td>
<td class="num fw6">Rp <%= Number(r.amount||0).toLocaleString('id-ID') %></td>
<td>
<span class="badge <%= processBadge %>"><%= processLabel %></span>
<% if (decidedRole === 'system') { %>
<div style="font-size:10px;color:var(--muted)">Kolektor auto-approve aktif</div>
<% } else if (r.status === 'pending') { %>
<div style="font-size:10px;color:var(--muted)">Menunggu admin atau kasir</div>
<% } else if (r.decided_by_name) { %>
<div style="font-size:10px;color:var(--muted)">Oleh: <%= r.decided_by_name %></div>
<% } %>
</td>
<td>
<span class="badge <%= r.status==='approved'?'bs':(r.status==='rejected'?'bd':'bm') %>"><%= String(r.status||'').toUpperCase() %></span>
<% if (r.note) { %><div style="font-size:10px;color:var(--muted)"><%= r.note %></div><% } %>
<% if (r.decided_by_name) { %><div style="font-size:10px;color:var(--muted)">Diproses: <%= r.decided_by_name %></div><% } %>
<% if (r.decided_note) { %><div style="font-size:10px;color:var(--muted)"><%= r.decided_note %></div><% } %>
</td>
<td style="white-space:nowrap">
<% if (status === 'pending') { %>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu" style="min-width:270px">
<form method="POST" action="/admin/collector-payments/<%= r.id %>/approve" style="display:flex;gap:6px;padding:4px 4px 6px">
<input class="fc" name="decided_note" placeholder="Catatan (opsional)" style="font-size:12px">
<button class="btn btn-s btn-sm" type="submit" title="Approve"><i class="bi bi-check-lg"></i> Setujui</button>
</form>
<div class="act-sep"></div>
<form method="POST" action="/admin/collector-payments/<%= r.id %>/reject" style="display:flex;gap:6px;padding:4px">
<input class="fc" name="decided_note" placeholder="Alasan (opsional)" style="font-size:12px">
<button class="btn btn-d btn-sm" type="submit" title="Reject"><i class="bi bi-x-lg"></i> Tolak</button>
</form>
</div>
</div>
<% } else { %>
<code>#<%= r.id %></code>
<% } %>
</td>
</tr>
<% }) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
function applyProcessFilter() {
const val = document.getElementById('processFilter')?.value || 'all';
document.querySelectorAll('table.dtbl tbody tr[data-process]').forEach((row) => {
const rowVal = row.getAttribute('data-process') || '';
row.style.display = (val === 'all' || rowVal === val) ? '' : 'none';
});
}
document.getElementById('processFilter')?.addEventListener('change', applyProcessFilter);
</script>
</body>
</html>
+241
View File
@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Kolektor | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'collectors', 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-person-badge"></i>
<span>Manajemen Kolektor</span>
</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<button class="btn btn-p btn-sm" onclick="openModal('addCollectorModal')"><i class="bi bi-plus-lg"></i> Tambah Kolektor</button>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='error'?'d':'s' %>" data-flash="1"><%= msg.text %></div>
<% } %>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-ul"></i> Daftar Kolektor</h6>
</div>
<div class="hint" style="padding:12px 16px 0 16px;font-size:12px;color:var(--muted)">
Pengaturan approval pembayaran berlaku per akun kolektor. `Langsung Lunas` berarti pembayaran dari kolektor langsung melunasi invoice, sedangkan `Perlu Approval` berarti harus disetujui admin atau kasir terlebih dahulu.
</div>
<div style="padding:12px 16px 0 16px;display:flex;gap:10px;flex-wrap:wrap">
<select id="collectorModeFilter" class="fs" style="max-width:220px">
<option value="all">Semua Mode Pembayaran</option>
<option value="direct">Langsung Lunas</option>
<option value="approval">Perlu Approval</option>
</select>
<select id="collectorStatusFilter" class="fs" style="max-width:180px">
<option value="all">Semua Status Akun</option>
<option value="active"><%= t('common.active','Aktif') %></option>
<option value="inactive"><%= t('common.inactive','Nonaktif') %></option>
</select>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('pwa.field.name','Nama') %></th>
<th>Username</th>
<th>Password</th>
<th>No. HP</th>
<th>Area Tugas</th>
<th><%= t('common.status','Status') %></th>
<th>Mode Pembayaran</th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (!collectors || collectors.length === 0) { %>
<tr><td colspan="8" class="text-center text-muted" style="padding:20px">Belum ada data kolektor.</td></tr>
<% } else { %>
<% collectors.forEach(c => { %>
<tr data-mode="<%= c.auto_approve ? 'direct' : 'approval' %>" data-status="<%= c.is_active ? 'active' : 'inactive' %>">
<td><strong><%= c.name %></strong></td>
<td><code><%= c.username %></code></td>
<td><span class="text-muted"><%= c.password %></span></td>
<td><%= c.phone || '-' %></td>
<td><span class="badge" style="background:rgba(99,102,241,0.15);color:#818cf8"><%= c.area || '-' %></span></td>
<td>
<span class="badge <%= c.is_active ? 'bs' : 'bd' %>"><%= c.is_active ? t('common.active','Aktif') : t('common.inactive','Nonaktif') %></span>
</td>
<td>
<span class="badge <%= c.auto_approve ? 'bs' : 'bm' %>">
<%= c.auto_approve ? 'Langsung Lunas' : 'Perlu Approval' %>
</span>
</td>
<td style="white-space:nowrap">
<form id="cdel-<%= c.id %>" action="/admin/collectors/<%= c.id %>/delete" method="POST" style="display:none" onsubmit="return confirm('Hapus kolektor ini?')"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick='editCollector(<%- JSON.stringify(c).split('<').join('\\u003c') %>)'><i class="bi bi-pencil"></i> Edit Kolektor</button>
<div class="act-sep"></div>
<button type="submit" form="cdel-<%= c.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Kolektor</button>
</div>
</div>
</td>
</tr>
<% }) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mo" id="addCollectorModal">
<div class="mb">
<form action="/admin/collectors" method="POST">
<div class="mh"><span class="mt">Tambah Kolektor Baru</span><button type="button" class="mc" onclick="closeModal('addCollectorModal')">&times;</button></div>
<div class="mbody">
<div class="fg"><label class="fl">Nama Lengkap</label><input type="text" name="name" class="fc" required></div>
<div class="fg"><label class="fl">Username Login</label><input type="text" name="username" class="fc" required></div>
<div class="fg"><label class="fl">Password</label><input type="text" name="password" class="fc" required></div>
<div class="fg"><label class="fl">No. HP</label><input type="text" name="phone" class="fc" placeholder="08123456789"></div>
<div class="fg">
<label class="fl">Area / Wilayah Tugas</label>
<select name="area" class="fs">
<option value="">-- Semua Area / Bebas --</option>
<% if (typeof masterAreas !== 'undefined' && masterAreas && masterAreas.length > 0) { %>
<% masterAreas.forEach(a => { %>
<option value="<%= a.name %>"><%= a.name %></option>
<% }) %>
<% } %>
</select>
</div>
<div class="fg">
<label class="fl">
<i class="bi bi-check-circle"></i> Auto-Approve Pembayaran
</label>
<select name="auto_approve" id="add-auto-approve" class="fs" onchange="updateCollectorModeBadge('add')">
<option value="0">Nonaktif (Perlu Approval Admin)</option>
<option value="1">Aktif (Langsung Lunas)</option>
</select>
<div id="add-mode-badge" class="hint" style="margin-top:8px;font-size:12px;color:#fbbf24">
Mode saat ini: Menunggu approval admin/kasir sebelum invoice menjadi lunas.
</div>
<div class="hint" style="margin-top:6px;font-size:11px;color:var(--muted)">
Jika aktif, pembayaran yang dilaporkan kolektor ini akan langsung diproses tanpa perlu approval admin/kasir.
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addCollectorModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><%= t('common.save','Simpan') %></button>
</div>
</form>
</div>
</div>
<div class="mo" id="editCollectorModal">
<div class="mb">
<form id="editForm" method="POST">
<div class="mh"><span class="mt">Edit Data Kolektor</span><button type="button" class="mc" onclick="closeModal('editCollectorModal')">&times;</button></div>
<div class="mbody">
<div class="fg"><label class="fl">Nama Lengkap</label><input type="text" name="name" id="e-name" class="fc" required></div>
<div class="fg"><label class="fl">Username Login</label><input type="text" name="username" id="e-username" class="fc" required></div>
<div class="fg"><label class="fl">Password</label><input type="text" name="password" id="e-password" class="fc" required></div>
<div class="fg"><label class="fl">No. HP</label><input type="text" name="phone" id="e-phone" class="fc"></div>
<div class="fg">
<label class="fl">Area / Wilayah Tugas</label>
<select name="area" id="e-area" class="fs">
<option value="">-- Semua Area / Bebas --</option>
<% if (typeof masterAreas !== 'undefined' && masterAreas && masterAreas.length > 0) { %>
<% masterAreas.forEach(a => { %>
<option value="<%= a.name %>"><%= a.name %></option>
<% }) %>
<% } %>
</select>
</div>
<div class="fg">
<label class="fl">Status Akun</label>
<select name="is_active" id="e-status" class="fs">
<option value="1"><%= t('common.active','Aktif') %></option>
<option value="0"><%= t('common.inactive','Nonaktif') %></option>
</select>
</div>
<div class="fg">
<label class="fl">
<i class="bi bi-check-circle"></i> Auto-Approve Pembayaran
</label>
<select name="auto_approve" id="e-auto-approve" class="fs" onchange="updateCollectorModeBadge('edit')">
<option value="0">Nonaktif (Perlu Approval Admin)</option>
<option value="1">Aktif (Langsung Lunas)</option>
</select>
<div id="edit-mode-badge" class="hint" style="margin-top:8px;font-size:12px;color:#fbbf24">
Mode saat ini: Menunggu approval admin/kasir sebelum invoice menjadi lunas.
</div>
<div class="hint" style="margin-top:6px;font-size:11px;color:var(--muted)">
Jika aktif, pembayaran yang dilaporkan kolektor ini akan langsung diproses tanpa perlu approval admin/kasir.
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('editCollectorModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><%= t('tech.mon.save','Simpan Perubahan') %></button>
</div>
</form>
</div>
</div>
<script>
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
function updateCollectorModeBadge(mode){
const isEdit = mode === 'edit';
const select = document.getElementById(isEdit ? 'e-auto-approve' : 'add-auto-approve');
const badge = document.getElementById(isEdit ? 'edit-mode-badge' : 'add-mode-badge');
if(!select || !badge) return;
const enabled = String(select.value) === '1';
badge.style.color = enabled ? '#22c55e' : '#fbbf24';
badge.innerHTML = enabled
? 'Mode saat ini: Pembayaran kolektor akan langsung membuat invoice lunas.'
: 'Mode saat ini: Pembayaran kolektor akan menunggu approval admin/kasir.';
}
function applyCollectorFilters(){
const mode = document.getElementById('collectorModeFilter')?.value || 'all';
const status = document.getElementById('collectorStatusFilter')?.value || 'all';
document.querySelectorAll('table.dtbl tbody tr[data-mode]').forEach((row) => {
const rowMode = row.getAttribute('data-mode') || '';
const rowStatus = row.getAttribute('data-status') || '';
const modeOk = mode === 'all' || rowMode === mode;
const statusOk = status === 'all' || rowStatus === status;
row.style.display = (modeOk && statusOk) ? '' : 'none';
});
}
function editCollector(c){
document.getElementById('editForm').action='/admin/collectors/'+c.id+'/update';
document.getElementById('e-name').value=c.name||'';
document.getElementById('e-username').value=c.username||'';
document.getElementById('e-password').value=c.password||'';
document.getElementById('e-phone').value=c.phone||'';
document.getElementById('e-area').value=c.area||'';
document.getElementById('e-status').value=String(c.is_active ? 1 : 0);
document.getElementById('e-auto-approve').value=String(c.auto_approve ? 1 : 0);
updateCollectorModeBadge('edit');
openModal('editCollectorModal');
}
document.getElementById('collectorModeFilter')?.addEventListener('change', applyCollectorFilters);
document.getElementById('collectorStatusFilter')?.addEventListener('change', applyCollectorFilters);
updateCollectorModeBadge('add');
updateCollectorModeBadge('edit');
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Diskon Tagihan | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.disc-filter{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
.cust-row.suspended td{opacity:.6}
.disc-badge{background:rgba(173,255,47,.16);color:#ADFF2F;border:1px solid rgba(173,255,47,.35);border-radius:999px;padding:2px 8px;font-size:11px;font-weight:700}
.disc-badge.amount{background:rgba(56,189,248,.14);color:#38BDF8;border-color:rgba(56,189,248,.35)}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'discounts', 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-percent"></i> Diskon Tagihan</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<form method="POST" action="/admin/discounts/apply" id="discForm">
<!-- Konfigurasi diskon -->
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-sliders"></i> Pengaturan Diskon</h6></div>
<div class="card-bd">
<div class="form-row">
<div class="fg">
<label class="fl">Target</label>
<select class="fs" name="apply_mode" id="applyMode">
<option value="selected" selected>Pelanggan terpilih (batch)</option>
<option value="single">Satu pelanggan (1 per 1)</option>
<option value="all">Semua pelanggan aktif</option>
</select>
</div>
<div class="fg" id="singleWrap" style="display:none">
<label class="fl">Pilih Pelanggan</label>
<select class="fs" name="customer_id">
<option value="">-- pilih pelanggan --</option>
<% customers.forEach(function(c){ %>
<option value="<%= c.id %>"><%= c.customer_code || c.id %> — <%= c.name %><%= c.area ? ' ('+c.area+')' : '' %></option>
<% }); %>
</select>
</div>
<div class="fg">
<label class="fl">Tipe Diskon</label>
<select class="fs" name="discount_type" id="discType">
<option value="percent">Persen (%)</option>
<option value="amount">Nominal (Rp)</option>
</select>
</div>
<div class="fg">
<label class="fl">Nilai Diskon</label>
<input class="fc" type="number" name="discount_value" id="discValue" min="1" step="1" placeholder="mis. 10 (persen) / 20000 (Rp)" required>
<div class="hint" id="discHint">Persen dihitung dari harga dasar (sebelum PPN/USO).</div>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Periode Tagihan — Bulan</label>
<select class="fs" name="period_month">
<% const mNames=['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
for(let m=1;m<=12;m++){ %>
<option value="<%= m %>" <%= (timeInfo.month===m)?'selected':'' %>><%= mNames[m-1] %></option>
<% } %>
</select>
</div>
<div class="fg">
<label class="fl">Tahun</label>
<select class="fs" name="period_year">
<% for(let y=(timeInfo.year||new Date().getFullYear())+1; y>=2023; y--){ %>
<option value="<%= y %>" <%= (timeInfo.year===y)?'selected':'' %>><%= y %></option>
<% } %>
</select>
</div>
<div class="fg" style="flex:2">
<label class="fl">Catatan (opsional)</label>
<input class="fc" name="note" placeholder="mis. promo bulan kemerdekaan">
</div>
</div>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-top:6px">
<div class="hint"><i class="bi bi-info-circle"></i> Diskon berlaku ke tagihan <b>belum bayar</b> pada periode terpilih. Tagihan lunas/hangus tidak diubah. Kode unik QRIS akan digenerate ulang dari nominal baru.</div>
<button type="submit" class="btn btn-p" id="applyBtn"><i class="bi bi-percent"></i> Terapkan Diskon</button>
</div>
</div>
</div>
<!-- Pilih pelanggan (batch) -->
<div class="card mb16" id="custCard">
<div class="card-hd">
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;width:100%">
<h6 style="margin:0"><i class="bi bi-people"></i> Pilih Pelanggan (<%= customers.length %>)</h6>
<div class="disc-filter">
<span class="disc-badge" id="selCount" title="Jumlah pelanggan terpilih">0 dipilih</span>
<label style="display:flex;align-items:center;gap:6px;font-size:12px;cursor:pointer">
<input type="checkbox" id="selectAll" style="width:16px;height:16px"> Pilih semua yang tampil
</label>
<input class="fc" id="custSearch" placeholder="Cari nama / ID / HP..." style="max-width:220px">
</div>
</div>
</div>
<div class="tbl-wrap" style="max-height:460px;overflow:auto">
<table class="dtbl" id="custTable">
<thead>
<tr>
<th style="width:34px"></th>
<th style="width:110px">ID Pelanggan</th>
<th><%= t('pwa.field.name','Nama') %></th>
<th style="width:130px">No. HP</th>
<th style="width:110px">Area</th>
<th style="width:90px"><%= t('common.status','Status') %></th>
<th style="width:70px">Tunggakan</th>
</tr>
</thead>
<tbody>
<% if (customers.length===0) { %>
<tr><td colspan="7" class="text-center text-muted" style="padding:24px">Tidak ada pelanggan.</td></tr>
<% } %>
<% customers.forEach(function(c){ %>
<tr class="cust-row <%= c.status==='suspended'?'suspended':'' %>">
<td><input type="checkbox" class="cust-sel" name="customer_ids" value="<%= c.id %>" style="width:16px;height:16px"></td>
<td class="mono" style="font-size:12px"><%= c.customer_code || ('#'+c.id) %></td>
<td><div class="fw6"><%= c.name %></div><div style="font-size:11px;color:var(--muted)"><%= c.pppoe_username||'' %></div></td>
<td style="font-size:12px"><%= c.phone||'-' %></td>
<td style="font-size:12px"><%= c.area||'-' %></td>
<td><span class="badge <%= c.status==='active'?'bs':(c.status==='suspended'?'bd':'bm') %>"><%= c.status %></span></td>
<td style="font-size:12px"><%= Number(c.unpaid_count||0) %></td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
</form>
<!-- Riwayat -->
<div class="card">
<div class="card-hd"><h6><i class="bi bi-clock-history"></i> Riwayat Diskon (100 terakhir)</h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th style="width:150px"><%= t('collector.col_time','Waktu') %></th>
<th><%= t('common.customer','Pelanggan') %></th>
<th style="width:110px"><%= t('pwa.field.period','Periode') %></th>
<th style="width:100px"><%= t('common.type','Tipe') %></th>
<th class="num" style="width:120px">Potongan</th>
<th class="num" style="width:120px">Awal → Akhir</th>
<th style="width:110px">Oleh</th>
<th>Catatan</th>
</tr>
</thead>
<tbody>
<% if (!logs || logs.length===0) { %>
<tr><td colspan="8" class="text-center text-muted" style="padding:24px">Belum ada riwayat diskon.</td></tr>
<% } %>
<% (logs||[]).forEach(function(l){ %>
<tr>
<td style="font-size:11px;color:var(--muted)"><%= l.created_at %></td>
<td style="font-size:12px"><%= l.customer_name || ('#'+l.customer_id) %><div style="font-size:10px;color:var(--muted)"><%= l.customer_code||'' %></div></td>
<td style="font-size:12px"><%= l.period_month %>/<%= l.period_year %></td>
<td><span class="disc-badge <%= l.discount_type==='amount'?'amount':'' %>"><%= l.discount_type==='percent' ? (l.discount_value+'%') : 'Rp' %></span></td>
<td class="num" style="color:var(--danger);font-weight:700">- Rp <%= Number(l.discount_amount||0).toLocaleString('id-ID') %></td>
<td class="num" style="font-size:12px">Rp <%= Number(l.amount_before||0).toLocaleString('id-ID') %> → <b>Rp <%= Number(l.amount_after||0).toLocaleString('id-ID') %></b></td>
<td style="font-size:12px"><%= l.applied_by||'-' %></td>
<td style="font-size:11px;color:var(--muted)"><%= l.note||'' %></td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
(function(){
var mode = document.getElementById('applyMode');
var singleWrap = document.getElementById('singleWrap');
var custCard = document.getElementById('custCard');
var type = document.getElementById('discType');
var hint = document.getElementById('discHint');
function syncMode(){
var m = mode.value;
singleWrap.style.display = (m === 'single') ? '' : 'none';
custCard.style.display = (m === 'selected') ? '' : 'none';
}
mode.addEventListener('change', function(){ syncMode(); updateSelCount(); }); syncMode();
function syncHint(){
hint.textContent = (type.value === 'percent')
? 'Persen dihitung dari harga dasar (sebelum PPN/USO). Contoh: 10 = potong 10%.'
: 'Nominal potongan langsung dalam Rupiah. Contoh: 20000 = potong Rp 20.000.';
}
type.addEventListener('change', syncHint); syncHint();
var all = document.getElementById('selectAll');
function visibleBoxes(){
var rows = document.querySelectorAll('#custTable tbody tr');
var out = [];
rows.forEach(function(tr){ if (tr.style.display !== 'none') { var b = tr.querySelector('.cust-sel'); if (b) out.push(b); } });
return out;
}
var selCount = document.getElementById('selCount');
var applyBtn = document.getElementById('applyBtn');
function updateSelCount(){
var n = document.querySelectorAll('#custTable .cust-sel:checked').length;
if (selCount) selCount.textContent = n + ' dipilih';
if (applyBtn) applyBtn.innerHTML = '<i class="bi bi-percent"></i> Terapkan Diskon' + ((mode.value === 'selected' && n) ? ' (' + n + ')' : '');
}
all.addEventListener('change', function(){ visibleBoxes().forEach(function(b){ b.checked = all.checked; }); updateSelCount(); });
document.getElementById('custTable').addEventListener('change', function(e){ if (e.target && e.target.classList.contains('cust-sel')) updateSelCount(); });
var search = document.getElementById('custSearch');
search.addEventListener('input', function(){
var s = search.value.toLowerCase();
document.querySelectorAll('#custTable tbody tr').forEach(function(tr){
if (!tr.querySelector('.cust-sel')) return;
tr.style.display = tr.textContent.toLowerCase().indexOf(s) !== -1 ? '' : 'none';
});
all.checked = false;
});
document.getElementById('discForm').addEventListener('submit', function(e){
var m = mode.value;
if (m === 'selected') {
var n = document.querySelectorAll('#custTable .cust-sel:checked').length;
if (n === 0) { e.preventDefault(); alert('Pilih minimal 1 pelanggan (atau pilih mode Semua/Satu).'); }
} else if (m === 'single') {
var sel = document.querySelector('select[name="customer_id"]');
if (!sel || !sel.value) { e.preventDefault(); alert('Pilih pelanggan terlebih dahulu.'); }
}
});
updateSelCount();
})();
</script>
</body>
</html>
+176
View File
@@ -0,0 +1,176 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Kas Masuk | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.val-box { background: var(--bg2); padding: 16px; border-radius: 12px; border: 1px solid var(--border); display: flex; align-items: center; gap: 14px; }
.val-icon { width: 44px; height: 44px; border-radius: 10px; display: grid; place-items: center; font-size: 20px; }
.val-text { font-size: 13px; color: var(--muted); }
.val-num { font-size: 22px; font-weight: 700; color: var(--text); line-height: 1.2; }
</style>
</head>
<body>
<%- include('../partials/sidebar', { activePage: 'cash_in', 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-cash-stack"></i> Kas Masuk (Lain-lain)</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<button class="btn btn-p btn-sm" onclick="openModal('addModal')"><i class="bi bi-plus-lg"></i> Tambah Kas Masuk</button>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="alert alert-w mb-3">
<i class="bi bi-info-circle-fill"></i> <strong>Informasi:</strong> Halaman ini digunakan untuk mencatat kas masuk / pemasukan <strong>selain</strong> dari pembayaran tagihan pelanggan (misal: modal awal, penjualan perangkat bekas, dll). Pendapatan tagihan pelanggan sudah otomatis dicatat di laporan.
</div>
<%
const totalThisMonth = cashIn.filter(e => e.date.startsWith(getNowLocal().substring(0,7))).reduce((a,b)=>a+Number(b.amount), 0);
const totalAll = cashIn.reduce((a,b)=>a+Number(b.amount), 0);
%>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(280px,1fr));gap:16px;margin-bottom:20px">
<div class="val-box">
<div class="val-icon" style="background:rgba(16, 185, 129, 0.1);color:#10b981"><i class="bi bi-graph-up-arrow"></i></div>
<div>
<div class="val-text">Kas Masuk Bulan Ini</div>
<div class="val-num">Rp <%= totalThisMonth.toLocaleString('id-ID') %></div>
</div>
</div>
<div class="val-box">
<div class="val-icon" style="background:rgba(59, 130, 246, 0.1);color:#3b82f6"><i class="bi bi-piggy-bank-fill"></i></div>
<div>
<div class="val-text">Total Kas Masuk (Lainnya)</div>
<div class="val-num">Rp <%= totalAll.toLocaleString('id-ID') %></div>
</div>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-hd"><h6><i class="bi bi-list-ul"></i> Riwayat Kas Masuk</h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('common.date','Tanggal') %></th>
<th><%= t('common.category','Kategori') %></th>
<th><%= t('common.desc_note','Deskripsi / Catatan') %></th>
<th>Metode</th>
<th class="num"><%= t('common.amount_qty','Jumlah') %></th>
<th>Pencatat</th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (cashIn.length===0) { %>
<tr><td colspan="7" class="text-center text-muted" style="padding:32px">Belum ada data kas masuk.</td></tr>
<% } %>
<% cashIn.forEach((item)=>{ %>
<tr>
<td style="white-space:nowrap"><%= formatDateLocal(item.date) %></td>
<td><span class="badge bs"><%= item.category %></span></td>
<td style="max-width:300px;white-space:normal">
<strong><%= item.description %></strong>
<% if(item.receipt_number){ %><div style="font-size:11px;color:var(--muted);margin-top:2px"><i class="bi bi-receipt"></i> <%= item.receipt_number %></div><% } %>
</td>
<td>
<span class="badge bm"><%= String(item.payment_method).toUpperCase() %></span>
</td>
<td class="num fw6" style="color:#10b981">+ Rp <%= Number(item.amount).toLocaleString('id-ID') %></td>
<td style="font-size:12px"><%= item.recorded_by_name || 'Admin' %></td>
<td>
<form method="POST" action="/admin/finance/cash-in/<%= item.id %>/delete" style="display:inline" onsubmit="return confirm('Hapus data kas masuk ini?')">
<button class="btn btn-d btn-icon btn-sm" title="Hapus"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Add -->
<div class="mo" id="addModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-plus-circle"></i> Catat Kas Masuk</span><button type="button" class="mc" onclick="closeModal('addModal')">&times;</button></div>
<form method="POST" action="/admin/finance/cash-in">
<div class="mbody">
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('common.date','Tanggal') %> <span class="text-danger">*</span></label>
<input type="date" class="fc" name="date" required value="<%= getNowLocal().substring(0,10) %>" max="<%= getNowLocal().substring(0,10) %>">
</div>
<div class="fg">
<label class="fl">Sumber / Kategori <span class="text-danger">*</span></label>
<select class="fc" name="category" required>
<option value="Modal Usaha">Modal Usaha / Suntikan Dana</option>
<option value="Penjualan Aset">Penjualan Aset/Barang Bekas</option>
<option value="Jasa Lainnya">Pendapatan Jasa Lainnya</option>
<option value="Lain-lain">Lain-lain</option>
</select>
</div>
</div>
<div class="fg">
<label class="fl">Jumlah / Nominal (Rp) <span class="text-danger">*</span></label>
<input type="text" class="fc" name="amount" id="amountInput" required placeholder="Contoh: 500.000" style="font-size:16px;font-weight:bold" autocomplete="off">
</div>
<div class="fg">
<label class="fl"><%= t('common.desc_note2','Deskripsi / Keterangan') %> <span class="text-danger">*</span></label>
<textarea class="fc" name="description" required placeholder="Suntikan modal dari owner..."></textarea>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Metode Penerimaan</label>
<select class="fc" name="payment_method">
<option value="cash">Tunai (Cash)</option>
<option value="transfer">Transfer Bank</option>
<option value="ewallet">E-Wallet (OVO/Dana/Gopay)</option>
</select>
</div>
<div class="fg">
<label class="fl">No. Referensi (Opsional)</label>
<input type="text" class="fc" name="receipt_number" placeholder="Bisa dikosongkan">
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> Simpan Pemasukan</button>
</div>
</form>
</div>
</div>
<script>
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
document.querySelectorAll('.mo').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show')}));
setTimeout(()=>document.querySelectorAll('.alert').forEach(a=>a.style.display='none'),5000);
const amtInput = document.getElementById('amountInput');
if(amtInput){
amtInput.addEventListener('keyup', function(e) {
let val = this.value.replace(/[^0-9]/g, '');
if(val){
this.value = parseInt(val, 10).toLocaleString('id-ID');
} else {
this.value = '';
}
});
}
</script>
</body>
</html>
+121
View File
@@ -0,0 +1,121 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Kategori Pengeluaran | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 16px; }
.cat-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 16px; display: flex; align-items: center; justify-content: space-between; transition: all 0.2s; }
.cat-card:hover { border-color: rgba(255,255,255,0.2); transform: translateY(-2px); }
.cat-info { display: flex; align-items: center; gap: 14px; }
.cat-icon { width: 42px; height: 42px; border-radius: 10px; display: grid; place-items: center; font-size: 18px; }
.cat-name { font-weight: 600; color: var(--text); font-size: 15px; margin-bottom: 2px; }
.cat-desc { font-size: 12px; color: var(--muted); line-height: 1.3; }
.icon-sel { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.icon-sel label { cursor: pointer; }
.icon-sel input { display: none; }
.icon-box { width: 36px; height: 36px; border-radius: 8px; background: rgba(255,255,255,0.05); border: 1px solid transparent; display: grid; place-items: center; font-size: 16px; color: var(--muted); transition: 0.2s; }
.icon-sel input:checked + .icon-box { background: rgba(59, 130, 246, 0.2); color: #3b82f6; border-color: #3b82f6; }
</style>
</head>
<body>
<%- include('../partials/sidebar', { activePage: 'expense_categories', 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-tags"></i> Kategori Pengeluaran</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<button class="btn btn-p btn-sm" onclick="openModal('addModal')"><i class="bi bi-plus-lg"></i> Tambah Kategori</button>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="alert alert-info mb-4" style="background:rgba(59,130,246,0.1); border:1px solid rgba(59,130,246,0.2); color:#bfdbfe;">
<i class="bi bi-lightbulb-fill"></i> Kategori ini digunakan untuk mengelompokkan jenis pengeluaran agar laporan laba rugi lebih mudah dibaca.
</div>
<div class="cat-grid">
<% categories.forEach(cat => { %>
<div class="cat-card">
<div class="cat-info">
<div class="cat-icon" <%- 'style="background: ' + cat.color + '20; color: ' + cat.color + ';"' %>>
<i class="<%= cat.icon || 'bi bi-tag' %>"></i>
</div>
<div>
<div class="cat-name"><%= cat.name %></div>
<div class="cat-desc"><%= cat.description || 'Tidak ada deskripsi' %></div>
</div>
</div>
<form method="POST" action="/admin/finance/expense-categories/<%= cat.id %>/delete" onsubmit="return confirm('Hapus kategori ini? (Pastikan tidak ada pengeluaran yang menggunakannya)')">
<button class="btn btn-d btn-icon btn-sm" style="opacity:0.7"><i class="bi bi-trash"></i></button>
</form>
</div>
<% }) %>
<% if(categories.length === 0) { %>
<div style="grid-column: 1/-1; padding: 40px; text-align:center; color:var(--muted); background:var(--bg2); border-radius:12px">
Belum ada kategori yang ditambahkan.
</div>
<% } %>
</div>
</div>
</div>
<!-- Modal Add -->
<div class="mo" id="addModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-plus-circle"></i> Tambah Kategori</span><button type="button" class="mc" onclick="closeModal('addModal')">&times;</button></div>
<form method="POST" action="/admin/finance/expense-categories">
<div class="mbody">
<div class="fg">
<label class="fl">Nama Kategori <span class="text-danger">*</span></label>
<input type="text" class="fc" name="name" required placeholder="Contoh: Bensin Kendaraan">
</div>
<div class="fg">
<label class="fl"><%= t('common.description','Deskripsi') %></label>
<textarea class="fc" name="description" placeholder="Beli Router Mikrotik RB750Gr3..."></textarea>
</div>
<div class="form-row mt-3">
<div class="fg">
<label class="fl">Pilih Ikon</label>
<div class="icon-sel">
<% const icons = ['bi-tag', 'bi-tools', 'bi-router', 'bi-wallet2', 'bi-lightning-charge', 'bi-car-front', 'bi-briefcase', 'bi-cup-hot', 'bi-house', 'bi-megaphone']; %>
<% icons.forEach((icon, i) => { %>
<label>
<input type="radio" name="icon" value="bi <%= icon %>" <%= i===0?'checked':'' %>>
<div class="icon-box"><i class="bi <%= icon %>"></i></div>
</label>
<% }) %>
</div>
</div>
<div class="fg" style="max-width: 120px;">
<label class="fl">Warna Label</label>
<input type="color" class="fc" name="color" value="#3b82f6" style="padding: 4px; height: 42px; cursor: pointer;">
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p">Simpan Kategori</button>
</div>
</form>
</div>
</div>
<script>
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
document.querySelectorAll('.mo').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show')}));
setTimeout(()=>document.querySelectorAll('.alert').forEach(a=>a.style.display='none'),5000);
</script>
</body>
</html>
+185
View File
@@ -0,0 +1,185 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Pengeluaran | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.val-box { background: var(--bg2); padding: 16px; border-radius: 12px; border: 1px solid var(--border); display: flex; align-items: center; gap: 14px; }
.val-icon { width: 44px; height: 44px; border-radius: 10px; display: grid; place-items: center; font-size: 20px; }
.val-text { font-size: 13px; color: var(--muted); }
.val-num { font-size: 22px; font-weight: 700; color: var(--text); line-height: 1.2; }
.filter-bar{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:14px 18px;margin-bottom:18px;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
</style>
</head>
<body>
<%- include('../partials/sidebar', { activePage: 'expenses', 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-wallet2"></i> Pengeluaran (Cash Out)</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<button class="btn btn-p btn-sm" onclick="openModal('addModal')"><i class="bi bi-plus-lg"></i> Catat Pengeluaran</button>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<%
const totalThisMonth = expenses.filter(e => e.date.startsWith(getNowLocal().substring(0,7))).reduce((a,b)=>a+Number(b.amount), 0);
const totalAll = expenses.reduce((a,b)=>a+Number(b.amount), 0);
%>
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(280px,1fr));gap:16px;margin-bottom:20px">
<div class="val-box">
<div class="val-icon" style="background:rgba(239, 68, 68, 0.1);color:#ef4444"><i class="bi bi-graph-down-arrow"></i></div>
<div>
<div class="val-text">Pengeluaran Bulan Ini</div>
<div class="val-num">Rp <%= totalThisMonth.toLocaleString('id-ID') %></div>
</div>
</div>
<div class="val-box">
<div class="val-icon" style="background:rgba(99, 102, 241, 0.1);color:#6366f1"><i class="bi bi-wallet-fill"></i></div>
<div>
<div class="val-text">Total Pengeluaran Dicatat</div>
<div class="val-num">Rp <%= totalAll.toLocaleString('id-ID') %></div>
</div>
</div>
</div>
<!-- Table -->
<div class="card">
<div class="card-hd"><h6><i class="bi bi-list-ul"></i> Riwayat Pengeluaran</h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('common.date','Tanggal') %></th>
<th><%= t('common.category','Kategori') %></th>
<th><%= t('common.desc_note','Deskripsi / Catatan') %></th>
<th>Vendor/Toko</th>
<th>Metode</th>
<th class="num"><%= t('common.amount_qty','Jumlah') %></th>
<th>Pencatat</th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (expenses.length===0) { %>
<tr><td colspan="8" class="text-center text-muted" style="padding:32px">Belum ada data pengeluaran.</td></tr>
<% } %>
<% expenses.forEach((item)=>{ %>
<tr>
<td style="white-space:nowrap"><%= formatDateLocal(item.date) %></td>
<td>
<span class="badge" <%- 'style="background: ' + (item.category_color ? item.category_color+'20' : 'rgba(255,255,255,0.1)') + '; color: ' + (item.category_color || 'white') + '; border: 1px solid ' + (item.category_color ? item.category_color+'50' : 'rgba(255,255,255,0.2)') + ';"' %>>
<i class="<%= item.category_icon || 'bi bi-tag' %>"></i> <%= item.category %>
</span>
</td>
<td style="max-width:300px;white-space:normal">
<strong><%= item.description %></strong>
<% if(item.receipt_number){ %><div style="font-size:11px;color:var(--muted);margin-top:2px"><i class="bi bi-receipt"></i> <%= item.receipt_number %></div><% } %>
</td>
<td><%= item.vendor || '-' %></td>
<td>
<span class="badge bm"><%= String(item.payment_method).toUpperCase() %></span>
</td>
<td class="num fw6" style="color:#ef4444">- Rp <%= Number(item.amount).toLocaleString('id-ID') %></td>
<td style="font-size:12px"><%= item.recorded_by_name || 'Admin' %></td>
<td>
<form method="POST" action="/admin/finance/expenses/<%= item.id %>/delete" style="display:inline" onsubmit="return confirm('Hapus data pengeluaran ini?')">
<button class="btn btn-d btn-icon btn-sm" title="Hapus"><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Add -->
<div class="mo" id="addModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-plus-circle"></i> Catat Pengeluaran Baru</span><button type="button" class="mc" onclick="closeModal('addModal')">&times;</button></div>
<form method="POST" action="/admin/finance/expenses">
<div class="mbody">
<div class="form-row">
<div class="fg">
<label class="fl">Tanggal Pengeluaran <span class="text-danger">*</span></label>
<input type="date" class="fc" name="date" required value="<%= getNowLocal().substring(0,10) %>" max="<%= getNowLocal().substring(0,10) %>">
</div>
<div class="fg">
<label class="fl"><%= t('common.category','Kategori') %> <span class="text-danger">*</span></label>
<select class="fc" name="category" required>
<option value="">-- Pilih Kategori --</option>
<% categories.forEach(c => { %>
<option value="<%= c.name %>"><%= c.name %></option>
<% }) %>
</select>
</div>
</div>
<div class="fg">
<label class="fl">Jumlah / Nominal (Rp) <span class="text-danger">*</span></label>
<input type="text" class="fc" name="amount" id="amountInput" required placeholder="Contoh: 150.000" style="font-size:16px;font-weight:bold" autocomplete="off">
</div>
<div class="fg">
<label class="fl"><%= t('common.desc_note2','Deskripsi / Keterangan') %> <span class="text-danger">*</span></label>
<textarea class="fc" name="description" required placeholder="Beli Router Mikrotik RB750Gr3..."></textarea>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Nama Toko/Vendor (Opsional)</label>
<input type="text" class="fc" name="vendor" placeholder="Misal: Tokopedia / Toko Komputer X">
</div>
<div class="fg">
<label class="fl">No. Nota / Kwitansi (Opsional)</label>
<input type="text" class="fc" name="receipt_number" placeholder="INV-123456">
</div>
</div>
<div class="fg">
<label class="fl">Metode Pembayaran</label>
<select class="fc" name="payment_method">
<option value="cash">Tunai (Cash)</option>
<option value="transfer">Transfer Bank</option>
<option value="ewallet">E-Wallet (OVO/Dana/Gopay)</option>
<option value="credit">Kredit / Paylater</option>
</select>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> Simpan Pengeluaran</button>
</div>
</form>
</div>
</div>
<script>
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
document.querySelectorAll('.mo').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show')}));
setTimeout(()=>document.querySelectorAll('.alert').forEach(a=>a.style.display='none'),5000);
// Format Rupiah Input
const amtInput = document.getElementById('amountInput');
if(amtInput){
amtInput.addEventListener('keyup', function(e) {
let val = this.value.replace(/[^0-9]/g, '');
if(val){
this.value = parseInt(val, 10).toLocaleString('id-ID');
} else {
this.value = '';
}
});
}
</script>
</body>
</html>
+310
View File
@@ -0,0 +1,310 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.inv-tabs { display: flex; gap: 4px; background: var(--bg3); padding: 4px; border-radius: 10px; margin-bottom: 20px; width: fit-content; }
.inv-tab { padding: 8px 20px; border-radius: 7px; border: none; background: transparent; color: var(--muted); font-size: 13px; font-weight: 500; cursor: pointer; transition: all .2s; }
.inv-tab.active { background: var(--bg2); color: var(--text); box-shadow: 0 2px 8px rgba(0,0,0,.3); }
.stock-badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
.stock-low { background: var(--ddim); color: var(--danger); }
.stock-ok { background: var(--sdim); color: var(--success); }
.log-type-in { color: var(--success); }
.log-type-out { color: var(--danger); }
.log-type-adjust { color: var(--warning); }
.flex-1 { flex: 1 1 auto; }
.w300 { width: 300px; }
.fs11 { font-size: 11px; }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'inventory', 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-boxes"></i>
<span><%= title %></span>
</div>
<div class="tb-right">
<button class="btn btn-p btn-sm" onclick="openAddItem()"><i class="bi bi-plus-lg"></i> <%= t('admin.inventory.add_item', 'Tambah Barang') %></button>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'success' ? 's' : 'd' %> mb16" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-triangle' %>"></i>
<%= msg.text %>
</div>
<% } %>
<div class="inv-tabs">
<button class="inv-tab active" onclick="showTab('items')"><%= t('admin.inventory.tab_items', 'Daftar Barang') %></button>
<button class="inv-tab" onclick="showTab('categories')"><%= t('admin.inventory.tab_categories', 'Kategori') %></button>
<button class="inv-tab" onclick="showTab('logs')"><%= t('admin.inventory.tab_logs', 'Riwayat Stok') %></button>
</div>
<!-- TABS: ITEMS -->
<div id="tab-items" class="tab-content">
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-ul"></i> <%= t('admin.inventory.title', 'Semua Barang Inventaris') %></h6>
<div class="sinput">
<i class="bi bi-search"></i>
<input type="text" class="fc fc-sm" placeholder="<%= t('admin.inventory.search', 'Cari barang...') %>" onkeyup="filterItems(this.value)">
</div>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('admin.inventory.th_name', 'Nama Barang') %></th>
<th><%= t('admin.inventory.th_category', 'Kategori') %></th>
<th><%= t('admin.inventory.th_brand', 'Brand / Model') %></th>
<th class="num"><%= t('admin.inventory.th_stock', 'Stok Tersedia') %></th>
<th class="num"><%= t('admin.inventory.th_assigned', 'Terpasang') %></th>
<th class="text-right"><%= t('admin.inventory.th_action', 'Aksi') %></th>
</tr>
</thead>
<tbody id="items-list">
<% items.forEach(item => { %>
<tr>
<td>
<div class="fw6"><%= item.name %></div>
<div class="text-muted fs11"><%= item.description %></div>
</td>
<td><span class="badge bp"><%= item.category_name || '-' %></span></td>
<td><%= item.brand %> <%= item.model %></td>
<td class="num">
<span class="stock-badge <%= (item.stock_available || 0) <= item.min_stock ? 'stock-low' : 'stock-ok' %>">
<%= item.stock_available || 0 %> <%= item.unit %>
</span>
</td>
<td class="num text-muted"><%= item.stock_assigned || 0 %> <%= item.unit %></td>
<td style="white-space:nowrap">
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" <%- `onclick="viewStock(${item.id}, '${item.name}')"` %>><i class="bi bi-eye"></i> <%= t('admin.inventory.detail_stock', 'Detail Stok') %></button>
<button type="button" class="act-item success" <%- `onclick="openAddStock(${item.id}, '${item.name}')"` %>><i class="bi bi-plus-circle"></i> <%= t('admin.inventory.add_stock', 'Tambah Stok') %></button>
<button type="button" class="act-item" <%- `onclick='openEditItem(${JSON.stringify(item)})'` %>><i class="bi bi-pencil"></i> Edit Barang</button>
</div>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
<!-- TABS: CATEGORIES -->
<div id="tab-categories" class="tab-content" style="display:none">
<div class="form-row">
<div class="card flex-1">
<div class="card-hd"><h6><i class="bi bi-tags"></i> <%= t('admin.inventory.cat_title', 'Daftar Kategori') %></h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('admin.inventory.cat_name', 'Nama Kategori') %></th>
<th><%= t('admin.inventory.cat_desc', 'Keterangan') %></th>
<th class="text-right"><%= t('admin.inventory.th_action', 'Aksi') %></th>
</tr>
</thead>
<tbody>
<% categories.forEach(cat => { %>
<tr>
<td class="fw6"><%= cat.name %></td>
<td class="text-muted"><%= cat.description %></td>
<td>
<div class="flex jc-fe gap">
<button class="btn btn-g btn-icon" <%- `onclick='openEditCategory(${JSON.stringify(cat)})'` %>><i class="bi bi-pencil"></i></button>
<form action="/admin/inventory/category/delete/<%= cat.id %>" method="POST" onsubmit="return confirm('<%= t('admin.inventory.confirm_del_cat', 'Hapus kategori ini?') %>')">
<button class="btn btn-g btn-icon text-danger"><i class="bi bi-trash"></i></button>
</form>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<div class="card w300">
<div class="card-hd"><h6><i class="bi bi-plus-lg"></i> <%= t('admin.inventory.cat_add', 'Tambah Kategori') %></h6></div>
<div class="card-bd">
<form action="/admin/inventory/category/add" method="POST">
<div class="fg"><label class="fl"><%= t('admin.inventory.cat_name', 'Nama Kategori') %></label><input class="fc" name="name" required></div>
<div class="fg"><label class="fl"><%= t('admin.inventory.cat_desc', 'Keterangan') %></label><textarea class="fc" name="description"></textarea></div>
<button class="btn btn-p w-100"><%= t('admin.inventory.cat_save', 'Simpan Kategori') %></button>
</form>
</div>
</div>
</div>
</div>
<!-- TABS: LOGS -->
<div id="tab-logs" class="tab-content" style="display:none">
<div class="card">
<div class="card-hd"><h6><i class="bi bi-clock-history"></i> <%= t('admin.inventory.logs_title', 'Riwayat Pergerakan Barang') %></h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('admin.inventory.th_time', 'Waktu') %></th>
<th><%= t('admin.inventory.th_item', 'Barang') %></th>
<th><%= t('admin.inventory.th_sn', 'SN') %></th>
<th><%= t('admin.inventory.th_type', 'Tipe') %></th>
<th class="num"><%= t('admin.inventory.th_qty', 'Qty') %></th>
<th><%= t('admin.inventory.th_actor', 'Petugas') %></th>
<th><%= t('admin.inventory.th_note', 'Catatan') %></th>
</tr>
</thead>
<tbody>
<% logs.forEach(log => { %>
<tr>
<td class="text-muted" style="font-size:11px"><%= new Date(log.created_at).toLocaleString('id-ID') %></td>
<td class="fw6"><%= log.item_name %></td>
<td class="text-muted"><%= log.serial_number || '-' %></td>
<td>
<span class="badge log-type-<%= log.type %>">
<%= log.type === 'in' ? t('admin.inventory.type_in','Masuk') : log.type === 'out' ? t('admin.inventory.type_out','Keluar') : log.type === 'adjust' ? t('admin.inventory.type_adjust','Penyesuaian') : log.type %>
</span>
</td>
<td class="num fw6"><%= log.quantity > 0 ? '+' : '' %><%= log.quantity %></td>
<td><%= log.actor %></td>
<td class="text-muted"><%= log.note %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL: ADD/EDIT ITEM -->
<div class="mo" id="modal-item">
<div class="mb">
<div class="mh"><div class="mt" id="item-modal-title"><%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %></div><button class="mc" onclick="closeModal('modal-item')">&times;</button></div>
<form id="item-form" method="POST" action="/admin/inventory/item/add">
<div class="mbody">
<div class="fg">
<label class="fl"><%= t('admin.inventory.f_category', 'Kategori') %></label>
<select class="fs" name="category_id" id="i_category_id" required>
<option value=""><%= t('admin.inventory.f_category_ph', '-- Pilih Kategori --') %></option>
<% categories.forEach(cat => { %>
<option value="<%= cat.id %>"><%= cat.name %></option>
<% }) %>
</select>
</div>
<div class="fg"><label class="fl"><%= t('admin.inventory.f_name', 'Nama Barang') %></label><input class="fc" name="name" id="i_name" required></div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.inventory.f_brand', 'Brand') %></label><input class="fc" name="brand" id="i_brand"></div>
<div class="fg"><label class="fl"><%= t('admin.inventory.f_model', 'Model') %></label><input class="fc" name="model" id="i_model"></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.inventory.f_unit', 'Satuan (Unit)') %></label><input class="fc" name="unit" id="i_unit" value="pcs" required></div>
<div class="fg"><label class="fl"><%= t('admin.inventory.f_min_stock', 'Stok Minimum') %></label><input class="fc" type="number" name="min_stock" id="i_min_stock" value="5" required></div>
</div>
<div class="fg"><label class="fl"><%= t('admin.inventory.f_desc', 'Deskripsi') %></label><textarea class="fc" name="description" id="i_description"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-item')"><%= t('common.cancel', 'Batal') %></button><button class="btn btn-p"><%= t('admin.inventory.save_item', 'Simpan Barang') %></button></div>
</form>
</div>
</div>
<!-- MODAL: ADD STOCK -->
<div class="mo" id="modal-stock">
<div class="mb">
<div class="mh"><div class="mt"><%= t('admin.inventory.stock_modal', 'Input Stok Masuk') %></div><button class="mc" onclick="closeModal('modal-stock')">&times;</button></div>
<form action="/admin/inventory/stock/add" method="POST">
<input type="hidden" name="item_id" id="s_item_id">
<div class="mbody">
<div class="fg"><label class="fl"><%= t('admin.inventory.f_name', 'Nama Barang') %></label><input class="fc" id="s_item_name" readonly></div>
<div class="fg"><label class="fl"><%= t('admin.inventory.f_serial', 'Serial Number (Opsional)') %></label><input class="fc" name="serial_number" placeholder="<%= t('admin.inventory.f_serial_ph', 'Contoh: SN12345678') %>"></div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.inventory.f_qty', 'Jumlah (Qty)') %></label><input class="fc" type="number" name="quantity" value="1" min="1" required></div>
<div class="fg">
<label class="fl"><%= t('admin.inventory.f_condition', 'Kondisi') %></label>
<select class="fs" name="condition">
<option value="new"><%= t('admin.inventory.cond_new', 'Baru') %></option>
<option value="used"><%= t('admin.inventory.cond_used', 'Bekas / Copotan') %></option>
</select>
</div>
</div>
<div class="fg"><label class="fl"><%= t('admin.inventory.f_location', 'Lokasi Penyimpanan') %></label><input class="fc" name="location" value="Gudang Utama"></div>
<div class="fg"><label class="fl"><%= t('admin.inventory.f_note', 'Catatan') %></label><textarea class="fc" name="note" placeholder="<%= t('admin.inventory.f_note_ph', 'Contoh: Pembelian dari Supplier X') %>"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-stock')"><%= t('common.cancel', 'Batal') %></button><button class="btn btn-p"><%= t('admin.inventory.add_stock', 'Tambah Stok') %></button></div>
</form>
</div>
</div>
<script>
var I18N_INV = {
addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>",
editTitle: "<%= t('admin.inventory.modal_edit', 'Edit Barang') %>"
};
function showTab(tab) {
document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
document.querySelectorAll('.inv-tab').forEach(el => el.classList.remove('active'));
document.getElementById('tab-' + tab).style.display = 'block';
event.currentTarget.classList.add('active');
}
function openAddItem() {
document.getElementById('item-modal-title').innerText = I18N_INV.addTitle;
document.getElementById('item-form').action = '/admin/inventory/item/add';
document.getElementById('item-form').reset();
document.getElementById('modal-item').classList.add('show');
}
function openEditItem(item) {
document.getElementById('item-modal-title').innerText = I18N_INV.editTitle;
document.getElementById('item-form').action = '/admin/inventory/item/edit/' + item.id;
document.getElementById('i_category_id').value = item.category_id || '';
document.getElementById('i_name').value = item.name;
document.getElementById('i_brand').value = item.brand;
document.getElementById('i_model').value = item.model;
document.getElementById('i_unit').value = item.unit;
document.getElementById('i_min_stock').value = item.min_stock;
document.getElementById('i_description').value = item.description;
document.getElementById('modal-item').classList.add('show');
}
function openAddStock(itemId, itemName) {
document.getElementById('s_item_id').value = itemId;
document.getElementById('s_item_name').value = itemName;
document.getElementById('modal-stock').classList.add('show');
}
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function filterItems(q) {
const rows = document.querySelectorAll('#items-list tr');
q = q.toLowerCase();
rows.forEach(row => {
const text = row.innerText.toLowerCase();
row.style.display = text.includes(q) ? '' : 'none';
});
}
function toggleSidebar() {
var sb = document.getElementById('sidebar') || document.querySelector('.sidebar');
var ov = document.getElementById('sidebarOverlay');
if (sb) sb.classList.toggle('open');
if (ov) ov.classList.toggle('show');
}
</script>
</body>
</html>
+165
View File
@@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('common.login', 'Login') %> Admin | <%= company %>
</title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
body {
background: radial-gradient(circle at top right, #1e1b4b, #0d1117);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-box {
width: 100%;
max-width: 400px;
animation: fadeIn 0.6s ease-out;
}
.login-card {
background: rgba(22, 27, 34, 0.8);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 40px 32px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
.login-head {
text-align: center;
margin-bottom: 32px;
}
.login-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--primary), #818cf8);
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin: 0 auto 20px;
box-shadow: 0 10px 20px rgba(99, 102, 241, 0.3);
}
.login-title {
font-size: 20px;
font-weight: 700;
color: #fff;
margin-bottom: 4px;
}
.login-sub {
font-size: 13px;
color: var(--muted);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.alert-d {
background: rgba(248, 81, 73, 0.1);
color: #ff7b72;
border: 1px solid rgba(248, 81, 73, 0.2);
padding: 12px;
border-radius: 10px;
font-size: 13px;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.back-btn {
display: block;
text-align: center;
margin-top: 24px;
font-size: 12px;
color: var(--muted);
text-decoration: none;
transition: color 0.2s;
}
.back-btn:hover {
color: var(--primary);
}
</style>
</head>
<body>
<button type="button" class="theme-toggle-btn" onclick="window.toggleAppTheme&&window.toggleAppTheme()" aria-label="Ganti tema" title="Mode Gelap / Terang" style="position:fixed;top:16px;right:16px;z-index:50;width:42px;height:42px;border-radius:12px;border:1px solid var(--border);background:var(--bg2);color:var(--text);cursor:pointer;display:grid;place-items:center"><i class="bi bi-circle-half"></i></button>
<div class="login-box">
<div class="login-card">
<div class="login-head">
<div class="login-icon">
<i class="bi bi-shield-lock-fill"></i>
</div>
<h1 class="login-title">
<%= company %>
</h1>
<p class="login-sub"><%= t('admin.login.subtitle', 'Administrator Portal') %></p>
</div>
<% if (typeof error !=='undefined' && error) { %>
<div class="alert-d">
<i class="bi bi-exclamation-circle"></i>
<%= error %>
</div>
<% } %>
<form method="POST" action="/admin/login">
<div class="fg">
<label class="fl"><%= t('admin.login.username', 'Username') %></label>
<div style="position:relative">
<i class="bi bi-person" style="position:absolute;left:13px;top:10px;color:var(--muted)"></i>
<input class="fc" name="username" placeholder="admin" required style="padding-left:40px">
</div>
</div>
<div class="fg" style="margin-bottom:24px">
<label class="fl"><%= t('admin.login.password', 'Password') %></label>
<div style="position:relative">
<i class="bi bi-key" style="position:absolute;left:13px;top:10px;color:var(--muted)"></i>
<input class="fc" type="password" name="password" placeholder="••••••••" required
style="padding-left:40px">
</div>
</div>
<button type="submit" class="btn btn-p w-100" style="padding:12px;justify-content:center">
<i class="bi bi-box-arrow-in-right"></i> <%= t('admin.login.sign_in', 'Masuk Sekarang') %>
</button>
</form>
<a href="/customer/login" class="back-btn">
<i class="bi bi-arrow-left"></i> <%= t('admin.login.back_customer', 'Kembali ke Portal Pelanggan') %>
</a>
</div>
</div>
<style>
.w-100 {
width: 100%;
}
</style>
</body>
</html>
File diff suppressed because it is too large. Load diff
+2309
View File
File diff suppressed because it is too large. Load diff
+569
View File
@@ -0,0 +1,569 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
body, html { width: 100%; height: 100%; overflow: hidden; background: #0A0D14; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; color: #F2F5F1; }
#map { width: 100%; height: 100%; z-index: 1; background: #080A0F; }
/* Floating Header Card */
.top-controls {
position: absolute;
top: 10px;
left: 10px;
right: 10px;
z-index: 1000;
background: rgba(15, 23, 42, 0.92);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid #1F293D;
border-radius: 16px;
padding: 10px 12px;
box-shadow: 0 10px 25px rgba(0,0,0,0.5);
}
.stats-bar {
display: flex;
gap: 6px;
margin-bottom: 8px;
}
.stat-chip {
flex: 1;
background: #111622;
border: 1px solid #1F293D;
border-radius: 10px;
padding: 6px 8px;
text-align: center;
}
.stat-chip .val { display: block; font-size: 15px; font-weight: 800; line-height: 1.2; }
.stat-chip .lbl { display: block; font-size: 9.5px; color: #8A96A6; font-weight: 700; text-transform: uppercase; }
.sc-odp .val { color: #F59E0B; }
.sc-active .val { color: #ADFF2F; }
.sc-iso .val { color: #EF4444; }
.sc-total .val { color: #4ADE80; }
.search-row {
display: flex;
align-items: center;
gap: 8px;
}
.search-wrap {
flex: 1;
position: relative;
}
.search-wrap i {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #8A96A6;
font-size: 13px;
}
.search-input {
width: 100%;
background: #111622;
border: 1px solid #1F293D;
color: #F2F5F1;
border-radius: 10px;
padding: 8px 12px 8px 32px;
font-size: 13px;
outline: none;
}
.search-input:focus {
border-color: #ADFF2F;
}
.filter-pills {
display: flex;
gap: 6px;
margin-top: 8px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: none;
}
.filter-pills::-webkit-scrollbar { display: none; }
.pill-btn {
padding: 4px 10px;
background: #111622;
border: 1px solid #1F293D;
border-radius: 20px;
color: #8A96A6;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
cursor: pointer;
}
.pill-btn.active {
background: #ADFF2F;
border-color: #3B82F6;
color: #FFFFFF;
}
/* GPS Locate Button */
.btn-gps {
position: absolute;
bottom: 24px;
right: 14px;
z-index: 1000;
width: 46px;
height: 46px;
border-radius: 50%;
background: #ADFF2F;
color: #FFFFFF;
border: none;
box-shadow: 0 4px 15px rgba(37,99,235,0.5);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
cursor: pointer;
}
.btn-gps:active {
background: #1D4ED8;
transform: scale(0.95);
}
/* Map Custom Marker Icons */
.odp-marker {
background: #F59E0B;
color: #000;
border-radius: 8px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
border: 2px solid #FFFFFF;
}
.onu-marker-active {
background: #ADFF2F;
color: #FFF;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 2px 8px rgba(37,99,235,0.7);
border: 2px solid #FFFFFF;
}
.onu-marker-iso {
background: #DC2626;
color: #FFF;
border-radius: 50%;
width: 26px;
height: 26px;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
box-shadow: 0 2px 8px rgba(220,38,38,0.7);
border: 2px solid #FFFFFF;
}
.marker-label-text {
background: rgba(15,23,42,0.85);
color: #F2F5F1;
font-size: 10px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
white-space: nowrap;
border: 1px solid #1F293D;
margin-top: 2px;
display: inline-block;
text-shadow: 0 1px 2px #000;
}
/* Cable Line Flowing Animation */
@keyframes cableFlow {
from { stroke-dashoffset: 30; }
to { stroke-dashoffset: 0; }
}
.flowing-cable {
stroke-dasharray: 8, 8;
animation: cableFlow 1.2s linear infinite;
}
/* Popups */
.leaflet-popup-content-wrapper {
background: #111622 !important;
color: #F2F5F1 !important;
border-radius: 16px !important;
border: 1px solid #1F293D !important;
box-shadow: 0 10px 30px rgba(0,0,0,0.6) !important;
padding: 0 !important;
}
.leaflet-popup-content {
margin: 14px !important;
line-height: 1.4 !important;
font-size: 12.5px !important;
min-width: 220px;
max-width: 280px;
}
.leaflet-popup-tip {
background: #111622 !important;
}
.pop-title {
font-size: 14px;
font-weight: 800;
color: #ADFF2F;
margin-bottom: 4px;
display: flex;
align-items: center;
gap: 6px;
}
.pop-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 6px;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
margin-bottom: 8px;
}
.pop-badge.active { background: #15803D; color: #DCFCE7; }
.pop-badge.iso { background: #991B1B; color: #FEE2E2; }
.pop-row {
display: flex;
justify-content: space-between;
padding: 3px 0;
border-bottom: 1px solid #1F293D;
font-size: 11.5px;
}
.pop-row .lbl { color: #8A96A6; }
.pop-row .val { color: #F2F5F1; font-weight: 600; }
.pop-actions {
display: flex;
gap: 6px;
margin-top: 10px;
}
.pop-btn {
flex: 1;
padding: 7px 10px;
border-radius: 8px;
font-size: 11.5px;
font-weight: 700;
text-align: center;
text-decoration: none;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
border: none;
cursor: pointer;
}
.btn-wa { background: #16A34A; color: #FFF; }
.btn-dir { background: #ADFF2F; color: #FFF; }
</style>
</head>
<body>
<div class="top-controls">
<div class="stats-bar">
<div class="stat-chip sc-odp">
<span class="val" id="cntOdp"><%= odps.length %></span>
<span class="lbl">ODP</span>
</div>
<div class="stat-chip sc-total">
<span class="val" id="cntTotal"><%= customers.length %></span>
<span class="lbl">Total ONU</span>
</div>
<div class="stat-chip sc-active">
<span class="val" id="cntActive"><%= customers.filter(c => c.status === 'active').length %></span>
<span class="lbl"><%= t('common.active','Aktif') %></span>
</div>
<div class="stat-chip sc-iso">
<span class="val" id="cntIso"><%= customers.filter(c => c.status !== 'active').length %></span>
<span class="lbl">Isolir</span>
</div>
</div>
<div class="search-row">
<div class="search-wrap">
<i class="bi bi-search"></i>
<input type="text" id="searchInput" class="search-input" placeholder="Cari nama, PPPoE, ODP, atau alamat...">
</div>
</div>
<div class="filter-pills">
<button class="pill-btn active" data-filter="all"><%= t('collector.filter_all','Semua') %></button>
<button class="pill-btn" data-filter="active"><i class="bi bi-circle-fill" style="color:#22c55e"></i> Aktif (<%= customers.filter(c => c.status === 'active').length %>)</button>
<button class="pill-btn" data-filter="isolated"><i class="bi bi-circle-fill" style="color:#ef4444"></i> Isolir (<%= customers.filter(c => c.status !== 'active').length %>)</button>
<button class="pill-btn" data-filter="odp"><i class="bi bi-archive-fill"></i> ODP Saja (<%= odps.length %>)</button>
</div>
</div>
<button class="btn-gps" id="btnGps" title="Lokasi Saya">
<i class="bi bi-crosshair"></i>
</button>
<div id="map"></div>
<script id="cust-data" type="application/json"><%- JSON.stringify(customers).replace(/<\/script/g, '<\\/script') %></script>
<script id="odp-data" type="application/json"><%- JSON.stringify(odps).replace(/<\/script/g, '<\\/script') %></script>
<script id="settings-data" type="application/json"><%- JSON.stringify(settings).replace(/<\/script/g, '<\\/script') %></script>
<script>
const customers = JSON.parse(document.getElementById('cust-data').textContent || '[]');
const odps = JSON.parse(document.getElementById('odp-data').textContent || '[]');
const settings = JSON.parse(document.getElementById('settings-data').textContent || '{}');
// Default center
let centerLat = parseFloat(settings.office_lat) || -6.200000;
let centerLng = parseFloat(settings.office_lng) || 106.816666;
let defaultZoom = 13;
// Center to first valid customer or ODP if available
const firstPoint = customers.find(c => c.lat && c.lng) || odps.find(o => o.lat && o.lng);
if (firstPoint && firstPoint.lat && firstPoint.lng) {
centerLat = parseFloat(firstPoint.lat);
centerLng = parseFloat(firstPoint.lng);
defaultZoom = 14;
}
const map = L.map('map', { zoomControl: false }).setView([centerLat, centerLng], defaultZoom);
L.control.zoom({ position: 'bottomleft' }).addTo(map);
// Google Satellite (Hybrid) Layer
const googleHybrid = L.tileLayer('https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', {
maxZoom: 20,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: '© Google Satellite'
});
// OSM Layer
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OpenStreetMap'
});
googleHybrid.addTo(map);
L.control.layers({
" Satelit (Google)": googleHybrid,
" Standar (OSM)": osm
}, null, { position: 'topright' }).addTo(map);
const odpLayers = L.layerGroup().addTo(map);
const customerLayers = L.layerGroup().addTo(map);
const cableLayers = L.layerGroup().addTo(map);
const odpMap = {};
odps.forEach(odp => {
if (odp.lat && odp.lng) {
const lat = parseFloat(odp.lat);
const lng = parseFloat(odp.lng);
odpMap[odp.id] = { coord: [lat, lng], name: odp.name };
const icon = L.divIcon({
className: 'custom-div',
html: `
<div style="text-align:center;">
<div class="odp-marker"><i class="bi bi-box-seam-fill"></i></div>
<span class="marker-label-text">${escapeHtml(odp.name)}</span>
</div>
`,
iconSize: [60, 44],
iconAnchor: [30, 22]
});
const marker = L.marker([lat, lng], { icon });
const dirUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
marker.bindPopup(`
<div class="pop-title"><i class="bi bi-box-seam-fill" style="color:#F59E0B"></i> ${escapeHtml(odp.name)}</div>
<span class="pop-badge" style="background:#B45309;color:#FEF3C7">ODP DISTRIBUSI</span>
<div class="pop-row"><span class="lbl">OLT:</span><span class="val">${escapeHtml(odp.olt_name || '-')}</span></div>
<div class="pop-row"><span class="lbl">PON Port:</span><span class="val">${escapeHtml(odp.pon_port || '-')}</span></div>
<div class="pop-row"><span class="lbl">Kapasitas:</span><span class="val">${odp.port_capacity || 16} Port</span></div>
<div class="pop-row"><span class="lbl">Deskripsi:</span><span class="val">${escapeHtml(odp.description || '-')}</span></div>
<div class="pop-actions">
<a href="${dirUrl}" target="_blank" class="pop-btn btn-dir"><i class="bi bi-signpost-2-fill"></i> Petunjuk Arah</a>
</div>
`);
odp.marker = marker;
odpLayers.addLayer(marker);
}
});
customers.forEach(cust => {
if (cust.lat && cust.lng) {
const lat = parseFloat(cust.lat);
const lng = parseFloat(cust.lng);
const isActive = cust.status === 'active';
// Draw cable to ODP
if (cust.odp_id && odpMap[cust.odp_id]) {
let pathCoords = [[lat, lng], odpMap[cust.odp_id].coord];
if (cust.cable_path) {
try {
const parsed = JSON.parse(cust.cable_path);
if (Array.isArray(parsed) && parsed.length >= 2) pathCoords = parsed;
} catch (_) {}
}
const polyline = L.polyline(pathCoords, {
color: isActive ? '#ADFF2F' : '#EF4444',
weight: 3,
opacity: 0.85,
className: 'flowing-cable'
});
cust.cablePolyline = polyline;
cableLayers.addLayer(polyline);
}
const markerClass = isActive ? 'onu-marker-active' : 'onu-marker-iso';
const iconSymbol = isActive ? 'bi-router' : 'bi-x-circle';
const icon = L.divIcon({
className: 'custom-div',
html: `
<div style="text-align:center;">
<div class="${markerClass}"><i class="bi ${iconSymbol}"></i></div>
<span class="marker-label-text">${escapeHtml(cust.name)}</span>
</div>
`,
iconSize: [60, 42],
iconAnchor: [30, 21]
});
const marker = L.marker([lat, lng], { icon });
const cleanPhone = String(cust.phone || '').replace(/\D/g, '').replace(/^0/, '62');
const waUrl = cleanPhone ? `https://wa.me/${cleanPhone}?text=${encodeURIComponent('Halo ' + cust.name + ', info dari admin jaringan:')}` : '#';
const dirUrl = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;
marker.bindPopup(`
<div class="pop-title"><i class="bi bi-person-circle"></i> ${escapeHtml(cust.name)}</div>
<span class="pop-badge ${isActive ? 'active' : 'iso'}">${isActive ? '<i class="bi bi-circle-fill" style="color:#22c55e"></i> AKTIF' : '<i class="bi bi-circle-fill" style="color:#ef4444"></i> TERISOLIR'}</span>
<div class="pop-row"><span class="lbl">PPPoE User:</span><span class="val">${escapeHtml(cust.pppoe_username || '-')}</span></div>
<div class="pop-row"><span class="lbl">No. WA / HP:</span><span class="val">${escapeHtml(cust.phone || '-')}</span></div>
<div class="pop-row"><span class="lbl">Paket:</span><span class="val">${escapeHtml(cust.package_name || '-')}</span></div>
<div class="pop-row"><span class="lbl">ODP Terhubung:</span><span class="val">${escapeHtml(cust.odp_name || (cust.odp_id && odpMap[cust.odp_id] ? odpMap[cust.odp_id].name : '-'))}</span></div>
<div class="pop-row"><span class="lbl">Alamat:</span><span class="val">${escapeHtml(cust.address || '-')}</span></div>
<div class="pop-actions">
${cleanPhone ? `<a href="${waUrl}" target="_blank" class="pop-btn btn-wa"><i class="bi bi-whatsapp"></i> Chat WA</a>` : ''}
<a href="${dirUrl}" target="_blank" class="pop-btn btn-dir"><i class="bi bi-signpost-2-fill"></i> Navigasi</a>
</div>
`);
cust.marker = marker;
customerLayers.addLayer(marker);
}
});
// Fit Bounds to all markers if exist
const allCoords = [];
customers.forEach(c => { if (c.lat && c.lng) allCoords.push([parseFloat(c.lat), parseFloat(c.lng)]); });
odps.forEach(o => { if (o.lat && o.lng) allCoords.push([parseFloat(o.lat), parseFloat(o.lng)]); });
if (allCoords.length > 0) {
map.fitBounds(allCoords, { padding: [100, 40] });
}
// Search and Filter Logic
let activeFilter = 'all';
const searchInput = document.getElementById('searchInput');
function applyFilterAndSearch() {
const q = searchInput.value.trim().toLowerCase();
let visibleCustCount = 0;
let visibleOdpCount = 0;
// Filter ODP
odps.forEach(odp => {
if (!odp.marker) return;
const matchesQuery = !q || (odp.name && odp.name.toLowerCase().includes(q)) || (odp.description && odp.description.toLowerCase().includes(q));
const matchesFilter = activeFilter === 'all' || activeFilter === 'odp';
if (matchesQuery && matchesFilter) {
if (!odpLayers.hasLayer(odp.marker)) odpLayers.addLayer(odp.marker);
visibleOdpCount++;
} else {
if (odpLayers.hasLayer(odp.marker)) odpLayers.removeLayer(odp.marker);
}
});
// Filter Customers
customers.forEach(cust => {
if (!cust.marker) return;
const matchesQuery = !q ||
(cust.name && cust.name.toLowerCase().includes(q)) ||
(cust.pppoe_username && cust.pppoe_username.toLowerCase().includes(q)) ||
(cust.phone && cust.phone.includes(q)) ||
(cust.address && cust.address.toLowerCase().includes(q));
const isActive = cust.status === 'active';
let matchesFilter = false;
if (activeFilter === 'all') matchesFilter = true;
else if (activeFilter === 'active' && isActive) matchesFilter = true;
else if (activeFilter === 'isolated' && !isActive) matchesFilter = true;
else if (activeFilter === 'odp') matchesFilter = false;
if (matchesQuery && matchesFilter) {
if (!customerLayers.hasLayer(cust.marker)) customerLayers.addLayer(cust.marker);
if (cust.cablePolyline && !cableLayers.hasLayer(cust.cablePolyline)) cableLayers.addLayer(cust.cablePolyline);
visibleCustCount++;
} else {
if (customerLayers.hasLayer(cust.marker)) customerLayers.removeLayer(cust.marker);
if (cust.cablePolyline && cableLayers.hasLayer(cust.cablePolyline)) cableLayers.removeLayer(cust.cablePolyline);
}
});
}
searchInput.addEventListener('input', applyFilterAndSearch);
document.querySelectorAll('.pill-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.pill-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
activeFilter = btn.getAttribute('data-filter');
applyFilterAndSearch();
});
});
// GPS User Location
let userLocMarker = null;
document.getElementById('btnGps').addEventListener('click', () => {
if (!navigator.geolocation) {
alert("Perangkat tidak mendukung geolokasi.");
return;
}
navigator.geolocation.getCurrentPosition(
pos => {
const lat = pos.coords.latitude;
const lng = pos.coords.longitude;
if (userLocMarker) map.removeLayer(userLocMarker);
userLocMarker = L.circleMarker([lat, lng], {
radius: 9,
color: '#FFFFFF',
weight: 3,
fillColor: '#ADFF2F',
fillOpacity: 1
}).addTo(map);
userLocMarker.bindPopup("<b><i class='bi bi-geo-alt-fill'></i> Lokasi Anda Saat Ini</b>").openPopup();
map.setView([lat, lng], 17);
},
err => {
alert("Gagal membaca sinyal GPS: " + err.message);
},
{ enableHighAccuracy: true, timeout: 10000 }
);
});
function escapeHtml(text) {
if (!text) return '';
return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+904
View File
@@ -0,0 +1,904 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js"></script>
<style>
/* Health Status Badge */
.health-badge {
font-size: 20px;
padding: 12px 24px;
border-radius: 10px;
font-weight: 600;
display: inline-block;
margin: 10px 0;
}
.health-healthy {
background: var(--sdim);
color: var(--success);
border: 1px solid rgba(63, 185, 80, 0.3);
}
.health-warning {
background: var(--wdim);
color: var(--warning);
border: 1px solid rgba(210, 153, 34, 0.3);
}
.health-critical {
background: var(--ddim);
color: var(--danger);
border: 1px solid rgba(248, 81, 73, 0.3);
}
/* Progress Bar */
.progress {
height: 20px;
font-size: 11px;
font-weight: 600;
background: var(--bg3);
border-radius: 6px;
overflow: hidden;
display: flex;
}
.progress-bar {
display: flex;
align-items: center;
justify-content: center;
color: #fff;
transition: width .6s ease;
}
.bg-success { background-color: var(--success); }
.bg-warning { background-color: var(--warning); }
.bg-danger { background-color: var(--danger); }
/* Issue/Warning Items */
.issue-item, .warning-item {
padding: 12px;
margin-bottom: 10px;
border-radius: 8px;
border-left: 4px solid;
font-size: 13px;
}
.issue-item {
background: var(--ddim);
border-left-color: var(--danger);
}
.warning-item {
background: var(--wdim);
border-left-color: var(--warning);
}
/* Metric Card Customization */
.metric-card {
text-align: center;
padding: 20px;
background: var(--bg3);
border-radius: 12px;
border: 1px solid var(--border);
}
.metric-card h3 {
font-size: 28px;
font-weight: 700;
margin: 0;
color: var(--text);
}
.metric-card p {
color: var(--muted);
margin: 4px 0 8px 0;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Chart Container */
.chart-container {
position: relative;
height: 250px;
}
/* Grid layout fixes */
.mon-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
margin-bottom: 18px;
}
@media (max-width: 768px) {
.mon-grid { grid-template-columns: 1fr; }
}
.chd-d { background: var(--ddim); color: var(--danger); border-bottom: 1px solid var(--danger); }
.chd-w { background: var(--wdim); color: var(--warning); border-bottom: 1px solid var(--warning); }
.stat-grid-3 { grid-template-columns: repeat(3, 1fr); }
.mb0 { margin-bottom: 0; }
.wb-all { word-break: break-all; }
.fs11 { font-size: 11px; }
.mh250 { max-height: 250px; }
.mh300 { max-height: 300px; }
.err-log-box { overflow-y: auto; background: rgba(0,0,0,0.2); padding: 10px; border-radius: 8px; }
.fs12 { font-size: 12px; }
.fs10 { font-size: 10px; }
.db { display: block; }
.m10-0 { margin: 10px 0; }
/* Alert Messages */
.alert {
padding: 12px 16px;
border-radius: 8px;
margin-top: 16px;
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.alert-success {
background: var(--sdim);
color: var(--success);
border: 1px solid rgba(63, 185, 80, 0.3);
}
.alert-danger {
background: var(--ddim);
color: var(--danger);
border: 1px solid rgba(248, 81, 73, 0.3);
}
.alert-info {
background: rgba(102, 126, 234, 0.1);
color: rgb(102, 126, 234);
border: 1px solid rgba(102, 126, 234, 0.3);
}
/* Form Styles */
.form-label {
display: block;
margin-bottom: 6px;
font-weight: 600;
font-size: 13px;
color: var(--text);
}
.form-control {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg2);
color: var(--text);
font-size: 14px;
}
.form-control:focus {
outline: none;
border-color: var(--primary);
}
.form-check {
display: flex;
align-items: center;
gap: 8px;
}
.form-check-input {
width: 40px;
height: 20px;
cursor: pointer;
}
.form-check-label {
font-size: 13px;
color: var(--text);
cursor: pointer;
}
/* Modal Styles */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
z-index: 9998;
backdrop-filter: blur(4px);
}
.modal-overlay.show {
display: block;
}
.modal-dialog {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--bg2);
border-radius: 12px;
border: 1px solid var(--border);
max-width: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
z-index: 9999;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.modal-dialog.show {
display: block;
animation: modalSlideIn 0.3s ease-out;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translate(-50%, -45%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.modal-header {
padding: 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h5 {
margin: 0;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.modal-body {
padding: 20px;
}
.modal-footer {
padding: 16px 20px;
border-top: 1px solid var(--border);
display: flex;
gap: 10px;
justify-content: flex-end;
}
.btn-close {
background: transparent;
border: none;
color: var(--muted);
font-size: 24px;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s;
}
.btn-close:hover {
background: var(--bg3);
color: var(--text);
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: activePage || 'monitoring', company }) %>
<div class="mw">
<!-- TOPBAR -->
<div class="topbar">
<div class="tb-title">
<button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button>
<i class="bi bi-activity"></i>
<span id="page-title">Monitoring Sistem</span>
</div>
<div class="tb-right">
<button onclick="location.reload()" class="btn btn-g btn-sm">
<i class="bi bi-arrow-clockwise"></i> Refresh
</button>
<span class="adm-badge">
<i class="bi bi-person-fill"></i>
<%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<!-- CONTENT -->
<div class="page">
<!-- Health Status -->
<div class="card mb16">
<div class="card-bd text-center">
<h6 class="mb16"><%= t('admin.monitoring.system_health', 'Status Kesehatan Sistem') %></h6>
<div class="health-badge health-<%= healthStatus.status %>">
<i class="bi bi-<%= healthStatus.status === 'healthy' ? 'check-circle' : healthStatus.status === 'warning' ? 'exclamation-triangle' : 'x-circle' %>"></i>
<%= healthStatus.status.toUpperCase() %>
</div>
<p class="text-muted mt-2 fs12">Update terakhir: <%= new Date(healthStatus.timestamp).toLocaleString('id-ID') %></p>
</div>
</div>
<!-- Issues and Warnings -->
<% if (healthStatus.issues.length > 0 || healthStatus.warnings.length > 0) { %>
<div class="mon-grid">
<% if (healthStatus.issues.length > 0) { %>
<div class="card">
<div class="card-hd chd-d">
<h6><i class="bi bi-exclamation-triangle-fill"></i> Issues (<%= healthStatus.issues.length %>)</h6>
</div>
<div class="card-bd">
<% healthStatus.issues.forEach(issue => {
if (issue) { %>
<div class="issue-item">
<i class="bi bi-x-circle-fill"></i> <%= issue %>
</div>
<% } }) %>
</div>
</div>
<% } %>
<% if (healthStatus.warnings.length > 0) { %>
<div class="card">
<div class="card-hd chd-w">
<h6><i class="bi bi-exclamation-triangle"></i> Warnings (<%= healthStatus.warnings.length %>)</h6>
</div>
<div class="card-bd">
<% healthStatus.warnings.forEach(warning => {
if (warning) { %>
<div class="warning-item">
<i class="bi bi-exclamation-circle"></i> <%= warning %>
</div>
<% } }) %>
</div>
</div>
<% } %>
</div>
<% } %>
<!-- System Metrics -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-cpu"></i> Metrik Sistem</h6>
</div>
<div class="card-bd">
<div class="stat-grid mb0">
<!-- CPU -->
<div class="metric-card">
<p>CPU Usage</p>
<h3><%= healthStatus.metrics.system.cpu.usage %>%</h3>
<div class="progress mt-2 m10-0">
<div class="progress-bar <%= parseFloat(healthStatus.metrics.system.cpu.usage) > 80 ? 'bg-danger' : parseFloat(healthStatus.metrics.system.cpu.usage) > 60 ? 'bg-warning' : 'bg-success' %>"
id="cpuProgressBar">
</div>
</div>
<small class="text-muted fs10 db">
<%= healthStatus.metrics.system.cpu.cores %> cores • <%= healthStatus.metrics.system.cpu.model %>
</small>
</div>
<!-- Memory -->
<div class="metric-card">
<p>Memory Usage</p>
<h3><%= healthStatus.metrics.system.memory.percentage %>%</h3>
<div class="progress mt-2 m10-0">
<div class="progress-bar <%= parseFloat(healthStatus.metrics.system.memory.percentage) > 80 ? 'bg-danger' : parseFloat(healthStatus.metrics.system.memory.percentage) > 60 ? 'bg-warning' : 'bg-success' %>"
id="memoryProgressBar">
</div>
</div>
<small class="text-muted fs10 db">
<%= healthStatus.metrics.system.memory.used %> / <%= healthStatus.metrics.system.memory.total %>
</small>
</div>
<!-- Load Average -->
<div class="metric-card">
<p>Load Average (1 min)</p>
<h3><%= healthStatus.metrics.system.loadAverage['1min'] %></h3>
<div class="progress mt-2 m10-0">
<div class="progress-bar <%= parseFloat(healthStatus.metrics.system.loadAverage['1min']) > healthStatus.metrics.system.cpu.cores * 2 ? 'bg-danger' : parseFloat(healthStatus.metrics.system.loadAverage['1min']) > healthStatus.metrics.system.cpu.cores ? 'bg-warning' : 'bg-success' %>"
id="loadProgressBar">
</div>
</div>
<small class="text-muted fs10 db">
5m: <%= healthStatus.metrics.system.loadAverage['5min'] %> • 15m: <%= healthStatus.metrics.system.loadAverage['15min'] %>
</small>
</div>
<!-- Disk Usage -->
<div class="metric-card">
<p>Disk Usage</p>
<h3><%= healthStatus.metrics.disk.percentage %></h3>
<div class="progress mt-2 m10-0">
<div class="progress-bar <%= healthStatus.metrics.disk.percentage !== 'N/A' && parseFloat(healthStatus.metrics.disk.percentage.replace('%', '')) > 80 ? 'bg-danger' : healthStatus.metrics.disk.percentage !== 'N/A' && parseFloat(healthStatus.metrics.disk.percentage.replace('%', '')) > 60 ? 'bg-warning' : 'bg-success' %>"
id="diskProgressBar">
</div>
</div>
<small class="text-muted fs10 db">
Used: <%= healthStatus.metrics.disk.used %> / <%= healthStatus.metrics.disk.total %>
</small>
</div>
</div>
</div>
</div>
<!-- Performance Charts -->
<div class="mon-grid">
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-graph-up"></i> CPU Usage History</h6>
</div>
<div class="card-bd">
<div class="chart-container">
<canvas id="cpuChart"></canvas>
</div>
</div>
</div>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-graph-up"></i> Memory Usage History</h6>
</div>
<div class="card-bd">
<div class="chart-container">
<canvas id="memoryChart"></canvas>
</div>
</div>
</div>
</div>
<!-- Database Info -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-database"></i> Database Info</h6>
</div>
<div class="card-bd">
<div class="mon-grid mb0">
<div>
<table class="dtbl">
<tr>
<td class="text-muted">Size</td>
<td class="fw6"><%= healthStatus.metrics.database.size %></td>
</tr>
<tr>
<td class="text-muted">Tables</td>
<td class="fw6"><%= healthStatus.metrics.database.totalTables %></td>
</tr>
<tr>
<td class="text-muted">Path</td>
<td class="fw6 wb-all fs11"><%= healthStatus.metrics.database.path %></td>
</tr>
</table>
</div>
<div class="tbl-wrap mh250">
<table class="dtbl">
<thead>
<tr>
<th>Table</th>
<th class="num">Records</th>
</tr>
</thead>
<tbody>
<% if (healthStatus.metrics.database.tables && typeof healthStatus.metrics.database.tables === 'object' && healthStatus.metrics.database.tables !== null) {
Object.keys(healthStatus.metrics.database.tables).forEach(tableName => { %>
<tr>
<td><%= tableName %></td>
<td class="num"><%= healthStatus.metrics.database.tables[tableName] %></td>
</tr>
<% }) } else { %>
<tr>
<td colspan="2" class="text-center text-muted">No table data available</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Performance Summary -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-journal-text"></i> Ringkasan Performa (10 Sample Terakhir)</h6>
</div>
<div class="card-bd">
<div class="stat-grid stat-grid-3 mb0">
<div>
<p class="sc-label">Rata-rata</p>
<table class="dtbl">
<tr>
<td class="text-muted">CPU</td>
<td class="fw6"><%= performanceSummary.averages.cpu %>%</td>
</tr>
<tr>
<td class="text-muted">Memory</td>
<td class="fw6"><%= performanceSummary.averages.memory %>%</td>
</tr>
<tr>
<td class="text-muted">Load (1m)</td>
<td class="fw6"><%= performanceSummary.averages.load1 %></td>
</tr>
</table>
</div>
<div>
<p class="sc-label">Maximum</p>
<table class="dtbl">
<tr>
<td class="text-muted">CPU</td>
<td class="fw6 text-danger"><%= performanceSummary.maximums.cpu %>%</td>
</tr>
<tr>
<td class="text-muted">Memory</td>
<td class="fw6 text-danger"><%= performanceSummary.maximums.memory %>%</td>
</tr>
<tr>
<td class="text-muted">Load (1m)</td>
<td class="fw6 text-danger"><%= performanceSummary.maximums.load1 %></td>
</tr>
</table>
</div>
<div>
<p class="sc-label"><%= t('pwa.field.period','Periode') %></p>
<table class="dtbl">
<tr>
<td class="text-muted">Start</td>
<td class="fw6" style="font-size: 11px;"><%= new Date(performanceSummary.period.start).toLocaleString('id-ID') %></td>
</tr>
<tr>
<td class="text-muted">End</td>
<td class="fw6" style="font-size: 11px;"><%= new Date(performanceSummary.period.end).toLocaleString('id-ID') %></td>
</tr>
<tr>
<td class="text-muted">Samples</td>
<td class="fw6"><%= performanceSummary.period.samples %></td>
</tr>
</table>
</div>
</div>
</div>
</div>
<!-- System Diagnostics -->
<div class="mon-grid">
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-patch-check"></i> Diagnosa Konektivitas</h6>
</div>
<div class="card-bd">
<p class="sc-label mb8">Dependensi Eksternal</p>
<table class="dtbl">
<tr>
<td>GenieACS</td>
<td class="text-right">
<span class="badge <%= dependencies.genieacs.status === 'online' ? 'bs' : 'bd' %>">
<%= dependencies.genieacs.status.toUpperCase() %>
</span>
</td>
</tr>
<tr>
<td>WhatsApp Bot</td>
<td class="text-right">
<span class="badge <%= dependencies.whatsapp.status === 'online' ? 'bs' : 'bd' %>">
<%= dependencies.whatsapp.status.toUpperCase() %>
</span>
</td>
</tr>
</table>
<p class="sc-label mt16 mb8">Router MikroTik</p>
<table class="dtbl">
<% dependencies.mikrotik.forEach(r => { %>
<tr>
<td><%= r.name %> (<%= r.host %>)</td>
<td class="text-right">
<span class="badge <%= r.status === 'online' ? 'bs' : 'bd' %>">
<%= r.status.toUpperCase() %>
</span>
</td>
</tr>
<% }) %>
</table>
</div>
</div>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-bug"></i> Log Error Terbaru</h6>
</div>
<div class="card-bd err-log-box mh300">
<% if (recentErrors.length === 0) { %>
<p class="text-muted text-center py-5">Tidak ada log error terbaru</p>
<% } else { %>
<% recentErrors.forEach(err => { %>
<div style="font-family: monospace; font-size: 11px; color: var(--danger); border-bottom: 1px solid rgba(255,255,255,0.05); padding: 4px 0; white-space: pre-wrap;">
<%= err %>
</div>
<% }) %>
<% } %>
</div>
</div>
</div>
<!-- Bottom Actions -->
<div class="text-center mb16">
<button onclick="location.reload()" class="btn btn-p">
<i class="bi bi-arrow-clockwise"></i> Refresh Data
</button>
</div>
</div>
</div>
<!-- Scripts -->
<script>
// Set progress bar widths dynamically
document.addEventListener('DOMContentLoaded', function() {
// CPU Progress Bar
const cpuProgressBar = document.getElementById('cpuProgressBar');
if (cpuProgressBar) {
const cpuUsage = parseFloat('<%= healthStatus.metrics.system.cpu.usage %>');
cpuProgressBar.style.width = cpuUsage + '%';
}
// Memory Progress Bar
const memoryProgressBar = document.getElementById('memoryProgressBar');
if (memoryProgressBar) {
const memoryUsage = parseFloat('<%= healthStatus.metrics.system.memory.percentage %>');
memoryProgressBar.style.width = memoryUsage + '%';
}
// Load Progress Bar
const loadProgressBar = document.getElementById('loadProgressBar');
if (loadProgressBar) {
const load1 = parseFloat('<%= healthStatus.metrics.system.loadAverage["1min"] %>');
const cores = parseInt('<%= healthStatus.metrics.system.cpu.cores %>');
const loadPercent = Math.min((load1 / cores) * 100, 100);
loadProgressBar.style.width = loadPercent + '%';
}
// Disk Progress Bar
const diskProgressBar = document.getElementById('diskProgressBar');
if (diskProgressBar) {
const diskPercentage = '<%= healthStatus.metrics.disk.percentage %>';
if (diskPercentage !== 'N/A') {
diskProgressBar.style.width = diskPercentage;
} else {
diskProgressBar.style.width = '0%';
}
}
});
// CPU Chart
const cpuCtx = document.getElementById('cpuChart').getContext('2d');
const cpuChart = new Chart(cpuCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'CPU Usage (%)',
data: [],
borderColor: 'rgb(102, 126, 234)',
backgroundColor: 'rgba(102, 126, 234, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100
}
}
}
});
// Memory Chart
const memoryCtx = document.getElementById('memoryChart').getContext('2d');
const memoryChart = new Chart(memoryCtx, {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Memory Usage (%)',
data: [],
borderColor: 'rgb(118, 75, 162)',
backgroundColor: 'rgba(118, 75, 162, 0.1)',
tension: 0.4,
fill: true
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
max: 100
}
}
}
});
// Load metrics history
async function loadMetricsHistory() {
try {
const response = await fetch('/admin/api/metrics/history?limit=10');
const history = await response.json();
const labels = history.map(m => new Date(m.timestamp).toLocaleTimeString('id-ID'));
const cpuData = history.map(m => parseFloat(m.system.cpu.usage));
const memoryData = history.map(m => parseFloat(m.system.memory.percentage));
cpuChart.data.labels = labels;
cpuChart.data.datasets[0].data = cpuData;
cpuChart.update();
memoryChart.data.labels = labels;
memoryChart.data.datasets[0].data = memoryData;
memoryChart.update();
} catch (error) {
console.error('Failed to load metrics history:', error);
}
}
// Load initial data
loadMetricsHistory();
// Auto refresh every 30 seconds
setInterval(() => {
loadMetricsHistory();
}, 30000);
// GenieACS Settings Form Handler (guard: form mungkin tidak ada di sebagian render)
var _acsForm = document.getElementById('genieacsSettingsForm');
if (_acsForm) _acsForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = {
genieacs_timeout: parseInt(document.getElementById('genieacs_timeout').value),
genieacs_rxpower_threshold: parseFloat(document.getElementById('genieacs_rxpower_threshold').value),
genieacs_monitoring_interval: parseInt(document.getElementById('genieacs_monitoring_interval').value),
genieacs_monitoring_enabled: document.getElementById('genieacs_monitoring_enabled').checked
};
try {
const response = await fetch('/admin/api/genieacs/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
const messageDiv = document.getElementById('genieacsMessage');
if (result.success) {
messageDiv.className = 'alert alert-success';
messageDiv.innerHTML = '<i class="bi bi-check-circle"></i> ' + result.message;
messageDiv.style.display = 'block';
// Hide message after 3 seconds
setTimeout(() => {
messageDiv.style.display = 'none';
}, 3000);
} else {
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> ' + result.message;
messageDiv.style.display = 'block';
}
} catch (error) {
const messageDiv = document.getElementById('genieacsMessage');
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> Error: ' + error.message;
messageDiv.style.display = 'block';
}
});
// Test GenieACS Connection
async function testGenieACSConnection() {
const messageDiv = document.getElementById('genieacsMessage');
messageDiv.className = 'alert alert-info';
messageDiv.innerHTML = '<i class="bi bi-hourglass-split"></i> Testing koneksi ke GenieACS...';
messageDiv.style.display = 'block';
try {
const response = await fetch('/admin/api/genieacs/test');
const result = await response.json();
if (result.success) {
messageDiv.className = 'alert alert-success';
messageDiv.innerHTML = '<i class="bi bi-check-circle"></i> ' + result.message +
(result.deviceCount !== undefined ? ' (' + result.deviceCount + ' devices)' : '');
} else {
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> ' + result.message;
}
} catch (error) {
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> Error: ' + error.message;
}
}
// Modal Functions
function openGenieACSSettings() {
document.getElementById('genieacsModalOverlay').classList.add('show');
document.getElementById('genieacsModal').classList.add('show');
}
function closeGenieACSSettings() {
document.getElementById('genieacsModalOverlay').classList.remove('show');
document.getElementById('genieacsModal').classList.remove('show');
}
async function saveGenieACSSettingsModal() {
const formData = {
genieacs_timeout: parseInt(document.getElementById('genieacs_timeout_modal').value),
genieacs_rxpower_threshold: parseFloat(document.getElementById('genieacs_rxpower_threshold_modal').value),
genieacs_monitoring_interval: parseInt(document.getElementById('genieacs_monitoring_interval_modal').value),
genieacs_monitoring_enabled: document.getElementById('genieacs_monitoring_enabled_modal').checked
};
try {
const response = await fetch('/admin/api/genieacs/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
const result = await response.json();
const messageDiv = document.getElementById('genieacsMessageModal');
if (result.success) {
messageDiv.className = 'alert alert-success';
messageDiv.innerHTML = '<i class="bi bi-check-circle"></i> ' + result.message;
messageDiv.style.display = 'block';
// Close modal after 2 seconds
setTimeout(() => {
closeGenieACSSettings();
location.reload();
}, 2000);
} else {
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> ' + result.message;
messageDiv.style.display = 'block';
}
} catch (error) {
const messageDiv = document.getElementById('genieacsMessageModal');
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> Error: ' + error.message;
messageDiv.style.display = 'block';
}
}
async function testGenieACSConnectionModal() {
const messageDiv = document.getElementById('genieacsMessageModal');
messageDiv.className = 'alert alert-info';
messageDiv.innerHTML = '<i class="bi bi-hourglass-split"></i> Testing koneksi ke GenieACS...';
messageDiv.style.display = 'block';
try {
const response = await fetch('/admin/api/genieacs/test');
const result = await response.json();
if (result.success) {
messageDiv.className = 'alert alert-success';
messageDiv.innerHTML = '<i class="bi bi-check-circle"></i> ' + result.message +
(result.deviceCount !== undefined ? ' (' + result.deviceCount + ' devices)' : '');
} else {
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> ' + result.message;
}
} catch (error) {
messageDiv.className = 'alert alert-danger';
messageDiv.innerHTML = '<i class="bi bi-x-circle"></i> Error: ' + error.message;
}
}
// Close modal on ESC key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeGenieACSSettings();
}
});
</script>
</body>
</html>
+1185
View File
File diff suppressed because it is too large. Load diff
+879
View File
@@ -0,0 +1,879 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ONU Provision | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="manifest" href="/admin/manifest.webmanifest">
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.onu-card {
background: rgba(30,41,59,.6);
border: 1px solid rgba(71,85,105,.4);
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s;
}
.onu-card:hover {
background: rgba(30,41,59,.8);
border-color: rgba(71,85,105,.6);
}
.onu-status {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
.onu-status.online {
background: rgba(34,197,94,.18);
color: #bbf7d0;
}
.onu-status.offline {
background: rgba(239,68,68,.18);
color: #fca5a5;
}
.onu-status.unconfigured {
background: rgba(245,158,11,.18);
color: #fde68a;
}
.vendor-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.vendor-zte {
background: rgba(59,130,246,.18);
color: #93c5fd;
}
.vendor-huawei {
background: rgba(239,68,68,.18);
color: #fca5a5;
}
.vendor-fiberhome {
background: rgba(13,148,136,.18);
color: #99f6e4;
}
.vendor-vsol {
background: rgba(16,185,129,.18);
color: #a7f3d0;
}
.vendor-cdata {
background: rgba(234,88,12,.18);
color: #fed7aa;
}
.vendor-hsgq {
background: rgba(124,58,237,.18);
color: #ddd6fe;
}
.vendor-hioso {
background: rgba(217,119,6,.18);
color: #fde68a;
}
.tab-buttons {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 2px solid rgba(71,85,105,.3);
padding-bottom: 0;
}
.tab-btn {
padding: 12px 20px;
background: transparent;
border: none;
color: #94a3b8;
cursor: pointer;
font-weight: 600;
border-bottom: 3px solid transparent;
transition: all 0.2s;
margin-bottom: -2px;
}
.tab-btn:hover {
color: #e2e8f0;
}
.tab-btn.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'onu_provision', 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-hdd-network-fill"></i> <%= t('admin.onu_provision.title', 'ONU Provision') %>
</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<span class="adm-badge">
<i class="bi bi-person-fill"></i>
<%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'success' ? 's' : 'd' %>" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-circle' %>"></i>
<%= msg.text %>
</div>
<% } %>
<!-- OLT Configuration -->
<div class="card">
<div class="card-hd">
<h6 style="margin:0"><i class="bi bi-gear"></i> Konfigurasi OLT</h6>
</div>
<div class="card-bd">
<form method="POST" action="/admin/onu-provision/configure-olt">
<div class="form-row3">
<div class="fg">
<label class="fl">Vendor OLT</label>
<select class="fs" name="vendor" id="oltVendor" required onchange="toggleVendorFields()">
<option value="">Pilih Vendor</option>
<option value="ZTE" <%= oltConfig?.vendor === 'ZTE' ? 'selected' : '' %>>ZTE C300/C320</option>
<option value="Huawei" <%= oltConfig?.vendor === 'Huawei' ? 'selected' : '' %>>Huawei MA5800</option>
<option value="Fiberhome" <%= oltConfig?.vendor === 'Fiberhome' ? 'selected' : '' %>>Fiberhome AN5516</option>
<option value="VSOL" <%= oltConfig?.vendor === 'VSOL' ? 'selected' : '' %>>VSOL V1600/V2400</option>
<option value="CData" <%= oltConfig?.vendor === 'CData' ? 'selected' : '' %>>C-Data FD1600/FD1800</option>
<option value="HSGQ" <%= oltConfig?.vendor === 'HSGQ' ? 'selected' : '' %>>HSGQ EPON/GPON</option>
<option value="Hioso" <%= oltConfig?.vendor === 'Hioso' ? 'selected' : '' %>>HIOSO EPON/GPON</option>
</select>
</div>
<div class="fg">
<label class="fl">Host/IP OLT</label>
<input type="text" class="fc" name="host" value="<%= oltConfig?.host || '' %>" placeholder="192.168.1.1" required>
</div>
<div class="fg">
<label class="fl">Port SSH</label>
<input type="number" class="fc" name="port" value="<%= oltConfig?.port || 22 %>" placeholder="22" required>
</div>
</div>
<div class="form-row2" style="margin-top:16px">
<div class="fg">
<label class="fl">Username</label>
<input type="text" class="fc" name="username" value="<%= oltConfig?.username || '' %>" placeholder="admin" required>
</div>
<div class="fg">
<label class="fl">Password</label>
<input type="password" class="fc" name="password" value="<%= oltConfig?.password || '' %>" placeholder="••••••" required>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px">
<button type="submit" name="action" value="test" class="btn btn-g">
<i class="bi bi-plug"></i> <%= t('admin.dash.test_conn','Test Koneksi') %>
</button>
<button type="submit" name="action" value="save" class="btn btn-p">
<i class="bi bi-save"></i> Simpan Konfigurasi
</button>
</div>
</form>
</div>
</div>
<% if (oltConfig && oltConfig.host) { %>
<!-- Tabs -->
<div class="tab-buttons">
<button class="tab-btn active" onclick="switchTab('unconfigured')">
<i class="bi bi-exclamation-triangle"></i> ONU Belum Terkonfigurasi
</button>
<button class="tab-btn" onclick="switchTab('configured')">
<i class="bi bi-check-circle"></i> ONU Terkonfigurasi
</button>
<button class="tab-btn" onclick="switchTab('provision')">
<i class="bi bi-plus-circle"></i> Provision Manual
</button>
</div>
<!-- Tab: Unconfigured ONUs -->
<div id="tab-unconfigured" class="tab-content active">
<div class="card">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center">
<h6 style="margin:0"><i class="bi bi-search"></i> Scan ONU Belum Terkonfigurasi</h6>
<button onclick="scanUnconfiguredONUs()" class="btn btn-p btn-sm">
<i class="bi bi-arrow-clockwise"></i> Scan Ulang
</button>
</div>
<div class="card-bd">
<% if (oltConfig.vendor === 'ZTE') { %>
<div class="fg">
<label class="fl">PON Interface (contoh: 1/1/1)</label>
<input type="text" class="fc" id="scanPon" placeholder="1/1/1">
</div>
<% } else { %>
<div class="form-row3">
<div class="fg">
<label class="fl">Frame</label>
<input type="number" class="fc" id="scanFrame" value="0" min="0">
</div>
<div class="fg">
<label class="fl">Slot</label>
<input type="number" class="fc" id="scanSlot" value="0" min="0">
</div>
<div class="fg">
<label class="fl">PON</label>
<input type="number" class="fc" id="scanPonNum" value="0" min="0">
</div>
</div>
<% } %>
<div id="unconfiguredList" style="margin-top:20px">
<div class="hint" style="text-align:center;padding:40px">
<i class="bi bi-info-circle" style="font-size:32px;opacity:0.5"></i>
<p>Klik "Scan Ulang" untuk mencari ONU yang belum terkonfigurasi</p>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Configured ONUs -->
<div id="tab-configured" class="tab-content">
<div class="card">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center">
<h6 style="margin:0"><i class="bi bi-list-check"></i> Daftar ONU Terkonfigurasi</h6>
<button onclick="scanConfiguredONUs()" class="btn btn-p btn-sm">
<i class="bi bi-arrow-clockwise"></i> Muat Ulang / Scan
</button>
</div>
<div class="card-bd">
<div id="configuredList">
<div class="hint" style="text-align:center;padding:40px">
<i class="bi bi-info-circle" style="font-size:32px;opacity:0.5"></i>
<p>Klik "Muat Ulang / Scan" untuk memantau status ONU terkonfigurasi via SNMP</p>
</div>
</div>
</div>
</div>
</div>
<!-- Tab: Manual Provision -->
<div id="tab-provision" class="tab-content">
<div class="card">
<div class="card-hd">
<h6 style="margin:0"><i class="bi bi-plus-circle"></i> Provision ONU Manual</h6>
</div>
<div class="card-bd">
<form method="POST" action="/admin/onu-provision/provision">
<input type="hidden" name="vendor" value="<%= oltConfig.vendor %>">
<% if (oltConfig.vendor === 'ZTE' || oltConfig.vendor === 'VSOL' || oltConfig.vendor === 'CData' || oltConfig.vendor === 'HSGQ' || oltConfig.vendor === 'Hioso') { %>
<!-- ZTE, VSOL, C-Data use similar PON interface format -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px">
<div class="fg">
<label class="fl">PON Interface</label>
<input type="text" class="fc" name="pon" placeholder="1/1/1" required>
</div>
<div class="fg">
<label class="fl">ONU ID</label>
<input type="number" class="fc" name="onuId" placeholder="1" min="1" max="128" required>
</div>
<div class="fg">
<label class="fl">Serial Number</label>
<input type="text" class="fc" name="sn" placeholder="<%= oltConfig.vendor === 'ZTE' ? 'ZTEGC1234567' : oltConfig.vendor === 'VSOL' ? 'VSOL12345678' : 'CDAT12345678' %>" required>
</div>
<div class="fg">
<label class="fl">Nama ONU</label>
<input type="text" class="fc" name="name" placeholder="Customer-001" required>
</div>
<div class="fg">
<label class="fl">VLAN ID</label>
<input type="number" class="fc" name="vlan" placeholder="100" min="1" max="4094" required>
</div>
<div class="fg">
<label class="fl">Bandwidth Profile</label>
<input type="text" class="fc" name="bandwidth" placeholder="default" value="default">
</div>
<div class="fg">
<label class="fl">ONU Type</label>
<input type="text" class="fc" name="onuType" placeholder="<%= oltConfig.vendor === 'ZTE' ? 'F660' : oltConfig.vendor === 'VSOL' ? 'V2802RGW' : 'FD1104S' %>" value="<%= oltConfig.vendor === 'ZTE' ? 'F660' : oltConfig.vendor === 'VSOL' ? 'V2802RGW' : 'FD1104S' %>">
</div>
</div>
<% } else if (oltConfig.vendor === 'Huawei' || oltConfig.vendor === 'Fiberhome') { %>
<!-- Huawei & Fiberhome use Frame/Slot/PON format -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:16px">
<div class="fg">
<label class="fl">Frame</label>
<input type="number" class="fc" name="frame" value="0" min="0" required>
</div>
<div class="fg">
<label class="fl">Slot</label>
<input type="number" class="fc" name="slot" value="0" min="0" required>
</div>
<div class="fg">
<label class="fl">PON</label>
<input type="number" class="fc" name="pon" value="0" min="0" required>
</div>
<div class="fg">
<label class="fl">ONU ID</label>
<input type="number" class="fc" name="onuId" placeholder="1" min="0" max="127" required>
</div>
<div class="fg">
<label class="fl">Serial Number</label>
<input type="text" class="fc" name="sn" placeholder="<%= oltConfig.vendor === 'Huawei' ? '48575443ABCD1234' : 'FHTT12345678' %>" required>
</div>
<div class="fg">
<label class="fl">Nama ONU</label>
<input type="text" class="fc" name="name" placeholder="Customer-001" required>
</div>
<div class="fg">
<label class="fl">VLAN ID</label>
<input type="number" class="fc" name="vlan" placeholder="100" min="1" max="4094" required>
</div>
<div class="fg">
<label class="fl">Line Profile ID</label>
<input type="number" class="fc" name="lineProfile" value="1" min="1" required>
</div>
<div class="fg">
<label class="fl">Service Profile ID</label>
<input type="number" class="fc" name="srvProfile" value="1" min="1" required>
</div>
</div>
<% } %>
<!-- Advanced Configuration with Toggle Cards -->
<div style="margin-top:24px;padding-top:24px;border-top:2px solid rgba(71,85,105,.3)">
<h6 style="margin:0 0 16px 0;color:#e2e8f0"><i class="bi bi-gear-fill"></i> Konfigurasi Lanjutan</h6>
<!-- WiFi Configuration Card -->
<div style="background:rgba(30,41,59,.4);border:1px solid rgba(71,85,105,.4);border-radius:12px;padding:16px;margin-bottom:12px">
<label style="display:flex;align-items:center;justify-content:space-between;cursor:pointer;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px">
<i class="bi bi-wifi" style="font-size:20px;color:#3b82f6"></i>
<div>
<div style="font-weight:700;color:#e2e8f0">Konfigurasi WiFi</div>
<div class="hint" style="font-size:12px;margin-top:2px">Set SSID dan password WiFi ONU</div>
</div>
</div>
<input type="checkbox" id="enableWifi" onchange="toggleWifiFields()" style="width:20px;height:20px;cursor:pointer">
</label>
<div id="wifiFields" style="display:none;padding-top:12px;border-top:1px solid rgba(71,85,105,.3)">
<div class="form-row2">
<div class="fg" style="margin:0">
<label class="fl">WiFi SSID</label>
<input type="text" class="fc" name="wifiSsid" placeholder="MyWiFi-Network">
</div>
<div class="fg" style="margin:0">
<label class="fl">WiFi Password</label>
<input type="password" class="fc" name="wifiPassword" placeholder="••••••••" minlength="8">
</div>
</div>
</div>
</div>
<!-- LAN Mode Card -->
<div style="background:rgba(30,41,59,.4);border:1px solid rgba(71,85,105,.4);border-radius:12px;padding:16px;margin-bottom:12px">
<label style="display:flex;align-items:center;justify-content:space-between;cursor:pointer;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px">
<i class="bi bi-ethernet" style="font-size:20px;color:#10b981"></i>
<div>
<div style="font-weight:700;color:#e2e8f0">Mode LAN</div>
<div class="hint" style="font-size:12px;margin-top:2px">Bridge (MikroTik dial) atau Router (ONU dial)</div>
</div>
</div>
<input type="checkbox" id="enableLanMode" onchange="toggleLanModeFields()" style="width:20px;height:20px;cursor:pointer">
</label>
<div id="lanModeFields" style="display:none;padding-top:12px;border-top:1px solid rgba(71,85,105,.3)">
<div class="fg" style="margin:0 0 12px 0">
<label class="fl">Pilih Mode</label>
<select class="fs" name="lanMode" id="lanMode" onchange="togglePPPoEFields()">
<option value="bridge">Bridge (Default)</option>
<option value="router">Router (PPPoE)</option>
</select>
</div>
<!-- PPPoE for Router Mode -->
<div id="onuPppoeFields" style="display:none">
<div class="form-row2">
<div class="fg" style="margin:0">
<label class="fl">PPPoE Username</label>
<input type="text" class="fc" name="pppoeUsername" placeholder="user@internet">
</div>
<div class="fg" style="margin:0">
<label class="fl">PPPoE Password</label>
<input type="password" class="fc" name="pppoePassword" placeholder="••••••••">
</div>
</div>
</div>
</div>
</div>
<!-- MikroTik Integration Card -->
<div id="mikrotikCard" style="background:rgba(30,41,59,.4);border:1px solid rgba(71,85,105,.4);border-radius:12px;padding:16px">
<label style="display:flex;align-items:center;justify-content:space-between;cursor:pointer;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px">
<i class="bi bi-router" style="font-size:20px;color:#f59e0b"></i>
<div>
<div style="font-weight:700;color:#e2e8f0">Integrasi MikroTik</div>
<div class="hint" style="font-size:12px;margin-top:2px">Auto-create PPPoE secret di MikroTik</div>
</div>
</div>
<input type="checkbox" name="createMikrotikPPPoE" id="createMikrotikPPPoE" onchange="toggleMikrotikFields()" style="width:20px;height:20px;cursor:pointer">
</label>
<div id="mikrotikPPPoEFields" style="display:none;padding-top:12px;border-top:1px solid rgba(71,85,105,.3)">
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<div class="fg" style="margin:0">
<label class="fl">PPPoE Username</label>
<input type="text" class="fc" name="mikrotikPppoeUsername" placeholder="customer001@internet">
</div>
<div class="fg" style="margin:0">
<label class="fl">PPPoE Password</label>
<input type="password" class="fc" name="mikrotikPppoePassword" placeholder="••••••••">
</div>
<div class="fg" style="margin:0">
<label class="fl">Bandwidth Profile</label>
<input type="text" class="fc" name="mikrotikProfile" placeholder="20Mbps">
</div>
</div>
</div>
</div>
<!-- TR069/CWMP Configuration Card -->
<div style="background:rgba(30,41,59,.4);border:1px solid rgba(71,85,105,.4);border-radius:12px;padding:16px;margin-top:12px">
<label style="display:flex;align-items:center;justify-content:space-between;cursor:pointer;margin-bottom:12px">
<div style="display:flex;align-items:center;gap:10px">
<i class="bi bi-cloud-arrow-up" style="font-size:20px;color:#8b5cf6"></i>
<div>
<div style="font-weight:700;color:#e2e8f0">TR069/CWMP Configuration</div>
<div class="hint" style="font-size:12px;margin-top:2px">Remote management via ACS (Auto Configuration Server)</div>
</div>
</div>
<input type="checkbox" id="enableTR069" onchange="toggleTR069Fields()" style="width:20px;height:20px;cursor:pointer">
</label>
<div id="tr069Fields" style="display:none;padding-top:12px;border-top:1px solid rgba(71,85,105,.3)">
<div style="display:grid;grid-template-columns:1fr;gap:12px">
<div class="fg" style="margin:0">
<label class="fl">ACS URL <span style="color:#ef4444">*</span></label>
<input type="text" class="fc" name="tr069AcsUrl" placeholder="http://acs.example.com:7547">
<div class="hint" style="font-size:11px;margin-top:4px">Format: http://host:port atau https://host:port</div>
</div>
<div class="form-row2">
<div class="fg" style="margin:0">
<label class="fl">ACS Username (Optional)</label>
<input type="text" class="fc" name="tr069AcsUsername" placeholder="acs_user">
</div>
<div class="fg" style="margin:0">
<label class="fl">ACS Password (Optional)</label>
<input type="password" class="fc" name="tr069AcsPassword" placeholder="••••••••">
</div>
</div>
<div class="fg" style="margin:0">
<label class="fl">Periodic Inform Interval (seconds)</label>
<input type="number" class="fc" name="tr069PeriodicInform" value="300" min="60" max="86400">
<div class="hint" style="font-size:11px;margin-top:4px">Default: 300 detik (5 menit). Range: 60-86400 detik</div>
</div>
</div>
</div>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:24px;flex-wrap:wrap">
<button type="button" class="btn btn-g" onclick="resetForm()">
<i class="bi bi-arrow-counterclockwise"></i> Reset Form
</button>
<button type="submit" class="btn btn-p">
<i class="bi bi-check-circle"></i> Provision ONU
</button>
</div>
</form>
</div>
</div>
</div>
<% } %>
</div>
</div>
<script>
function switchTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
});
// Show selected tab
document.getElementById('tab-' + tabName).classList.add('active');
event.target.classList.add('active');
}
function toggleVendorFields() {
const vendor = document.getElementById('oltVendor').value;
// Additional logic if needed
}
async function scanUnconfiguredONUs() {
const vendor = '<%= oltConfig && oltConfig.vendor ? oltConfig.vendor : "" %>';
let params = {};
if (vendor === 'ZTE' || vendor === 'VSOL' || vendor === 'CData' || vendor === 'HSGQ' || vendor === 'Hioso') {
const pon = document.getElementById('scanPon').value;
if (!pon) {
alert('Masukkan PON interface (contoh: 1/1/1 atau epon 0/1)');
return;
}
params.pon = pon;
} else if (vendor === 'Huawei' || vendor === 'Fiberhome') {
params.frame = document.getElementById('scanFrame').value;
params.slot = document.getElementById('scanSlot').value;
params.pon = document.getElementById('scanPonNum').value;
}
const listDiv = document.getElementById('unconfiguredList');
listDiv.innerHTML = '<div class="hint" style="text-align:center;padding:40px"><i class="bi bi-hourglass-split" style="font-size:32px"></i><p>Scanning...</p></div>';
try {
const response = await fetch('/admin/onu-provision/scan-unconfigured', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
});
const data = await response.json();
if (data.success && data.onus && data.onus.length > 0) {
let html = '';
data.onus.forEach(onu => {
html += `
<div class="onu-card">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px">
<div>
<span class="vendor-badge vendor-${onu.vendor.toLowerCase()}">${onu.vendor}</span>
<span class="onu-status unconfigured">Belum Terkonfigurasi</span>
</div>
<button onclick="provisionONU('${JSON.stringify(onu).replace(/"/g, '"')}')" class="btn btn-p btn-sm">
<i class="bi bi-plus-circle"></i> Provision
</button>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px">
<div>
<div class="hint" style="font-size:11px">Serial Number</div>
<div style="font-weight:700;font-family:monospace">${onu.sn}</div>
</div>
<div>
<div class="hint" style="font-size:11px">Model</div>
<div style="font-weight:600">${onu.model}</div>
</div>
<div>
<div class="hint" style="font-size:11px">PON Interface</div>
<div style="font-weight:600">${onu.pon || onu.frame + '/' + onu.slot + '/' + onu.pon}</div>
</div>
<div>
<div class="hint" style="font-size:11px">ONU ID</div>
<div style="font-weight:600">${onu.onuId}</div>
</div>
</div>
</div>
`;
});
listDiv.innerHTML = html;
} else {
listDiv.innerHTML = '<div class="hint" style="text-align:center;padding:40px"><i class="bi bi-check-circle" style="font-size:32px;color:#22c55e"></i><p>Tidak ada ONU yang belum terkonfigurasi</p></div>';
}
} catch (error) {
listDiv.innerHTML = '<div class="alert alert-d"><i class="bi bi-exclamation-circle"></i> Error: ' + error.message + '</div>';
}
}
function provisionONU(onuDataStr) {
const onu = JSON.parse(onuDataStr.replace(/"/g, '"'));
// Switch to provision tab and fill form
switchTab('provision');
// Fill form based on vendor
if (onu.vendor === 'ZTE' || onu.vendor === 'VSOL' || onu.vendor === 'CData' || onu.vendor === 'HSGQ' || onu.vendor === 'Hioso') {
document.querySelector('[name="pon"]').value = onu.pon;
document.querySelector('[name="onuId"]').value = onu.onuId;
document.querySelector('[name="sn"]').value = onu.sn;
} else {
document.querySelector('[name="frame"]').value = onu.frame;
document.querySelector('[name="slot"]').value = onu.slot;
document.querySelector('[name="pon"]').value = onu.pon;
document.querySelector('[name="onuId"]').value = onu.onuId;
document.querySelector('[name="sn"]').value = onu.sn;
}
// Scroll to form
document.getElementById('tab-provision').scrollIntoView({ behavior: 'smooth' });
}
// Toggle WiFi configuration fields
function toggleWifiFields() {
const checkbox = document.getElementById('enableWifi');
const fields = document.getElementById('wifiFields');
if (checkbox.checked) {
fields.style.display = 'block';
} else {
fields.style.display = 'none';
// Clear fields when disabled
document.querySelector('[name="wifiSsid"]').value = '';
document.querySelector('[name="wifiPassword"]').value = '';
}
}
// Toggle LAN Mode configuration fields
function toggleLanModeFields() {
const checkbox = document.getElementById('enableLanMode');
const fields = document.getElementById('lanModeFields');
const mikrotikCard = document.getElementById('mikrotikCard');
if (checkbox.checked) {
fields.style.display = 'block';
// Check current mode to show/hide MikroTik card
togglePPPoEFields();
} else {
fields.style.display = 'none';
document.getElementById('onuPppoeFields').style.display = 'none';
// Show MikroTik card when LAN mode is disabled (default bridge)
mikrotikCard.style.display = 'block';
}
}
// Toggle PPPoE fields based on LAN mode selection
function togglePPPoEFields() {
const lanMode = document.getElementById('lanMode').value;
const onuPppoeFields = document.getElementById('onuPppoeFields');
const mikrotikCard = document.getElementById('mikrotikCard');
if (lanMode === 'router') {
// Show PPPoE fields for ONU (ONU will dial)
onuPppoeFields.style.display = 'block';
// Hide MikroTik card since ONU is dialing, not MikroTik
mikrotikCard.style.display = 'none';
// Uncheck and hide MikroTik fields
document.getElementById('createMikrotikPPPoE').checked = false;
document.getElementById('mikrotikPPPoEFields').style.display = 'none';
} else {
// Bridge mode: Hide ONU PPPoE fields, show MikroTik card
// In bridge mode, MikroTik will dial PPPoE
onuPppoeFields.style.display = 'none';
mikrotikCard.style.display = 'block';
// Clear ONU PPPoE fields
const pppoeUsernameField = document.querySelector('[name="pppoeUsername"]');
const pppoePasswordField = document.querySelector('[name="pppoePassword"]');
if (pppoeUsernameField) pppoeUsernameField.value = '';
if (pppoePasswordField) pppoePasswordField.value = '';
}
}
// Toggle MikroTik PPPoE fields
function toggleMikrotikFields() {
const checkbox = document.getElementById('createMikrotikPPPoE');
const fields = document.getElementById('mikrotikPPPoEFields');
if (checkbox.checked) {
fields.style.display = 'block';
} else {
fields.style.display = 'none';
}
}
// Toggle TR069 configuration fields
function toggleTR069Fields() {
const checkbox = document.getElementById('enableTR069');
const fields = document.getElementById('tr069Fields');
if (checkbox.checked) {
fields.style.display = 'block';
} else {
fields.style.display = 'none';
// Clear fields when disabled
document.querySelector('[name="tr069AcsUrl"]').value = '';
document.querySelector('[name="tr069AcsUsername"]').value = '';
document.querySelector('[name="tr069AcsPassword"]').value = '';
document.querySelector('[name="tr069PeriodicInform"]').value = '300';
}
}
// Reset form
function resetForm() {
if (confirm('Reset semua field form?')) {
document.querySelector('form[action="/admin/onu-provision/provision"]').reset();
// Reset all toggle states
document.getElementById('enableWifi').checked = false;
document.getElementById('wifiFields').style.display = 'none';
document.getElementById('enableLanMode').checked = false;
document.getElementById('lanModeFields').style.display = 'none';
document.getElementById('onuPppoeFields').style.display = 'none';
document.getElementById('createMikrotikPPPoE').checked = false;
document.getElementById('mikrotikPPPoEFields').style.display = 'none';
document.getElementById('mikrotikCard').style.display = 'block';
document.getElementById('enableTR069').checked = false;
document.getElementById('tr069Fields').style.display = 'none';
}
}
// Scan and render configured ONUs
async function scanConfiguredONUs() {
const listDiv = document.getElementById('configuredList');
listDiv.innerHTML = '<div class="hint" style="text-align:center;padding:40px"><i class="bi bi-hourglass-split" style="font-size:32px"></i><p>Scanning OLT SNMP...</p></div>';
try {
const response = await fetch('/admin/onu-provision/scan-configured', {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success && data.onus && data.onus.length > 0) {
let html = '';
data.onus.forEach(onu => {
const vendorClass = (onu.vendor || 'ZTE').toLowerCase();
html += `
<div class="onu-card">
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px">
<div>
<span class="vendor-badge vendor-${vendorClass}">${onu.vendor || 'ZTE'}</span>
<span class="onu-status ${onu.status.toLowerCase() === 'online' ? 'online' : 'offline'}">${onu.status}</span>
<span style="margin-left: 8px; font-weight: 700; color: #e2e8f0;">${onu.name}</span>
</div>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick="renameConfiguredOnu('${data.oltId}', '${onu.index}', '${onu.name}')"><i class="bi bi-pencil"></i> Rename ONU</button>
<button type="button" class="act-item warn" onclick="rebootConfiguredOnu('${data.oltId}', '${onu.index}')"><i class="bi bi-arrow-clockwise"></i> Reboot ONU</button>
<div class="act-sep"></div>
<button type="button" class="act-item danger" onclick="deleteConfiguredOnu('${onu.index}', '${onu.sn}')"><i class="bi bi-trash"></i> Hapus ONU</button>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px">
<div>
<div class="hint" style="font-size:11px">Serial Number</div>
<div style="font-weight:700;font-family:monospace">${onu.sn}</div>
</div>
<div>
<div class="hint" style="font-size:11px">Interface / Index</div>
<div style="font-weight:600">${onu.id || onu.index}</div>
</div>
<div>
<div class="hint" style="font-size:11px">Sinyal RX / TX</div>
<div style="font-weight:600; color:${parseFloat(onu.rx) < -27 ? '#fca5a5' : '#e2e8f0'}">${onu.rx} / ${onu.tx}</div>
</div>
<div>
<div class="hint" style="font-size:11px">Uptime / Jarak</div>
<div style="font-weight:600">${onu.uptime || '-'} / ${onu.distance || '-'}</div>
</div>
${onu.customer_name ? `
<div>
<div class="hint" style="font-size:11px"><%= t('iso.cust_name','Nama Pelanggan') %></div>
<div style="font-weight:600; color:#38bdf8"><i class="bi bi-person-fill"></i> ${onu.customer_name}</div>
</div>
` : ''}
${onu.wifi_ssid && onu.wifi_ssid !== '-' ? `
<div>
<div class="hint" style="font-size:11px">WiFi SSID (ACS)</div>
<div style="font-weight:600; color:#3b82f6"><i class="bi bi-wifi"></i> ${onu.wifi_ssid}</div>
</div>
` : ''}
${onu.client_count !== undefined && onu.client_count > 0 ? `
<div>
<div class="hint" style="font-size:11px">Klien Connected (ACS)</div>
<div style="font-weight:600; color:#10b981"><i class="bi bi-people"></i> ${onu.client_count} device</div>
</div>
` : ''}
</div>
</div>
`;
});
listDiv.innerHTML = html;
} else {
const errorMsg = data.error || 'Tidak ada ONU terkonfigurasi yang ditemukan';
listDiv.innerHTML = `<div class="hint" style="text-align:center;padding:40px"><i class="bi bi-info-circle" style="font-size:32px;color:#f59e0b"></i><p>\${errorMsg}</p></div>`;
}
} catch (error) {
listDiv.innerHTML = '<div class="alert alert-d"><i class="bi bi-exclamation-circle"></i> Error: ' + error.message + '</div>';
}
}
// Reboot Configured ONU
async function rebootConfiguredOnu(oltId, index) {
if (!confirm('Apakah Anda yakin ingin me-reboot ONU dengan index ' + index + '?')) return;
try {
const res = await fetch(`/admin/olts/\${oltId}/onu/\${index}/reboot`, { method: 'POST' });
const data = await res.json();
if (data.success) {
alert('Perintah reboot berhasil dikirim!');
scanConfiguredONUs();
} else {
alert('Gagal: ' + data.error);
}
} catch (error) {
alert('Error: ' + error.message);
}
}
// Rename Configured ONU
async function renameConfiguredOnu(oltId, index, oldName) {
const newName = prompt('Masukkan nama baru untuk ONU:', oldName);
if (!newName || newName.trim() === '') return;
try {
const res = await fetch(`/admin/olts/\${oltId}/onu/\${index}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ name: newName })
});
const data = await res.json();
if (data.success) {
alert('Nama ONU berhasil diubah!');
scanConfiguredONUs();
} else {
alert('Gagal: ' + data.error);
}
} catch (error) {
alert('Error: ' + error.message);
}
}
// Delete Configured ONU
async function deleteConfiguredOnu(index, sn) {
if (!confirm('Apakah Anda yakin ingin menghapus/menghilangkan otorisasi ONU dengan Serial Number ' + sn + '?')) return;
try {
const res = await fetch('/admin/onu-provision/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
vendor: '<%= oltConfig.vendor %>',
params: { index, sn }
})
});
const data = await res.json();
if (data.success) {
alert('ONU berhasil dihapus!');
scanConfiguredONUs();
} else {
alert('Gagal: ' + data.error);
}
} catch (error) {
alert('Error: ' + error.message);
}
}
</script>
</body>
</html>
+423
View File
@@ -0,0 +1,423 @@
<!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.onustickers.title', 'Cetak Stiker Modem / ONU') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
@page { size: A4 portrait; margin: 6mm 6mm; }
/* Print Page Container */
.print-sheet {
width: 210mm;
min-height: 297mm;
margin: 0 auto 20px auto;
background: #ffffff;
padding: 4mm 4mm;
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.35);
border-radius: 4px;
page-break-after: always;
break-after: page;
}
/* Sticker Grid: 2 Columns x 5 Rows = 10 stickers per A4 */
.sticker-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 3.5mm 3.5mm;
}
/* Individual Sticker: Name Tag Size (~96mm x 53mm) */
.sticker-card {
width: 100%;
height: 54mm;
border: 1px dashed #cbd5e1;
border-radius: 7px;
padding: 4.5mm 5mm;
background: #ffffff;
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: space-between;
page-break-inside: avoid;
break-inside: avoid;
box-sizing: border-box;
}
.sticker-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg, #0284c7, #0ea5e9, #38bdf8);
}
/* Header */
.st-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
border-bottom: 1px solid #e2e8f0;
padding-bottom: 3px;
margin-bottom: 2px;
}
.st-brand {
display: flex;
align-items: center;
gap: 6px;
}
.st-logo {
height: 20px;
max-width: 60px;
object-fit: contain;
}
.st-company {
font-size: 10.5px;
font-weight: 900;
color: #0f172a;
letter-spacing: .2px;
line-height: 1.1;
text-transform: uppercase;
}
.st-badge {
font-size: 8px;
font-weight: 700;
color: #0369a1;
background: #e0f2fe;
padding: 1.5px 5px;
border-radius: 999px;
border: 0.5px solid #bae6fd;
white-space: nowrap;
}
/* Body 2 Columns */
.st-body {
display: flex;
gap: 5px;
align-items: stretch;
flex: 1;
margin: 2px 0;
}
/* Left Side: Cust details & Guidance (62%) */
.st-left {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
}
.cust-info-box {
background: #f8fafc;
border: 0.5px solid #e2e8f0;
border-radius: 4px;
padding: 3px 5px;
line-height: 1.25;
}
.cust-row {
display: flex;
align-items: baseline;
font-size: 8.5px;
color: #334155;
}
.cust-row .label {
width: 48px;
color: #64748b;
font-weight: 700;
flex-shrink: 0;
}
.cust-row .val {
font-weight: 800;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Important Warnings / Tips Box */
.tips-box {
background: #fffbeb;
border: 0.8px solid #fde68a;
border-radius: 5px;
padding: 4px 6px;
display: flex;
flex-direction: column;
justify-content: space-evenly;
gap: 3px;
flex: 1;
}
.tip-item {
display: flex;
align-items: flex-start;
gap: 4.5px;
font-size: 8.6px;
color: #78350f;
line-height: 1.3;
}
.tip-item i {
font-size: 10px;
flex-shrink: 0;
margin-top: 0.5px;
}
.tip-item strong {
color: #9a3412;
font-weight: 800;
letter-spacing: .1px;
}
/* Right Side: QR Code (38%) */
.st-right {
width: 29mm;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
background: #f8fafc;
border: 0.5px solid #e2e8f0;
border-radius: 5px;
padding: 2px;
}
.qr-img {
width: 23.5mm;
height: 23.5mm;
object-fit: contain;
display: block;
}
.qr-label {
font-size: 7.5px;
font-weight: 900;
color: #0284c7;
text-transform: uppercase;
letter-spacing: .2px;
margin-top: 1px;
line-height: 1.1;
}
.qr-sub {
font-size: 6.5px;
color: #64748b;
line-height: 1;
margin-top: 0.5px;
}
/* Footer */
.st-footer {
border-top: 0.5px solid #e2e8f0;
padding-top: 2px;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 7.5px;
color: #475569;
}
.st-cs {
display: flex;
align-items: center;
gap: 3px;
font-weight: 800;
color: #047857;
}
.st-cs i {
color: #10b981;
font-size: 8.5px;
}
.st-notice {
color: #94a3b8;
font-size: 6.8px;
}
@media print {
body {
background: #ffffff !important;
padding: 0 !important;
}
.screen-controls {
display: none !important;
}
.print-sheet {
box-shadow: none !important;
margin: 0 !important;
padding: 0 !important;
border-radius: 0 !important;
}
.sticker-card {
border: 0.5px dashed #cbd5e1 !important;
}
}
@media screen {
.sheets-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; padding-bottom: 8px; max-width: 100%; }
}
@media (max-width: 720px) {
.topbar { flex-wrap: wrap; gap: 8px; }
.tb-title { min-width: 0; }
.tb-right { flex-wrap: wrap; }
}
/* Layout admin: sembunyikan kontrol saat cetak */
@media print {
.topbar, .card, .hb-menu, .bottom-nav { display: none !important; }
.page { padding: 0 !important; margin: 0 !important; }
.sheets-scroll { overflow: visible !important; padding: 0 !important; }
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'onu_stickers', 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-qr-code-scan"></i> <%= t('admin.onustickers.topbar_title', 'Cetak Stiker ONU') %>
</div>
<div class="tb-right">
<button type="button" class="btn btn-p btn-sm" onclick="window.print()"><i class="bi bi-printer-fill"></i> <%= t('admin.onustickers.print', 'Cetak Stiker (A4 / PDF)') %></button>
<a href="/admin/customers" 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">
<div class="card mb16">
<div class="card-hd"><h6 style="margin:0"><i class="bi bi-sliders"></i> <%= t('admin.onustickers.ctrl_title', 'Cetak Stiker Modem / ONU (Name Tag Size)') %></h6></div>
<div class="card-bd">
<form method="GET" action="/admin/onu-stickers" id="filterForm">
<div class="form-row" style="grid-template-columns:repeat(auto-fit,minmax(210px,1fr))">
<div class="fg">
<label class="fl"><%= t('admin.onustickers.mode', 'Mode Cetak') %></label>
<select class="fs" name="mode" onchange="document.getElementById('filterForm').submit()">
<option value="blank" <%= mode === 'blank' ? 'selected' : '' %>><%= t('admin.onustickers.mode_blank', 'Template Blank (Stok Teknisi Lapangan)') %></option>
<option value="customers" <%= mode === 'customers' ? 'selected' : '' %>><%= t('admin.onustickers.mode_customers', 'Data Pelanggan (Personalized)') %></option>
</select>
</div>
<% if (mode === 'blank') { %>
<div class="fg">
<label class="fl"><%= t('admin.onustickers.count', 'Jumlah Stiker') %></label>
<select class="fs" name="count" onchange="document.getElementById('filterForm').submit()">
<option value="10" <%= count === 10 ? 'selected' : '' %>><%= t('admin.onustickers.count10', '10 Stiker (1 Lembar A4)') %></option>
<option value="20" <%= count === 20 ? 'selected' : '' %>><%= t('admin.onustickers.count20', '20 Stiker (2 Lembar A4)') %></option>
<option value="30" <%= count === 30 ? 'selected' : '' %>><%= t('admin.onustickers.count30', '30 Stiker (3 Lembar A4)') %></option>
<option value="1" <%= count === 1 ? 'selected' : '' %>><%= t('admin.onustickers.count1', '1 Stiker (Thermal / Satuan)') %></option>
</select>
</div>
<% } else { %>
<div class="fg">
<label class="fl"><%= t('admin.onustickers.filter_router', 'Filter Router') %></label>
<select class="fs" name="router_id" onchange="document.getElementById('filterForm').submit()">
<option value=""><%= t('admin.onustickers.all_routers', 'Semua Router') %></option>
<% (routers || []).forEach(r => { %>
<option value="<%= r.id %>" <%= String(selectedRouterId) === String(r.id) ? 'selected' : '' %>><%= r.name %></option>
<% }) %>
</select>
</div>
<div class="fg">
<label class="fl"><%= t('admin.onustickers.search', 'Cari Pelanggan') %></label>
<input class="fc" type="text" name="q" value="<%= searchQuery || '' %>" placeholder="<%= t('admin.onustickers.search_ph', 'Ketik nama / ID...') %>" />
</div>
<% } %>
<div class="fg">
<label class="fl"><%= t('admin.onustickers.cs_label', 'No. CS / WA Bantuan (Tampil di Stiker)') %></label>
<input class="fc" type="text" name="cs_phone" value="<%= csPhone %>" placeholder="08xxxxxxxxxx" onchange="document.getElementById('filterForm').submit()" />
</div>
</div>
<button type="submit" class="btn btn-g btn-sm"><i class="bi bi-funnel"></i> <%= t('common.apply', 'Terapkan') %></button>
</form>
<div class="hint" style="margin-top:10px"><i class="bi bi-info-circle"></i> <%= t('admin.onustickers.info', 'Ukuran stiker diatur presisi standar Name Tag / Kartu ID (~8.5cm x 5.4cm). Format A4 memuat 10 stiker per lembar. Gunakan kertas stiker HVS atau Glossy.') %></div>
</div>
</div>
<div class="sheets-scroll">
<!-- Printable Sheets (Auto breaks pages if items > 10) -->
<%
const STICKERS_PER_PAGE = 10;
const totalPages = Math.max(1, Math.ceil(stickers.length / STICKERS_PER_PAGE));
for (let pageIdx = 0; pageIdx < totalPages; pageIdx++) {
const pageItems = stickers.slice(pageIdx * STICKERS_PER_PAGE, (pageIdx + 1) * STICKERS_PER_PAGE);
%>
<div class="print-sheet">
<div class="sticker-grid">
<% pageItems.forEach(item => { %>
<div class="sticker-card">
<!-- 1. Header -->
<div class="st-header">
<div class="st-brand">
<% if (companyLogo) { %>
<img src="<%= companyLogo %>" class="st-logo" alt="Logo" />
<% } else { %>
<i class="bi bi-wifi" style="font-size:14px; color:#0284c7"></i>
<% } %>
<span class="st-company"><%= company %></span>
</div>
<span class="st-badge"><i class="bi bi-router"></i> <%= t('admin.onustickers.badge', 'MODEM ONU') %></span>
</div>
<!-- 2. Body -->
<div class="st-body">
<!-- Left: Info & Warnings -->
<div class="st-left">
<div class="cust-info-box">
<div class="cust-row">
<span class="label"><%= t('admin.onustickers.l_name', 'Nama') %></span>: <span class="val"><%= item.name || '..................................................' %></span>
</div>
<div class="cust-row">
<span class="label"><%= t('admin.onustickers.l_id', 'ID / Inet') %></span>: <span class="val"><%= item.id_display || '..................................................' %></span>
</div>
<% if (item.package_name) { %>
<div class="cust-row">
<span class="label"><%= t('admin.onustickers.l_pkg', 'Paket') %></span>: <span class="val" style="color:#0284c7"><%= item.package_name %></span>
</div>
<% } %>
</div>
<div class="tips-box">
<div class="tip-item">
<i class="bi bi-lightning-charge-fill" style="color:#d97706"></i>
<div><strong><%= t('admin.onustickers.tip1_t', 'Waspada Petir:') %></strong> <%= t('admin.onustickers.tip1_x', 'Cabut adaptor daya & kabel saat hujan lebat!') %></div>
</div>
<div class="tip-item">
<i class="bi bi-arrow-repeat" style="color:#2563eb"></i>
<div><strong><%= t('admin.onustickers.tip2_t', 'Cara Reboot:') %></strong> <%= t('admin.onustickers.tip2_x', 'Matikan power 10 detik, nyalakan kembali saat lambat.') %></div>
</div>
<div class="tip-item">
<i class="bi bi-exclamation-triangle-fill" style="color:#dc2626"></i>
<div><strong><%= t('admin.onustickers.tip3_t', 'LOS Merah:') %></strong> <%= t('admin.onustickers.tip3_x', 'Kabel optik bermasalah/putus, segera kontak CS.') %></div>
</div>
</div>
</div>
<!-- Right: QR Code -->
<div class="st-right">
<img src="<%= item.qrCodeDataUrl %>" class="qr-img" alt="QR Server" />
<div class="qr-label"><i class="bi bi-phone"></i> <%= t('admin.onustickers.qr_label', 'SCAN DI APLIKASI') %></div>
<div class="qr-sub"><%= t('admin.onustickers.qr_sub', 'Auto-koneksi Server') %></div>
</div>
</div>
<!-- 3. Footer -->
<div class="st-footer">
<div class="st-cs">
<i class="bi bi-whatsapp"></i> <%= t('admin.onustickers.footer_cs', 'CS / Bantuan:') %> <%= csPhone %>
</div>
<div class="st-notice">
<%= t('admin.onustickers.footer_notice', 'Tempel pada Modem Pelanggan') %>
</div>
</div>
</div>
<% }) %>
</div>
</div>
<% } %>
</div>
</div>
</div>
</body>
</html>
+714
View File
@@ -0,0 +1,714 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.packages.title', 'Paket Internet') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
/* Responsive Mobile CSS */
@media (max-width: 768px) {
.card-hd { flex-direction: column !important; align-items: stretch !important; gap: 10px !important; }
.card-hd > div { flex-wrap: wrap !important; width: 100% !important; justify-content: stretch !important; }
.card-hd select { width: 100% !important; }
.card-hd .btn { flex: 1 1 auto; justify-content: center; }
/* modal mengikuti style global (bottom-sheet + footer sticky) */
.form-row { flex-direction: column !important; gap: 8px !important; }
.form-row > .fg { width: 100% !important; max-width: 100% !important; margin-bottom: 6px !important; }
.mf { flex-wrap: wrap !important; gap: 8px !important; }
.mf button, .mf a { flex: 1 1 100% !important; text-align: center !important; justify-content: center !important; }
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'packages', 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-box-seam"></i> <%= t('admin.packages.title', 'Paket Internet') %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="card mb16" style="border:1px solid rgba(239,68,68,.35)">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
<h6 style="margin:0"><i class="bi bi-shield-slash" style="color:#ef4444"></i> <%= t('admin.packages.isolir_title', 'Profil Isolir') %> <span class="text-muted" style="font-weight:400;font-size:11.5px">— read-only</span></h6>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<span class="badge <%= isolirSource === 'radius' ? 'bp' : 'bm' %>"><%= isolirSource === 'radius' ? 'Sumber: RADIUS' : 'Sumber: MikroTik' %></span>
<% if (isolirSource !== 'radius') { %><span class="badge <%= isolirExists ? 'bs' : 'bd' %>"><%= isolirExists ? 'Ada di MikroTik' : 'Belum ada' %></span><% } %>
<% if (isolirSource !== 'radius') { %><a class="btn btn-g btn-sm" href="/admin/mikrotik"><i class="bi bi-pencil"></i> Kelola di PPPoE Profiles</a><% } %>
<a class="btn btn-g btn-sm" href="/admin/radius-settings"><i class="bi bi-broadcast"></i> RADIUS Settings</a>
</div>
</div>
<div class="card-bd">
<% if (isolirSource === 'radius') { %>
<div class="form-row">
<div class="fg"><label class="fl">Aksi isolir</label><div class="mono"><%= radiusIsolir.action %></div></div>
<div class="fg"><label class="fl">Rate-limit</label><div class="mono"><%= radiusIsolir.rateLimit %></div></div>
<div class="fg"><label class="fl">Pool</label><div class="mono"><%= radiusIsolir.pool %></div></div>
<div class="fg"><label class="fl">IP Pool isolir</label><div class="mono"><%= radiusIsolir.ipPoolEnabled ? (radiusIsolir.ipPoolStart + ' – ' + radiusIsolir.ipPoolEnd) : 'nonaktif' %></div></div>
</div>
<div class="hint"><i class="bi bi-info-circle"></i> Mode <b>RADIUS</b> — nilai di atas dibaca dari pengaturan RADIUS. Untuk mengubah, buka <b>RADIUS Settings</b> (tombol di atas).</div>
<% } else { %>
<div class="form-row">
<div class="fg"><label class="fl">Nama Profil</label><div class="mono"><%= isolirProfileName %></div></div>
<div class="fg"><label class="fl">Rate-limit (live)</label><div class="mono"><%= isolirExists ? (isolirLiveRl || '-') : '—' %></div></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Local Address (live)</label><div class="mono"><%= isolirExists ? (isolirLiveLocal || '-') : '—' %></div></div>
<div class="fg"><label class="fl">Remote / Pool (live)</label><div class="mono"><%= isolirExists ? (isolirLiveRemote || '-') : '—' %></div></div>
</div>
<div class="hint"><i class="bi bi-info-circle"></i> Mode <b>MikroTik</b> — nilai di atas dibaca dari profil PPP di router. Untuk mengubah, edit di <b>MikroTik &rarr; PPPoE Profiles</b>.</div>
<% } %>
</div>
</div>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-box-seam"></i> <%= t('admin.packages.list', 'Daftar Paket') %> (<%= packages.length %>)</h6>
<div style="display:flex;align-items:center;gap:10px">
<% if (typeof routers !== 'undefined' && routers && routers.length > 0) { %>
<select class="fs" style="width:160px" onchange="window.location.href='/admin/set-active-router?router_id=' + this.value">
<option value="all" <%= (!selectedRouterId) ? 'selected' : '' %>> Semua Router</option>
<% routers.forEach(r => { %>
<option value="<%= r.id %>" <%= (selectedRouterId === r.id) ? 'selected' : '' %>> <%= r.name %></option>
<% }) %>
</select>
<% } %>
<button class="btn btn-p btn-sm" onclick="openModal('addModal')"><i class="bi bi-plus-lg"></i> <%= t('admin.packages.add', 'Tambah Paket') %></button>
</div>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>#</th>
<th><%= t('admin.packages.table.name', 'Nama Paket') %></th>
<th><%= t('admin.packages.table.price_month', 'Harga / Bulan') %></th>
<th>Promo / Prorata</th>
<th><%= t('admin.packages.table.speed', 'Kecepatan') %></th>
<th><%= t('admin.packages.table.description', 'Deskripsi') %></th>
<th><%= t('admin.packages.table.isolir', 'Profil Isolir') %></th>
<th><%= t('admin.packages.table.customers', 'Pelanggan') %></th>
<th><%= t('admin.packages.table.status', 'Status') %></th>
<th><%= t('common.actions', 'Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (packages.length===0) { %>
<tr><td colspan="10" class="text-center text-muted" style="padding:32px"><%= t('admin.packages.empty', 'Belum ada paket. Tambah paket pertama!') %></td></tr>
<% } %>
<% packages.forEach((p,i)=>{ %>
<tr>
<td class="text-muted"><%= i+1 %></td>
<td class="fw6">
<div><%= p.name %></div>
<div style="margin-top:3px">
<% if (p.billing_type === 'prepaid') { %>
<span class="badge bs" style="font-size:10px;padding:2px 6px;font-weight:700;"><i class="bi bi-clock-history"></i> Prabayar (<%= p.duration_days || 30 %> Hari)</span>
<% } else { %>
<span class="badge bp" style="font-size:10px;padding:2px 6px;font-weight:600;"><i class="bi bi-calendar-check"></i> Pascabayar</span>
<% } %>
</div>
</td>
<td class="fw6" style="color:var(--success)">
Rp <%= p.price.toLocaleString('id-ID') %>
<% if (p.use_ppn) { %><br><span class="badge bp" style="font-size:9px;padding:2px 4px;margin-top:2px;display:inline-block">+ PPN <%= p.ppn_percentage %>%</span><% } %>
<% if (p.use_uso) { %><br><span class="badge bm" style="font-size:9px;padding:2px 4px;margin-top:2px;display:inline-block">+ USO <%= p.uso_percentage %>%</span><% } %>
</td>
<td style="font-size:11px;max-width:160px">
<% if (p.promo_cycles > 0 && p.promo_price != null && p.promo_price !== '') { %>
<span class="badge bp">Promo</span> Rp <%= Number(p.promo_price).toLocaleString('id-ID') %> × <%= p.promo_cycles %> bln
<% } else { %><span class="text-muted">—</span><% } %>
<% if (p.prorate_first_invoice) { %><br><span class="badge bs" style="margin-top:4px;display:inline-block">Prorata 1×</span><% } %>
</td>
<td>
<% if (p.speed_down) { %>
<div style="white-space:nowrap;font-weight:700;">
<span class="badge bp"><i class="bi bi-download"></i> <%= (p.speed_down/1000).toFixed(0) %> Mbps</span>
<% if (p.speed_down_upto && p.speed_down_upto > p.speed_down) { %>
<span class="badge bs" style="font-size:10px;padding:2px 5px;">UpTo <%= (p.speed_down_upto/1000).toFixed(0) %>M</span>
<% } %>
</div>
<div style="white-space:nowrap;font-weight:700;margin-top:4px;">
<span class="badge bm"><i class="bi bi-upload"></i> <%= (p.speed_up/1000).toFixed(0) %> Mbps</span>
<% if (p.speed_up_upto && p.speed_up_upto > p.speed_up) { %>
<span class="badge bs" style="font-size:10px;padding:2px 5px;">UpTo <%= (p.speed_up_upto/1000).toFixed(0) %>M</span>
<% } %>
</div>
<% if (p.rate_limit) { %><div class="text-muted mono" style="font-size:10.5px;margin-top:2px" title="Rate-limit (MikroTik & RADIUS)"><i class="bi bi-speedometer2"></i> <%= p.rate_limit %></div><% } %>
<% } else { %><span class="text-muted">-</span><% } %>
</td>
<td style="color:var(--muted);font-size:12px"><%= p.description||'-' %></td>
<td><% if (p.isolir_profile) { %><span class="badge" style="background:rgba(239,68,68,.16);color:#ef4444;border:1px solid rgba(239,68,68,.3)"><i class="bi bi-shield-slash"></i> <%= p.isolir_profile %></span><% } else { %><span class="text-muted">—</span><% } %></td>
<td class="text-center"><span class="badge bm"><%= p.customer_count %> <%= t('admin.packages.customers_suffix', 'pelanggan') %></span></td>
<td><% if(p.is_active){%><span class="badge bs"><%= t('common.active', 'Aktif') %></span><%}else{%><span class="badge bd"><%= t('common.inactive', 'Nonaktif') %></span><%}%></td>
<td style="white-space:nowrap">
<form id="pkdel-<%= p.id %>" method="POST" action="/admin/packages/<%= p.id %>/delete" style="display:none" onsubmit="return confirm('<%= t('admin.packages.confirm.delete', 'Hapus paket ini? Pelanggan yang menggunakan paket ini akan kehilangan referensi paket.') %>')"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" data-idx="<%= i %>" onclick="editPkg(+this.dataset.idx)"><i class="bi bi-pencil"></i> Edit Paket</button>
<div class="act-sep"></div>
<button type="submit" form="pkdel-<%= p.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Paket</button>
</div>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Tambah -->
<div class="mo" id="addModal">
<div class="mb" style="max-width:680px;">
<div class="mh"><span class="mt"><i class="bi bi-plus-square"></i> <%= t('admin.packages.add', 'Tambah Paket') %></span><button class="mc" onclick="closeModal('addModal')">&times;</button></div>
<form method="POST" action="/admin/packages">
<div class="mbody" style="max-height:70vh; overflow-y:auto; padding-right:6px;">
<div class="fg">
<label class="fl"><%= t('admin.packages.form.name_profile', 'Nama Paket (Profile MikroTik)') %> *</label>
<div style="display:flex;gap:8px">
<select class="fs" id="add_profile_select" style="max-width:200px" onchange="document.getElementById('add_name').value=this.value">
<option value=""><%= t('admin.packages.form.pick_from_mikrotik', '-- Pilih dari MikroTik --') %></option>
</select>
<input class="fc" name="name" id="add_name" required placeholder="<%= t('admin.packages.form.type_manual', 'Atau ketik manual...') %>">
</div>
</div>
<div class="hint" style="margin:-2px 0 10px;font-size:11px">
<% if (typeof profileMode !== 'undefined' && profileMode === 'radius-attributes') { %>
<i class="bi bi-broadcast"></i> Mode RADIUS: rate-limit &amp; IP dari <b>atribut RADIUS</b> — profil PPP MikroTik tidak dibuat otomatis.
<% } else { %>
<i class="bi bi-router"></i> Profil PPP MikroTik akan <b>dibuat/diperbarui otomatis</b> dari speed paket<% if (typeof profileMode !== 'undefined' && profileMode === 'radius-profile') { %> (mode RADIUS pakai profil MikroTik)<% } %>.
<% } %>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.packages.form.price_month', 'Harga / Bulan (Rp)') %> *</label><input class="fc" name="price" type="number" min="0" required placeholder="150000"></div>
<div class="fg"><label class="fl"><%= t('admin.packages.form.status', 'Status') %></label>
<select class="fs" name="is_active"><option value="1"><%= t('common.active', 'Aktif') %></option><option value="0"><%= t('common.inactive', 'Nonaktif') %></option></select>
</div>
</div>
<!-- Tipe Billing: Pascabayar vs Prabayar -->
<div class="form-row" style="background:rgba(99,102,241,0.05);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px solid rgba(99,102,241,0.15);">
<div class="fg">
<label class="fl"><i class="bi bi-credit-card-2-front text-primary me-1"></i> Tipe Penagihan / Billing *</label>
<select class="fs" name="billing_type" id="add_billing_type" onchange="toggleAddBillingType(this.value)">
<option value="postpaid" selected>Pascabayar (Kalender / Jatuh Tempo Tetap)</option>
<option value="prepaid">Prabayar (Masa Aktif Bergulir)</option>
</select>
</div>
<div class="fg" id="add_duration_days_fg" style="display:none">
<label class="fl"><i class="bi bi-clock-history text-warning me-1"></i> Durasi Masa Aktif (Hari) *</label>
<input class="fc" name="duration_days" id="add_duration_days" type="number" min="1" value="30" placeholder="30">
<small class="text-muted" style="font-size:11px;display:block;margin-top:2px;">
Pelanggan aktif selama X hari sejak aktivasi / pembayaran.
</small>
</div>
</div>
<!-- Kecepatan Berdampingan (Download & Upload) -->
<div class="form-row" style="background:rgba(59,130,246,0.04);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px solid rgba(59,130,246,0.12);">
<div class="fg">
<label class="fl" style="color:var(--primary);font-weight:700;"><i class="bi bi-download"></i> Kecepatan Download (Mbps) *</label>
<input class="fc" name="speed_down" id="add_down" type="number" step="any" min="0" placeholder="Contoh: 10" required>
</div>
<div class="fg">
<label class="fl" style="color:var(--info);font-weight:700;"><i class="bi bi-upload"></i> Kecepatan Upload (Mbps) *</label>
<input class="fc" name="speed_up" id="add_up" type="number" step="any" min="0" placeholder="Contoh: 5" required>
</div>
</div>
<div class="hint" style="margin-top:6px;font-size:11px">
<i class="bi bi-speedometer2"></i> Rate-limit asli (MikroTik &amp; RADIUS): <b class="mono" id="ratePreview" style="color:var(--primary)">-</b>
<span style="color:var(--muted)">— format <span class="mono">rx/tx</span> (+ burst: <span class="mono">rx/tx rxB/txB rxT/txT time</span>). Nilai sama dipakai untuk profil MikroTik <b>dan</b> atribut RADIUS.</span>
</div>
<!-- Kecepatan UpTo / Maximum Burst Rate (Opsional Manual) -->
<div style="background:rgba(16,185,129,0.04);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px dashed rgba(16,185,129,0.25);">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--success)"><i class="bi bi-speedometer2"></i> Fitur Kecepatan UpTo / Max Rate (Opsional)</div>
<div class="form-row">
<div class="fg">
<label class="fl">Download UpTo (Mbps)</label>
<input class="fc" name="speed_down_upto" id="add_down_upto" type="number" step="any" min="0" placeholder="Kosongkan jika tanpa UpTo (mis: 20)">
</div>
<div class="fg">
<label class="fl">Upload UpTo (Mbps)</label>
<input class="fc" name="speed_up_upto" id="add_up_upto" type="number" step="any" min="0" placeholder="Kosongkan jika tanpa UpTo (mis: 10)">
</div>
</div>
<small class="text-muted" style="font-size:11px;display:block;margin-top:2px;">
Bila diisi, paket akan tampil sebagai: <strong>10 Mbps (Up to 20 Mbps)</strong> di invoice & portal pelanggan.
</small>
</div>
<div style="background: rgba(34,197,94,0.06); padding: 12px 14px; border-radius: 10px; margin-top: 10px;">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--success)"><i class="bi bi-percent"></i> Promo & prorata tagihan</div>
<div class="form-row">
<div class="fg"><label class="fl">Harga promo (Rp / siklus)</label><input class="fc" name="promo_price" type="number" min="0" placeholder="Opsional, mis. 99000"></div>
<div class="fg"><label class="fl">Jumlah bulan promo</label><input class="fc" name="promo_cycles" type="number" min="0" value="0" placeholder="0 = tanpa promo"></div>
</div>
<div class="flex ai-c" style="gap:8px;margin-top:4px">
<input type="checkbox" name="prorate_first_invoice" id="add_prorate" value="1">
<label for="add_prorate" style="margin:0;font-size:12px">Prorata tagihan <strong>pertama</strong> jika pasang di tengah bulan (proporsi sisa hari, perlu tanggal pasang di data pelanggan)</label>
</div>
</div>
<div style="background: rgba(99,102,241,0.06); padding: 12px 14px; border-radius: 10px; margin-top: 10px;">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--primary)"><i class="bi bi-calculator"></i> Pengaturan Pajak & Regulasi (PPN & USO)</div>
<div class="form-row" style="margin-bottom: 8px;">
<div class="fg">
<label style="display:flex; align-items:center; gap:8px; font-size:12px; font-weight:700; margin-top: 26px;">
<input type="checkbox" name="use_ppn" id="add_use_ppn" value="1">
Aktifkan PPN
</label>
</div>
<div class="fg">
<label class="fl">Persentase PPN (%)</label>
<input class="fc" name="ppn_percentage" type="number" step="any" min="0" value="11.0">
</div>
</div>
<div class="form-row">
<div class="fg">
<label style="display:flex; align-items:center; gap:8px; font-size:12px; font-weight:700; margin-top: 26px;">
<input type="checkbox" name="use_uso" id="add_use_uso" value="1">
Aktifkan ULO/USO
</label>
</div>
<div class="fg">
<label class="fl">Persentase ULO/USO (%)</label>
<input class="fc" name="uso_percentage" type="number" step="any" min="0" value="1.75">
</div>
</div>
</div>
<div class="fg"><label class="fl"><%= t('admin.packages.form.description', 'Deskripsi') %></label><textarea class="fc" name="description" placeholder="<%= t('admin.packages.form.optional', 'Opsional...') %>"></textarea></div>
<!-- IP Pool & Profil MikroTik -->
<div style="background:rgba(59,130,246,0.05);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px solid rgba(59,130,246,0.15);">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--info)"><i class="bi bi-hdd-network"></i> IP Pool &amp; Profil MikroTik <span class="text-muted" style="font-weight:400">(comment: "manage by cloud")</span></div>
<div class="form-row">
<div class="fg"><label class="fl">Nama IP Pool</label><input class="fc" name="pool_name" id="add_pool_name" placeholder="mis. HOME10"></div>
<div class="fg"><label class="fl">Rentang IP (ranges)</label><input class="fc" name="pool_ranges" id="add_pool_ranges" placeholder="10.20.0.10-10.20.0.254"></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Local Address (gateway)</label><input class="fc" name="local_address" id="add_local_address" placeholder="10.20.0.1/24"></div>
<div class="fg"><label class="fl">Only One</label><select class="fs" name="only_one" id="add_only_one"><option value="1" selected>Ya</option><option value="0">Tidak</option></select></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Parent Queue</label><select class="fs" name="parent_queue" id="add_parent_queue"><option value="">— none —</option></select></div>
<div class="fg"><label class="fl">Queue Type</label>
<select class="fs" name="queue_type" id="add_queue_type">
<option value="">Default</option>
<option value="default">default</option>
<option value="ethernet-default">ethernet-default</option>
<option value="wireless-default">wireless-default</option>
<option value="synchronous-default">synchronous-default</option>
<option value="hotspot-default">hotspot-default</option>
<option value="pcq-upload-default">pcq-upload-default</option>
<option value="pcq-download-default">pcq-download-default</option>
<option value="only-hardware-queue">only-hardware-queue</option>
<option value="multi-queue-ethernet-default">multi-queue-ethernet-default</option>
<option value="default-small">default-small</option>
</select>
</div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><i class="bi bi-shield-slash"></i> Profil Isolir (paket ini)</label><select class="fs" name="isolir_profile" id="add_isolir_profile"><option value="">— pakai default global/customer —</option></select></div>
</div>
<small class="text-muted" style="font-size:11px;display:block;margin-top:2px">Profil PPP + IP pool dibuat/diperbarui otomatis ke MikroTik (mode MikroTik / RADIUS profil). Pada mode RADIUS-atribut, IP &amp; rate-limit dari RADIUS.</small>
</div>
<!-- Dynamic Speed & FUP Section -->
<div style="background: rgba(0,0,0,0.02); padding: 15px; border-radius: 12px; margin-top: 15px;">
<div class="flex ai-c mb8" style="gap:10px">
<input type="checkbox" name="use_night_speed" id="add_use_night" value="1" onchange="toggleAddNight()">
<h6 style="font-size:12px; margin: 0; color: var(--primary); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-clock"></i> Aktifkan Jam Kalong</h6>
</div>
<div id="add_night_fields" style="display:none">
<div class="fg">
<label class="fl">Nama Profile Malam (MikroTik)</label>
<div style="display:flex;gap:8px">
<select class="fs" id="add_night_profile_select" style="max-width:200px" onchange="document.getElementById('add_night_profile_input').value=this.value">
<option value="">-- Pilih Profile --</option>
</select>
<input class="fc" name="night_profile_name" id="add_night_profile_input" placeholder="<%= t('admin.common.type_manual','Atau ketik manual...') %>">
</div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Download Malam (Mbps)</label><input class="fc" name="night_speed_down" type="number" step="any" min="0" placeholder="20"></div>
<div class="fg"><label class="fl">Upload Malam (Mbps)</label><input class="fc" name="night_speed_up" type="number" step="any" min="0" placeholder="10"></div>
</div>
<div style="font-size: 10px; color: var(--muted); margin-top: 4px;">Sistem akan ganti profile ppp pelanggan ke profile ini pada jam 00:00 - 06:00.</div>
</div>
<hr style="opacity:0.1; margin: 15px 0;">
<div class="flex ai-c mb8" style="gap:10px">
<input type="checkbox" name="use_fup" id="add_use_fup" value="1" onchange="toggleAddFup()">
<h6 style="font-size:12px; margin: 0; color: var(--danger); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-speedometer2"></i> Aktifkan FUP</h6>
</div>
<div id="add_fup_fields" style="display:none">
<div class="fg">
<label class="fl">Nama Profile FUP (MikroTik)</label>
<div style="display:flex;gap:8px">
<select class="fs" id="add_fup_profile_select" style="max-width:200px" onchange="document.getElementById('add_fup_profile_input').value=this.value">
<option value="">-- Pilih Profile --</option>
</select>
<input class="fc" name="fup_profile_name" id="add_fup_profile_input" placeholder="<%= t('admin.common.type_manual','Atau ketik manual...') %>">
</div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Batas Kuota (GB)</label><input class="fc" name="fup_limit_gb" type="number" step="any" min="0" placeholder="300"></div>
<div class="fg"><label class="fl">Speed Setelah FUP (Mbps)</label><input class="fc" name="fup_speed_down" type="number" step="any" min="0" placeholder="2"></div>
</div>
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> <%= t('common.save', 'Simpan') %></button>
</div>
</form>
</div>
</div>
<!-- Modal Edit -->
<div class="mo" id="editModal">
<div class="mb" style="max-width:680px;">
<div class="mh"><span class="mt"><i class="bi bi-pencil"></i> <%= t('admin.packages.edit', 'Edit Paket') %></span><button class="mc" onclick="closeModal('editModal')">&times;</button></div>
<form method="POST" id="editForm">
<div class="mbody" style="max-height:70vh; overflow-y:auto; padding-right:6px;">
<div class="fg">
<label class="fl"><%= t('admin.packages.form.name_profile', 'Nama Paket (Profile MikroTik)') %> *</label>
<div style="display:flex;gap:8px">
<select class="fs" id="e_profile_select" style="max-width:200px" onchange="document.getElementById('e_name').value=this.value">
<option value=""><%= t('admin.packages.form.pick_from_mikrotik', '-- Pilih dari MikroTik --') %></option>
</select>
<input class="fc" name="name" id="e_name" required>
</div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.packages.form.price_month', 'Harga / Bulan (Rp)') %> *</label><input class="fc" name="price" id="e_price" type="number" min="0" required></div>
<div class="fg"><label class="fl"><%= t('admin.packages.form.status', 'Status') %></label>
<select class="fs" name="is_active" id="e_active"><option value="1"><%= t('common.active', 'Aktif') %></option><option value="0"><%= t('common.inactive', 'Nonaktif') %></option></select>
</div>
</div>
<!-- Tipe Billing: Pascabayar vs Prabayar (Edit) -->
<div class="form-row" style="background:rgba(99,102,241,0.05);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px solid rgba(99,102,241,0.15);">
<div class="fg">
<label class="fl"><i class="bi bi-credit-card-2-front text-primary me-1"></i> Tipe Penagihan / Billing *</label>
<select class="fs" name="billing_type" id="e_billing_type" onchange="toggleEditBillingType(this.value)">
<option value="postpaid">Pascabayar (Kalender / Jatuh Tempo Tetap)</option>
<option value="prepaid">Prabayar (Masa Aktif Bergulir)</option>
</select>
</div>
<div class="fg" id="e_duration_days_fg" style="display:none">
<label class="fl"><i class="bi bi-clock-history text-warning me-1"></i> Durasi Masa Aktif (Hari) *</label>
<input class="fc" name="duration_days" id="e_duration_days" type="number" min="1" value="30" placeholder="30">
<small class="text-muted" style="font-size:11px;display:block;margin-top:2px;">
Pelanggan aktif selama X hari sejak aktivasi / pembayaran.
</small>
</div>
</div>
<!-- Kecepatan Berdampingan (Download & Upload - Edit) -->
<div class="form-row" style="background:rgba(59,130,246,0.04);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px solid rgba(59,130,246,0.12);">
<div class="fg">
<label class="fl" style="color:var(--primary);font-weight:700;"><i class="bi bi-download"></i> Kecepatan Download (Mbps) *</label>
<input class="fc" name="speed_down" id="e_down" type="number" step="any" min="0" required>
</div>
<div class="fg">
<label class="fl" style="color:var(--info);font-weight:700;"><i class="bi bi-upload"></i> Kecepatan Upload (Mbps) *</label>
<input class="fc" name="speed_up" id="e_up" type="number" step="any" min="0" required>
</div>
</div>
<!-- Kecepatan UpTo (Batas Maksimum - Edit) -->
<div style="background:rgba(16,185,129,0.04);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px dashed rgba(16,185,129,0.25);">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--success)"><i class="bi bi-speedometer2"></i> Fitur Kecepatan UpTo / Max Rate (Opsional)</div>
<div class="form-row">
<div class="fg">
<label class="fl">Download UpTo (Mbps)</label>
<input class="fc" name="speed_down_upto" id="e_down_upto" type="number" step="any" min="0" placeholder="Kosongkan jika tanpa UpTo (mis: 20)">
</div>
<div class="fg">
<label class="fl">Upload UpTo (Mbps)</label>
<input class="fc" name="speed_up_upto" id="e_up_upto" type="number" step="any" min="0" placeholder="Kosongkan jika tanpa UpTo (mis: 10)">
</div>
</div>
<small class="text-muted" style="font-size:11px;display:block;margin-top:2px;">
Bila diisi, paket akan tampil sebagai: <strong>10 Mbps (Up to 20 Mbps)</strong> di invoice & portal pelanggan.
</small>
</div>
<div style="background: rgba(34,197,94,0.06); padding: 12px 14px; border-radius: 10px; margin-top: 10px;">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--success)"><i class="bi bi-percent"></i> Promo & prorata tagihan</div>
<div class="form-row">
<div class="fg"><label class="fl">Harga promo (Rp / siklus)</label><input class="fc" name="promo_price" id="e_promo_price" type="number" min="0" placeholder="Kosong = tanpa harga promo"></div>
<div class="fg"><label class="fl">Jumlah bulan promo</label><input class="fc" name="promo_cycles" id="e_promo_cycles" type="number" min="0" value="0"></div>
</div>
<div class="flex ai-c" style="gap:8px;margin-top:4px">
<input type="checkbox" name="prorate_first_invoice" id="e_prorate" value="1">
<label for="e_prorate" style="margin:0;font-size:12px">Prorata tagihan pertama (tanggal pasang = bulan tagihan)</label>
</div>
</div>
<div style="background: rgba(99,102,241,0.06); padding: 12px 14px; border-radius: 10px; margin-top: 10px;">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--primary)"><i class="bi bi-calculator"></i> Pengaturan Pajak & Regulasi (PPN & USO)</div>
<div class="form-row" style="margin-bottom: 8px;">
<div class="fg">
<label style="display:flex; align-items:center; gap:8px; font-size:12px; font-weight:700; margin-top: 26px;">
<input type="checkbox" name="use_ppn" id="e_use_ppn" value="1">
Aktifkan PPN
</label>
</div>
<div class="fg">
<label class="fl">Persentase PPN (%)</label>
<input class="fc" name="ppn_percentage" id="e_ppn_percentage" type="number" step="any" min="0">
</div>
</div>
<div class="form-row">
<div class="fg">
<label style="display:flex; align-items:center; gap:8px; font-size:12px; font-weight:700; margin-top: 26px;">
<input type="checkbox" name="use_uso" id="e_use_uso" value="1">
Aktifkan ULO/USO
</label>
</div>
<div class="fg">
<label class="fl">Persentase ULO/USO (%)</label>
<input class="fc" name="uso_percentage" id="e_uso_percentage" type="number" step="any" min="0">
</div>
</div>
</div>
<div class="fg" style="margin-top:10px;"><label class="fl"><%= t('admin.packages.form.description', 'Deskripsi') %></label><textarea class="fc" name="description" id="e_desc"></textarea></div>
<!-- IP Pool & Profil MikroTik -->
<div style="background:rgba(59,130,246,0.05);padding:12px 14px;border-radius:10px;margin-top:10px;border:1px solid rgba(59,130,246,0.15);">
<div class="fw6" style="font-size:12px;margin-bottom:8px;color:var(--info)"><i class="bi bi-hdd-network"></i> IP Pool &amp; Profil MikroTik <span class="text-muted" style="font-weight:400">(comment: "manage by cloud")</span></div>
<div class="form-row">
<div class="fg"><label class="fl">Nama IP Pool</label><input class="fc" name="pool_name" id="e_pool_name" placeholder="mis. HOME10"></div>
<div class="fg"><label class="fl">Rentang IP (ranges)</label><input class="fc" name="pool_ranges" id="e_pool_ranges" placeholder="10.20.0.10-10.20.0.254"></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Local Address (gateway)</label><input class="fc" name="local_address" id="e_local_address" placeholder="10.20.0.1/24"></div>
<div class="fg"><label class="fl">Only One</label><select class="fs" name="only_one" id="e_only_one"><option value="1">Ya</option><option value="0">Tidak</option></select></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Parent Queue</label><select class="fs" name="parent_queue" id="e_parent_queue"><option value="">— none —</option></select></div>
<div class="fg"><label class="fl">Queue Type</label>
<select class="fs" name="queue_type" id="e_queue_type">
<option value="">Default</option>
<option value="default">default</option>
<option value="ethernet-default">ethernet-default</option>
<option value="wireless-default">wireless-default</option>
<option value="synchronous-default">synchronous-default</option>
<option value="hotspot-default">hotspot-default</option>
<option value="pcq-upload-default">pcq-upload-default</option>
<option value="pcq-download-default">pcq-download-default</option>
<option value="only-hardware-queue">only-hardware-queue</option>
<option value="multi-queue-ethernet-default">multi-queue-ethernet-default</option>
<option value="default-small">default-small</option>
</select>
</div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><i class="bi bi-shield-slash"></i> Profil Isolir (paket ini)</label><select class="fs" name="isolir_profile" id="e_isolir_profile"><option value="">— pakai default global/customer —</option></select></div>
</div>
<small class="text-muted" style="font-size:11px;display:block;margin-top:2px">Profil PPP + IP pool dibuat/diperbarui otomatis ke MikroTik (mode MikroTik / RADIUS profil). Pada mode RADIUS-atribut, IP &amp; rate-limit dari RADIUS.</small>
</div>
<!-- Dynamic Speed & FUP Section (Edit) -->
<div style="background: rgba(0,0,0,0.02); padding: 15px; border-radius: 12px; margin-top: 15px;">
<div class="flex ai-c mb8" style="gap:10px">
<input type="checkbox" name="use_night_speed" id="e_use_night" value="1" onchange="toggleEditNight()">
<h6 style="font-size:12px; margin: 0; color: var(--primary); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-clock"></i> Aktifkan Jam Kalong</h6>
</div>
<small class="text-muted" style="font-size:11px;display:block;margin:-6px 0 8px 26px">Centang untuk mengatur (kolom terbuka otomatis bila paket ini memakai Jam Kalong).</small>
<div id="e_night_fields" style="display:none">
<div class="fg">
<label class="fl">Nama Profile Malam (MikroTik)</label>
<div style="display:flex;gap:8px">
<select class="fs" id="e_night_profile_select" style="max-width:200px" onchange="document.getElementById('e_night_profile').value=this.value">
<option value="">-- Pilih Profile --</option>
</select>
<input class="fc" name="night_profile_name" id="e_night_profile">
</div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Download Malam (Mbps)</label><input class="fc" name="night_speed_down" id="e_night_down" type="number" step="any" min="0"></div>
<div class="fg"><label class="fl">Upload Malam (Mbps)</label><input class="fc" name="night_speed_up" id="e_night_up" type="number" step="any" min="0"></div>
</div>
</div>
<hr style="opacity:0.1; margin: 15px 0;">
<div class="flex ai-c mb8" style="gap:10px">
<input type="checkbox" name="use_fup" id="e_use_fup" value="1" onchange="toggleEditFup()">
<h6 style="font-size:12px; margin: 0; color: var(--danger); text-transform: uppercase; letter-spacing: 0.5px;"><i class="bi bi-speedometer2"></i> Aktifkan FUP</h6>
</div>
<small class="text-muted" style="font-size:11px;display:block;margin:-6px 0 8px 26px">Centang untuk mengatur (kolom terbuka otomatis bila paket ini memakai FUP).</small>
<div id="e_fup_fields" style="display:none">
<div class="fg">
<label class="fl">Nama Profile FUP (MikroTik)</label>
<div style="display:flex;gap:8px">
<select class="fs" id="e_fup_profile_select" style="max-width:200px" onchange="document.getElementById('e_fup_profile').value=this.value">
<option value="">-- Pilih Profile --</option>
</select>
<input class="fc" name="fup_profile_name" id="e_fup_profile">
</div>
</div>
<div class="form-row">
<div class="fg"><label class="fl">Batas Kuota (GB)</label><input class="fc" name="fup_limit_gb" id="e_fup_limit" type="number" step="any" min="0"></div>
<div class="fg"><label class="fl">Speed Setelah FUP (Mbps)</label><input class="fc" name="fup_speed_down" id="e_fup_speed" type="number" step="any" min="0"></div>
</div>
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('editModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> <%= t('admin.packages.update', 'Update') %></button>
</div>
</form>
</div>
</div>
<script type="application/json" id="packages-data"><%- JSON.stringify(packages).split('<').join('\\u003c') %></script>
<script>
const packagesData = JSON.parse(document.getElementById('packages-data').textContent);
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
document.querySelectorAll('.mo').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show')}));
function editPkg(idx) {
const p = packagesData[idx];
if (!p) return;
document.getElementById('editForm').action='/admin/packages/'+p.id+'/update';
document.getElementById('e_name').value=p.name||'';
document.getElementById('e_price').value=p.price||0;
document.getElementById('e_promo_price').value = (p.promo_price != null && p.promo_price !== '') ? p.promo_price : '';
document.getElementById('e_promo_cycles').value = p.promo_cycles != null ? p.promo_cycles : 0;
document.getElementById('e_prorate').checked = !!p.prorate_first_invoice;
document.getElementById('e_use_ppn').checked = !!p.use_ppn;
document.getElementById('e_ppn_percentage').value = p.ppn_percentage != null ? p.ppn_percentage : 11.0;
document.getElementById('e_use_uso').checked = !!p.use_uso;
document.getElementById('e_uso_percentage').value = p.uso_percentage != null ? p.uso_percentage : 1.75;
document.getElementById('e_down').value=p.speed_down ? (p.speed_down/1000) : 0;
document.getElementById('e_up').value=p.speed_up ? (p.speed_up/1000) : 0;
document.getElementById('e_down_upto').value=p.speed_down_upto ? (p.speed_down_upto/1000) : '';
document.getElementById('e_up_upto').value=p.speed_up_upto ? (p.speed_up_upto/1000) : '';
document.getElementById('e_night_down').value=p.night_speed_down ? (p.night_speed_down/1000) : 0;
document.getElementById('e_night_up').value=p.night_speed_up ? (p.night_speed_up/1000) : 0;
document.getElementById('e_night_profile').value=p.night_profile_name || '';
document.getElementById('e_use_night').checked = !!p.use_night_speed;
document.getElementById('e_night_fields').style.display = p.use_night_speed ? 'block' : 'none';
document.getElementById('e_fup_limit').value=p.fup_limit_gb || 0;
document.getElementById('e_fup_speed').value=p.fup_speed_down ? (p.fup_speed_down/1000) : 0;
document.getElementById('e_fup_profile').value=p.fup_profile_name || '';
document.getElementById('e_use_fup').checked = !!p.use_fup;
document.getElementById('e_fup_fields').style.display = p.use_fup ? 'block' : 'none';
document.getElementById('e_active').value=p.is_active?'1':'0';
document.getElementById('e_desc').value=p.description||'';
document.getElementById('e_pool_name').value=p.pool_name||'';
document.getElementById('e_pool_ranges').value=p.pool_ranges||'';
document.getElementById('e_local_address').value=p.local_address||'';
document.getElementById('e_only_one').value=(p.only_one===0||p.only_one==='0')?'0':'1';
document.getElementById('e_parent_queue').value=p.parent_queue||'';
document.getElementById('e_queue_type').value=p.queue_type||'';
document.getElementById('e_isolir_profile').value=p.isolir_profile||'';
const isPrepaid = p.billing_type === 'prepaid';
document.getElementById('e_billing_type').value = isPrepaid ? 'prepaid' : 'postpaid';
document.getElementById('e_duration_days').value = p.duration_days || 30;
document.getElementById('e_duration_days_fg').style.display = isPrepaid ? 'block' : 'none';
openModal('editModal');
}
function toggleAddBillingType(val) {
document.getElementById('add_duration_days_fg').style.display = (val === 'prepaid') ? 'block' : 'none';
}
function toggleEditBillingType(val) {
document.getElementById('e_duration_days_fg').style.display = (val === 'prepaid') ? 'block' : 'none';
}
function toggleAddNight() { document.getElementById('add_night_fields').style.display = document.getElementById('add_use_night').checked ? 'block' : 'none'; }
function toggleAddFup() { document.getElementById('add_fup_fields').style.display = document.getElementById('add_use_fup').checked ? 'block' : 'none'; }
function toggleEditNight() { document.getElementById('e_night_fields').style.display = document.getElementById('e_use_night').checked ? 'block' : 'none'; }
function toggleEditFup() { document.getElementById('e_fup_fields').style.display = document.getElementById('e_use_fup').checked ? 'block' : 'none'; }
function _rlKb(v){ v=Number(v)||0; return Math.round(v*1000); }
function _rlStr(k){ if(k<=0) return null; return (k>=1000 && k%1000===0)?(k/1000)+'M':k+'k'; }
function computeRateLimit(d,u,du,uu){ var rx=_rlStr(_rlKb(u)), tx=_rlStr(_rlKb(d)); if(!rx||!tx) return ''; var rxB=_rlStr(_rlKb(uu)), txB=_rlStr(_rlKb(du)); if(rxB&&txB&&(_rlKb(uu)>_rlKb(u)||_rlKb(du)>_rlKb(d))){ return rx+'/'+tx+' '+rxB+'/'+txB+' '+rx+'/'+tx+' 8/8'; } return rx+'/'+tx; }
function updateRatePreview(){ var el=document.getElementById('ratePreview'); if(!el) return; var g=function(id){var e=document.getElementById(id);return e?e.value:0;}; el.textContent = computeRateLimit(g('add_down'),g('add_up'),g('add_down_upto'),g('add_up_upto')) || '-'; }
['add_down','add_up','add_down_upto','add_up_upto'].forEach(function(id){ var e=document.getElementById(id); if(e) e.addEventListener('input', updateRatePreview); });
updateRatePreview();
async function loadQueues() {
async function fill(ep, ids) {
try {
const r = await fetch('/admin/api/mikrotik/' + ep);
if (!r.ok) return;
const names = await r.json();
ids.forEach(function (sid) {
const sel = document.getElementById(sid);
if (!sel) return;
(names || []).forEach(function (n) {
if (!Array.from(sel.options).some(function (o) { return o.value === n; })) {
const o = document.createElement('option'); o.value = n; o.textContent = n; sel.appendChild(o);
}
});
});
} catch (e) {}
}
await fill('queue-types', ['add_queue_type', 'e_queue_type']);
await fill('queue-parents', ['add_parent_queue', 'e_parent_queue']);
}
loadQueues();
setTimeout(()=>document.querySelectorAll('.alert').forEach(a=>a.style.display='none'),4000);
// Load MikroTik profiles into select dropdowns
async function loadProfiles() {
try {
const routerParam = '<%= (typeof selectedRouterId !== "undefined" && selectedRouterId) ? selectedRouterId : "" %>';
const res = await fetch('/admin/api/mikrotik/profiles' + (routerParam ? '?routerId=' + routerParam : ''));
if (!res.ok) return;
const profiles = await res.json();
const selAdd = document.getElementById('add_profile_select');
const selEdit = document.getElementById('e_profile_select');
const selAddNight = document.getElementById('add_night_profile_select');
const selAddFup = document.getElementById('add_fup_profile_select');
const selEditNight = document.getElementById('e_night_profile_select');
const selEditFup = document.getElementById('e_fup_profile_select');
const selAddIsolir = document.getElementById('add_isolir_profile');
const selEditIsolir = document.getElementById('e_isolir_profile');
profiles.forEach(p => {
const opt = `<option value="${p.name}">${p.name}</option>`;
selAdd.innerHTML += opt;
selEdit.innerHTML += opt;
if (selAddNight) selAddNight.innerHTML += opt;
if (selAddFup) selAddFup.innerHTML += opt;
if (selEditNight) selEditNight.innerHTML += opt;
if (selEditFup) selEditFup.innerHTML += opt;
if (selAddIsolir) selAddIsolir.innerHTML += opt;
if (selEditIsolir) selEditIsolir.innerHTML += opt;
});
} catch (e) {
console.error('Failed to load MikroTik profiles', e);
}
}
loadProfiles();
</script>
</body>
</html>
+33
View File
@@ -0,0 +1,33 @@
<%
// ── Section switcher untuk grup menu (bukan tab bar, agar tidak "tab dalam tab") ──
// Halaman-halaman anggota sudah punya tab internal masing-masing; jadi navigasi
// antar-halaman grup pakai dropdown ringkas berlabel.
var _isCashierOnly = (typeof session !== 'undefined' && session && session.isCashier && !session.isAdmin);
var _groups = {
network: {
label: t('admin.nav.network', 'Jaringan'),
icon: 'bi bi-diagram-3',
items: [
{ key: 'mikrotik', href: '/admin/mikrotik', label: t('admin.nav.mikrotik', 'MikroTik'), roles: ['admin', 'cashier'] },
{ key: 'radius', href: '/admin/radius-settings', label: t('admin.nav.radius_server', 'RADIUS Server'), roles: ['admin'] },
{ key: 'acs', href: '/admin/acs', label: t('admin.nav.acs_pro', 'GenieACS Pro'), roles: ['admin'] },
{ key: 'onu', href: '/admin/onu-provision', label: t('admin.nav.onu_provision', 'ONU Provision'), roles: ['admin'] },
{ key: 'monitoring', href: '/admin/monitoring', label: t('admin.nav.monitoring', 'Monitoring Sistem'), roles: ['admin'] }
]
}
};
var _gkey = (typeof group !== 'undefined' && group && _groups[group]) ? group : 'network';
var _g = _groups[_gkey];
var _items = (_g.items || []).filter(function (it) {
if (_isCashierOnly && it.roles && it.roles.indexOf('cashier') === -1) return false;
return true;
});
%>
<div class="grp-nav">
<span class="grp-nav-title"><i class="<%= _g.icon %>"></i> <%= _g.label %></span>
<select class="grp-nav-select" onchange="if(this.value){window.location.href=this.value;}" aria-label="<%= _g.label %>">
<% _items.forEach(function (it) { %>
<option value="<%= it.href %>" <%= (typeof active !== 'undefined' && active === it.key) ? 'selected' : '' %>><%= it.label %></option>
<% }); %>
</select>
</div>
+243
View File
@@ -0,0 +1,243 @@
<!-- views/admin/partials/sidebar.ejs -->
<script>
(function() {
const savedTheme = localStorage.getItem('app-theme') || 'dark';
document.documentElement.setAttribute('data-theme', savedTheme);
if (savedTheme === 'light') {
document.documentElement.classList.add('light-theme');
} else {
document.documentElement.classList.remove('light-theme');
}
})();
function toggleAppTheme() {
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', newTheme);
if (newTheme === 'light') {
document.documentElement.classList.add('light-theme');
if (document.body) document.body.classList.add('light-theme');
} else {
document.documentElement.classList.remove('light-theme');
if (document.body) document.body.classList.remove('light-theme');
}
localStorage.setItem('app-theme', newTheme);
updateThemeToggleIcons(newTheme);
}
function updateThemeToggleIcons(theme) {
const isLight = theme === 'light';
document.querySelectorAll('.theme-toggle-icon').forEach(icon => {
icon.className = isLight ? 'bi bi-sun-fill theme-toggle-icon' : 'bi bi-moon-stars-fill theme-toggle-icon';
icon.style.color = isLight ? '#f59e0b' : '#fbbf24';
});
document.querySelectorAll('.theme-toggle-btn').forEach(btn => {
btn.setAttribute('title', isLight ? 'Ganti ke Mode Gelap (Dark)' : 'Ganti ke Mode Terang (Light)');
});
}
document.addEventListener('DOMContentLoaded', () => {
const theme = localStorage.getItem('app-theme') || 'dark';
document.documentElement.setAttribute('data-theme', theme);
if (theme === 'light') {
document.documentElement.classList.add('light-theme');
if (document.body) document.body.classList.add('light-theme');
}
updateThemeToggleIcons(theme);
});
</script>
<div class="sidebar-overlay" id="sidebarOverlay" onclick="toggleSidebar()"></div>
<aside class="sidebar" id="sidebar">
<div class="sb-brand">
<div class="sb-icon"><i class="bi bi-wifi"></i></div>
<div style="flex:1">
<div class="sb-name">
<%= company %>
</div>
<div class="sb-sub"><%= t('admin.panel', 'Admin Panel') %></div>
<div style="margin-top:8px;display:flex;gap:6px;align-items:center">
<a href="/lang/id" class="btn btn-g btn-sm" style="padding:4px 8px">ID</a>
<a href="/lang/en" class="btn btn-g btn-sm" style="padding:4px 8px">EN</a>
<button type="button" class="btn btn-g btn-sm theme-toggle-btn" onclick="toggleAppTheme()" title="Ganti Tema Dark / Light" style="padding:4px 8px;margin-left:auto">
<i class="bi bi-moon-stars-fill theme-toggle-icon" style="color:#fbbf24"></i> <%= t('admin.side.theme','Tema') %>
</button>
</div>
<% if (typeof multiRouterMode !== 'undefined' && multiRouterMode && allActiveRouters && allActiveRouters.length > 0) { %>
<div style="margin-top:10px;padding:8px;background:rgba(255,255,255,0.06);border-radius:10px;border:1px solid rgba(255,255,255,0.1)">
<div style="font-size:11px;color:#94a3b8;margin-bottom:4px;font-weight:600;display:flex;align-items:center;gap:4px">
<i class="bi bi-router"></i> <%= t('admin.side.router_filter','Router Filter') %>
</div>
<select class="form-select form-select-sm" style="font-size:12px;cursor:pointer" onchange="window.location.href='/admin/set-active-router?router_id=' + this.value">
<option value="all" <%= (!selectedRouterId) ? 'selected' : '' %>> <%= t('admin.side.all_routers','Semua Router') %><%= allActiveRouters.length %>)</option>
<% allActiveRouters.forEach(r => { %>
<option value="<%= r.id %>" <%= (selectedRouterId === r.id) ? 'selected' : '' %>>
<i class="bi bi-plug-fill"></i> <%= r.name %> (<%= r.host %>)
</option>
<% }) %>
</select>
</div>
<% } %>
</div>
<button class="sb-close btn-g btn-sm" onclick="toggleSidebar()"><i class="bi bi-x-lg"></i></button>
</div>
<nav class="sb-nav">
<% (sidebarSections || []).forEach((section) => { %>
<div class="sb-section"><%= section.labelKey ? t(section.labelKey, section.labelDefault) : section.labelDefault %></div>
<% section.items.forEach((item) => { %>
<% const _kids = item.children || []; %>
<% const _kidActive = _kids.some(k => (k.activePages || []).includes(activePage)); %>
<% const _selfActive = (item.activePages || []).includes(activePage) || _kidActive; %>
<% if (_kids.length) { %>
<a href="javascript:void(0)" class="sb-item sb-parent <%= _selfActive ? 'active' : '' %>" onclick="return sbToggleGroup(event, this)">
<i class="<%= item.icon %>"></i>
<span><%= item.labelKey ? t(item.labelKey, item.labelDefault) : item.labelDefault %></span>
<i class="bi bi-chevron-down sb-caret"></i>
</a>
<div class="sb-sub" style="display:<%= _selfActive ? 'block' : 'none' %>">
<% _kids.forEach((k) => { %>
<a href="<%= k.locked ? 'javascript:void(0)' : k.hrefResolved %>" class="sb-item sb-child <%= (k.activePages || []).includes(activePage) ? 'active' : '' %>"<% if (k.locked) { %> onclick='return showLockedMenuNotice(<%- JSON.stringify(k.lockedMessage).split('<').join('\\u003c') %>)'<% } %>>
<i class="<%= k.icon %>"></i>
<span><%= k.labelKey ? t(k.labelKey, k.labelDefault) : k.labelDefault %></span>
<% if (k.locked) { %><span style="margin-left:auto;padding:2px 6px;border-radius:999px;background:rgba(245,158,11,.18);color:#fde68a;font-size:10px"><%= t('admin.lock.lock','Kunci') %></span><% } %>
</a>
<% }) %>
</div>
<% } else { %>
<a
href="<%= item.hrefResolved %>"
class="sb-item <%= (item.activePages || []).includes(activePage) ? 'active' : '' %>"
<% if (item.locked) { %>
onclick='return showLockedMenuNotice(<%- JSON.stringify(item.lockedMessage).split('<').join('\\u003c') %>)'
<% } %>
>
<i class="<%= item.icon %>"></i>
<span><%= item.labelKey ? t(item.labelKey, item.labelDefault) : item.labelDefault %></span>
<% if (item.locked) { %>
<span style="margin-left:auto;padding:2px 8px;border-radius:999px;background:rgba(245,158,11,.18);color:#fde68a;font-size:11px"><%= t('admin.lock.lock','Kunci') %></span>
<% } %>
</a>
<% } %>
<% }) %>
<% }) %>
<div class="sb-section"><%= t('admin.side.section_control','KONTROL') %></div>
<a href="/admin/sidebar-settings" class="sb-item <%= activePage==='sidebar_settings' ? 'active' : '' %>">
<i class="bi bi-layout-sidebar-inset"></i><span><%= t('admin.side.settings','Pengaturan Sidebar') %></span>
</a>
<a href="/admin/logout" class="sb-item logout">
<i class="bi bi-box-arrow-left"></i><span><%= t('common.logout', 'Logout') %></span>
</a>
<div class="mt-4 px-3 py-2 text-center" style="border-top: 1px solid rgba(255,255,255,0.1);">
<a href="https://github.com/oxpoe/open-isp" target="_blank" rel="noopener" style="color: #cbd5e1; font-size: 11px; text-decoration: none; font-weight: 700;">
OPEN-ISP BILLING SYSTEM
</a>
<div style="color:#64748b;font-size:10px;margin-top:3px;line-height:1.5">
v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5') %> (pickle-lime) · ISC · oxpoe
</div>
</div>
</nav>
</aside>
<div id="lockedMenuModal" style="display:none;position:fixed;inset:0;z-index:1200;background:rgba(2,6,23,.72);backdrop-filter:blur(4px);padding:16px;align-items:center;justify-content:center">
<div style="width:min(100%,420px);background:linear-gradient(180deg,rgba(15,23,42,.98),rgba(30,41,59,.98));border:1px solid rgba(148,163,184,.18);border-radius:18px;box-shadow:0 24px 60px rgba(2,6,23,.45);overflow:hidden">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 18px;border-bottom:1px solid rgba(148,163,184,.14)">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:40px;height:40px;border-radius:12px;background:rgba(245,158,11,.16);color:#fde68a;display:grid;place-items:center;font-size:18px">
<i class="bi bi-lock-fill"></i>
</div>
<div>
<div style="font-weight:800;color:#f8fafc"><%= t('admin.lock.title','Menu Terkunci') %></div>
<div style="font-size:12px;color:#94a3b8"><%= t('admin.lock.reason','Aktivasi diperlukan untuk membuka fitur ini') %></div>
</div>
</div>
<button type="button" class="btn btn-g btn-sm" onclick="hideLockedMenuNotice()" style="padding:6px 10px">
<i class="bi bi-x-lg"></i>
</button>
</div>
<div style="padding:18px">
<div id="lockedMenuModalMessage" style="color:#e2e8f0;line-height:1.6">
<%= t('admin.lock.desc','Menu ini sedang terkunci.') %>
</div>
<div style="margin-top:16px;padding:12px 14px;border-radius:14px;background:rgba(59,130,246,.12);border:1px solid rgba(59,130,246,.22);color:#bfdbfe;font-size:13px">
<%= t('admin.lock.code_label','Kode aktivasi menu ini') %> <strong style="color:#86efac">GRATIS</strong> <%= t('admin.lock.wiki_note','— tanpa perlu donasi untuk membukanya.') %>
</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:8px">
<a href="https://github.com/oxpoe/open-isp/wiki" target="_blank" rel="noopener" class="btn btn-p" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px">
<i class="bi bi-github"></i> <%= t('admin.lock.get_code','Ambil Kode Aktivasi (Gratis)') %>
</a>
<a href="/admin/sidebar-settings" class="btn btn-g" style="width:100%;display:flex;align-items:center;justify-content:center;gap:8px" onclick="hideLockedMenuNotice()">
<i class="bi bi-layout-sidebar-inset"></i> <%= t('admin.lock.open_settings','Buka Pengaturan Sidebar') %>
</a>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;padding:0 18px 18px 18px">
<button type="button" class="btn btn-p" onclick="hideLockedMenuNotice()">
<i class="bi bi-check2-circle"></i> <%= t('admin.lock.understood','Mengerti') %>
</button>
</div>
</div>
</div>
<script>
function toggleSidebar() {
const sb = document.getElementById('sidebar');
const ov = document.getElementById('sidebarOverlay');
sb.classList.toggle('open');
ov.classList.toggle('show');
}
function showLockedMenuNotice(message) {
const modal = document.getElementById('lockedMenuModal');
const messageBox = document.getElementById('lockedMenuModalMessage');
if (messageBox) {
messageBox.textContent = message || 'Menu ini sedang terkunci.';
}
if (modal) {
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
return false;
}
function hideLockedMenuNotice() {
const modal = document.getElementById('lockedMenuModal');
if (modal) {
modal.style.display = 'none';
document.body.style.overflow = '';
}
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') hideLockedMenuNotice();
});
</script>
<script src="/js/theme.js"></script>
<!-- Bottom Navigation (Mobile Only) -->
<nav class="bottom-nav" id="bottomNav">
<% (sidebarBottomNavItems || []).slice(0, 4).forEach((item) => { %>
<a
href="<%= item.hrefResolved %>"
class="bn-item <%= (item.activePages || []).includes(activePage) ? 'active' : '' %>"
<% if (item.locked) { %>
onclick='return showLockedMenuNotice(<%- JSON.stringify(item.lockedMessage).split('<').join('\\u003c') %>)'
<% } %>
>
<i class="<%= item.icon %>"></i>
<span><%= item.labelKey ? t(item.labelKey, item.labelDefault) : item.labelDefault %></span>
</a>
<% }) %>
<button class="bn-item" onclick="toggleSidebar()">
<i class="bi bi-grid-3x3-gap"></i>
<span><%= t('common.menu', 'Menu') %></span>
</button>
</nav>
<script>
window.sbToggleGroup = function(e, el){ try{ e && e.preventDefault && e.preventDefault(); }catch(_){}
var sub = el.nextElementSibling;
if(sub){ var open = sub.style.display !== 'none'; sub.style.display = open ? 'none' : 'block'; el.classList.toggle('open', !open); }
return false;
};
</script>
+93
View File
@@ -0,0 +1,93 @@
<header class="bg-slate-900/85 backdrop-blur-xl border-b border-[color:var(--border)] px-3 sm:px-6 min-h-[64px] sm:min-h-[72px] sticky top-0 z-40 flex items-center justify-between gap-3 transition-all duration-300">
<div class="flex items-center gap-2 sm:gap-3 min-w-0">
<!-- Unified Sidebar Toggle -->
<button class="flex items-center justify-center w-9 h-9 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800/80 border border-transparent hover:border-slate-700/60 transition shadow-sm shrink-0" onclick="window.innerWidth < 1024 ? toggleSidebar() : toggleSidebarMin()" title="Toggle Sidebar">
<i class="bi bi-list text-xl lg:hidden"></i>
<i class="bi bi-layout-sidebar-inset text-lg hidden lg:block" id="desktop-toggle-icon"></i>
</button>
<!-- Breadcrumb / Title -->
<div class="flex items-center gap-2 sm:gap-2.5 ml-1 min-w-0">
<div class="w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-gradient-to-br from-primary-500 to-fun-purple flex items-center justify-center shadow-lg shadow-primary-500/20 shrink-0">
<i class="<%= locals.pageIcon || 'ti-layout-dashboard' %> text-white text-base sm:text-lg"></i>
</div>
<div class="min-w-0">
<div class="font-black text-white text-sm sm:text-base leading-tight truncate" id="page-title"><%- locals.pageTitle || 'Dashboard' %></div>
<div class="text-[10px] sm:text-[11px] text-slate-400 font-semibold leading-none mt-0.5 truncate"><%- locals.pageDesc || 'Control Panel' %></div>
</div>
</div>
</div>
<!-- Right Side Actions -->
<div class="flex items-center gap-2 sm:gap-3 shrink-0">
<% if (locals.showRouterSelector && locals.routers) { %>
<div class="uiverse-select-wrapper relative z-50 max-w-[150px] sm:max-w-[220px]" id="wrapper-topbar-router">
<input type="hidden" id="global-router-id" value="<%= locals.selectedRouterId || locals.activeRouterId || '' %>">
<div class="uiverse-select-trigger" style="padding: 6px 10px; font-size: 11.5px;" onclick="toggleUiverseDropdown('wrapper-topbar-router')">
<span class="flex items-center gap-1.5 truncate pointer-events-none">
<i class="bi bi-router text-primary-400 shrink-0 text-xs"></i>
<span id="selected-topbar-router-label" class="truncate font-bold text-slate-200">
<%
const curRouter = routers.find(r => String(locals.selectedRouterId || locals.activeRouterId || "") === String(r.id));
%>
<%= curRouter ? curRouter.name : t("admin.mikrotik.default_router", "-- Default / Semua Router --") %>
</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" class="uiverse-arrow pointer-events-none shrink-0" style="width:10px;height:10px;margin-left:4px;">
<path fill="currentColor" d="M233.4 406.6c12.5 12.5 32.8 12.5 45.3 0l192-192c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L256 338.7 86.6 169.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3l192 192z"></path>
</svg>
</div>
<div class="uiverse-options topbar-dropdown-menu">
<div class="uiverse-option <%= (!locals.selectedRouterId && !locals.activeRouterId) ? 'active' : '' %>" onclick="selectTopbarRouter('', '<%= t("admin.mikrotik.default_router", "-- Default / Semua Router --") %>')">
<span class="flex items-center gap-2 pointer-events-none truncate">
<i class="bi bi-layers text-slate-400 shrink-0"></i>
<span class="truncate"><%= t("admin.mikrotik.default_router", "-- Default / Semua Router --") %></span>
</span>
<i class="bi bi-check-lg uiverse-opt-check pointer-events-none"></i>
</div>
<% routers.forEach(r => { %>
<div class="uiverse-option <%= (String(locals.selectedRouterId || locals.activeRouterId || "") === String(r.id)) ? 'active' : '' %>" onclick="selectTopbarRouter('<%= r.id %>', '<%= r.name %>')">
<span class="flex items-center gap-2 pointer-events-none truncate">
<i class="bi bi-router text-primary-400 shrink-0"></i>
<span class="truncate"><%= r.name %></span>
</span>
<i class="bi bi-check-lg uiverse-opt-check pointer-events-none"></i>
</div>
<% }) %>
</div>
</div>
<script>
function selectTopbarRouter(val, label) {
const inp = document.getElementById('global-router-id');
if (inp) inp.value = val;
const lbl = document.getElementById('selected-topbar-router-label');
if (lbl) lbl.textContent = label;
const wrap = document.getElementById('wrapper-topbar-router');
if (wrap) {
wrap.querySelectorAll('.uiverse-option').forEach(opt => opt.classList.remove('active'));
wrap.classList.remove('open');
}
if (typeof changeRouter === 'function') {
changeRouter();
} else {
window.location.href = '/admin/set-active-router?router_id=' + (val || 'all');
}
}
</script>
<% } else { %>
<input type="hidden" id="global-router-id" value="">
<% } %>
<% if (locals.showLiveUpdate) { %>
<div class="hidden md:flex items-center gap-2 px-3 py-1.5 rounded-full bg-accent-500/10 border border-accent-500/30 text-accent-400 text-xs font-semibold shadow-sm">
<span class="w-2 h-2 rounded-full bg-accent-500 pulse-dot"></span>
<span><%= t('admin.dashboard.live_update', 'Live Update') %></span>
</div>
<% } %>
<span class="px-2.5 sm:px-3.5 py-1.5 rounded-full bg-primary-500/10 border border-primary-500/30 text-primary-400 text-xs font-bold flex items-center gap-1.5 shadow-sm">
<i class="bi bi-person text-sm"></i>
<span class="hidden sm:inline"><%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
</span>
</div>
</header>
+291
View File
@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.tab-bar{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--border);padding-bottom:8px}
.tab-btn{padding:8px 16px;border:none;background:none;color:var(--muted);cursor:pointer;border-radius:8px 8px 0 0;font-size:13px;font-weight:600;transition:.2s}
.tab-btn.active{background:var(--bg3);color:var(--primary)}
.tab-btn:hover{color:var(--text)}
.tab-content{display:none}.tab-content.active{display:block}
.emp-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:16px;display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
.emp-info{display:flex;align-items:center;gap:12px}
.emp-avatar{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700}
.type-technician .emp-avatar{background:rgba(59,130,246,.15);color:#3b82f6}
.type-cashier .emp-avatar{background:rgba(34,197,94,.15);color:#22c55e}
.type-collector .emp-avatar{background:rgba(249,115,22,.15);color:#f97316}
.emp-name{font-weight:600;font-size:14px;color:var(--text)}
.emp-role{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px}
.emp-salary{font-size:14px;font-weight:700;color:var(--primary)}
.slip-status{padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600}
.slip-draft{background:rgba(125,133,144,.15);color:#7d8590}
.slip-approved{background:rgba(59,130,246,.15);color:#3b82f6}
.slip-paid{background:rgba(34,197,94,.15);color:#22c55e}
.period-select{display:flex;gap:8px;align-items:center;margin-bottom:16px}
.period-select select,.period-select input{padding:8px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg2);color:var(--text);font-size:13px}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
@media(max-width:768px){.detail-grid{grid-template-columns:1fr}.emp-card{flex-direction:column;gap:12px;align-items:flex-start}}
.detail-item{background:var(--bg3);padding:12px;border-radius:8px}
.detail-label{font-size:11px;color:var(--muted);margin-bottom:4px}
.detail-value{font-size:16px;font-weight:700;color:var(--text)}
.detail-value.positive{color:#22c55e}
.detail-value.negative{color:#f85149}
.slip-card{background:var(--bg2);border:1px solid var(--border);border-radius:10px;padding:16px;margin-bottom:12px}
.slip-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
.slip-breakdown{display:grid;grid-template-columns:1fr auto;gap:4px 16px;font-size:13px}
.slip-breakdown .label{color:var(--muted)}.slip-breakdown .value{text-align:right;font-weight:600}
.slip-breakdown .sep{grid-column:1/-1;border-top:1px solid var(--border);margin:6px 0}
.slip-breakdown .total .value{color:var(--primary);font-size:16px}
.action-row{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'payroll', 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-wallet2"></i> <%= title %>
</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'success' ? 's' : 'd' %>" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<!-- Stat Cards -->
<div class="stat-grid" style="grid-template-columns:repeat(4,1fr)">
<div class="sc p"><i class="bi bi-people sc-icon"></i><div class="sc-label">Total Karyawan</div><div class="sc-val"><%= employees.length %></div><div class="sc-sub"><%= t('common.active','Aktif') %></div></div>
<div class="sc s"><i class="bi bi-wallet2 sc-icon"></i><div class="sc-label">Total Gaji Bersih</div><div class="sc-val">Rp <%= summary.total_net.toLocaleString('id-ID') %></div><div class="sc-sub"><%= selectedMonth %>/<%= selectedYear %></div></div>
<div class="sc p"><i class="bi bi-file-earmark-text sc-icon"></i><div class="sc-label">Slip Gaji</div><div class="sc-val"><%= summary.total_slips %></div><div class="sc-sub"><span style="color:#7d8590"><%= summary.draft_count %> draft</span> · <span style="color:#3b82f6"><%= summary.approved_count %> approved</span> · <span style="color:#22c55e"><%= summary.paid_count %> paid</span></div></div>
<div class="sc d"><i class="bi bi-cash-stack sc-icon"></i><div class="sc-label">Total Potongan</div><div class="sc-val">Rp <%= summary.total_deductions.toLocaleString('id-ID') %></div><div class="sc-sub"><%= selectedMonth %>/<%= selectedYear %></div></div>
</div>
<!-- Tab Navigation -->
<div class="tab-bar" style="margin-top:20px">
<button class="tab-btn active" onclick="switchTab('settings')"><i class="bi bi-gear"></i> Pengaturan Gaji</button>
<button class="tab-btn" onclick="switchTab('slips')"><i class="bi bi-receipt"></i> Slip Gaji</button>
</div>
<!-- TAB 1: Pengaturan Gaji -->
<div class="tab-content active" id="tab-settings">
<div class="card">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center">
<h6><i class="bi bi-gear"></i> Pengaturan Gaji Karyawan</h6>
</div>
<div class="card-bd">
<% if (employees.length === 0) { %>
<div style="text-align:center;padding:40px"><div style="font-size:48px;opacity:.3"><i class="bi bi-person-fill"></i></div><p style="color:var(--muted)">Belum ada karyawan aktif</p></div>
<% } else { %>
<% employees.forEach(emp => { %>
<div class="emp-card type-<%= emp.employee_type %>">
<div class="emp-info">
<div class="emp-avatar"><%= emp.name.charAt(0).toUpperCase() %></div>
<div>
<div class="emp-name"><%= emp.name %></div>
<div class="emp-role"><%= emp.employee_type %> · ID #<%= emp.id %></div>
</div>
</div>
<div style="display:flex;align-items:center;gap:12px">
<% if (emp.payroll) { %>
<div class="emp-salary">Rp <%= emp.payroll.base_salary.toLocaleString('id-ID') %></div>
<% } else { %>
<span style="color:var(--muted);font-size:12px">Belum diset</span>
<% } %>
<button class="btn btn-p btn-sm" data-type="<%= emp.employee_type %>" data-id="<%= emp.id %>" data-name="<%= emp.name %>" onclick="editPayroll(this.dataset.type, this.dataset.id, this.dataset.name)">
<i class="bi bi-pencil"></i> Set Gaji
</button>
</div>
</div>
<% }) %>
<% } %>
</div>
</div>
</div>
<!-- TAB 2: Slip Gaji -->
<div class="tab-content" id="tab-slips">
<div class="card">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px">
<h6><i class="bi bi-receipt"></i> Slip Gaji — <%= selectedMonth %>/<%= selectedYear %></h6>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<form method="GET" action="/admin/payroll" style="display:flex;gap:6px;align-items:center">
<select name="month" class="fc" style="width:auto;padding:6px 10px">
<% for(let m=1;m<=12;m++){%><option value="<%=m%>" <%=m==selectedMonth?'selected':''%>><%= ['','Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'][m] %></option><%}%>
</select>
<input type="number" name="year" value="<%= selectedYear %>" class="fc" style="width:80px;padding:6px 10px">
<button type="submit" class="btn btn-p btn-sm"><i class="bi bi-funnel"></i> Filter</button>
</form>
<form method="POST" action="/admin/payroll/generate" onsubmit="return confirm('Generate slip gaji untuk semua karyawan?')">
<input type="hidden" name="month" value="<%= selectedMonth %>">
<input type="hidden" name="year" value="<%= selectedYear %>">
<button type="submit" class="btn btn-s btn-sm"><i class="bi bi-magic"></i> Generate Semua</button>
</form>
</div>
</div>
<div class="card-bd">
<% if (slips.length === 0) { %>
<div style="text-align:center;padding:40px"><div style="font-size:48px;opacity:.3"><i class="bi bi-inbox"></i></div><p style="color:var(--muted)">Belum ada slip gaji untuk periode ini</p><p style="color:var(--muted);font-size:12px">Klik "Generate Semua" untuk membuat slip gaji otomatis</p></div>
<% } else { %>
<!-- Bulk Actions -->
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
<form method="POST" action="/admin/payroll/bulk-approve" style="display:inline" onsubmit="return confirm('Approve semua slip draft?')">
<input type="hidden" name="month" value="<%= selectedMonth %>"><input type="hidden" name="year" value="<%= selectedYear %>">
<button type="submit" class="btn btn-p btn-sm"><i class="bi bi-check-all"></i> Approve Semua Draft</button>
</form>
<form method="POST" action="/admin/payroll/bulk-paid" style="display:inline" onsubmit="return confirm('Tandai semua slip approved sebagai paid?')">
<input type="hidden" name="month" value="<%= selectedMonth %>"><input type="hidden" name="year" value="<%= selectedYear %>">
<button type="submit" class="btn btn-s btn-sm"><i class="bi bi-cash-coin"></i> Tandai Semua Paid</button>
</form>
<form method="POST" action="/admin/payroll/delete-drafts" style="display:inline" onsubmit="return confirm('Hapus semua slip draft? Ini tidak bisa dikembalikan.')">
<input type="hidden" name="month" value="<%= selectedMonth %>"><input type="hidden" name="year" value="<%= selectedYear %>">
<button type="submit" class="btn btn-d btn-sm"><i class="bi bi-trash"></i> Hapus Draft</button>
</form>
</div>
<% slips.forEach(slip => { %>
<div class="slip-card">
<div class="slip-header">
<div style="display:flex;align-items:center;gap:10px">
<strong><%= slip.employee_name %></strong>
<span class="slip-status slip-<%= slip.status %>"><%= slip.status.toUpperCase() %></span>
</div>
<span style="color:var(--muted);font-size:12px"><%= slip.employee_type %> · <%= slip.period_month %>/<%= slip.period_year %></span>
</div>
<div class="slip-breakdown">
<span class="label">Gaji Pokok</span><span class="value">Rp <%= slip.base_salary.toLocaleString('id-ID') %></span>
<% if(slip.transport_allowance){%><span class="label">Tunj. Transport</span><span class="value">Rp <%= slip.transport_allowance.toLocaleString('id-ID') %></span><%}%>
<% if(slip.meal_allowance){%><span class="label">Tunj. Makan</span><span class="value">Rp <%= slip.meal_allowance.toLocaleString('id-ID') %></span><%}%>
<% if(slip.phone_allowance){%><span class="label">Tunj. Pulsa</span><span class="value">Rp <%= slip.phone_allowance.toLocaleString('id-ID') %></span><%}%>
<% if(slip.other_allowance){%><span class="label">Tunj. Lain (<%= slip.other_allowance_note || '-' %>)</span><span class="value">Rp <%= slip.other_allowance.toLocaleString('id-ID') %></span><%}%>
<% if(slip.ticket_bonus){%><span class="label">Bonus Tiket (<%= slip.total_tickets_resolved %> tiket)</span><span class="value" style="color:#22c55e">+Rp <%= slip.ticket_bonus.toLocaleString('id-ID') %></span><%}%>
<% if(slip.collection_commission){%><span class="label">Komisi Tagihan</span><span class="value" style="color:#22c55e">+Rp <%= slip.collection_commission.toLocaleString('id-ID') %></span><%}%>
<% if(slip.overtime_bonus){%><span class="label">Lembur (<%= slip.overtime_hours %> jam)</span><span class="value" style="color:#22c55e">+Rp <%= slip.overtime_bonus.toLocaleString('id-ID') %></span><%}%>
<div class="sep"></div>
<span class="label"><strong>Total Pendapatan</strong></span><span class="value"><strong>Rp <%= slip.gross_salary.toLocaleString('id-ID') %></strong></span>
<% if(slip.absence_deduction){%><span class="label">Potongan Absen (<%= slip.absent_days %> hari)</span><span class="value" style="color:#f85149">-Rp <%= slip.absence_deduction.toLocaleString('id-ID') %></span><%}%>
<% if(slip.late_deduction){%><span class="label">Potongan Terlambat (<%= slip.late_days %> hari)</span><span class="value" style="color:#f85149">-Rp <%= slip.late_deduction.toLocaleString('id-ID') %></span><%}%>
<% if(slip.other_deduction){%><span class="label">Potongan Lain (<%= slip.other_deduction_note || '-' %>)</span><span class="value" style="color:#f85149">-Rp <%= slip.other_deduction.toLocaleString('id-ID') %></span><%}%>
<div class="sep"></div>
<span class="label">Hadir: <%= slip.working_days %> hari · Absen: <%= slip.absent_days %> hari</span><span class="value"></span>
<span class="label total"><strong>GAJI BERSIH</strong></span><span class="value total" style="color:var(--primary);font-size:18px"><strong>Rp <%= slip.net_salary.toLocaleString('id-ID') %></strong></span>
</div>
<div class="action-row">
<form id="papp-<%= slip.id %>" method="POST" action="/admin/payroll/slip/<%= slip.id %>/approve" style="display:none" onsubmit="return confirm('Approve slip ini?')"></form>
<form id="pdel-<%= slip.id %>" method="POST" action="/admin/payroll/slip/<%= slip.id %>/delete" style="display:none" onsubmit="return confirm('Hapus slip ini?')"></form>
<form id="ppaid-<%= slip.id %>" method="POST" action="/admin/payroll/slip/<%= slip.id %>/paid" style="display:none" onsubmit="return confirm('Tandai sebagai paid?')"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<% if(slip.status==='draft'){%>
<button type="button" class="act-item" data-id="<%= slip.id %>" onclick="editDeduction(this.dataset.id)"><i class="bi bi-pencil"></i> Potongan Lain</button>
<button type="submit" form="papp-<%= slip.id %>" class="act-item success"><i class="bi bi-check-lg"></i> <%= t('admin.common.approve','Approve') %></button>
<%}else if(slip.status==='approved'){%>
<button type="submit" form="ppaid-<%= slip.id %>" class="act-item success"><i class="bi bi-cash-coin"></i> Tandai Paid</button>
<%}%>
<div class="act-sep"></div>
<a class="act-item" href="/admin/payroll/slip/<%= slip.id %>/print" target="_blank"><i class="bi bi-printer"></i> Cetak</a>
<a class="act-item" href="/admin/payroll/slip/<%= slip.id %>/pdf" target="_blank"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
<button type="button" class="act-item" data-id="<%= slip.id %>" onclick="sendSlipWA(this.dataset.id)"><i class="bi bi-whatsapp"></i> Kirim WA</button>
<% if(slip.status==='draft'){%>
<div class="act-sep"></div>
<button type="submit" form="pdel-<%= slip.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Slip</button>
<%}%>
</div>
</div>
</div>
</div>
<% }) %>
<% } %>
</div>
</div>
</div>
</div>
</div>
<!-- Modal Set Gaji -->
<div class="mo" id="payrollModal" style="display:none">
<div class="mb" style="max-width:520px">
<div class="mb-hd"><h6 id="modalTitle">Set Gaji Karyawan</h6><button class="mo-close" onclick="closeModal()">&times;</button></div>
<form method="POST" action="/admin/payroll/settings" id="payrollForm">
<input type="hidden" name="employee_type" id="f_type">
<input type="hidden" name="employee_id" id="f_id">
<div class="mb-bd" style="max-height:70vh;overflow-y:auto">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px">
<div><label class="fl">Gaji Pokok</label><input type="number" name="base_salary" id="f_base" class="fc" required></div>
<div><label class="fl">Hari Kerja/Bulan</label><input type="number" name="working_days_per_month" id="f_days" class="fc" value="26"></div>
<div><label class="fl">Tunj. Transport</label><input type="number" name="transport_allowance" id="f_transport" class="fc" value="0"></div>
<div><label class="fl">Tunj. Makan</label><input type="number" name="meal_allowance" id="f_meal" class="fc" value="0"></div>
<div><label class="fl">Tunj. Pulsa</label><input type="number" name="phone_allowance" id="f_phone" class="fc" value="0"></div>
<div><label class="fl">Tunj. Lain</label><input type="number" name="other_allowance" id="f_other" class="fc" value="0"></div>
<div style="grid-column:1/-1"><label class="fl">Ket. Tunj. Lain</label><input type="text" name="other_allowance_note" id="f_other_note" class="fc" placeholder="BPJS, dll"></div>
<div><label class="fl">Potongan/Hari Absen</label><input type="number" name="absence_deduction_per_day" id="f_absence" class="fc" value="0"></div>
<div><label class="fl">Bonus/Tiket (Teknisi)</label><input type="number" name="bonus_per_ticket" id="f_bonus" class="fc" value="0"></div>
<div style="grid-column:1/-1"><label class="fl">Komisi % Tagihan (Kolektor)</label><input type="number" step="0.1" name="commission_percentage" id="f_commission" class="fc" value="0"></div>
</div>
</div>
<div class="mb-ft"><button type="button" class="btn btn-g" onclick="closeModal()"><%= t('common.cancel','Batal') %></button><button type="submit" class="btn btn-p"><%= t('common.save','Simpan') %></button></div>
</form>
</div>
</div>
<script>
const payrollData = JSON.parse('<%- JSON.stringify(employees.reduce(function(acc, e) { if(e.payroll) acc[e.employee_type+"_"+e.id] = e.payroll; return acc; }, {})).split('<').join('\\u003c') %>');
function switchTab(name){
document.querySelectorAll('.tab-btn').forEach((b,i) => b.classList.toggle('active', (name==='settings'?i===0:i===1)));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.getElementById('tab-'+name).classList.add('active');
}
function editPayroll(type, id, name){
document.getElementById('modalTitle').textContent = 'Set Gaji — ' + name;
document.getElementById('f_type').value = type;
document.getElementById('f_id').value = id;
const d = payrollData[type+'_'+id];
document.getElementById('f_base').value = d ? d.base_salary : 0;
document.getElementById('f_days').value = d ? d.working_days_per_month : 26;
document.getElementById('f_transport').value = d ? d.transport_allowance : 0;
document.getElementById('f_meal').value = d ? d.meal_allowance : 0;
document.getElementById('f_phone').value = d ? d.phone_allowance : 0;
document.getElementById('f_other').value = d ? d.other_allowance : 0;
document.getElementById('f_other_note').value = d ? d.other_allowance_note : '';
document.getElementById('f_absence').value = d ? d.absence_deduction_per_day : 0;
document.getElementById('f_bonus').value = d ? d.bonus_per_ticket : 0;
document.getElementById('f_commission').value = d ? d.commission_percentage : 0;
document.getElementById('payrollModal').style.display = 'flex';
}
function closeModal(){ document.getElementById('payrollModal').style.display = 'none'; }
function editDeduction(id){
const amount = prompt('Jumlah potongan lain (Rp):','0');
if(amount===null) return;
const note = prompt('Keterangan potongan:','');
fetch('/admin/payroll/slip/'+id+'/deduction',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({other_deduction:amount,other_deduction_note:note||''})})
.then(r=>r.json()).then(d=>{if(d.success){alert(' Berhasil');location.reload()}else alert(' '+d.message)}).catch(e=>alert('Error: '+e.message));
}
function sendSlipWA(id){
if(!confirm('Kirim slip gaji via WhatsApp?')) return;
fetch('/admin/payroll/slip/'+id+'/send-wa',{method:'POST'})
.then(r=>r.json()).then(d=>{alert(d.success?' Terkirim':' '+(d.message||'Gagal'))}).catch(e=>alert('Error: '+e.message));
}
document.getElementById('payrollModal').addEventListener('click',function(e){if(e.target===this)closeModal()});
</script>
</body>
</html>
+748
View File
@@ -0,0 +1,748 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoice #INV-<%= String(invoice.id).padStart(4, '0') %>-<%= getCurrentTimeInfo().year %> | <%= company %></title>
<link rel="stylesheet" href="/css/admin.css">
<!-- no-admin-theme -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
:root {
--primary: #4f46e5;
--primary-dark: #3730a3;
--accent: #06b6d4;
--success: #10b981;
--success-bg: #ecfdf5;
--danger: #ef4444;
--danger-bg: #fef2f2;
--text-dark: #0f172a;
--text-muted: #64748b;
--border-color: #e2e8f0;
--bg-light: #f8fafc;
}
@page {
size: A4 portrait;
margin: 12mm 15mm;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12px;
color: var(--text-dark);
background-color: #f1f5f9;
line-height: 1.4;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.invoice-wrapper {
max-width: 800px;
margin: 20px auto;
background: #ffffff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
overflow: hidden;
position: relative;
}
/* Top Accent Line */
.top-accent-bar {
height: 6px;
background: linear-gradient(90deg, var(--primary) 0%, var(--accent) 100%);
}
/* Inner Container */
.invoice-container {
padding: 28px 36px;
position: relative;
}
/* Header Section */
.invoice-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding-bottom: 20px;
border-bottom: 1.5px dashed var(--border-color);
margin-bottom: 20px;
}
.brand-section {
display: flex;
flex-direction: column;
gap: 6px;
}
.logo-container {
display: flex;
align-items: center;
gap: 12px;
}
.company-logo {
max-height: 48px;
max-width: 160px;
object-fit: contain;
display: block;
}
.company-avatar-fallback {
width: 44px;
height: 44px;
border-radius: 10px;
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #ffffff;
font-weight: 800;
font-size: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.25);
}
.company-title {
font-size: 18px;
font-weight: 800;
color: var(--text-dark);
letter-spacing: -0.3px;
}
.company-details {
font-size: 11px;
color: var(--text-muted);
line-height: 1.5;
}
.invoice-title-block {
text-align: right;
}
.doc-type {
font-size: 22px;
font-weight: 800;
letter-spacing: 1px;
color: var(--primary);
text-transform: uppercase;
}
.invoice-num {
font-size: 13px;
font-weight: 700;
color: var(--text-dark);
margin-top: 2px;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 14px;
border-radius: 20px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 8px;
}
.status-badge.paid {
background-color: var(--success-bg);
color: #047857;
border: 1px solid #a7f3d0;
}
.status-badge.unpaid {
background-color: var(--danger-bg);
color: #b91c1c;
border: 1px solid #fecaca;
}
.badge-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background-color: currentColor;
}
/* Grid Info Section */
.billing-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
.info-card {
background-color: var(--bg-light);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 14px 18px;
}
.card-heading {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
margin-bottom: 8px;
}
.client-name {
font-size: 15px;
font-weight: 700;
color: var(--text-dark);
margin-bottom: 4px;
}
.meta-list {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
color: var(--text-muted);
}
.meta-item {
display: flex;
align-items: center;
gap: 6px;
}
.summary-list {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.summary-item {
display: flex;
flex-direction: column;
}
.summary-item .label {
font-size: 10px;
color: var(--text-muted);
font-weight: 500;
}
.summary-item .value {
font-size: 12px;
font-weight: 700;
color: var(--text-dark);
}
/* Table Section */
.items-table-container {
margin-bottom: 20px;
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
position: relative;
}
.items-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.items-table th {
background-color: var(--bg-light);
padding: 10px 14px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.8px;
color: var(--text-muted);
border-bottom: 1px solid var(--border-color);
}
.items-table td {
padding: 12px 14px;
border-bottom: 1px solid var(--border-color);
font-size: 12px;
}
.items-table tr:last-child td {
border-bottom: none;
}
.item-title {
font-weight: 700;
color: var(--text-dark);
}
.item-sub {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
}
.text-right {
text-align: right;
}
/* Watermark */
.watermark-stamp {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%) rotate(-25deg);
font-size: 72px;
font-weight: 900;
letter-spacing: 6px;
text-transform: uppercase;
opacity: 0.07;
pointer-events: none;
user-select: none;
z-index: 1;
}
.watermark-stamp.paid {
color: var(--success);
}
.watermark-stamp.unpaid {
color: var(--danger);
}
/* Totals & Notes Layout */
.bottom-layout {
display: grid;
grid-template-columns: 1.2fr 1fr;
gap: 20px;
align-items: flex-start;
margin-bottom: 20px;
}
.notes-card {
background-color: var(--bg-light);
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 12px 14px;
font-size: 11px;
color: var(--text-muted);
display: flex;
flex-direction: column;
gap: 6px;
}
.notes-card strong {
color: var(--text-dark);
}
.totals-card {
border: 1px solid var(--border-color);
border-radius: 10px;
overflow: hidden;
}
.total-line {
display: flex;
justify-content: space-between;
padding: 8px 14px;
font-size: 12px;
border-bottom: 1px solid var(--border-color);
}
.total-line:last-child {
border-bottom: none;
}
.total-line.grand-total {
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
color: #ffffff;
padding: 10px 14px;
}
.total-line.grand-total .lbl {
font-weight: 700;
color: rgba(255, 255, 255, 0.9);
}
.total-line.grand-total .val {
font-size: 16px;
font-weight: 800;
color: #ffffff;
}
/* Signature & Footer Section */
.invoice-footer {
padding-top: 16px;
border-top: 1px solid var(--border-color);
display: flex;
justify-content: space-between;
align-items: flex-end;
}
.footer-info {
font-size: 10px;
color: var(--text-muted);
line-height: 1.5;
}
.signature-area {
text-align: center;
min-width: 140px;
}
.sig-title {
font-size: 10px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 30px;
}
.sig-line {
border-top: 1.5px solid var(--text-dark);
margin-bottom: 4px;
}
.sig-name {
font-size: 11px;
font-weight: 700;
color: var(--text-dark);
}
.sig-role {
font-size: 9px;
color: var(--text-muted);
}
/* Print Controls Floating */
.print-controls {
position: fixed;
bottom: 24px;
right: 24px;
display: flex;
gap: 10px;
z-index: 1000;
}
.btn-action {
border: none;
padding: 12px 20px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s ease;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.15);
}
.btn-print {
background: linear-gradient(135deg, var(--primary), var(--accent));
color: #ffffff;
}
.btn-print:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(79, 70, 229, 0.35);
}
.btn-close {
background: #ffffff;
color: var(--text-dark);
border: 1px solid var(--border-color);
}
.btn-close:hover {
background: var(--bg-light);
}
@media print {
@page {
size: A4 portrait;
margin: 8mm 10mm;
}
body {
background: #ffffff !important;
padding: 0 !important;
margin: 0 !important;
}
.invoice-wrapper {
margin: 0 !important;
box-shadow: none !important;
border: 1px solid #cbd5e1 !important;
border-radius: 6px !important;
max-width: 100% !important;
width: 100% !important;
page-break-inside: avoid !important;
break-inside: avoid !important;
}
.invoice-container {
padding: 16px 20px !important;
}
.print-controls {
display: none !important;
}
button, .btn-action {
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: 'billing', 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.invoice.print_title', 'Cetak Invoice') %> #INV-<%= String(invoice.id).padStart(4, '0') %></div>
<div class="tb-right">
<button type="button" class="btn btn-p btn-sm" onclick="window.print()"><i class="bi bi-printer-fill"></i> <%= t('admin.common.print_pdf', 'Cetak / PDF') %></button>
<a href="/admin/billing" 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">
<%
const mns = (lang || 'id') === 'en'
? ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
: ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des'];
const isPaid = invoice.status === 'paid';
const companyInitial = (company || 'A').charAt(0).toUpperCase();
const customerInitial = (customer.name || 'P').charAt(0).toUpperCase();
const invNo = 'INV-' + String(invoice.id).padStart(4, '0') + '-' + getCurrentTimeInfo().year;
const periodStr = mns[invoice.period_month - 1] + ' ' + invoice.period_year;
%>
<div class="invoice-wrapper">
<div class="top-accent-bar"></div>
<div class="invoice-container">
<!-- Header -->
<div class="invoice-header">
<div class="brand-section">
<div class="logo-container">
<img src="/img/logo.png?v=<%= Date.now() %>"
alt="<%= company %> Logo"
class="company-logo"
onerror="this.style.display='none'; document.getElementById('companyAvatar').style.display='flex';">
<div class="company-avatar-fallback" id="companyAvatar" style="display:none;"><%= companyInitial %></div>
<div>
<div class="company-title"><%= company %></div>
<div class="company-details"><%= settings.company_address || 'Pusat Layanan Internet Terpercaya' %></div>
<% if (settings.whatsapp_admin_numbers && settings.whatsapp_admin_numbers.length > 0) { %>
<div class="company-details">Telp/WA: +<%= settings.whatsapp_admin_numbers[0] %></div>
<% } %>
</div>
</div>
</div>
<div class="invoice-title-block">
<div class="doc-type">INVOICE</div>
<div class="invoice-num"># <%= invNo %></div>
<div>
<% if (isPaid) { %>
<span class="status-badge paid"><span class="badge-dot"></span>LUNAS</span>
<% } else { %>
<span class="status-badge unpaid"><span class="badge-dot"></span>BELUM BAYAR</span>
<% } %>
</div>
</div>
</div>
<!-- Grid Billing Details -->
<div class="billing-grid">
<!-- Client Details -->
<div class="info-card">
<div class="card-heading">Ditagihkan Kepada</div>
<div class="client-name"><%= customer.name %></div>
<% if (customer.customer_code) { %>
<div class="meta-item"><span><i class="bi bi-person-badge"></i></span> ID Pelanggan: <strong><%= customer.customer_code %></strong></div>
<% } %>
<div class="meta-list">
<% if (customer.phone) { %>
<div class="meta-item"><span><i class="bi bi-phone"></i></span> <%= customer.phone %></div>
<% } %>
<% if (customer.pppoe_username) { %>
<div class="meta-item"><span><i class="bi bi-link-45deg"></i></span> PPPoE: <%= customer.pppoe_username %></div>
<% } %>
<% if (customer.address) { %>
<div class="meta-item"><span><i class="bi bi-geo-alt-fill"></i></span> <%= customer.address %></div>
<% } %>
</div>
</div>
<!-- Transaction Info -->
<div class="info-card">
<div class="card-heading">Informasi Transaksi</div>
<div class="summary-list">
<div class="summary-item">
<span class="label"><%= t('common.date','Tanggal') %> <%= isPaid ? 'Pembayaran' : 'Tagihan' %></span>
<span class="value"><%= isPaid && invoice.paid_at ? formatDateLocal(invoice.paid_at) : getNowLocal() %></span>
</div>
<div class="summary-item">
<span class="label">Periode Tagihan</span>
<span class="value"><%= periodStr %></span>
</div>
<div class="summary-item" style="margin-top:6px;">
<span class="label">Kasir / Penerima</span>
<span class="value"><%= invoice.paid_by_name || 'Sistem' %></span>
</div>
<div class="summary-item" style="margin-top:6px;">
<span class="label">Metode Pembayaran</span>
<span class="value"><%= invoice.payment_gateway ? invoice.payment_gateway.toUpperCase() : 'Tunai / Transfer' %></span>
</div>
</div>
</div>
</div>
<!-- Table Items -->
<div class="items-table-container">
<% if (isPaid) { %>
<div class="watermark-stamp paid">LUNAS</div>
<% } else { %>
<div class="watermark-stamp unpaid">TAGIHAN</div>
<% } %>
<table class="items-table">
<thead>
<tr>
<th style="width: 50px;">#</th>
<th>Deskripsi Layanan</th>
<th style="width: 120px;"><%= t('pwa.field.period','Periode') %></th>
<th class="text-right" style="width: 140px;"><%= t('common.amount_qty','Jumlah') %></th>
</tr>
</thead>
<tbody>
<tr>
<td>01</td>
<td>
<div class="item-title">Layanan Internet — <%= customer.package_name || 'Paket Internet Broadband' %></div>
<div class="item-sub">Langganan bulanan internet high-speed</div>
</td>
<td><%= periodStr %></td>
<td class="text-right" style="font-weight:700;">Rp <%= Number(invoice.amount || 0).toLocaleString('id-ID') %></td>
</tr>
</tbody>
</table>
</div>
<!-- Bottom Layout (Notes & Totals) -->
<div class="bottom-layout">
<div class="notes-card">
<div><i class="bi bi-clipboard-check"></i> <strong>Catatan Pembayaran:</strong></div>
<div><%= invoice.notes || 'Terima kasih telah berlangganan layanan internet kami. Simpan struk ini sebagai bukti pembayaran yang sah.' %></div>
<% if (invoice.payment_order_id) { %>
<div style="font-size:10px; margin-top:2px; color:var(--primary);">Referensi Transaksi: <strong><%= invoice.payment_order_id %></strong></div>
<% } %>
</div>
<div class="totals-card">
<div class="total-line">
<span class="lbl">Subtotal</span>
<span class="val">Rp <%= Number(invoice.amount_before_discount || invoice.amount || 0).toLocaleString('id-ID') %></span>
</div>
<div class="total-line">
<span class="lbl">Diskon / Potongan<% if (Number(invoice.discount_amount||0) > 0 && invoice.discount_type==='percent') { %> (<%= invoice.discount_value %>%)<% } %></span>
<span class="val"><%= Number(invoice.discount_amount||0) > 0 ? ('- Rp ' + Number(invoice.discount_amount).toLocaleString('id-ID')) : 'Rp 0' %></span>
</div>
<div class="total-line grand-total">
<span class="lbl">Total Bayar</span>
<span class="val">Rp <%= Number(invoice.amount || 0).toLocaleString('id-ID') %></span>
</div>
</div>
</div>
<!-- Footer & Signatures -->
<div class="invoice-footer">
<div class="footer-info">
<div style="font-weight:700; color:var(--text-dark);"><%= company %></div>
<div><%= settings.company_address || 'Pusat Layanan Internet' %></div>
<div>Dicetak Otomatis pada <%= getNowLocal() %></div>
</div>
<div class="signature-area">
<div class="sig-title">Hormat Kami,</div>
<div class="sig-line"></div>
<div class="sig-name"><%= settings.company_manager || 'Admin Pengelola' %></div>
<div class="sig-role">Manajer Operasional</div>
</div>
</div>
</div>
</div>
<!-- Floating Action Buttons -->
<div class="print-controls">
<button class="btn-action btn-print" onclick="window.print();" title="Cetak Faktur A4">
<span><i class="bi bi-printer-fill"></i></span> Cetak A4
</button>
<a href="/admin/billing/<%= invoice.id %>/print-thermal" target="_blank" class="btn-action btn-print" style="text-decoration:none; background: linear-gradient(135deg, #0284c7, #06b6d4);" title="Cetak Struk Bluetooth Thermal 58mm/80mm">
<span><i class="bi bi-receipt"></i></span> Struk Thermal
</a>
<a href="/admin/billing/<%= invoice.id %>/pdf" target="_blank" class="btn-action btn-print" style="text-decoration:none; background: linear-gradient(135deg, #059669, #10b981);">
<span><i class="bi bi-download"></i></span> Unduh PDF
</a>
<% if (customer && customer.phone) { %>
<button class="btn-action btn-print" id="btnSendWaPdf" onclick="sendInvoicePdfWa('<%= invoice.id %>')" style="background: linear-gradient(135deg, #25d366, #128c7e);">
<span><i class="bi bi-phone"></i></span> Kirim PDF WA
</button>
<% } %>
<button class="btn-action btn-close" onclick="window.close();">
<span><i class="bi bi-x-lg"></i></span> <%= t('common.close','Tutup') %>
</button>
</div>
<script>
window.onload = function() {
setTimeout(function() {
if (!window.location.search.includes('noprint')) {
window.print();
}
}, 500);
};
async function sendInvoicePdfWa(invoiceId) {
const btn = document.getElementById('btnSendWaPdf');
if (!btn) return;
const oldText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span><i class="bi bi-hourglass-split"></i></span> Mengirim...';
try {
const res = await fetch(`/admin/billing/${invoiceId}/send-pdf-wa`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
alert(' ' + data.message);
} else {
alert(' ' + data.message);
}
} catch (err) {
alert(' Error: ' + err.message);
} finally {
btn.disabled = false;
btn.innerHTML = oldText;
}
}
</script>
</div>
</div>
</body>
</html>
+137
View File
@@ -0,0 +1,137 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.payslip.title', 'Slip Gaji') %> — <%= slip.employee_name %></title>
<link rel="stylesheet" href="/css/admin.css">
<!-- no-admin-theme -->
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Segoe UI',Arial,sans-serif;font-size:13px;color:#1a1a1a;background:#fff;padding:20px}
.slip{max-width:600px;margin:0 auto;border:2px solid #333;padding:24px}
.header{text-align:center;border-bottom:2px solid #333;padding-bottom:12px;margin-bottom:16px}
.company{font-size:18px;font-weight:700;text-transform:uppercase;letter-spacing:1px}
.slip-title{font-size:14px;font-weight:600;margin-top:4px;color:#555}
.info-grid{display:grid;grid-template-columns:1fr 1fr;gap:4px 16px;margin-bottom:16px;font-size:12px}
.info-grid .label{color:#666}.info-grid .value{font-weight:600;text-align:right}
table{width:100%;border-collapse:collapse;margin-bottom:16px}
th,td{padding:6px 8px;text-align:left;border-bottom:1px solid #ddd;font-size:12px}
th{background:#f5f5f5;font-weight:600}
.amount{text-align:right;font-family:'Courier New',monospace}
.section-header{background:#e8e8e8;font-weight:700;font-size:13px}
.total-row{font-weight:700;font-size:14px;border-top:2px solid #333;background:#f9f9f9}
.net-salary{font-size:18px;color:#1a1a1a}
.footer{margin-top:24px;display:flex;justify-content:space-between;font-size:11px}
.sign-box{text-align:center;width:40%}
.sign-line{border-top:1px solid #333;margin-top:60px;padding-top:4px}
.status-badge{display:inline-block;padding:2px 10px;border-radius:10px;font-size:11px;font-weight:600}
.status-draft{background:#eee;color:#666}
.status-approved{background:#dbeafe;color:#2563eb}
.status-paid{background:#dcfce7;color:#16a34a}
@media print{
@page{size:A4 portrait;margin:10mm 15mm}
body{padding:0;background:#fff !important;-webkit-print-color-adjust:exact !important;print-color-adjust:exact !important}
.slip{border:1.5px solid #333 !important;max-width:100% !important;padding:20px !important;border-radius:6px}
.no-print{display:none !important}
}
.no-print{margin:20px auto;max-width:600px;text-align:center;display:flex;justify-content:center;gap:10px}
</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: 'payroll', 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.payslip.doc_title', 'Slip Gaji Karyawan') %></div>
<div class="tb-right">
<a href="/admin/payroll/slip/<%= slip.id %>/pdf" target="_blank" class="btn btn-p btn-sm"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
<a href="/admin/payroll" 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">
<div class="no-print print-controls">
<button onclick="window.print()" style="padding:10px 24px;background:#6366f1;color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600"><i class="bi bi-printer-fill"></i> <%= t('admin.payslip.print', 'Cetak Slip Gaji') %></button>
<button onclick="window.close()" style="padding:10px 24px;background:#666;color:#fff;border:none;border-radius:8px;cursor:pointer"><i class="bi bi-x-lg"></i> <%= t('admin.payslip.close', 'Tutup') %></button>
</div>
<div class="slip">
<div class="header">
<div class="company"><%= company %></div>
<div class="slip-title"><%= t('admin.payslip.doc_title', 'SLIP GAJI KARYAWAN') %></div>
<div style="font-size:11px;color:#888;margin-top:4px"><%= t('admin.payslip.period', 'Periode') %>: <%= new Date(2000, (Number(slip.period_month)||1)-1, 1).toLocaleDateString((typeof lang !== 'undefined' && lang === 'en') ? 'en-US' : 'id-ID', { month: 'long' }) %> <%= slip.period_year %></div>
</div>
<div class="info-grid">
<span class="label"><%= t('admin.payslip.employee', 'Nama Karyawan') %>:</span><span class="value"><%= slip.employee_name %></span>
<span class="label"><%= t('admin.payslip.position', 'Jabatan') %>:</span><span class="value"><%= slip.employee_type === 'technician' ? t('admin.role.technician','Teknisi') : slip.employee_type === 'cashier' ? t('admin.role.cashier','Kasir') : t('admin.role.collector','Kolektor') %></span>
<span class="label"><%= t('admin.payslip.work_days', 'Hari Kerja') %>:</span><span class="value"><%= slip.working_days %> <%= t('common.days', 'hari') %></span>
<span class="label"><%= t('admin.payslip.absent_days', 'Hari Absen') %>:</span><span class="value"><%= slip.absent_days %> <%= t('common.days', 'hari') %></span>
<span class="label"><%= t('admin.payslip.late', 'Terlambat') %>:</span><span class="value"><%= slip.late_days %> <%= t('common.days', 'hari') %></span>
<span class="label"><%= t('admin.payslip.status', 'Status') %>:</span><span class="value"><span class="status-badge status-<%= slip.status %>"><%= slip.status.toUpperCase() %></span></span>
</div>
<table>
<tr class="section-header"><td colspan="2"><%= t('admin.payslip.income', 'PENDAPATAN') %></td></tr>
<tr><td><%= t('admin.payslip.base_salary', 'Gaji Pokok') %></td><td class="amount">Rp <%= slip.base_salary.toLocaleString('id-ID') %></td></tr>
<% if(slip.transport_allowance) { %><tr><td><%= t('admin.payslip.allow_transport', 'Tunjangan Transport') %></td><td class="amount">Rp <%= slip.transport_allowance.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.meal_allowance) { %><tr><td><%= t('admin.payslip.allow_meal', 'Tunjangan Makan') %></td><td class="amount">Rp <%= slip.meal_allowance.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.phone_allowance) { %><tr><td><%= t('admin.payslip.allow_phone', 'Tunjangan Pulsa') %></td><td class="amount">Rp <%= slip.phone_allowance.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.other_allowance) { %><tr><td><%= t('admin.payslip.allow_other', 'Tunjangan Lain') %> (<%= slip.other_allowance_note || '-' %>)</td><td class="amount">Rp <%= slip.other_allowance.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.ticket_bonus) { %><tr><td><%= t('admin.payslip.ticket_bonus', 'Bonus Tiket') %> (<%= slip.total_tickets_resolved %> <%= t('admin.payslip.tickets_done', 'tiket selesai') %>)</td><td class="amount" style="color:#16a34a">+Rp <%= slip.ticket_bonus.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.collection_commission) { %><tr><td><%= t('admin.payslip.collection_commission', 'Komisi Tagihan') %> (Rp <%= slip.total_collection_amount.toLocaleString('id-ID') %>)</td><td class="amount" style="color:#16a34a">+Rp <%= slip.collection_commission.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.overtime_bonus) { %><tr><td><%= t('admin.payslip.overtime', 'Lembur') %> (<%= slip.overtime_hours %> <%= t('common.hours', 'jam') %>)</td><td class="amount" style="color:#16a34a">+Rp <%= slip.overtime_bonus.toLocaleString('id-ID') %></td></tr><% } %>
<tr style="font-weight:600;background:#f9f9f9"><td><%= t('admin.payslip.total_income', 'Total Pendapatan') %></td><td class="amount">Rp <%= slip.gross_salary.toLocaleString('id-ID') %></td></tr>
<% if(slip.total_deductions > 0) { %>
<tr class="section-header"><td colspan="2"><%= t('admin.payslip.deductions', 'POTONGAN') %></td></tr>
<% if(slip.absence_deduction) { %><tr><td><%= t('admin.payslip.ded_absent', 'Potongan Absen') %> (<%= slip.absent_days %> <%= t('common.days', 'hari') %>)</td><td class="amount" style="color:#dc2626">-Rp <%= slip.absence_deduction.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.late_deduction) { %><tr><td><%= t('admin.payslip.ded_late', 'Potongan Terlambat') %> (<%= slip.late_days %> <%= t('common.days', 'hari') %>)</td><td class="amount" style="color:#dc2626">-Rp <%= slip.late_deduction.toLocaleString('id-ID') %></td></tr><% } %>
<% if(slip.other_deduction) { %><tr><td><%= t('admin.payslip.ded_other', 'Potongan Lain') %> (<%= slip.other_deduction_note || '-' %>)</td><td class="amount" style="color:#dc2626">-Rp <%= slip.other_deduction.toLocaleString('id-ID') %></td></tr><% } %>
<tr style="font-weight:600;background:#f9f9f9"><td><%= t('admin.payslip.total_deductions', 'Total Potongan') %></td><td class="amount" style="color:#dc2626">-Rp <%= slip.total_deductions.toLocaleString('id-ID') %></td></tr>
<% } %>
<tr class="total-row"><td><%= t('admin.payslip.net', 'GAJI BERSIH') %></td><td class="amount net-salary">Rp <%= slip.net_salary.toLocaleString('id-ID') %></td></tr>
</table>
<% if(slip.notes) { %>
<div style="font-size:11px;color:#666;margin-bottom:16px"><%= t('admin.payslip.note', 'Catatan') %>: <%= slip.notes %></div>
<% } %>
<div class="footer">
<div class="sign-box">
<div><%= t('admin.payslip.sign_employee', 'Karyawan') %></div>
<div class="sign-line"><%= slip.employee_name %></div>
</div>
<div class="sign-box">
<div><%= t('admin.payslip.sign_admin', 'Admin / Pimpinan') %></div>
<div class="sign-line">( __________________ )</div>
</div>
</div>
<div style="text-align:center;margin-top:16px;font-size:10px;color:#888">
<%= t('admin.payslip.printed', 'Dicetak') %>: <%= getNowLocal() %> — <%= t('admin.payslip.slip_id', 'Slip ID') %>: #<%= slip.id %>
</div>
</div>
<script>
window.onload = function() {
setTimeout(function() {
if (!window.location.search.includes('noprint')) {
window.print();
}
}, 400);
};
</script>
</div>
</div>
</body>
</html>
+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>
+432
View File
@@ -0,0 +1,432 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Manajemen Promo Slides | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="manifest" href="/admin/manifest.webmanifest">
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'promo_slides', 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-image"></i> Manajemen Promo Slides</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> Admin</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> Logout</a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<!-- TAMBAH SLIDE BARU -->
<div class="card mb16">
<div class="card-hd">
<h6 style="margin:0"><i class="bi bi-plus-circle"></i> Tambah Promo Slide Baru</h6>
</div>
<div class="card-bd">
<form method="POST" action="/admin/promo-slides" enctype="multipart/form-data">
<div class="form-row">
<div class="fg">
<label class="fl">Judul *</label>
<input class="fc" name="title" placeholder="Contoh: Diskon 50% Paket Internet" required>
</div>
<div class="fg">
<label class="fl"><%= t('common.description','Deskripsi') %></label>
<input class="fc" name="description" placeholder="Deskripsi singkat untuk slide">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Gambar *</label>
<input class="fc" type="file" name="image" accept="image/*" required>
<div class="hint" style="margin-top:6px">Ukuran optimal: 800x300 px (rasio 8:3)</div>
</div>
<div class="fg">
<label class="fl">Link (URL)</label>
<input class="fc" name="url" placeholder="https://example.com" type="url">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('common.order','Urutan') %></label>
<input class="fc" type="number" name="sort_order" value="0" min="0">
</div>
<div class="fg">
<label class="fl"><%= t('admin.fin.start_date','Tanggal Mulai') %></label>
<input class="fc" type="date" name="start_date">
</div>
<div class="fg">
<label class="fl"><%= t('admin.fin.end_date','Tanggal Akhir') %></label>
<input class="fc" type="date" name="end_date">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl" style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" name="open_in_new_tab">
<%= t('admin.common.open_new_tab','Buka Link di Tab Baru') %>
</label>
</div>
<div class="fg">
<label class="fl" style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" name="is_active" checked>
<%= t('common.active','Aktif') %>
</label>
</div>
</div>
<button type="submit" class="btn btn-p" style="margin-top:12px;"><i class="bi bi-plus"></i> Tambah Slide</button>
</form>
</div>
</div>
<!-- DAFTAR SLIDES -->
<div class="card mb16">
<div class="card-hd">
<h6 style="margin:0"><i class="bi bi-list"></i> Daftar Promo Slides (<%= slides.length %>)</h6>
</div>
<div class="card-bd">
<% if (slides && slides.length > 0) { %>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th style="width:60px">ID</th>
<th style="width:120px">Gambar</th>
<th>Judul</th>
<th style="width:80px"><%= t('common.order','Urutan') %></th>
<th style="width:100px"><%= t('admin.fin.start_date','Tanggal Mulai') %></th>
<th style="width:100px"><%= t('admin.fin.end_date','Tanggal Akhir') %></th>
<th style="width:70px"><%= t('common.status','Status') %></th>
<th style="width:100px"><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% slides.forEach(s => { %>
<tr>
<td><strong><%= s.id %></strong></td>
<td><img src="<%= s.image_path %>" style="max-width:100px; height:auto; border-radius:6px; object-fit:cover;" alt="<%= s.title %>"></td>
<td>
<div style="font-weight:600;"><%= s.title || '(Tanpa Judul)' %></div>
<div style="font-size:12px; color:#888; margin-top:4px;"><%= s.description || '-' %></div>
<% if (s.url) { %>
<div style="font-size:11px; color:#6366f1; margin-top:4px;"><i class="bi bi-link-45deg"></i> <%= s.url %></div>
<% } %>
</td>
<td><strong><%= s.sort_order %></strong></td>
<td><%= s.start_date || '-' %></td>
<td><%= s.end_date || '-' %></td>
<td>
<% if (s.is_active) { %>
<span class="badge" style="background:#22c55e; color:white;"><i class="bi bi-check-circle"></i> <%= t('common.active','Aktif') %></span>
<% } else { %>
<span class="badge" style="background:#666; color:white;"><i class="bi bi-x-circle"></i> <%= t('common.inactive','Nonaktif') %></span>
<% } %>
</td>
<td style="white-space:nowrap">
<form id="sdel-<%= s.id %>" method="POST" action="/admin/promo-slides/<%= s.id %>/delete" style="display:none" onsubmit="return confirm('Hapus slide ini?');"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick="editSlide(<%= s.id %>)"><i class="bi bi-pencil"></i> Edit Slide</button>
<div class="act-sep"></div>
<button type="submit" form="sdel-<%= s.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Slide</button>
</div>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } else { %>
<div style="text-align:center; padding:40px; color:#888;">
<i class="bi bi-inbox" style="font-size:48px; display:block; margin-bottom:12px; opacity:0.5;"></i>
<div>Belum ada promo slide</div>
</div>
<% } %>
</div>
</div>
</div>
</div>
<style>
.alert { padding:12px 16px; border-radius:8px; margin-bottom:16px; display:flex; align-items:center; gap:12px; }
.alert-s { background:#dcfce7; color:#166534; border:1px solid #bbf7d0; }
.alert-d { background:#fee2e2; color:#991b1b; border:1px solid #fecaca; }
.badge { padding:4px 8px; border-radius:4px; font-size:11px; font-weight:600; }
.btn { padding:8px 12px; border:none; border-radius:6px; cursor:pointer; font-size:13px; font-weight:600; display:inline-flex; align-items:center; gap:6px; text-decoration:none; }
.btn-p { background:#6366f1; color:white; }
.btn-p:hover { background:#4f46e5; }
.btn-g { background:#64748b; color:white; }
.btn-g:hover { background:#475569; }
.btn-d { background:#ef4444; color:white; }
.btn-d:hover { background:#dc2626; }
.btn-sm { padding:6px 10px; font-size:12px; }
</style>
<!-- EDIT SLIDE MODAL -->
<div id="editSlideModal" class="modal-wrapper" style="display:none;">
<div class="modal-overlay" onclick="closeEditModal()"></div>
<div class="modal-content">
<div class="modal-header">
<h5><i class="bi bi-pencil-square"></i> Edit Promo Slide</h5>
<button type="button" class="modal-close" onclick="closeEditModal()"><i class="bi bi-x"></i></button>
</div>
<form id="editForm" method="POST" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" id="editSlideId" name="id">
<div class="form-row">
<div class="fg">
<label class="fl">Judul *</label>
<input class="fc" id="editTitle" name="title" placeholder="Contoh: Diskon 50% Paket Internet" required>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('common.description','Deskripsi') %></label>
<input class="fc" id="editDescription" name="description" placeholder="Deskripsi singkat untuk slide">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Gambar Saat Ini</label>
<div style="margin-bottom:8px;">
<img id="editImagePreview" src="" style="max-width:100%; height:auto; max-height:150px; border-radius:6px;">
</div>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Ganti Gambar (Opsional)</label>
<input class="fc" type="file" id="editImage" name="image" accept="image/*">
<div class="hint" style="margin-top:6px">Ukuran optimal: 800x300 px (rasio 8:3) - Kosongkan jika tidak ingin mengganti</div>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Link (URL)</label>
<input class="fc" id="editUrl" name="url" placeholder="https://example.com" type="url">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('common.order','Urutan') %></label>
<input class="fc" type="number" id="editSortOrder" name="sort_order" value="0" min="0">
</div>
<div class="fg">
<label class="fl"><%= t('admin.fin.start_date','Tanggal Mulai') %></label>
<input class="fc" type="date" id="editStartDate" name="start_date">
</div>
<div class="fg">
<label class="fl"><%= t('admin.fin.end_date','Tanggal Akhir') %></label>
<input class="fc" type="date" id="editEndDate" name="end_date">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl" style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editOpenInNewTab" name="open_in_new_tab">
<%= t('admin.common.open_new_tab','Buka Link di Tab Baru') %>
</label>
</div>
<div class="fg">
<label class="fl" style="display:flex; align-items:center; gap:8px; cursor:pointer; margin:0;">
<input type="checkbox" id="editIsActive" name="is_active" checked>
<%= t('common.active','Aktif') %>
</label>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-g" onclick="closeEditModal()"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check"></i> <%= t('tech.mon.save','Simpan Perubahan') %></button>
</div>
</form>
</div>
</div>
<style>
.modal-wrapper {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.modal-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
}
.modal-content {
position: relative;
background: white;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 16px;
border-bottom: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h5 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: #666;
}
.modal-close:hover {
color: #000;
}
.modal-body {
padding: 16px;
}
.modal-footer {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
gap: 8px;
justify-content: flex-end;
}
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 12px;
}
.fg {
display: flex;
flex-direction: column;
}
.fl {
font-size: 12px;
font-weight: 600;
margin-bottom: 6px;
color: #333;
}
.fc {
padding: 8px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
font-family: inherit;
}
.fc:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.hint {
font-size: 11px;
color: #888;
}
</style>
<script>
window.__PROMO_SLIDES = <%- JSON.stringify(slides || []).replace(/</g, '\\u003c') %>;
function openEditModal(id) {
const slide = (window.__PROMO_SLIDES || []).find(function(s){ return Number(s.id) === Number(id); });
if (!slide) {
alert('Slide tidak ditemukan');
return;
}
document.getElementById('editSlideId').value = slide.id;
document.getElementById('editTitle').value = slide.title || '';
document.getElementById('editDescription').value = slide.description || '';
document.getElementById('editUrl').value = slide.url || '';
document.getElementById('editSortOrder').value = slide.sort_order || 0;
document.getElementById('editStartDate').value = slide.start_date || '';
document.getElementById('editEndDate').value = slide.end_date || '';
document.getElementById('editOpenInNewTab').checked = slide.open_in_new_tab === 1;
document.getElementById('editIsActive').checked = slide.is_active === 1;
document.getElementById('editImagePreview').src = slide.image_path;
document.getElementById('editSlideModal').style.display = 'flex';
}
function closeEditModal() {
document.getElementById('editSlideModal').style.display = 'none';
document.getElementById('editForm').reset();
}
document.getElementById('editForm').addEventListener('submit', function(e) {
e.preventDefault();
const slideId = document.getElementById('editSlideId').value;
const formData = new FormData(this);
fetch(`/admin/promo-slides/${slideId}/update`, {
method: 'POST',
body: formData
})
.then(r => r.text())
.then(() => {
// Reload page after successful update
window.location.reload();
})
.catch(e => {
alert('Gagal: ' + e.message);
});
});
function editSlide(id) {
openEditModal(id);
}
function toggleSidebar() {
var sb = document.getElementById('sidebar');
var ov = document.getElementById('sidebarOverlay');
if (sb) sb.classList.toggle('open');
if (ov) ov.classList.toggle('show');
}
</script>
</body>
</html>
+794
View File
@@ -0,0 +1,794 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.radius.title', 'Pengaturan RADIUS') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.mtabs { display: flex; gap: 8px; border-bottom: 1px solid var(--border); padding-bottom: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.mtab { display: inline-flex; align-items: center; gap: 7px; padding: 9px 18px; border-radius: 8px; font-size: 13px; font-weight: 600; color: var(--muted); background: var(--bg2); border: 1px solid var(--border); cursor: pointer; transition: all .2s; }
.mtab:hover { color: var(--text); background: rgba(255,255,255,0.06); }
.mtab.active { color: #ffffff !important; background: var(--primary) !important; border-color: var(--primary) !important; box-shadow: 0 4px 12px var(--pdim); }
html[data-theme="light"] .mtab { background: #ffffff !important; color: #64748b !important; border-color: #cbd5e1 !important; }
html[data-theme="light"] .mtab:hover { color: #0f172a !important; background: #f1f5f9 !important; }
html[data-theme="light"] .mtab.active { color: #ffffff !important; background: #4f46e5 !important; border-color: #4f46e5 !important; }
.settings-form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(290px, 1fr)); gap: 18px; }
/* Mobile Responsive Optimizations */
@media (max-width: 768px) {
.settings-form-grid { grid-template-columns: 1fr; }
.mtabs {
overflow-x: auto;
flex-wrap: nowrap;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
padding-bottom: 8px;
margin-bottom: 14px;
scrollbar-width: thin;
}
.mtab {
padding: 8px 14px;
font-size: 12px;
flex-shrink: 0;
}
.stat-grid {
grid-template-columns: repeat(2, 1fr) !important;
gap: 10px !important;
}
.tbl-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
width: 100%;
margin-bottom: 0;
}
.dtbl {
min-width: 600px;
}
.topbar .tb-title span {
font-size: 13px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
/* modal mengikuti style global */
.mf {
flex-wrap: wrap !important;
gap: 8px !important;
}
.mf button, .mf a {
flex: 1 1 100% !important;
justify-content: center !important;
}
}
@media (max-width: 480px) {
.stat-grid {
grid-template-columns: 1fr !important;
}
.topbar .tb-title span {
max-width: 140px;
}
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: activePage || 'radius_settings', company }) %>
<div class="mw">
<!-- TOPBAR -->
<div class="topbar">
<div class="tb-title">
<button class="hb-menu" onclick="toggleSidebar()"><i class="bi bi-list"></i></button>
<i class="bi bi-broadcast"></i>
<span><%= t('admin.radius.title', 'Pengaturan RADIUS') %> &amp; Monitoring</span>
</div>
<div class="tb-right">
<span class="adm-badge" style="font-size:12px;opacity:0.7;"><%= company %></span>
<span class="adm-badge">
<i class="bi bi-person-fill"></i>
<%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<!-- CONTENT -->
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'danger' || msg.type === 'error' ? 'd' : msg.type === 'success' ? 's' : 'w' %> mb16" data-flash="1" id="autoAlert">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-triangle' %>"></i>
<%= msg.text %>
</div>
<% } %>
<!-- SUMMARY STAT CARDS -->
<div class="stat-grid mb16">
<div class="sc p">
<i class="bi bi-broadcast sc-icon"></i>
<div class="sc-label" style="display:flex;align-items:center;justify-content:space-between;gap:6px;">
<span>Status RADIUS Server</span>
<form action="/admin/radius/restart" method="POST" style="margin:0;">
<button type="submit" class="btn btn-w btn-sm" style="font-size:10px;padding:2px 8px;height:auto;" title="Restart Service UDP RADIUS (1812/1813)">
<i class="bi bi-arrow-clockwise"></i> Restart
</button>
</form>
</div>
<div class="sc-val">
<% if (radiusStatus.running) { %>
<span class="badge bs"><i class="bi bi-play-circle-fill"></i> Running</span>
<% } else { %>
<span class="badge bd"><i class="bi bi-stop-circle-fill"></i> Stopped</span>
<% } %>
</div>
<div class="sc-sub">UDP Auth: <%= radiusStatus.authPort %> | Acct: <%= radiusStatus.acctPort %></div>
</div>
<div class="sc s">
<i class="bi bi-wifi sc-icon"></i>
<div class="sc-label">User RADIUS Online</div>
<div class="sc-val"><%= onlineSessions.length %></div>
<div class="sc-sub">Sesi Aktif Terhubung Saat Ini</div>
</div>
<div class="sc i">
<i class="bi bi-router sc-icon"></i>
<div class="sc-label">NAS Router Client</div>
<div class="sc-val"><%= nasList.length %></div>
<div class="sc-sub">Router NAS Terdaftar di Server</div>
</div>
<div class="sc w">
<i class="bi bi-activity sc-icon"></i>
<div class="sc-label">Trafik RADIUS Hari Ini</div>
<div class="sc-val"><%= typeof todayTrafficMB !== 'undefined' ? todayTrafficMB : '0.0' %><span style="font-size:14px;"> MB</span></div>
<div class="sc-sub"><%= typeof todayEvents !== 'undefined' ? todayEvents : 0 %> Log Accounting Hari Ini</div>
</div>
</div>
<!-- TABS NAVIGATION -->
<div class="mtabs mb16">
<button type="button" class="mtab active" id="tab-btn-online" onclick="switchTab('online')">
<i class="bi bi-wifi"></i> Sesi Active RADIUS (<%= onlineSessions.length %>)
</button>
<button type="button" class="mtab" id="tab-btn-settings" onclick="switchTab('settings')">
<i class="bi bi-sliders"></i> Pengaturan RADIUS
</button>
<button type="button" class="mtab" id="tab-btn-nas" onclick="switchTab('nas')">
<i class="bi bi-router"></i> Daftar NAS Router (<%= nasList.length %>)
</button>
<button type="button" class="mtab" id="tab-btn-accounting" onclick="switchTab('accounting')">
<i class="bi bi-journal-text"></i> Log Accounting
</button>
</div>
<!-- TAB 1: PENGATURAN RADIUS -->
<div id="tab-content-settings" class="tab-pane" style="display:none;">
<form action="/admin/radius-settings" method="POST">
<div class="settings-form-grid mb16">
<!-- Card 1: Core Service Settings -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-cpu"></i> Layanan RADIUS Server</h6>
</div>
<div class="card-bd">
<div class="fg">
<label class="fl">Status Service RADIUS</label>
<select name="radius_enabled" class="fs">
<option value="1" <%= radiusStatus.enabled ? 'selected' : '' %>>Aktif (Running Background UDP Service)</option>
<option value="0" <%= !radiusStatus.enabled ? 'selected' : '' %>>Non-Aktif (Disabled)</option>
</select>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">
Mendengarkan request autentikasi & accounting dari Router NAS MikroTik.
</small>
</div>
<div class="fg">
<label class="fl">Global Shared Secret RADIUS</label>
<input type="text" name="radius_secret" class="fc" value="<%= radiusStatus.secret %>" required>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">
Secret default yang digunakan jika IP NAS tidak didaftarkan secara khusus.
</small>
</div>
</div>
</div>
<!-- Card 2: Port UDP & Protocol -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-ethernet"></i> Port Jaringan UDP</h6>
</div>
<div class="card-bd">
<div class="form-row">
<div class="fg">
<label class="fl">Port Auth (UDP)</label>
<input type="number" name="radius_auth_port" class="fc" value="<%= radiusStatus.authPort %>" required>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">Default: 1812</small>
</div>
<div class="fg">
<label class="fl">Port Acct (UDP)</label>
<input type="number" name="radius_acct_port" class="fc" value="<%= radiusStatus.acctPort %>" required>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">Default: 1813</small>
</div>
</div>
</div>
</div>
<!-- Card 3: Limitasi Koneksi & Sesi -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-speedometer2"></i> Limitasi Kecepatan & Sesi Aktif</h6>
</div>
<div class="card-bd">
<div class="fg">
<label class="fl">Pembatasan Sesi Aktif Ganda (Simultaneous-Use)</label>
<select name="radius_limit_simultaneous" class="fs">
<option value="1" <%= (typeof radiusStatus.limitSimultaneous === 'undefined' || radiusStatus.limitSimultaneous) ? 'selected' : '' %>>Aktif - Batasi 1 Akun 1 Sesi Online (Rejek Login Ganda)</option>
<option value="0" <%= (typeof radiusStatus.limitSimultaneous !== 'undefined' && !radiusStatus.limitSimultaneous) ? 'selected' : '' %>>Non-Aktif - Bebas Multi-Login</option>
</select>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">
Mencegah 1 akun dipakai secara bersamaan di banyak perangkat.
</small>
</div>
<div class="fg">
<label class="fl">Default Rate Limit (Fallback Kecepatan)</label>
<input type="text" name="radius_default_rate_limit" class="fc" value="<%= typeof radiusStatus.defaultRateLimit !== 'undefined' ? radiusStatus.defaultRateLimit : '5M/10M' %>" placeholder="Contoh: 5M/10M">
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">
Digunakan jika pengguna/pelanggan tidak terikat pada Paket Internet khusus. (Format: <code>Upload/Download</code>).
</small>
</div>
</div>
</div>
<!-- Card 3: Isolir Settings -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-shield-slash"></i> Isolir & Suspended Policy RADIUS</h6>
</div>
<div class="card-bd">
<div class="fg">
<label class="fl">Aksi Pelanggan Terisolir</label>
<select name="radius_isolir_action" class="fs">
<option value="pool" <%= radiusStatus.isolirAction === 'pool' ? 'selected' : '' %>>Access-Accept dengan Dynamic IP Pool Isolir (Rekomendasi)</option>
<option value="reject" <%= radiusStatus.isolirAction === 'reject' ? 'selected' : '' %>>Access-Reject (Koneksi Ditolak Total)</option>
</select>
</div>
<div class="fg">
<label class="fl">Alokasi IP Pool Isolir oleh RADIUS Server</label>
<select name="radius_isolir_ip_pool_enabled" class="fs">
<option value="1" <%= (typeof radiusStatus.isolirIpPoolEnabled === 'undefined' || radiusStatus.isolirIpPoolEnabled) ? 'selected' : '' %>>Aktif - RADIUS Alokasikan IP Isolir Khusus Secara Otomatis</option>
<option value="0" <%= (typeof radiusStatus.isolirIpPoolEnabled !== 'undefined' && !radiusStatus.isolirIpPoolEnabled) ? 'selected' : '' %>>Non-Aktif - Hanya Kirim Mikrotik-Group / Framed-Pool</option>
</select>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">Pelanggan terisolir akan mendapatkan IP khusus dari subnet isolir RADIUS tanpa perlu user/pool di MikroTik.</small>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Rentang IP Pool Isolir (Awal)</label>
<input type="text" name="radius_isolir_ip_pool_start" class="fc" value="<%= typeof radiusStatus.isolirIpPoolStart !== 'undefined' ? radiusStatus.isolirIpPoolStart : '10.10.99.2' %>" placeholder="10.10.99.2" required>
</div>
<div class="fg">
<label class="fl">Rentang IP Pool Isolir (Akhir)</label>
<input type="text" name="radius_isolir_ip_pool_end" class="fc" value="<%= typeof radiusStatus.isolirIpPoolEnd !== 'undefined' ? radiusStatus.isolirIpPoolEnd : '10.10.99.254' %>" placeholder="10.10.99.254" required>
</div>
</div>
<div class="fg">
<label class="fl">Kecepatan Limit Pelanggan Terisolir (Rate-Limit)</label>
<input type="text" name="radius_isolir_rate_limit" class="fc" value="<%= typeof radiusStatus.isolirRateLimit !== 'undefined' ? radiusStatus.isolirRateLimit : '512k/512k' %>" placeholder="Contoh: 512k/512k atau 1M/1M" required>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">Format: <code>Upload/Download</code> (misal: 512k/512k, 1M/1M, 256k/512k). Dikirimkan langsung ke MikroTik saat pelanggan terisolir.</small>
</div>
<div class="fg">
<label class="fl">Nama Pool / Group Isolir RADIUS (Attribute)</label>
<input type="text" name="radius_isolir_pool" class="fc" value="<%= radiusStatus.isolirPool %>" required>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">Atribut Mikrotik-Group / Framed-Pool tambahan untuk pelanggan terisolir.</small>
</div>
</div>
</div>
<!-- Card 4: Dynamic IP Pool RADIUS -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-diagram-3-fill"></i> Pembagian IP Pool RADIUS Otomatis</h6>
</div>
<div class="card-bd">
<div class="fg">
<label class="fl">Alokasi IP Dinamis oleh RADIUS Server</label>
<select name="radius_ip_pool_enabled" class="fs">
<option value="1" <%= (typeof radiusStatus.ipPoolEnabled === 'undefined' || radiusStatus.ipPoolEnabled) ? 'selected' : '' %>>Aktif - RADIUS Bagikan IP Otomatis dari Rentang IP Pool (Rekomendasi)</option>
<option value="0" <%= (typeof radiusStatus.ipPoolEnabled !== 'undefined' && !radiusStatus.ipPoolEnabled) ? 'selected' : '' %>>Non-Aktif - Gunakan Dynamic Pool dari MikroTik (Framed-Pool)</option>
</select>
<small class="text-muted" style="font-size:11px;display:block;margin-top:4px;">
Jika aktif, RADIUS akan membagikan IP dinamis otomatis ke pelanggan tanpa IP statis sehingga tidak tergantung pada pool lokal MikroTik.
</small>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Rentang IP Pool Normal (Awal)</label>
<input type="text" name="radius_ip_pool_start" class="fc" value="<%= typeof radiusStatus.ipPoolStart !== 'undefined' ? radiusStatus.ipPoolStart : '10.10.10.2' %>" placeholder="10.10.10.2" required>
</div>
<div class="fg">
<label class="fl">Rentang IP Pool Normal (Akhir)</label>
<input type="text" name="radius_ip_pool_end" class="fc" value="<%= typeof radiusStatus.ipPoolEnd !== 'undefined' ? radiusStatus.ipPoolEnd : '10.10.10.254' %>" placeholder="10.10.10.254" required>
</div>
</div>
<div class="fg">
<label class="fl">Nama Framed Pool Default (Fallback)</label>
<input type="text" name="radius_framed_pool" class="fc" value="<%= radiusStatus.framedPool %>" required>
</div>
</div>
</div>
<!-- Card: Auto-Generate PPPoE -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-key-fill"></i> Auto-Generate Akun PPPoE (Pelanggan Baru)</h6>
</div>
<div class="card-bd">
<div class="hint" style="margin-bottom:10px">Dipakai saat admin <b>menyetujui pendaftar</b> atau menekan <b>Generate Akun PPPoE</b>. Berlaku untuk mode MikroTik maupun RADIUS.</div>
<div class="form-row">
<div class="fg">
<label class="fl">Otomatis buat akun PPPoE</label>
<select name="pppoe_auto_generate" class="fs">
<option value="1" <%= (String((typeof settings!=='undefined'&&settings&&settings.pppoe_auto_generate)||'1')==='1')?'selected':'' %>>Ya (otomatis)</option>
<option value="0" <%= (String((typeof settings!=='undefined'&&settings&&settings.pppoe_auto_generate)||'1')==='0')?'selected':'' %>>Tidak (isi manual)</option>
</select>
</div>
<div class="fg">
<label class="fl">Pola Username</label>
<select name="pppoe_username_pattern" class="fs">
<option value="name" <%= (String((typeof settings!=='undefined'&&settings&&settings.pppoe_username_pattern)||'name')==='name')?'selected':'' %>><%= t('iso.cust_name','Nama Pelanggan') %></option>
<option value="customer_code" <%= (String((typeof settings!=='undefined'&&settings&&settings.pppoe_username_pattern)||'name')==='customer_code')?'selected':'' %>>ID Pelanggan (SKY-xxxxx)</option>
</select>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Domain / Realm</label>
<input type="text" name="pppoe_domain" class="fc" value="<%= (typeof settings!=='undefined'&&settings&&settings.pppoe_domain)||'sky.id' %>" placeholder="sky.id">
<div class="hint">Username = pola + @ + domain. Contoh: <span class="mono">budi_santoso@sky.id</span></div>
</div>
<div class="fg">
<label class="fl">Password Default</label>
<input type="text" name="pppoe_default_password" class="fc" value="<%= (typeof settings!=='undefined'&&settings&&settings.pppoe_default_password)||'openisp' %>" placeholder="openisp">
</div>
</div>
</div>
</div>
<!-- Card: Mode Profil MikroTik -->
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-sliders"></i> Mode Profil MikroTik (RADIUS)</h6></div>
<div class="card-bd">
<div class="hint" style="margin-bottom:10px">Jika <b>Ya</b>: RADIUS hanya mengautentikasi & mengirim <b>Mikrotik-Group</b> (nama paket) — <b>rate-limit & IP diambil dari profil PPP di MikroTik</b> (RADIUS tidak mengirim atribut Rate-Limit maupun IP). Pastikan nama profil di MikroTik = nama paket.</div>
<div class="fg">
<label class="fl">Gunakan Profil &amp; IP dari MikroTik</label>
<select name="radius_use_profile" class="fs">
<option value="0" <%= (String((typeof settings!=='undefined'&&settings&&settings.radius_use_profile)||'0')==='0')?'selected':'' %>>Tidak (RADIUS atur rate-limit &amp; IP)</option>
<option value="1" <%= (String((typeof settings!=='undefined'&&settings&&settings.radius_use_profile)||'0')==='1')?'selected':'' %>>Ya (ambil dari profil PPP MikroTik)</option>
</select>
</div>
</div>
</div>
<!-- Card 5: Panduan Firewall Linux UFW & Script MikroTik -->
<div class="card mb16">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px;">
<h6 style="margin:0;"><i class="bi bi-terminal-fill"></i> Script Setup Otomatis MikroTik (RADIUS & Isolir)</h6>
<div style="display:flex;align-items:center;gap:8px;">
<label style="font-size:12px;margin:0;color:var(--muted);white-space:nowrap;">IP Server Billing:</label>
<input type="text" id="billing-ip-input" class="fc" value="<%= typeof serverIp !== 'undefined' ? serverIp : '192.168.8.88' %>" oninput="updateScriptIp(this.value)" style="width:140px;height:30px;font-size:12px;padding:3px 8px;" title="Ubah IP server jika router mengakses lewat IP lain/VPS">
<button type="button" class="btn btn-s btn-sm" onclick="copyMikrotikScript()"><i class="bi bi-clipboard-check"></i> Salin Script MikroTik</button>
</div>
</div>
<div class="card-bd">
<div class="fg" style="margin-bottom:14px;">
<label class="fl">1. Perintah Firewall Linux Server (UFW)</label>
<small class="text-muted" style="font-size:11px;display:block;margin-bottom:6px;">Jalankan perintah ini di terminal server Linux (VPS / Server Produksi) agar router dapat menghubungi server:</small>
<div style="background:var(--bg3);padding:10px 12px;border-radius:8px;border:1px solid var(--border);font-family:monospace;font-size:12px;color:var(--info);">
sudo ufw allow <%= radiusStatus.authPort %>/udp<br>
sudo ufw allow <%= radiusStatus.acctPort %>/udp
</div>
</div>
<div class="fg" style="margin-bottom:0;">
<label class="fl">2. Script Integrasi RADIUS & Isolir di MikroTik (RouterOS Terminal)</label>
<small class="text-muted" style="font-size:11px;display:block;margin-bottom:6px;">Copy dan pastekan seluruh script di bawah ini ke <strong>Terminal MikroTik</strong> (IP server otomatis terisi dari IP di atas):</small>
<textarea id="mikrotik-script-area" class="fc" rows="14" style="font-family:monospace;font-size:11px;background:var(--bg3);color:#10b981;line-height:1.4;white-space:pre;" readonly># =========================================================
# SCRIPT SETUP OTOMATIS RADIUS BILLING & ISOLIR MIKROTIK
# =========================================================
# 1. Aktifkan Fitur RADIUS pada PPP MikroTik
/ppp aaa set use-radius=yes accounting=yes interim-update=1m
# 2. Buka Akses Disconnect / CoA RADIUS MikroTik (Port UDP 3799)
/radius incoming set accept=yes port=3799
# 3. Tambahkan Server RADIUS Billing ke MikroTik
/radius remove [find comment="RTRW-Billing-RADIUS"]
/radius add service=ppp address=<%= typeof serverIp !== 'undefined' ? serverIp : '192.168.8.88' %> secret="<%= radiusStatus.secret %>" authentication-port=<%= radiusStatus.authPort %> accounting-port=<%= radiusStatus.acctPort %> timeout=3000ms comment="RTRW-Billing-RADIUS"
# 4. Tambahkan IP Address Gateway Subnet Isolir RADIUS
/ip address remove [find comment="Gateway-Isolir-RADIUS"]
:do { /ip address add address=<%= (typeof radiusStatus.isolirIpPoolStart !== 'undefined' ? radiusStatus.isolirIpPoolStart.replace(/\.\d+$/, '.1') : '10.10.99.1') %>/24 interface=([/interface find type=bridge]->0) comment="Gateway-Isolir-RADIUS" } on-error={ /ip address add address=<%= (typeof radiusStatus.isolirIpPoolStart !== 'undefined' ? radiusStatus.isolirIpPoolStart.replace(/\.\d+$/, '.1') : '10.10.99.1') %>/24 interface=([/interface find]->0) comment="Gateway-Isolir-RADIUS" }
# 5. Filter Drop Akses Internet Pelanggan Terisolir RADIUS (Universal)
/ip firewall filter remove [find comment="Isolir-RADIUS-Block-Internet"]
/ip firewall filter add chain=forward src-address=<%= (typeof radiusStatus.isolirIpPoolStart !== 'undefined' ? radiusStatus.isolirIpPoolStart.replace(/\.\d+$/, '.0') : '10.10.99.0') %>/24 action=drop comment="Isolir-RADIUS-Block-Internet"</textarea>
</div>
</div>
</div>
</div>
<div>
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> Simpan Konfigurasi RADIUS</button>
</div>
</form>
</div>
<!-- TAB 2: DAFTAR NAS CLIENTS -->
<div id="tab-content-nas" class="tab-pane" style="display:none;">
<div class="card mb16">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;">
<h6 style="margin:0;"><i class="bi bi-router"></i> Daftar Router NAS (Network Access Server)</h6>
<button type="button" class="btn btn-p btn-sm" onclick="showModal('addNasModal')"><i class="bi bi-plus-lg"></i> Tambah NAS Client</button>
</div>
<div class="card-bd" style="padding:0;">
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>IP Address NAS</th>
<th>Nama Shortname</th>
<th>Shared Secret</th>
<th>Keterangan</th>
<th><%= t('common.status','Status') %></th>
<th style="width:140px;text-align:right;"><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (nasList && nasList.length > 0) { %>
<% nasList.forEach(nas => { %>
<tr>
<td><strong><%= nas.nasname %></strong></td>
<td><%= nas.shortname || '-' %></td>
<td><code><%= nas.secret %></code></td>
<td><%= nas.description || '-' %></td>
<td>
<% if (nas.is_active) { %>
<span class="badge bs"><%= t('common.active','Aktif') %></span>
<% } else { %>
<span class="badge bd"><%= t('admin.common.non_active','Non-Aktif') %></span>
<% } %>
</td>
<td style="text-align:right;white-space:nowrap;">
<form id="ntg-<%= nas.id %>" action="/admin/radius/nas/toggle" method="POST" style="display:none;"><input type="hidden" name="id" value="<%= nas.id %>"></form>
<form id="ndl-<%= nas.id %>" action="/admin/radius/nas/delete" method="POST" style="display:none;" onsubmit="return confirm('Hapus NAS <%= nas.nasname %>?')"><input type="hidden" name="id" value="<%= nas.id %>"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="submit" form="ntg-<%= nas.id %>" class="act-item <%= nas.is_active ? 'warn' : 'success' %>"><i class="bi bi-<%= nas.is_active ? 'pause-circle' : 'play-circle' %>"></i> <%= nas.is_active ? 'Non-aktifkan' : 'Aktifkan' %></button>
<button type="button" class="act-item primary" onclick='openEditNasModal(<%- JSON.stringify(nas).split('<').join('\\u003c') %>)'><i class="bi bi-pencil"></i> Edit NAS</button>
<div class="act-sep"></div>
<button type="submit" form="ndl-<%= nas.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus NAS</button>
</div>
</div>
</td>
</tr>
<% }); %>
<% } else { %>
<tr>
<td colspan="6" class="text-center text-muted" style="padding:24px;">
Belum ada NAS khusus terdaftar. Server akan menggunakan <strong>Global Shared Secret</strong> untuk semua request router.
</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- TAB 3: SESI ONLINE RADIUS -->
<div id="tab-content-online" class="tab-pane">
<div class="card mb16">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;">
<h6 style="margin:0;"><i class="bi bi-wifi"></i> Sesi Aktif RADIUS (Active Connections)</h6>
<% if (onlineSessions && onlineSessions.length > 0) { %>
<form action="/admin/radius/clear-stale" method="POST" onsubmit="return confirm('Bersihkan semua sesi aktif yang tidak ada pembaruan lebih dari 2 jam?')" style="margin:0;">
<button type="submit" class="btn btn-w btn-sm" title="Tandai sesi yang sudah tidak ada pembaruan > 2 jam sebagai stopped">
<i class="bi bi-clock-history"></i> Bersihkan Sesi Usang
</button>
</form>
<% } %>
</div>
<div class="card-bd" style="padding:0;">
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>Username</th>
<th><%= t('iso.cust_name','Nama Pelanggan') %></th>
<th>Framed IP</th>
<th>NAS IP</th>
<th>Upload (In)</th>
<th>Download (Out)</th>
<th>Durasi Sesi</th>
<th>Waktu Update</th>
<th style="width:110px;text-align:right;"><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (onlineSessions && onlineSessions.length > 0) { %>
<% onlineSessions.forEach(s => { %>
<%
const totalIn = ((s.input_gigawords || 0) * 4294967296) + (s.input_octets || 0);
const totalOut = ((s.output_gigawords || 0) * 4294967296) + (s.output_octets || 0);
const inMB = totalIn >= 1073741824 ? (totalIn / 1073741824).toFixed(2) + ' GB' : (totalIn / 1048576).toFixed(2) + ' MB';
const outMB = totalOut >= 1073741824 ? (totalOut / 1073741824).toFixed(2) + ' GB' : (totalOut / 1048576).toFixed(2) + ' MB';
const st = s.session_time || 0;
const h = Math.floor(st / 3600);
const m = Math.floor((st % 3600) / 60);
const sec = st % 60;
const stStr = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(sec).padStart(2,'0')}`;
%>
<tr>
<td><strong><%= s.username %></strong></td>
<td><%= s.customer_name || '<span class="text-muted">-</span>' %></td>
<td><%= s.framed_ip || '-' %></td>
<td><%= s.nas_ip || '-' %></td>
<td><%= inMB %></td>
<td><%= outMB %></td>
<td><code><%= stStr %></code></td>
<td><%= s.updated_at %></td>
<td style="text-align:right;white-space:nowrap;">
<form action="/admin/radius/disconnect" method="POST" onsubmit="return confirm('Putuskan sesi aktif RADIUS untuk user \'<%= s.username %>\'?')" style="display:inline;">
<input type="hidden" name="username" value="<%= s.username %>">
<input type="hidden" name="session_id" value="<%= s.session_id %>">
<input type="hidden" name="nas_ip" value="<%= s.nas_ip %>">
<button type="submit" class="btn btn-d btn-sm" title="Putuskan Sesi Active RADIUS">
<i class="bi bi-power"></i> Putuskan
</button>
</form>
</td>
</tr>
<% }); %>
<% } else { %>
<tr>
<td colspan="9" class="text-center text-muted" style="padding:24px;">Belum ada sesi online RADIUS yang terhubung.</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- TAB 4: LOG ACCOUNTING -->
<div id="tab-content-accounting" class="tab-pane" style="display:none;">
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-journal-text"></i> Log Accounting RADIUS (100 Terakhir)</h6>
</div>
<div class="card-bd" style="padding:0;">
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>ID</th>
<th>Username</th>
<th>Session ID</th>
<th>NAS IP</th>
<th>Status Type</th>
<th>Upload</th>
<th>Download</th>
<th><%= t('admin.common.uptime','Uptime') %></th>
<th>Timestamp</th>
</tr>
</thead>
<tbody>
<% if (acctLogs && acctLogs.length > 0) { %>
<% acctLogs.forEach(log => { %>
<%
const logIn = ((log.input_gigawords || 0) * 4294967296) + (log.input_octets || 0);
const logOut = ((log.output_gigawords || 0) * 4294967296) + (log.output_octets || 0);
const logInStr = logIn >= 1073741824 ? (logIn / 1073741824).toFixed(2) + ' GB' : (logIn / 1048576).toFixed(2) + ' MB';
const logOutStr = logOut >= 1073741824 ? (logOut / 1073741824).toFixed(2) + ' GB' : (logOut / 1048576).toFixed(2) + ' MB';
const lst = log.session_time || 0;
const lh = Math.floor(lst / 3600);
const lm = Math.floor((lst % 3600) / 60);
const ls = lst % 60;
const lstStr = `${String(lh).padStart(2,'0')}:${String(lm).padStart(2,'0')}:${String(ls).padStart(2,'0')}`;
%>
<tr>
<td>#<%= log.id %></td>
<td><strong><%= log.username %></strong></td>
<td><code><%= log.session_id %></code></td>
<td><%= log.nas_ip %></td>
<td>
<% if (log.status_type === 1) { %>
<span class="badge bs">Start</span>
<% } else if (log.status_type === 2) { %>
<span class="badge bd">Stop</span>
<% } else { %>
<span class="badge bp">Interim</span>
<% } %>
</td>
<td><%= logInStr %></td>
<td><%= logOutStr %></td>
<td><code><%= lstStr %></code></td>
<td><%= log.created_at %></td>
</tr>
<% }); %>
<% } else { %>
<tr>
<td colspan="9" class="text-center text-muted" style="padding:24px;">Belum ada data log accounting RADIUS.</td>
</tr>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL TAMBAH NAS -->
<div class="mo" id="addNasModal">
<div class="mb">
<div class="mh">
<div class="mt"><i class="bi bi-plus-circle"></i> Tambah NAS Client RADIUS</div>
<button type="button" class="mc" onclick="closeModal('addNasModal')">&times;</button>
</div>
<form action="/admin/radius/nas/add" method="POST">
<div class="mbody">
<div class="fg">
<label class="fl">IP Address Router NAS</label>
<input type="text" name="nasname" class="fc" placeholder="Contoh: 192.168.88.1 atau 0.0.0.0" required>
</div>
<div class="fg">
<label class="fl">Nama Singkat Router (Shortname)</label>
<input type="text" name="shortname" class="fc" placeholder="Contoh: Router-Utama">
</div>
<div class="fg">
<label class="fl">Shared Secret Router</label>
<input type="text" name="secret" class="fc" placeholder="Contoh: secret123" required>
</div>
<div class="fg">
<label class="fl"><%= t('common.note_optional','Keterangan (Opsional)') %></label>
<input type="text" name="description" class="fc" placeholder="Lokasi / Catatan">
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addNasModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> Simpan NAS Client</button>
</div>
</form>
</div>
</div>
<!-- MODAL EDIT NAS -->
<div class="mo" id="editNasModal">
<div class="mb">
<div class="mh">
<div class="mt"><i class="bi bi-pencil-square"></i> Edit NAS Client RADIUS</div>
<button type="button" class="mc" onclick="closeModal('editNasModal')">&times;</button>
</div>
<form action="/admin/radius/nas/edit" method="POST">
<input type="hidden" name="id" id="edit_nas_id">
<div class="mbody">
<div class="fg">
<label class="fl">IP Address Router NAS</label>
<input type="text" name="nasname" id="edit_nas_nasname" class="fc" placeholder="Contoh: 192.168.88.1" required>
</div>
<div class="fg">
<label class="fl">Nama Singkat Router (Shortname)</label>
<input type="text" name="shortname" id="edit_nas_shortname" class="fc" placeholder="Contoh: Router-Utama">
</div>
<div class="fg">
<label class="fl">Shared Secret Router</label>
<input type="text" name="secret" id="edit_nas_secret" class="fc" placeholder="Contoh: secret123" required>
</div>
<div class="fg">
<label class="fl"><%= t('common.note_optional','Keterangan (Opsional)') %></label>
<input type="text" name="description" id="edit_nas_description" class="fc" placeholder="Lokasi / Catatan">
</div>
<div class="fg">
<label class="fl">Status NAS Client</label>
<select name="is_active" id="edit_nas_is_active" class="fs">
<option value="1"><%= t('common.active','Aktif') %></option>
<option value="0"><%= t('admin.common.non_active','Non-Aktif') %></option>
</select>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('editNasModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> <%= t('tech.mon.save','Simpan Perubahan') %></button>
</div>
</form>
</div>
</div>
<script>
function switchTab(tabId) {
document.querySelectorAll('.mtab').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-pane').forEach(c => c.style.display = 'none');
const targetBtn = document.getElementById('tab-btn-' + tabId);
const targetPane = document.getElementById('tab-content-' + tabId);
if (targetBtn) targetBtn.classList.add('active');
if (targetPane) targetPane.style.display = 'block';
}
function showModal(id) {
const el = document.getElementById(id);
if (el) el.classList.add('show');
}
function closeModal(id) {
const el = document.getElementById(id);
if (el) el.classList.remove('show');
}
function openEditNasModal(nas) {
if (!nas) return;
document.getElementById('edit_nas_id').value = nas.id || '';
document.getElementById('edit_nas_nasname').value = nas.nasname || '';
document.getElementById('edit_nas_shortname').value = nas.shortname || '';
document.getElementById('edit_nas_secret').value = nas.secret || '';
document.getElementById('edit_nas_description').value = nas.description || '';
document.getElementById('edit_nas_is_active').value = nas.is_active ? '1' : '0';
showModal('editNasModal');
}
function updateScriptIp(newIp) {
const el = document.getElementById('mikrotik-script-area');
if (!el) return;
const cleanIp = String(newIp || '').trim() || '192.168.8.88';
el.value = el.value.replace(/address=\S+ secret=/, 'address=' + cleanIp + ' secret=');
}
function copyMikrotikScript() {
const el = document.getElementById('mikrotik-script-area');
if (!el) return;
el.select();
navigator.clipboard.writeText(el.value).then(() => {
alert('Script setup MikroTik & Isolir RADIUS berhasil disalin! Silakan tempelkan (paste) di Terminal MikroTik Anda.');
}).catch(() => {
document.execCommand('copy');
alert('Script MikroTik berhasil disalin!');
});
}
// Auto dismiss notification alert after 4 seconds
setTimeout(() => {
const alertEl = document.getElementById('autoAlert');
if (alertEl) {
alertEl.style.transition = 'opacity 0.5s ease, max-height 0.5s ease, margin 0.5s ease, padding 0.5s ease';
alertEl.style.opacity = '0';
alertEl.style.maxHeight = '0';
alertEl.style.margin = '0';
alertEl.style.padding = '0';
alertEl.style.overflow = 'hidden';
setTimeout(() => alertEl.remove(), 500);
}
}, 4000);
</script>
</body>
</html>
+275
View File
@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.nav.finance_report', 'Laporan Keuangan') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
details.card > summary { cursor: pointer; list-style: none; }
details.card > summary::-webkit-details-marker { display: none; }
details.card > summary::after { content: '\F282'; font-family: 'bootstrap-icons'; color: var(--muted); }
details.card[open] > summary::after { content: '\F286'; }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'reports', 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-bar-chart-line"></i> <%= t('admin.nav.finance_report', 'Laporan Keuangan') %></div>
<div class="tb-right" style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<select class="fs" id="yearSelect" style="width:90px" onchange="window.location='/admin/reports?year='+this.value">
<% for(let y=new Date().getFullYear(); y>=2023; y--){ %>
<option value="<%= y %>" <%= filterYear==y?'selected':'' %>><%= y %></option>
<% } %>
</select>
<a href="/admin/reports/export-csv?year=<%= filterYear %>" class="btn btn-g btn-sm" title="Export Excel (CSV)"><i class="bi bi-file-earmark-excel" style="color:#10b981"></i> Excel</a>
<a href="/admin/reports/pdf?year=<%= filterYear %>&mode=before" target="_blank" class="btn btn-g btn-sm" title="PDF — Laba Sebelum Pajak"><i class="bi bi-file-earmark-pdf" style="color:#ef4444"></i> PDF (Sblm Pajak)</a>
<a href="/admin/reports/pdf?year=<%= filterYear %>&mode=after" target="_blank" class="btn btn-g btn-sm" title="PDF — Laba Sesudah Pajak"><i class="bi bi-file-earmark-pdf" style="color:#ef4444"></i> PDF (Sstlh Pajak)</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">
<!-- ═══ A. RINGKASAN ═══ -->
<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(190px,1fr))">
<div class="sc i"><i class="bi bi-cash-stack sc-icon"></i>
<div class="sc-label"><%= t('admin.fin.gross','Pemasukan (Bruto)') %> <%= filterYear %></div>
<div class="sc-val" style="font-size:20px">Rp <%= (cashInYear||0).toLocaleString('id-ID') %></div>
<div class="sc-sub">Bulan ini: Rp <%= (cashInThisMonth||0).toLocaleString('id-ID') %></div>
</div>
<div class="sc d"><i class="bi bi-wallet2 sc-icon"></i>
<div class="sc-label">Pengeluaran <%= filterYear %></div>
<div class="sc-val" style="font-size:20px">- Rp <%= (expensesYear||0).toLocaleString('id-ID') %></div>
<div class="sc-sub">Bulan ini: Rp <%= (expensesMonth||0).toLocaleString('id-ID') %></div>
</div>
<div class="sc w"><i class="bi bi-receipt sc-icon"></i>
<div class="sc-label">PPh Badan (<%= finance.pph %>%)</div>
<div class="sc-val" style="font-size:20px">- Rp <%= Math.round(ply.pph).toLocaleString('id-ID') %></div>
<div class="sc-sub">Laba sblm pajak: Rp <%= Math.round(ply.sebelumPph).toLocaleString('id-ID') %></div>
</div>
<div class="sc s" style="background:rgba(16,185,129,0.15);border-color:rgba(16,185,129,0.3)"><i class="bi bi-piggy-bank-fill sc-icon" style="color:#10b981"></i>
<div class="sc-label" style="color:#10b981"><%= t('admin.fin.net','Laba Bersih') %> <%= filterYear %></div>
<div style="font-size:11.5px;color:var(--muted);margin-bottom:2px">Sebelum Pajak: <b style="color:var(--text)">Rp <%= Math.round(ply.sebelumPph).toLocaleString('id-ID') %></b></div>
<div style="font-size:11.5px;color:var(--muted);margin-bottom:4px">Sesudah Pajak: <b style="color:#10b981">Rp <%= Math.round(ply.bersih).toLocaleString('id-ID') %></b></div>
<div class="sc-sub" style="color:#10b981">Bulan ini (sstlh): Rp <%= Math.round(pm.bersih).toLocaleString('id-ID') %></div>
</div>
<div class="sc p"><i class="bi bi-hourglass sc-icon"></i>
<div class="sc-label">Piutang Belum Bayar</div>
<div class="sc-val" style="font-size:20px">Rp <%= (pendingAmount||0).toLocaleString('id-ID') %></div>
<div class="sc-sub">Pelanggan aktif: <%= activeCustomers %></div>
</div>
</div>
<!-- ═══ B. LAPORAN LABA / RUGI ═══ -->
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-receipt-cutoff"></i> Laporan Laba / Rugi — Pajak &amp; Retribusi <%= filterYear %></h6>
</div>
<div class="card-bd" style="padding:0">
<table class="dtbl">
<tbody>
<tr><td><%= t('admin.fin.gross','Pemasukan (Bruto)') %></td><td class="num fw6">Rp <%= Math.round(ply.bruto).toLocaleString('id-ID') %></td></tr>
<tr><td>DPP — Dasar Pengenaan Pajak</td><td class="num">Rp <%= Math.round(ply.dpp).toLocaleString('id-ID') %></td></tr>
<tr><td>PPN Keluaran (<%= finance.ppn %>%) <span class="badge bm">disetor, bukan pendapatan</span></td><td class="num" style="color:var(--info)">Rp <%= Math.round(ply.ppn).toLocaleString('id-ID') %></td></tr>
<tr><td>Pengeluaran</td><td class="num" style="color:var(--danger)">- Rp <%= Math.round(ply.keluar).toLocaleString('id-ID') %></td></tr>
<tr><td>USO (<%= finance.uso %>%)</td><td class="num" style="color:var(--warning)">- Rp <%= Math.round(ply.uso).toLocaleString('id-ID') %></td></tr>
<tr><td>BHP (<%= finance.bhp %>%)</td><td class="num" style="color:var(--warning)">- Rp <%= Math.round(ply.bhp).toLocaleString('id-ID') %></td></tr>
<tr><td class="fw6">Laba Bersih Sebelum Pajak</td><td class="num fw6">Rp <%= Math.round(ply.sebelumPph).toLocaleString('id-ID') %></td></tr>
<tr><td>PPh Badan (<%= finance.pph %>%)</td><td class="num" style="color:var(--danger)">- Rp <%= Math.round(ply.pph).toLocaleString('id-ID') %></td></tr>
<tr style="background:rgba(16,185,129,.08)"><td class="fw6" style="color:#10b981">Laba Bersih Sesudah Pajak</td><td class="num fw6" style="color:#10b981;font-size:16px">Rp <%= Math.round(ply.bersih).toLocaleString('id-ID') %></td></tr>
</tbody>
</table>
</div>
<div class="card-bd" style="padding-top:10px">
<div class="hint">
<i class="bi bi-info-circle"></i> Tagihan hangus (void) tahun ini: <b><%= voidedYearCount||0 %></b> tagihan · Rp <%= (voidedYearAmount||0).toLocaleString('id-ID') %>
— <span class="text-muted">dikecualikan dari pendapatan (audit).</span>
</div>
</div>
</div>
<!-- ═══ C. REKAP PER BULAN (AUDIT) ═══ -->
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-calendar3"></i> Rekap Laba / Rugi per Bulan <%= filterYear %> <span class="text-muted" style="font-weight:400;font-size:11.5px">— export &amp; cetak tiap bulan</span></h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>Bulan</th>
<th class="num">Pemasukan</th>
<th class="num">Pengeluaran</th>
<th class="num">PPN</th>
<th class="num">USO</th>
<th class="num">BHP</th>
<th class="num">Laba Sblm Pajak</th>
<th class="num">PPh Badan</th>
<th class="num">Laba Sstlh Pajak</th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody>
<% const _mn = (lang === 'en') ? ['January','February','March','April','May','June','July','August','September','October','November','December'] : ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
let _tBruto=0,_tKeluar=0,_tPpn=0,_tUso=0,_tBhp=0,_tSebelum=0,_tPph=0,_tBersih=0; %>
<% monthlyFinance.forEach(r => { _tBruto+=r.bruto; _tKeluar+=r.keluar; _tPpn+=r.ppn; _tUso+=r.uso; _tBhp+=r.bhp; _tSebelum+=r.sebelumPph; _tPph+=r.pph; _tBersih+=r.bersih; %>
<tr>
<td class="fw6"><%= _mn[r.month-1] %></td>
<td class="num">Rp <%= Math.round(r.bruto).toLocaleString('id-ID') %></td>
<td class="num" style="color:var(--danger)">Rp <%= Math.round(r.keluar).toLocaleString('id-ID') %></td>
<td class="num" style="color:var(--info)">Rp <%= Math.round(r.ppn).toLocaleString('id-ID') %></td>
<td class="num" style="color:var(--warning)">Rp <%= Math.round(r.uso).toLocaleString('id-ID') %></td>
<td class="num" style="color:var(--warning)">Rp <%= Math.round(r.bhp).toLocaleString('id-ID') %></td>
<td class="num" style="color:var(--muted)">Rp <%= Math.round(r.sebelumPph).toLocaleString('id-ID') %></td>
<td class="num" style="color:var(--danger)">Rp <%= Math.round(r.pph).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:#10b981">Rp <%= Math.round(r.bersih).toLocaleString('id-ID') %></td>
<td style="white-space:nowrap">
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<a class="act-item" href="/admin/reports/export-csv?year=<%= filterYear %>&amp;month=<%= r.month %>"><i class="bi bi-file-earmark-excel"></i> Export Excel (CSV)</a>
<a class="act-item" target="_blank" href="/admin/reports/pdf?year=<%= filterYear %>&amp;month=<%= r.month %>&amp;mode=after"><i class="bi bi-file-earmark-pdf"></i> PDF Bulan Ini</a>
</div>
</div>
</td>
</tr>
<% }) %>
<tr style="background:var(--bg3)">
<td class="fw6">TOTAL <%= filterYear %></td>
<td class="num fw6">Rp <%= Math.round(_tBruto).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:var(--danger)">Rp <%= Math.round(_tKeluar).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:var(--info)">Rp <%= Math.round(_tPpn).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:var(--warning)">Rp <%= Math.round(_tUso).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:var(--warning)">Rp <%= Math.round(_tBhp).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:var(--muted)">Rp <%= Math.round(_tSebelum).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:var(--danger)">Rp <%= Math.round(_tPph).toLocaleString('id-ID') %></td>
<td class="num fw6" style="color:#10b981">Rp <%= Math.round(_tBersih).toLocaleString('id-ID') %></td>
<td></td>
</tr>
</tbody>
</table>
</div>
<div class="card-bd" style="padding-top:0">
<div class="hint"><i class="bi bi-info-circle"></i> Baris <b>TOTAL</b> = penjumlahan tiap bulan; cocokkan dengan <b>Laporan Laba / Rugi</b> (tahun) di atas untuk audit.</div>
</div>
</div>
<!-- ═══ D. GRAFIK ═══ -->
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-bar-chart"></i> <%= t('admin.reports.monthly_revenue', 'Pendapatan Bulanan') %> <%= filterYear %></h6></div>
<div class="card-bd"><canvas id="revenueChart" height="200"></canvas></div>
</div>
<!-- ═══ E. DETAIL AUDIT ═══ -->
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px">
<div class="card">
<div class="card-hd"><h6><i class="bi bi-pie-chart"></i> Rincian Pengeluaran (<%= filterYear %>)</h6></div>
<div class="card-bd" style="padding:0">
<% if (typeof expensesByCategory === 'undefined' || expensesByCategory.length===0) { %>
<div class="text-center text-muted" style="padding:24px;font-size:13px">Belum ada pengeluaran dicatat</div>
<% } %>
<% if(typeof expensesByCategory !== 'undefined') expensesByCategory.forEach(c=>{ %>
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="font-size:13px;font-weight:600;min-width:0;overflow-wrap:anywhere"><i class="bi bi-tag" style="color:var(--muted);margin-right:6px"></i> <%= c.category %></div>
<span class="badge" style="background:rgba(239,68,68,.1);color:#ef4444;white-space:nowrap">Rp <%= Number(c.total).toLocaleString('id-ID') %></span>
</div>
<% }) %>
</div>
</div>
<div class="card">
<div class="card-hd"><h6><i class="bi bi-exclamation-triangle"></i> <%= t('admin.reports.top_unpaid', 'Piutang Tertinggi') %></h6></div>
<div class="card-bd" style="padding:0">
<% if (topUnpaid.length===0) { %><div class="text-center text-muted" style="padding:24px;font-size:13px"><%= t('admin.reports.all_paid', 'Semua pelanggan sudah lunas') %></div><% } %>
<% topUnpaid.slice(0,5).forEach(u=>{ %>
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;gap:10px">
<div style="min-width:0"><div style="font-size:13px;font-weight:600;overflow-wrap:anywhere"><%= u.name %></div><div style="font-size:11px;color:var(--muted)"><%= u.phone %></div></div>
<span class="badge bd" style="white-space:nowrap">Rp <%= Number(u.total_unpaid).toLocaleString('id-ID') %></span>
</div>
<% }) %>
</div>
</div>
</div>
<!-- ═══ F. PEMBAYARAN TERAKHIR ═══ -->
<div class="card mb16" style="margin-top:18px">
<div class="card-hd"><h6><i class="bi bi-clock-history"></i> <%= t('admin.reports.recent_payments', 'Pembayaran Terakhir') %></h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead><tr><th><%= t('admin.reports.table.customer','Pelanggan') %></th><th><%= t('admin.reports.table.period','Periode') %></th><th class="num"><%= t('admin.reports.table.amount','Jumlah') %></th><th><%= t('admin.reports.table.recorded_by','Dicatat Oleh') %></th><th><%= t('admin.reports.table.time','Waktu') %></th></tr></thead>
<tbody>
<% const mn2 = (lang === 'en') ? ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] : ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des']; %>
<% if(recentPayments.length===0){%><tr><td colspan="5" class="text-center text-muted" style="padding:24px"><%= t('admin.reports.no_payments','Belum ada pembayaran') %></td></tr><%}%>
<% recentPayments.forEach(p=>{ %>
<tr>
<td class="fw6"><%= p.customer_name %></td>
<td><span class="badge bm"><%= mn2[p.period_month-1] %> <%= p.period_year %></span></td>
<td class="num fw6" style="color:var(--success)">Rp <%= Number(p.amount).toLocaleString('id-ID') %></td>
<td style="font-size:12px"><%= p.paid_by_name||'-' %></td>
<td style="font-size:11px;color:var(--muted)"><%= p.paid_at ? new Date(p.paid_at).toLocaleString('id-ID',{day:'2-digit',month:'short',hour:'2-digit',minute:'2-digit'}) : '-' %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
<!-- ═══ G. PENGATURAN PAJAK & RETRIBUSI (collapsible) ═══ -->
<details class="card">
<summary class="card-hd"><h6 style="margin:0"><i class="bi bi-percent"></i> Pengaturan Pajak &amp; Retribusi <span class="text-muted" style="font-weight:400;font-size:11.5px">— dihitung otomatis</span></h6></summary>
<div class="card-bd">
<form method="POST" action="/admin/reports/finance">
<div class="form-row" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">
<div class="fg"><label class="fl">PPN (%)</label><input class="fc" type="number" step="0.01" min="0" name="finance_ppn_pct" value="<%= finance.ppn %>"></div>
<div class="fg"><label class="fl">PPh Badan (%)</label><input class="fc" type="number" step="0.01" min="0" name="finance_pph_pct" value="<%= finance.pph %>"></div>
<div class="fg"><label class="fl">USO (%)</label><input class="fc" type="number" step="0.01" min="0" name="finance_uso_pct" value="<%= finance.uso %>"></div>
<div class="fg"><label class="fl">BHP (%)</label><input class="fc" type="number" step="0.01" min="0" name="finance_bhp_pct" value="<%= finance.bhp %>"></div>
<div class="fg"><label class="fl">PPN di dalam harga?</label>
<select class="fs" name="finance_revenue_includes_ppn">
<option value="0" <%= finance.inclPpn ? '' : 'selected' %>>Tidak — PPN ditambahkan</option>
<option value="1" <%= finance.inclPpn ? 'selected' : '' %>>Ya — PPN sudah termasuk</option>
</select>
</div>
</div>
<button type="submit" class="btn btn-p btn-sm"><i class="bi bi-save"></i> Simpan Pengaturan</button>
<span class="hint" style="margin-left:10px">PPh Badan, PPN, USO &amp; BHP dihitung otomatis dari nilai ini.</span>
</form>
</div>
</details>
</div>
</div>
<script>
const monthLabels = JSON.parse(`<%- JSON.stringify((lang === 'en')
? ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']
: ['Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agt','Sep','Okt','Nov','Des']).split('<').join('\\u003c') %>`);
const revenueData = JSON.parse(`<%- JSON.stringify(chartData).split('<').join('\\u003c') %>`);
const labels = monthLabels;
const values = Array(12).fill(0);
revenueData.forEach(d=>{ values[d.month-1] = d.revenue; });
new Chart(document.getElementById('revenueChart'), {
type: 'bar',
data: {
labels,
datasets: [{
label: "<%= t('admin.reports.chart.revenue_label', 'Pendapatan (Rp)') %>",
data: values,
backgroundColor: 'rgba(173,255,47,0.35)',
borderColor: '#ADFF2F',
borderWidth: 2,
borderRadius: 6,
}]
},
options: {
responsive: true,
plugins: { legend: { display: false }, tooltip: { callbacks: { label: ctx => 'Rp ' + ctx.raw.toLocaleString('id-ID') } } },
scales: {
x: { ticks: { color: '#7d8590' }, grid: { color: 'rgba(240,246,252,0.05)' } },
y: { ticks: { color: '#7d8590', callback: v => 'Rp ' + (v/1000).toFixed(0)+'k' }, grid: { color: 'rgba(240,246,252,0.05)' } }
}
}
});
</script>
</body>
</html>
+444
View File
@@ -0,0 +1,444 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= t('admin.reportprint.title', 'Laporan Keuangan') %> <%= filterYear %> — <%= company %></title>
<link rel="stylesheet" href="/css/admin.css">
<!-- no-admin-theme -->
<style>
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700&display=swap');
@page {
size: A4 portrait;
margin: 12mm 15mm;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 11px;
color: #0f172a;
background-color: #f8fafc;
line-height: 1.4;
padding: 20px;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.report-wrapper {
max-width: 820px;
margin: 0 auto;
background: #ffffff;
padding: 28px 32px;
border: 1px solid #e2e8f0;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
/* Letterhead / Header */
.report-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
border-bottom: 2px solid #0f172a;
padding-bottom: 16px;
margin-bottom: 20px;
}
.company-name {
font-size: 20px;
font-weight: 800;
color: #0f172a;
letter-spacing: -0.5px;
text-transform: uppercase;
}
.company-sub {
font-size: 11px;
color: #475569;
margin-top: 2px;
max-width: 420px;
}
.report-title-box {
text-align: right;
}
.report-title {
font-size: 16px;
font-weight: 800;
color: #1e3a8a;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.report-period {
font-size: 12px;
font-weight: 600;
color: #64748b;
margin-top: 2px;
}
/* KPI Summary Cards */
.kpi-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.kpi-card {
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 12px 14px;
background: #f8fafc;
text-align: center;
}
.kpi-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
margin-bottom: 4px;
}
.kpi-val {
font-size: 16px;
font-weight: 800;
font-family: 'JetBrains Mono', monospace;
color: #0f172a;
}
.kpi-val.income { color: #0284c7; }
.kpi-val.expense { color: #dc2626; }
.kpi-val.profit { color: #16a34a; }
/* Section Headings */
.section-title {
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #0f172a;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1.5px solid #cbd5e1;
}
/* Tables */
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
font-size: 10.5px;
}
th {
background: #f1f5f9;
color: #0f172a;
font-weight: 700;
text-transform: uppercase;
font-size: 9px;
letter-spacing: 0.5px;
padding: 7px 10px;
border: 1px solid #cbd5e1;
text-align: left;
}
td {
padding: 6px 10px;
border: 1px solid #e2e8f0;
color: #1e293b;
}
tr:nth-child(even) td {
background: #f8fafc;
}
.text-right { text-align: right; }
.text-center { text-align: center; }
.mono { font-family: 'JetBrains Mono', monospace; }
tfoot th {
background: #e2e8f0;
font-weight: 800;
font-size: 10.5px;
padding: 8px 10px;
border-top: 2px solid #0f172a;
}
/* Signatures */
.signature-grid {
display: flex;
justify-content: space-between;
margin-top: 36px;
page-break-inside: avoid;
}
.sig-box {
width: 220px;
text-align: center;
}
.sig-role {
font-size: 11px;
color: #475569;
margin-bottom: 60px;
}
.sig-name {
font-weight: 700;
font-size: 12px;
border-top: 1px solid #0f172a;
padding-top: 4px;
}
/* Controls (Screen Only) */
.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;
}
.report-wrapper {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
max-width: 100% !important;
}
.print-controls {
display: none !important;
}
}
/* HARDEN print-scroll: hanya layar, cetak tidak berubah */
@media screen {
.report-wrapper { overflow-x: auto; max-width: 100%; }
.kpi-grid { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); }
}
</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: 'reports', 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.reportprint.title', 'Laporan Keuangan') %> &middot; <%= filterYear %><% if (filterMonth) { %> &middot; <%= String(filterMonth).padStart(2, '0') %><% } %></div>
<div class="tb-right">
<button type="button" class="btn btn-p btn-sm" onclick="window.print()"><i class="bi bi-printer-fill"></i> <%= t('admin.common.print_pdf', 'Cetak / PDF') %></button>
<a href="/admin/reports" 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">
<div class="report-wrapper">
<!-- Header -->
<div class="report-header">
<div>
<div class="company-name"><%= company %></div>
<div class="company-sub"><%= settings?.company_address || t('admin.reportprint.company_sub', 'Pusat Layanan Internet Broadband & RT/RW Net') %></div>
<% if (settings?.company_phone) { %>
<div class="company-sub"><%= t('admin.reportprint.telp', 'Telp/WA') %>: <%= settings.company_phone %> | <%= t('admin.reportprint.email', 'Email') %>: <%= settings?.company_email || '-' %></div>
<% } %>
</div>
<div class="report-title-box">
<div class="report-title"><%= t('admin.reportprint.title_box', 'LAPORAN LABA / RUGI') %></div>
<div class="report-period"><%= t('admin.reportprint.period', 'Periode Tahun') %>: <strong><%= filterYear %><% if (filterMonth) { %> &middot; <%= String(filterMonth).padStart(2, '0') %><% } %></strong></div>
<div style="font-size: 9.5px; color: #64748b; margin-top: 4px;"><%= t('admin.reportprint.printed', 'Dicetak') %>: <%= getNowLocal() %></div>
</div>
</div>
<!-- KPI Cards -->
<div class="kpi-grid">
<div class="kpi-card">
<div class="kpi-label"><%= t('admin.reportprint.kpi_income', 'Total Pemasukan (Gross)') %></div>
<div class="kpi-val income">Rp <%= Number(cashInYear || 0).toLocaleString('id-ID') %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t('admin.reportprint.kpi_expense', 'Total Pengeluaran &amp; Modal') %></div>
<div class="kpi-val expense">-Rp <%= Number(expensesYear || 0).toLocaleString('id-ID') %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t('admin.reportprint.kpi_profit', 'Laba Bersih (Net Profit)') %></div>
<div class="kpi-val profit">Rp <%= Number(netProfitYear || 0).toLocaleString('id-ID') %></div>
</div>
</div>
<!-- Pajak & Retribusi -->
<div class="section-title">Pajak &amp; Retribusi<% if (filterMonth) { %> — Bulan <%= String(filterMonth).padStart(2, '0') %><% } %></div>
<table>
<tbody>
<tr><td><%= t('admin.fin.gross','Pemasukan (Bruto)') %></td><td class="text-right">Rp <%= Math.round(LR.bruto).toLocaleString('id-ID') %></td></tr>
<tr><td>DPP — Dasar Pengenaan Pajak</td><td class="text-right">Rp <%= Math.round(LR.dpp).toLocaleString('id-ID') %></td></tr>
<tr><td>PPN Keluaran (<%= fin.ppn %>%) — disetor, bukan pendapatan</td><td class="text-right">Rp <%= Math.round(LR.ppn).toLocaleString('id-ID') %></td></tr>
<tr><td>Pengeluaran</td><td class="text-right">- Rp <%= Math.round(LR.keluar).toLocaleString('id-ID') %></td></tr>
<tr><td>USO (<%= fin.uso %>%)</td><td class="text-right">- Rp <%= Math.round(LR.uso).toLocaleString('id-ID') %></td></tr>
<tr><td>BHP (<%= fin.bhp %>%)</td><td class="text-right">- Rp <%= Math.round(LR.bhp).toLocaleString('id-ID') %></td></tr>
<tr><td><b>Laba Sebelum PPh</b></td><td class="text-right"><b>Rp <%= Math.round(LR.sebelumPph).toLocaleString('id-ID') %></b></td></tr>
<tr><td>PPh Badan (<%= fin.pph %>%)</td><td class="text-right">- Rp <%= Math.round(LR.pph).toLocaleString('id-ID') %></td></tr>
<tr style="background:#f0fdf4"><td><b><%= t('admin.fin.net','Laba Bersih') %></b></td><td class="text-right"><b>Rp <%= Math.round(LR.bersih).toLocaleString('id-ID') %></b></td></tr>
</tbody>
</table>
<!-- Monthly Breakdown Table -->
<div class="section-title"><%= t('admin.reportprint.sec1', '1. Rincian Pemasukan &amp; Pengeluaran Bulanan') %> (<%= filterYear %>)</div>
<table>
<thead>
<tr>
<th style="width: 40px;" class="text-center"><%= t('admin.reportprint.th_no', 'No') %></th>
<th><%= t('admin.reportprint.th_month', 'Bulan') %></th>
<th class="text-right" style="width: 140px;"><%= t('admin.reportprint.th_income', 'Pemasukan (Rp)') %></th>
<th class="text-right" style="width: 140px;"><%= t('admin.reportprint.th_expense', 'Pengeluaran (Rp)') %></th>
<th class="text-right" style="width: 140px;"><%= t('admin.reportprint.th_net', 'Laba Bersih (Rp)') %></th>
</tr>
</thead>
<tbody>
<%
const monthNames = (typeof lang !== 'undefined' && lang === 'en') ? ['January','February','March','April','May','June','July','August','September','October','November','December'] : ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
const _repLang = (typeof lang !== 'undefined' && lang === 'en') ? 'en-US' : 'id-ID';
if (typeof monthlyData !== 'undefined' && Array.isArray(monthlyData) && monthlyData.length > 0) {
monthlyData.forEach((m, idx) => {
const inc = Number(m.revenue || m.total || m.income || 0);
const exp = Number(m.expense || 0);
const net = inc - exp;
%>
<tr>
<td class="text-center"><%= idx + 1 %></td>
<td style="font-weight: 600;"><%= monthNames[idx] || m.month %></td>
<td class="text-right mono"><%= inc.toLocaleString('id-ID') %></td>
<td class="text-right mono" style="color: #dc2626;"><%= exp > 0 ? '-' + exp.toLocaleString('id-ID') : '0' %></td>
<td class="text-right mono" style="font-weight: 700; color: <%= net >= 0 ? '#16a34a' : '#dc2626' %>;">
<%= net.toLocaleString('id-ID') %>
</td>
</tr>
<% }); } else { %>
<tr>
<td colspan="5" class="text-center" style="color: #64748b; padding: 14px;"><%= t('admin.reportprint.no_monthly', 'Data rekap bulanan belum tersedia.') %></td>
</tr>
<% } %>
</tbody>
<tfoot>
<tr>
<th colspan="2" class="text-right"><%= t('admin.reportprint.total_year', 'TOTAL TAHUN') %> <%= filterYear %>:</th>
<th class="text-right mono">Rp <%= Number(cashInYear || 0).toLocaleString('id-ID') %></th>
<th class="text-right mono" style="color: #dc2626;">-Rp <%= Number(expensesYear || 0).toLocaleString('id-ID') %></th>
<th class="text-right mono" style="color: #16a34a; font-size: 11.5px;">Rp <%= Number(netProfitYear || 0).toLocaleString('id-ID') %></th>
</tr>
</tfoot>
</table>
<!-- Expense Categories Breakdown -->
<div class="section-title"><%= t('admin.reportprint.sec2', '2. Rincian Pos Pengeluaran Terbesar') %> (<%= filterYear %>)</div>
<table>
<thead>
<tr>
<th style="width: 40px;" class="text-center">No</th>
<th><%= t('admin.reportprint.th_pos', 'Kategori / Pos Pengeluaran') %></th>
<th class="text-right" style="width: 140px;"><%= t('admin.reportprint.th_total', 'Total Biaya (Rp)') %></th>
<th class="text-right" style="width: 90px;"><%= t('admin.reportprint.th_share', 'Porsi (%)') %></th>
</tr>
</thead>
<tbody>
<% if (!expensesByCategory || expensesByCategory.length === 0) { %>
<tr>
<td colspan="4" class="text-center" style="color: #64748b;"><%= t('admin.reportprint.no_expenses', 'Tidak ada catatan pengeluaran di tahun ini.') %></td>
</tr>
<% } else {
let totalExpSum = 0;
expensesByCategory.forEach(e => { totalExpSum += Number(e.total || 0); });
expensesByCategory.forEach((c, idx) => {
const itemTotal = Number(c.total || 0);
const percent = totalExpSum > 0 ? ((itemTotal / totalExpSum) * 100).toFixed(1) : '0';
%>
<tr>
<td class="text-center"><%= idx + 1 %></td>
<td style="font-weight: 600;"><%= c.category %></td>
<td class="text-right mono"><%= itemTotal.toLocaleString('id-ID') %></td>
<td class="text-right mono"><%= percent %>%</td>
</tr>
<% }); } %>
</tbody>
<tfoot>
<tr>
<th colspan="2" class="text-right"><%= t('admin.reportprint.total_expense', 'TOTAL PENGELUARAN:') %></th>
<th class="text-right mono">Rp <%= Number(expensesYear || 0).toLocaleString('id-ID') %></th>
<th class="text-right mono">100%</th>
</tr>
</tfoot>
</table>
<!-- Signatures -->
<div class="signature-grid">
<div class="sig-box">
<div class="sig-role"><%= t('admin.reportprint.sig1', 'Dibuat Oleh,') %><br><strong><%= t('admin.reportprint.sig1_role', 'Bagian Keuangan / Admin') %></strong></div>
<div class="sig-name"><%= settings?.company_cashier || t('admin.reportprint.sig1_name', 'Staf Administrasi') %></div>
</div>
<div class="sig-box">
<div class="sig-role"><%= t('admin.reportprint.sig2', 'Mengetahui &amp; Menyetujui,') %><br><strong><%= t('admin.reportprint.sig2_role', 'Pimpinan / Direktur') %></strong></div>
<div class="sig-name"><%= settings?.company_manager || t('admin.reportprint.sig2_name', 'Direktur Utama') %></div>
</div>
</div>
</div>
<!-- Floating Controls -->
<div class="print-controls">
<button class="btn-action" onclick="window.print()"><i class="bi bi-printer-fill"></i> <%= t('admin.reportprint.print_btn', 'Cetak Dokumen') %></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(function() {
if (!window.location.search.includes('noprint')) {
window.print();
}
}, 400);
};
</script>
</div>
</div>
</body>
</html>
+992
View File
@@ -0,0 +1,992 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.nav.router_management', 'Manajemen Router') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.mtabs { display: flex; gap: 10px; margin-bottom: 18px; border-bottom: 1px solid var(--border); padding-bottom: 12px; }
.mtab { padding: 9px 16px; border-radius: 10px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; display: flex; align-items: center; gap: 8px; }
.mtab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.mtab.active { background: linear-gradient(135deg, #6366f1 0%, #3b82f6 100%); color: white; box-shadow: 0 4px 14px rgba(99, 102, 241, 0.4); }
/* Router Grid Cards */
.router-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(360px, 1fr));
gap: 20px;
margin-bottom: 24px;
}
.rc-card {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.85) 0%, rgba(15, 23, 42, 0.95) 100%);
border: 1px solid rgba(99, 102, 241, 0.22);
border-radius: 18px;
padding: 22px;
position: relative;
overflow: hidden;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.rc-card:hover {
transform: translateY(-4px);
border-color: rgba(99, 102, 241, 0.55);
box-shadow: 0 18px 45px rgba(99, 102, 241, 0.25);
}
.rc-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.rc-title-box {
display: flex;
align-items: center;
gap: 12px;
}
.rc-icon {
width: 48px;
height: 48px;
border-radius: 14px;
background: linear-gradient(135deg, rgba(99, 102, 241, 0.25) 0%, rgba(59, 130, 246, 0.15) 100%);
color: #818cf8;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
border: 1px solid rgba(99, 102, 241, 0.35);
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
.rc-name {
font-size: 18px;
font-weight: 800;
color: #f8fafc;
margin: 0;
line-height: 1.2;
}
.rc-host {
font-size: 12px;
color: #94a3b8;
font-family: ui-monospace, monospace;
margin-top: 3px;
}
.rc-status-pill {
padding: 5px 12px;
border-radius: 20px;
font-size: 11px;
font-weight: 800;
display: inline-flex;
align-items: center;
gap: 6px;
letter-spacing: 0.3px;
}
.rc-status-pill.online {
background: rgba(34, 197, 94, 0.18);
color: #4ade80;
border: 1px solid rgba(34, 197, 94, 0.4);
}
.rc-status-pill.offline {
background: rgba(239, 68, 68, 0.18);
color: #f87171;
border: 1px solid rgba(239, 68, 68, 0.4);
}
.rc-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.rc-status-pill.online .rc-status-dot {
background: #4ade80;
box-shadow: 0 0 10px #4ade80;
}
.rc-status-pill.offline .rc-status-dot {
background: #f87171;
}
/* Meter Bars */
.rc-metrics {
display: flex;
flex-direction: column;
gap: 12px;
background: rgba(15, 23, 42, 0.6);
border-radius: 14px;
padding: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
margin-bottom: 16px;
}
.rc-metric-item {
display: flex;
flex-direction: column;
gap: 5px;
}
.rc-metric-head {
display: flex;
justify-content: space-between;
font-size: 11px;
color: #cbd5e1;
font-weight: 700;
}
.rc-progress {
height: 7px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
overflow: hidden;
}
.rc-progress-bar {
height: 100%;
border-radius: 10px;
transition: width 0.6s ease;
}
.bg-green { background: linear-gradient(90deg, #10b981, #34d399); }
.bg-yellow { background: linear-gradient(90deg, #f59e0b, #fbbf24); }
.bg-red { background: linear-gradient(90deg, #ef4444, #f87171); }
/* Specs Badges */
.rc-specs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-bottom: 18px;
text-align: center;
}
.rc-spec-box {
background: rgba(30, 41, 59, 0.6);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 12px;
padding: 10px 4px;
}
.rc-spec-val {
font-size: 16px;
font-weight: 800;
color: #f1f5f9;
}
.rc-spec-lbl {
font-size: 10px;
color: #94a3b8;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.rc-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: auto;
padding-top: 14px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.btn-detail-live {
background: linear-gradient(135deg, #6366f1 0%, #3b82f6 100%);
color: white !important;
border: none;
border-radius: 10px;
padding: 8px 14px;
font-weight: 700;
font-size: 13px;
box-shadow: 0 4px 14px rgba(99, 102, 241, 0.35);
transition: all 0.25s ease;
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn-detail-live:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.5);
filter: brightness(1.1);
}
.view-toggle-btn {
padding: 6px 14px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
border: 1px solid var(--border);
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.view-toggle-btn.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
/* Modal Styling Improvements */
.modal-tab-btn {
padding: 8px 14px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(15, 23, 42, 0.6);
color: #94a3b8;
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition: all 0.2s;
}
.modal-tab-btn:hover {
background: rgba(30, 41, 59, 0.8);
color: #f1f5f9;
}
.modal-tab-btn.active {
background: linear-gradient(135deg, #6366f1 0%, #3b82f6 100%);
color: white;
border-color: #6366f1;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
/* ── Detail Router modal (gaya MikroDash, tema admin) ── */
.mono { font-family: 'JetBrains Mono', ui-monospace, monospace; }
.mt-head { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 16px; border: 1px solid var(--border); border-radius: 14px; background: var(--bg3); margin-bottom: 14px; }
.mt-head-name { font-size: 16px; font-weight: 800; display: flex; align-items: center; gap: 8px; color: var(--text); }
.mt-head-name i { color: var(--primary); }
.mt-head-name span { overflow-wrap: anywhere; }
.mt-head-sub { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
.mt-tabs { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.mt-tab { padding: 8px 14px; border-radius: 10px; border: 1px solid var(--border); background: transparent; color: var(--muted); font-size: 12.5px; font-weight: 700; cursor: pointer; display: inline-flex; align-items: center; gap: 7px; transition: .18s; font-family: inherit; }
.mt-tab:hover { color: var(--text); background: rgba(255,255,255,.05); }
.mt-tab.active { color: #0A0D14; background: var(--primary); border-color: var(--primary); }
.mt-tab-badge { background: rgba(0,0,0,.22); padding: 0 7px; border-radius: 999px; font-size: 11px; font-weight: 800; }
.mt-tab.active .mt-tab-badge { background: rgba(0,0,0,.18); }
.mt-metrics { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 14px; }
.mt-metric { background: var(--bg3); border: 1px solid var(--border); border-radius: 14px; padding: 14px; text-align: center; }
.mt-metric .lbl { font-size: 10px; font-weight: 800; letter-spacing: .7px; text-transform: uppercase; color: var(--muted); }
.mt-metric .val { font-size: 28px; font-weight: 800; line-height: 1.15; font-family: 'JetBrains Mono', ui-monospace, monospace; margin-top: 4px; color: var(--text); }
.mt-metric .val .u { font-size: 14px; opacity: .65; margin-left: 1px; }
.mt-metric .sub { font-size: 10.5px; color: var(--muted); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }
.mt-metric .val.ok { color: var(--success); } .mt-metric .val.warn { color: var(--warning); } .mt-metric .val.crit { color: var(--danger); }
.mt-bar { height: 6px; border-radius: 999px; background: rgba(148,163,184,.2); overflow: hidden; margin-top: 9px; }
.mt-bar > span { display: block; height: 100%; border-radius: 999px; background: var(--primary); transition: width .4s ease; }
.mt-bar > span.ok { background: var(--success); } .mt-bar > span.warn { background: var(--warning); } .mt-bar > span.crit { background: var(--danger); }
.mt-kv { border: 1px solid var(--border); border-radius: 14px; overflow: hidden; margin-bottom: 14px; }
.mt-kv .row { display: flex; justify-content: space-between; gap: 14px; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 13px; }
.mt-kv .row:last-child { border-bottom: none; }
.mt-kv .row:nth-child(odd) { background: rgba(255,255,255,.015); }
.mt-kv .row > span { color: var(--muted); white-space: nowrap; }
.mt-kv .row > b { font-weight: 700; color: var(--text); text-align: right; overflow-wrap: anywhere; }
.mt-kv .row > b.mt-ok { color: var(--success); }
.mt-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.mt-stat { background: var(--bg3); border: 1px solid var(--border); border-radius: 12px; padding: 12px; text-align: center; min-width: 0; }
.mt-stat > span { display: block; font-size: 10.5px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }
.mt-stat > b { font-size: 20px; font-weight: 800; font-family: 'JetBrains Mono', monospace; }
.mt-stat > b.mt-ok { color: var(--success); } .mt-stat > b.mt-warn { color: var(--warning); }
.mt-tablewrap { border: 1px solid var(--border); border-radius: 14px; overflow: auto; max-height: 300px; }
.mt-tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.mt-tbl thead th { position: sticky; top: 0; z-index: 1; background: var(--bg3); text-align: left; padding: 9px 12px; font-size: 10px; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); white-space: nowrap; }
.mt-tbl td { padding: 9px 12px; border-bottom: 1px solid var(--border); white-space: nowrap; }
.mt-tbl tbody tr:last-child td { border-bottom: none; }
.mt-tbl tbody tr:hover td { background: rgba(255,255,255,.02); }
.mt-tbl .mono { font-family: 'JetBrains Mono', monospace; color: var(--muted); }
.mt-tbl .mt-idx { color: var(--muted); }
.mt-tbl .mt-strong { font-weight: 700; color: var(--text); }
.mt-tbl .mt-warn { color: var(--warning); }
.mt-empty { text-align: center; color: var(--muted); padding: 22px; white-space: normal; }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'mikrotik', 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-hdd-network"></i> <%= t('admin.nav.router_management', 'Manajemen Router') %>
</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% const mTab = 'routers'; %>
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'success' ? 's' : 'd' %>" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="mtabs">
<a href="/admin/mikrotik" class="mtab <%= mTab==='monitoring' ? 'active' : '' %>"><i class="bi bi-router"></i> Monitoring</a>
<a href="/admin/vouchers" class="mtab <%= mTab==='vouchers' ? 'active' : '' %>"><i class="bi bi-ticket-perforated"></i> Voucher Hotspot</a>
<a href="/admin/routers" class="mtab <%= mTab==='routers' ? 'active' : '' %>"><i class="bi bi-hdd-network"></i> Router</a>
</div>
<!-- Header Actions & View Mode -->
<div class="card mb-3" style="border:none; background:transparent;">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:12px;">
<div style="display:flex; align-items:center; gap:8px;">
<h5 class="fw-bold text-white mb-0"><i class="bi bi-cpu text-primary me-2"></i>Daftar Router MikroTik (<%= routers.length %>)</h5>
</div>
<div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<div class="d-flex gap-1 bg-dark p-1 rounded-3 border border-secondary border-opacity-25">
<button type="button" class="view-toggle-btn active" id="btn-view-grid" onclick="switchView('grid')"><i class="bi bi-grid-fill me-1"></i> Grid Kartu</button>
<button type="button" class="view-toggle-btn" id="btn-view-table" onclick="switchView('table')"><i class="bi bi-table me-1"></i> Tabel</button>
</div>
<button type="button" class="btn btn-outline-light btn-sm" onclick="openIsolirScriptModal()"><i class="bi bi-filetype-txt me-1"></i> Script Isolir</button>
<button class="btn btn-p btn-sm" onclick="openModal('addModal')"><i class="bi bi-plus-lg me-1"></i> Tambah Router</button>
</div>
</div>
</div>
<!-- View 1: Modern Router Grid Cards -->
<div id="view-grid-container" class="router-grid">
<% if (routers.length === 0) { %>
<div class="card p-4 text-center text-muted" style="grid-column: 1 / -1;">
<i class="bi bi-hdd-network fs-1 mb-2"></i>
<h6><%= t('admin.routers.empty', 'Belum ada router yang ditambahkan') %></h6>
<button class="btn btn-p btn-sm mt-2" onclick="openModal('addModal')"><i class="bi bi-plus-lg me-1"></i> Tambah Router Sekarang</button>
</div>
<% } %>
<% routers.forEach((r, i) => { %>
<div class="rc-card" id="router-card-<%= r.id %>">
<div>
<div class="rc-header">
<div class="rc-title-box">
<div class="rc-icon"><i class="bi bi-router-fill"></i></div>
<div>
<h6 class="rc-name"><%= r.name %></h6>
<div class="rc-host"><%= r.host %>:<%= r.port %></div>
</div>
</div>
<div>
<% if (r.is_active) { %>
<span class="rc-status-pill online" id="status-pill-<%= r.id %>"><span class="rc-status-dot"></span> <%= t('common.active','Aktif') %></span>
<% } else { %>
<span class="rc-status-pill offline" id="status-pill-<%= r.id %>"><span class="rc-status-dot"></span> <%= t('common.inactive','Nonaktif') %></span>
<% } %>
</div>
</div>
<!-- Live Metrics (Loaded via JS) -->
<div class="rc-metrics" id="metrics-box-<%= r.id %>">
<div class="rc-metric-item">
<div class="rc-metric-head">
<span><i class="bi bi-speedometer2 me-1"></i> CPU Load</span>
<span id="cpu-load-lbl-<%= r.id %>">--%</span>
</div>
<div class="rc-progress">
<div class="rc-progress-bar bg-green" id="cpu-load-bar-<%= r.id %>" style="width: 0%;"></div>
</div>
</div>
<div class="rc-metric-item">
<div class="rc-metric-head">
<span><i class="bi bi-memory me-1"></i> RAM Usage</span>
<span id="ram-usage-lbl-<%= r.id %>">-- MB</span>
</div>
<div class="rc-progress">
<div class="rc-progress-bar bg-yellow" id="ram-usage-bar-<%= r.id %>" style="width: 0%;"></div>
</div>
</div>
</div>
<!-- System Specs & Active Stats -->
<div class="rc-specs">
<div class="rc-spec-box">
<div class="rc-spec-val text-info" id="spec-cust-<%= r.id %>">-</div>
<div class="rc-spec-lbl"><%= t('common.customer','Pelanggan') %></div>
</div>
<div class="rc-spec-box">
<div class="rc-spec-val text-success" id="spec-pppoe-<%= r.id %>">-</div>
<div class="rc-spec-lbl">PPPoE Aktif</div>
</div>
<div class="rc-spec-box">
<div class="rc-spec-val text-warning" id="spec-hs-<%= r.id %>">-</div>
<div class="rc-spec-lbl">Hotspot</div>
</div>
</div>
</div>
<div class="rc-actions">
<button type="button" class="btn-detail-live flex-fill" title="Detail Live Status" onclick="openLiveDetailModal(<%= r.id %>)">
<i class="bi bi-cpu-fill me-1"></i> Detail Live
</button>
<button type="button" class="btn btn-g btn-sm" title="<%= t('admin.routers.actions.test', 'Test Koneksi') %>" onclick="testConn(<%= r.id %>)">
<i class="bi bi-plug"></i>
</button>
<button type="button" class="btn btn-g btn-sm" title="Setup Firewall Isolir" onclick="setupFirewall(<%= r.id %>)">
<i class="bi bi-shield-lock"></i>
</button>
<button type="button" class="btn btn-g btn-sm" title="<%= t('common.edit', 'Edit') %>" onclick="editRouter(<%= i %>)">
<i class="bi bi-pencil"></i>
</button>
<form method="POST" action="/admin/routers/<%= r.id %>/delete" style="display:inline" onsubmit="return confirm('<%= t('admin.routers.confirm.delete', 'Hapus router ini?') %>')">
<button class="btn btn-d btn-sm" title="<%= t('common.delete', 'Hapus') %>"><i class="bi bi-trash"></i></button>
</form>
</div>
</div>
<% }) %>
</div>
<!-- View 2: Detailed Table View (Hidden by Default) -->
<div id="view-table-container" class="card" style="display: none;">
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>#</th>
<th><%= t('admin.routers.table.name', 'Nama Router') %></th>
<th><%= t('admin.routers.table.host', 'Host / IP') %></th>
<th><%= t('admin.routers.table.port', 'Port') %></th>
<th><%= t('admin.routers.table.username', 'Username') %></th>
<th><%= t('admin.routers.table.status', 'Status') %></th>
<th><%= t('common.actions', 'Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (routers.length === 0) { %>
<tr><td colspan="7" class="text-center text-muted" style="padding:32px"><%= t('admin.routers.empty', 'Belum ada router yang ditambahkan') %></td></tr>
<% } %>
<% routers.forEach((r, i) => { %>
<tr>
<td class="text-muted"><%= i+1 %></td>
<td class="fw6"><%= r.name %></td>
<td><%= r.host %></td>
<td><%= r.port %></td>
<td><%= r.user %></td>
<td>
<% if (r.is_active) { %><span class="badge bs"><%= t('common.active', 'Aktif') %></span><% } else { %><span class="badge bm"><%= t('common.inactive', 'Nonaktif') %></span><% } %>
</td>
<td style="white-space:nowrap">
<form id="rdel-<%= r.id %>" method="POST" action="/admin/routers/<%= r.id %>/delete" style="display:none" onsubmit="return confirm('<%= t('admin.routers.confirm.delete', 'Hapus router ini?') %>')"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item primary" onclick="openLiveDetailModal(<%= r.id %>)"><i class="bi bi-cpu"></i> Detail Live Status</button>
<button type="button" class="act-item" onclick="testConn(<%= r.id %>)"><i class="bi bi-plug"></i> <%= t('admin.dash.test_conn','Test Koneksi') %></button>
<button type="button" class="act-item" onclick="setupFirewall(<%= r.id %>)"><i class="bi bi-shield-lock"></i> Setup Firewall Isolir</button>
<div class="act-sep"></div>
<button type="button" class="act-item" onclick="editRouter(<%= i %>)"><i class="bi bi-pencil"></i> Edit Router</button>
<button type="submit" form="rdel-<%= r.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Router</button>
</div>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Live Detail Router -->
<div class="mo" id="routerLiveModal">
<div class="mb" style="max-width: 740px;">
<div class="mh">
<span class="mt"><i class="bi bi-cpu-fill text-primary me-2"></i> <span id="modal-router-name">Detail Live Router</span></span>
<button class="mc" onclick="closeModal('routerLiveModal')">&times;</button>
</div>
<div class="mbody" id="modal-router-body" style="min-height:360px;">
<div class="text-center py-5 text-muted">
<i class="bi bi-arrow-repeat spin fs-2 mb-2 d-block text-primary"></i>
<span>Memuat metrik sistem & data live router...</span>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('routerLiveModal')"><%= t('common.close','Tutup') %></button>
<button type="button" id="modal-btn-refresh" class="btn btn-p" onclick="refreshCurrentModal()"><i class="bi bi-arrow-clockwise me-1"></i> Refresh Data</button>
</div>
</div>
</div>
<!-- Modal Script & Setup Firewall Isolir Portal -->
<div class="mo" id="isolirScriptModal">
<div class="mb" style="max-width:740px">
<div class="mh">
<span class="mt"><i class="bi bi-shield-lock text-warning me-2"></i> Setup Firewall & Halaman Isolir Pelanggan</span>
<button class="mc" onclick="closeModal('isolirScriptModal')">&times;</button>
</div>
<div class="mbody">
<!-- Tabs Header -->
<div class="d-flex gap-2 mb-3 border-bottom pb-2">
<button type="button" class="btn btn-sm btn-p" id="tab-btn-auto" onclick="switchIsolirTab('auto')">
<i class="bi bi-lightning-charge-fill me-1"></i> 1-Click Auto Setup (API)
</button>
<button type="button" class="btn btn-sm btn-g" id="tab-btn-script" onclick="switchIsolirTab('script')">
<i class="bi bi-filetype-txt me-1"></i> Copy Script RouterOS (.rsc)
</button>
</div>
<!-- TAB 1: Auto Setup via API -->
<div id="isolir-tab-auto">
<div class="alert alert-info p-3 small mb-3" style="background:rgba(99,102,241,0.12); border:1px solid rgba(99,102,241,0.3); color:#e0e7ff;">
<h6 class="fw-bold mb-1"><i class="bi bi-info-circle me-1 text-info"></i> Otomatisasi Redirect Halaman Isolir</h6>
<p class="mb-0 small" style="opacity:0.9;">
Sistem akan mengirimkan perintah API ke MikroTik untuk membuat <strong>Rule NAT Redirect (Port 80/443)</strong>, <strong>Firewall Rule (LIST_ISOLIR)</strong>, serta memasang <strong>Hook Script on-up di Profil PPPoE Isolir</strong> secara otomatis.
</p>
</div>
<div class="form-group mb-3">
<label class="fl fw-bold text-white mb-2">Pilih Router MikroTik Target *</label>
<select class="fs w-100" id="isolir-modal-router-select">
<% routers.forEach(r => { %>
<option value="<%= r.id %>"><%= r.name %> (<%= r.host %>:<%= r.port %>)</option>
<% }) %>
</select>
</div>
<button type="button" class="btn btn-primary fw-bold w-100 py-2 shadow-sm" onclick="runAutoSetupFirewallFromModal()">
<i class="bi bi-play-circle-fill me-1"></i> Jalankan Setup Firewall & Hook Isolir Sekarang
</button>
<div id="isolir-auto-setup-result" style="display:none;"></div>
</div>
<!-- TAB 2: RouterOS Manual Script (.rsc) -->
<div id="isolir-tab-script" style="display:none;">
<p class="small text-muted mb-2">Script ini di-generate otomatis berdasarkan IP server & URL billing (app_url). Anda dapat menyalin dan menempelkannya di Winbox Terminal MikroTik.</p>
<div id="isolir-script-meta" class="small text-muted mb-2 fw-bold" style="color:#a5b4fc !important;"></div>
<textarea id="isolir-script-body" class="fc" readonly rows="14" style="width:100%;font-family:ui-monospace,monospace;font-size:12px;background:rgba(15,23,42,0.9);color:#818cf8;border-radius:8px;"></textarea>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('isolirScriptModal')"><%= t('common.close', 'Tutup') %></button>
<button type="button" class="btn btn-p" id="btn-copy-script-modal" style="display:none;" onclick="copyIsolirScript()"><i class="bi bi-clipboard me-1"></i> <%= t('admin.routers.isolir_script_copy', 'Salin script') %></button>
</div>
</div>
</div>
<!-- Modal Tambah -->
<div class="mo" id="addModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-hdd-network"></i> <%= t('admin.routers.add', 'Tambah Router') %></span><button class="mc" onclick="closeModal('addModal')">&times;</button></div>
<form method="POST" action="/admin/routers">
<div class="mbody">
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.routers.form.name', 'Nama Router') %> *</label><input class="fc" name="name" required placeholder="<%= t('admin.routers.form.name_placeholder', 'Contoh: Router Pusat') %>"></div>
<div class="fg"><label class="fl"><%= t('admin.routers.form.host', 'Host / IP') %> *</label><input class="fc" name="host" required placeholder="192.168.1.1"></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.routers.form.api_port', 'Port API') %> *</label><input class="fc" name="port" type="number" value="8728" required></div>
<div class="fg"><label class="fl"><%= t('admin.routers.form.username', 'Username MikroTik') %> *</label><input class="fc" name="user" required></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.routers.form.password', 'Password') %> *</label><input class="fc" name="password" type="password" required></div>
<div class="fg">
<label class="fl"><%= t('admin.routers.form.status', 'Status') %></label>
<select class="fs" name="is_active">
<option value="1"><%= t('common.active', 'Aktif') %></option>
<option value="0"><%= t('common.inactive', 'Nonaktif') %></option>
</select>
</div>
</div>
<div class="fg"><label class="fl"><%= t('admin.routers.form.description', 'Deskripsi / Lokasi') %></label><textarea class="fc" name="description" placeholder="<%= t('admin.routers.form.optional', 'Opsional...') %>"></textarea></div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> <%= t('admin.routers.actions.save', 'Simpan Router') %></button>
</div>
</form>
</div>
</div>
<!-- Modal Edit -->
<div class="mo" id="editModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-pencil"></i> <%= t('admin.routers.actions.edit_title', 'Edit Router') %></span><button class="mc" onclick="closeModal('editModal')">&times;</button></div>
<form method="POST" id="editForm">
<div class="mbody">
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.routers.form.name', 'Nama Router') %> *</label><input class="fc" name="name" id="e_name" required></div>
<div class="fg"><label class="fl"><%= t('admin.routers.form.host', 'Host / IP') %> *</label><input class="fc" name="host" id="e_host" required></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.routers.form.api_port', 'Port API') %> *</label><input class="fc" name="port" id="e_port" type="number" required></div>
<div class="fg"><label class="fl"><%= t('admin.routers.form.username', 'Username MikroTik') %> *</label><input class="fc" name="user" id="e_user" required></div>
</div>
<div class="form-row">
<div class="fg"><label class="fl"><%= t('admin.routers.form.password_keep', 'Password (Kosongkan jika tetap)') %></label><input class="fc" name="password" type="password"></div>
<div class="fg">
<label class="fl"><%= t('admin.routers.form.status', 'Status') %></label>
<select class="fs" name="is_active" id="e_active">
<option value="1"><%= t('common.active', 'Aktif') %></option>
<option value="0"><%= t('common.inactive', 'Nonaktif') %></option>
</select>
</div>
</div>
<div class="fg"><label class="fl"><%= t('admin.routers.form.description', 'Deskripsi / Lokasi') %></label><textarea class="fc" name="description" id="e_desc"></textarea></div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('editModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> <%= t('admin.routers.actions.update', 'Update Router') %></button>
</div>
</form>
</div>
</div>
<script id="routers-data" type="application/json"><%- JSON.stringify(routers).split('<').join('\\u003c') %></script>
<script>
const routersData = JSON.parse(document.getElementById('routers-data').textContent);
let currentModalRouterId = null;
let currentModalData = null;
const I18N = {
testConfirm: "<%= t('admin.routers.confirm.test', 'Test koneksi ke router ini?') %>",
systemError: "<%= t('admin.routers.js.system_error', 'Terjadi kesalahan sistem.') %>"
};
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
function switchView(mode) {
const gridBtn = document.getElementById('btn-view-grid');
const tableBtn = document.getElementById('btn-view-table');
const gridContainer = document.getElementById('view-grid-container');
const tableContainer = document.getElementById('view-table-container');
if (mode === 'grid') {
gridBtn.classList.add('active');
tableBtn.classList.remove('active');
gridContainer.style.display = 'grid';
tableContainer.style.display = 'none';
} else {
tableBtn.classList.add('active');
gridBtn.classList.remove('active');
gridContainer.style.display = 'none';
tableContainer.style.display = 'block';
}
}
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 MB';
const mb = bytes / (1024 * 1024);
return mb >= 1024 ? (mb / 1024).toFixed(1) + ' GB' : mb.toFixed(0) + ' MB';
}
async function loadRouterLiveSummary(r) {
try {
const res = await fetch('/admin/api/routers/' + r.id + '/details');
const data = await res.json();
if (!data.success || !data.live) return;
const live = data.live;
const pill = document.getElementById('status-pill-' + r.id);
const cpuLbl = document.getElementById('cpu-load-lbl-' + r.id);
const cpuBar = document.getElementById('cpu-load-bar-' + r.id);
const ramLbl = document.getElementById('ram-usage-lbl-' + r.id);
const ramBar = document.getElementById('ram-usage-bar-' + r.id);
const specCust = document.getElementById('spec-cust-' + r.id);
const specPppoe = document.getElementById('spec-pppoe-' + r.id);
const specHs = document.getElementById('spec-hs-' + r.id);
if (pill) {
if (live.connected) {
pill.className = 'rc-status-pill online';
pill.innerHTML = '<span class="rc-status-dot"></span> Online';
} else {
pill.className = 'rc-status-pill offline';
pill.innerHTML = '<span class="rc-status-dot"></span> Offline';
}
}
if (specCust) specCust.textContent = live.customersCount || 0;
if (specPppoe) specPppoe.textContent = live.activePppoe || 0;
if (specHs) specHs.textContent = live.activeHotspot || 0;
if (live.connected) {
const cpu = live.cpuLoad || 0;
if (cpuLbl) cpuLbl.textContent = cpu + '%';
if (cpuBar) {
cpuBar.style.width = cpu + '%';
cpuBar.className = 'rc-progress-bar ' + (cpu > 80 ? 'bg-red' : (cpu > 50 ? 'bg-yellow' : 'bg-green'));
}
if (live.totalMemory > 0) {
const usedMem = live.totalMemory - live.freeMemory;
const memPercent = Math.round((usedMem / live.totalMemory) * 100);
if (ramLbl) ramLbl.textContent = formatBytes(usedMem) + ' / ' + formatBytes(live.totalMemory);
if (ramBar) {
ramBar.style.width = memPercent + '%';
ramBar.className = 'rc-progress-bar ' + (memPercent > 85 ? 'bg-red' : (memPercent > 65 ? 'bg-yellow' : 'bg-green'));
}
}
}
} catch (e) {}
}
function switchModalTab(tabName) {
const tabs = ['sys', 'pppoe', 'hs'];
tabs.forEach(t => {
const btn = document.getElementById('mtab-btn-' + t);
const content = document.getElementById('mtab-content-' + t);
if (btn) btn.classList.toggle('active', t === tabName);
if (content) content.style.display = (t === tabName) ? 'block' : 'none';
});
}
async function openLiveDetailModal(id) {
currentModalRouterId = id;
const router = routersData.find(r => r.id === id);
const nameEl = document.getElementById('modal-router-name');
const bodyEl = document.getElementById('modal-router-body');
if (nameEl) nameEl.textContent = 'Detail Router: ' + (router ? router.name : '#' + id);
bodyEl.innerHTML = `
<div class="text-center py-5 text-muted">
<i class="bi bi-arrow-repeat spin fs-2 mb-2 d-block text-primary"></i>
<span>Mengambil metrik sistem & sesi live dari MikroTik...</span>
</div>
`;
openModal('routerLiveModal');
try {
const res = await fetch('/admin/api/routers/' + id + '/details');
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Gagal mengambil data router');
currentModalData = data;
const l = data.live;
const r = data.router;
const usedMem = l.totalMemory - l.freeMemory;
const memPercent = l.totalMemory > 0 ? Math.round((usedMem / l.totalMemory) * 100) : 0;
const usedHdd = l.totalHdd - l.freeHdd;
const hddPercent = l.totalHdd > 0 ? Math.round((usedHdd / l.totalHdd) * 100) : 0;
const pppoeRows = (l.activePppoeList || []).map((p, idx) => `
<tr>
<td class="mt-idx">${idx + 1}</td>
<td class="mt-strong">${p.name}</td>
<td class="mono">${p.address}</td>
<td class="mt-warn">${p.uptime}</td>
<td class="mono">${p.callerId}</td>
</tr>
`).join('');
const hsRows = (l.activeHotspotList || []).map((h, idx) => `
<tr>
<td class="mt-idx">${idx + 1}</td>
<td class="mt-strong">${h.user}</td>
<td class="mono">${h.address}</td>
<td class="mono">${h.mac}</td>
<td class="mt-warn">${h.uptime}</td>
</tr>
`).join('');
const _cl = (p) => p > 80 ? 'crit' : (p > 50 ? 'warn' : 'ok');
const cpuCls = _cl(l.cpuLoad), memCls = _cl(memPercent), hddCls = _cl(hddPercent);
bodyEl.innerHTML = `
<div class="mt-head">
<div style="min-width:0">
<div class="mt-head-name"><i class="bi bi-hdd-network"></i> <span>${r.name}</span></div>
<div class="mt-head-sub mono">${r.host}:${r.port} &middot; ${r.user || '-'}</div>
</div>
<span class="rc-status-pill ${l.connected ? 'online' : 'offline'}"><span class="rc-status-dot"></span> ${l.connected ? 'Online' : 'Offline'}</span>
</div>
${l.error ? `<div class="alert alert-d"><i class="bi bi-exclamation-triangle"></i> ${l.error}</div>` : ''}
<div class="mt-tabs">
<button type="button" class="mt-tab active" id="mtab-btn-sys" onclick="switchModalTab('sys')"><i class="bi bi-cpu"></i> Sistem &amp; Metrik</button>
<button type="button" class="mt-tab" id="mtab-btn-pppoe" onclick="switchModalTab('pppoe')"><i class="bi bi-plug"></i> PPPoE <span class="mt-tab-badge">${l.activePppoe}</span></button>
<button type="button" class="mt-tab" id="mtab-btn-hs" onclick="switchModalTab('hs')"><i class="bi bi-wifi"></i> Hotspot <span class="mt-tab-badge">${l.activeHotspot}</span></button>
</div>
<div id="mtab-content-sys">
<div class="mt-metrics">
<div class="mt-metric">
<div class="lbl">CPU Load</div>
<div class="val ${cpuCls}">${l.cpuLoad}<span class="u">%</span></div>
<div class="mt-bar"><span class="${cpuCls}" style="width:${Math.min(100, l.cpuLoad)}%"></span></div>
</div>
<div class="mt-metric">
<div class="lbl">RAM Terpakai</div>
<div class="val ${memCls}">${memPercent}<span class="u">%</span></div>
<div class="sub">${formatBytes(usedMem)} / ${formatBytes(l.totalMemory)}</div>
<div class="mt-bar"><span class="${memCls}" style="width:${memPercent}%"></span></div>
</div>
<div class="mt-metric">
<div class="lbl">Disk</div>
<div class="val ${hddCls}">${hddPercent}<span class="u">%</span></div>
<div class="sub">${formatBytes(usedHdd)} / ${formatBytes(l.totalHdd)}</div>
<div class="mt-bar"><span class="${hddCls}" style="width:${hddPercent}%"></span></div>
</div>
</div>
<div class="mt-kv">
<div class="row"><span>Identity RouterOS</span><b class="mono">${l.identity}</b></div>
<div class="row"><span>Board / Tipe</span><b>${l.boardName}</b></div>
<div class="row"><span>RouterOS</span><b class="mt-ok">${l.version}</b></div>
<div class="row"><span>CPU</span><b>${l.architecture} &middot; ${l.cpuCount} core @ ${l.cpuFrequency} MHz</b></div>
<div class="row"><span><%= t('admin.common.uptime','Uptime') %></span><b class="mono">${l.uptime}</b></div>
</div>
<div class="mt-stats">
<div class="mt-stat"><span>Pelanggan DB</span><b>${l.customersCount}</b></div>
<div class="mt-stat"><span>PPPoE Aktif</span><b class="mt-ok">${l.activePppoe}</b></div>
<div class="mt-stat"><span>Hotspot Aktif</span><b class="mt-warn">${l.activeHotspot}</b></div>
</div>
</div>
<div id="mtab-content-pppoe" style="display:none;">
<div class="mt-tablewrap">
<table class="mt-tbl">
<thead><tr><th>#</th><th>Username</th><th>IP Address</th><th><%= t('admin.common.uptime','Uptime') %></th><th>Caller ID / MAC</th></tr></thead>
<tbody>${pppoeRows || '<tr><td colspan="5" class="mt-empty">Tidak ada sesi PPPoE aktif di router ini</td></tr>'}</tbody>
</table>
</div>
</div>
<div id="mtab-content-hs" style="display:none;">
<div class="mt-tablewrap">
<table class="mt-tbl">
<thead><tr><th>#</th><th>User / Voucher</th><th>IP Address</th><th>MAC Address</th><th><%= t('admin.common.uptime','Uptime') %></th></tr></thead>
<tbody>${hsRows || '<tr><td colspan="5" class="mt-empty">Tidak ada pengguna Hotspot aktif di router ini</td></tr>'}</tbody>
</table>
</div>
</div>
`;
} catch (e) {
bodyEl.innerHTML = `<div class="alert alert-d"><i class="bi bi-exclamation-triangle"></i> ${e.message}</div>`;
}
}
function refreshCurrentModal() {
if (currentModalRouterId) openLiveDetailModal(currentModalRouterId);
}
function editRouter(idx) {
const r = routersData[idx];
document.getElementById('editForm').action = '/admin/routers/' + r.id + '/update';
document.getElementById('e_name').value = r.name;
document.getElementById('e_host').value = r.host;
document.getElementById('e_port').value = r.port;
document.getElementById('e_user').value = r.user;
document.getElementById('e_active').value = r.is_active;
document.getElementById('e_desc').value = r.description || '';
openModal('editModal');
}
async function testConn(id) {
if(!confirm(I18N.testConfirm)) return;
try {
const res = await fetch('/admin/api/routers/' + id + '/test');
const data = await res.json();
alert(data.success ? ' ' + data.message : ' ' + data.error);
loadRouterLiveSummary({ id });
} catch (e) { alert(I18N.systemError); }
}
function switchIsolirTab(tab) {
const autoTab = document.getElementById('isolir-tab-auto');
const scriptTab = document.getElementById('isolir-tab-script');
const autoBtn = document.getElementById('tab-btn-auto');
const scriptBtn = document.getElementById('tab-btn-script');
const copyBtn = document.getElementById('btn-copy-script-modal');
if (tab === 'auto') {
autoTab.style.display = 'block';
scriptTab.style.display = 'none';
autoBtn.className = 'btn btn-sm btn-p';
scriptBtn.className = 'btn btn-sm btn-g';
if (copyBtn) copyBtn.style.display = 'none';
} else {
autoTab.style.display = 'none';
scriptTab.style.display = 'block';
autoBtn.className = 'btn btn-sm btn-g';
scriptBtn.className = 'btn btn-sm btn-p';
if (copyBtn) copyBtn.style.display = 'inline-block';
}
}
async function setupFirewall(routerId) {
openIsolirScriptModal(routerId);
switchIsolirTab('auto');
}
async function openIsolirScriptModal(routerId = null) {
const ta = document.getElementById('isolir-script-body');
const meta = document.getElementById('isolir-script-meta');
const routerSel = document.getElementById('isolir-modal-router-select');
const statusBox = document.getElementById('isolir-auto-setup-result');
if (statusBox) statusBox.style.display = 'none';
if (routerSel && routerId) {
routerSel.value = String(routerId);
}
ta.value = '<%= t('common.loading', 'Memuat...') %>';
meta.textContent = '';
openModal('isolirScriptModal');
try {
const res = await fetch('/admin/api/isolir-portal-script');
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Gagal');
ta.value = data.script || '';
meta.textContent = (data.appUrl ? ('app_url: ' + data.appUrl + ' | ') : '') + 'IP Server: ' + (data.billingIp || '-') + ' | NAT → HTTP:' + (data.httpNatPort ?? '-') + ' HTTPS:' + (data.httpsNatPort ?? '-');
} catch (e) {
ta.value = '';
meta.textContent = e.message || 'Error';
}
}
async function runAutoSetupFirewallFromModal() {
const routerSel = document.getElementById('isolir-modal-router-select');
const routerId = routerSel ? routerSel.value : null;
const statusBox = document.getElementById('isolir-auto-setup-result');
if (!routerId) {
alert('Pilih router terlebih dahulu!');
return;
}
statusBox.style.display = 'block';
statusBox.className = 'alert alert-info p-3 small mt-3';
statusBox.innerHTML = '<i class="bi bi-arrow-repeat spin me-1"></i> Sedang menghubungkan dan memasang rule Firewall/NAT isolir di MikroTik...';
try {
const res = await fetch('/admin/api/routers/' + routerId + '/setup-firewall', { method: 'POST' });
const data = await res.json();
if (data.success) {
statusBox.className = 'alert alert-success p-3 small mt-3';
statusBox.innerHTML = '<i class="bi bi-check-circle-fill me-1"></i> <strong>BERHASIL:</strong> ' + data.message;
} else {
statusBox.className = 'alert alert-danger p-3 small mt-3';
statusBox.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-1"></i> <strong>GAGAL:</strong> ' + (data.error || data.message || 'Terjadi kesalahan');
}
} catch (e) {
statusBox.className = 'alert alert-danger p-3 small mt-3';
statusBox.innerHTML = '<i class="bi bi-exclamation-triangle-fill me-1"></i> <strong>ERROR:</strong> ' + e.message;
}
}
function copyIsolirScript() {
const ta = document.getElementById('isolir-script-body');
if (!ta || !ta.value) return;
ta.select();
document.execCommand('copy');
alert('<%= t('admin.routers.isolir_script_copied', 'Script disalin ke clipboard.') %>');
}
// Auto load metrics for all routers on page load
window.addEventListener('DOMContentLoaded', () => {
routersData.forEach(r => {
loadRouterLiveSummary(r);
});
});
</script>
</body>
</html>
+145
View File
@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.service_notice.title', 'Status Layanan &amp; Pengumuman') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="manifest" href="/admin/manifest.webmanifest">
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.sn-preview{display:flex;gap:14px;align-items:flex-start;padding:16px 18px;border-radius:16px;border:1px solid var(--border);background:rgba(255,255,255,.03);position:relative;overflow:hidden}
.sn-preview::before{content:"";position:absolute;top:0;bottom:0;left:0;width:4px;background:var(--brand,#ADFF2F)}
.sn-preview[data-tone="danger"]::before{background:#fb7185}
.sn-preview .sn-ico{width:44px;height:44px;border-radius:12px;display:grid;place-items:center;font-size:21px;flex:0 0 auto;background:rgba(173,255,47,.14);color:var(--brand,#ADFF2F)}
.sn-preview[data-tone="danger"] .sn-ico{background:rgba(251,113,133,.16);color:#fb7185}
.sn-preview .sn-main{min-width:0}
.sn-preview .sn-title{font-weight:800;font-size:14.5px}
.sn-preview .sn-text{font-size:13.5px;color:var(--muted);margin-top:3px;line-height:1.5}
.sn-preview .sn-eta{font-size:12.5px;margin-top:9px}
.sn-preview .sn-since{color:var(--muted)}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: (typeof activePage !== 'undefined' && activePage ? activePage : 'service_notice'), 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-megaphone-fill"></i> <%= t('admin.service_notice.title', 'Status Layanan &amp; Pengumuman') %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<a href="/" target="_blank" rel="noopener" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-up-right"></i> <%= t('admin.service_notice.view_landing', 'Lihat Landing') %></a>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> <%= t('common.logout', 'Logout') %></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<form method="POST" action="/admin/service-notice" id="noticeForm">
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-broadcast-pin"></i> <%= t('admin.service_notice.mode_time', 'Mode &amp; Waktu') %></h6></div>
<div class="card-bd">
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('admin.service_notice.mode', 'Mode Pengumuman') %></label>
<select class="fs" name="notice_mode" id="n_mode">
<option value="NORMAL" <%= (settings.notice_mode || 'NORMAL') === 'NORMAL' ? 'selected' : '' %>><%= t('admin.service_notice.mode_normal', 'NORMAL — tidak ada pengumuman') %></option>
<option value="GANGGUAN_MASAL" <%= settings.notice_mode === 'GANGGUAN_MASAL' ? 'selected' : '' %>><%= t('admin.service_notice.mode_mass', 'Gangguan Masal — warning + estimasi') %></option>
<option value="PERBAIKAN" <%= settings.notice_mode === 'PERBAIKAN' ? 'selected' : '' %>><%= t('admin.service_notice.mode_repair', 'Perbaikan Sistem — info ringan') %></option>
</select>
</div>
<div class="fg">
<label class="fl"><%= t('admin.service_notice.start', 'Jam Mulai Gangguan/Perbaikan') %></label>
<input class="fc" type="datetime-local" name="notice_start" id="n_start" value="<%= settings.notice_start || '' %>">
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('admin.service_notice.interval', 'Interval Estimasi (jam)') %></label>
<input class="fc" type="number" min="1" max="168" name="notice_interval_hours" id="n_interval" value="<%= settings.notice_interval_hours || 5 %>">
<div class="hint"><%= t('admin.service_notice.interval_hint', 'Estimasi maju otomatis tiap interval ini bila belum ditandai selesai.') %></div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.service_notice.note', 'Catatan Kustom (opsional)') %></label>
<input class="fc" name="notice_note" id="n_note" value="<%= settings.notice_note || '' %>" placeholder="<%= t('admin.service_notice.note_ph', 'Kosongkan untuk teks bawaan') %>">
</div>
</div>
</div>
<div class="card-bd" style="border-top:1px solid var(--border);padding-top:16px;text-align:right">
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> <%= t('common.save', 'Simpan') %></button>
</div>
</div>
</form>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-eye"></i> <%= t('admin.service_notice.preview', 'Pratinjau Kotak Pesan') %></h6></div>
<div class="card-bd">
<div id="snPreview" class="sn-preview" data-tone="primary">
<div class="sn-ico"><i class="bi bi-tools" id="snIcon"></i></div>
<div class="sn-main">
<div class="sn-title" id="snTitle"><%= t('admin.service_notice.js_repair', 'Perbaikan Sistem') %></div>
<div class="sn-text" id="snText"><%= t('admin.service_notice.preview_text', 'Sedang ada sedikit perbaikan sistem.') %></div>
<div class="sn-eta" id="snEta"></div>
</div>
</div>
<div class="hint" style="margin-top:10px"><i class="bi bi-info-circle"></i> <%= t('admin.service_notice.preview_hint', 'Pratinjau kasar. Tampilan final mengikuti tema landing &amp; dashboard pelanggan (di bawah iklan).') %></div>
</div>
</div>
</div>
</div>
<script>
var I18N_SN = {
mass: "<%= t('admin.service_notice.js_mass', 'Gangguan Masal') %>",
repair: "<%= t('admin.service_notice.js_repair', 'Perbaikan Sistem') %>",
massText: "<%= t('admin.service_notice.js_mass_text', 'Sedang terjadi gangguan pada jaringan kami. Tim teknis sedang menangani dan akan segera memulihkan layanan.') %>",
repairText: "<%= t('admin.service_notice.js_repair_text', 'Sedang ada sedikit perbaikan sistem. Mohon kesabarannya ya, layanan akan kembali normal secepatnya.') %>",
eta: "<%= t('admin.service_notice.js_eta', 'Estimasi selesai:') %>",
since: "<%= t('admin.service_notice.js_since', 'sejak') %>"
};
var els = {
mode: document.getElementById('n_mode'),
start: document.getElementById('n_start'),
interval: document.getElementById('n_interval'),
note: document.getElementById('n_note')
};
var out = {
box: document.getElementById('snPreview'),
ico: document.getElementById('snIcon'),
title: document.getElementById('snTitle'),
text: document.getElementById('snText'),
eta: document.getElementById('snEta')
};
function pad(n){ return String(n).length < 2 ? '0' + n : String(n); }
function fmt(d){ return pad(d.getHours()) + '.' + pad(d.getMinutes()) + ' ' + pad(d.getDate()) + '/' + pad(d.getMonth() + 1) + '/' + d.getFullYear(); }
function render(){
var mode = els.mode.value;
if (mode === 'NORMAL') { out.box.style.display = 'none'; return; }
out.box.style.display = 'flex';
var mass = mode === 'GANGGUAN_MASAL';
out.box.setAttribute('data-tone', mass ? 'danger' : 'primary');
out.ico.className = 'bi ' + (mass ? 'bi-exclamation-triangle-fill' : 'bi-tools');
out.title.textContent = mass ? I18N_SN.mass : I18N_SN.repair;
out.text.textContent = els.note.value.trim() || (mass ? I18N_SN.massText : I18N_SN.repairText);
var startVal = els.start.value;
if (!startVal) { out.eta.innerHTML = ''; return; }
var start = new Date(startVal);
if (isNaN(start.getTime())) { out.eta.innerHTML = ''; return; }
var iv = parseInt(els.interval.value, 10); if (!iv || iv < 1) iv = 5;
var ms = iv * 3600 * 1000;
var steps = Math.max(1, Math.floor((Date.now() - start.getTime()) / ms) + 1);
var est = new Date(start.getTime() + steps * ms);
out.eta.innerHTML = '<i class="bi bi-clock-history"></i> ' + I18N_SN.eta + ' <b>' + fmt(est) + '</b> <span class="sn-since">· ' + I18N_SN.since + ' ' + fmt(start) + '</span>';
}
['change', 'input'].forEach(function(ev){
[els.mode, els.start, els.interval, els.note].forEach(function(el){ el.addEventListener(ev, render); });
});
render();
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.sb.title', 'Pengaturan Sidebar') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="manifest" href="/admin/manifest.webmanifest">
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'sidebar_settings', 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-layout-sidebar-inset"></i> <%= t('admin.sb.title', 'Pengaturan Sidebar') %>
</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-shield-lock"></i> <%= t('admin.sb.control', 'Kontrol Menu') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> <%= t('common.logout', 'Logout') %></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'success' ? 's' : 'd' %>" data-flash="1">
<i class="bi bi-<%= msg.type === 'success' ? 'check-circle' : 'exclamation-circle' %>"></i>
<%= msg.text %>
</div>
<% } %>
<div style="display:grid;gap:16px">
<div class="card">
<div class="card-hd" style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
<h6 style="margin:0"><i class="bi bi-grid-3x3-gap"></i> <%= t('admin.sb.status_all', 'Status Semua Menu Sidebar') %></h6>
<% if (canManageSidebar) { %>
<span class="badge badge-success" style="background:rgba(34,197,94,.18);color:#bbf7d0;padding:6px 10px;border-radius:999px"><%= t('admin.sb.can_manage', 'Admin dapat mengubah semua menu') %></span>
<% } else { %>
<span class="badge badge-warning" style="background:rgba(245,158,11,.18);color:#fde68a;padding:6px 10px;border-radius:999px"><%= t('admin.sb.readonly', 'Mode baca saja untuk akun ini') %></span>
<% } %>
</div>
<div class="card-bd">
<p class="hint" style="margin-top:0">
<%= t('admin.sb.intro', 'Setiap menu bisa diatur menjadi Tampil, Sembunyikan, atau Kunci. Menu yang dikunci akan meminta password khusus sebelum dibuka.') %>
</p>
<% if (canManageSidebar) { %>
<form method="POST" action="/admin/sidebar-settings">
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('admin.sb.th_menu', 'Menu') %></th>
<th><%= t('admin.sb.th_section', 'Section') %></th>
<th><%= t('admin.sb.th_role', 'Role') %></th>
<th><%= t('admin.sb.th_status', 'Status') %></th>
</tr>
</thead>
<tbody>
<% menuConfigs.forEach((menu) => { %>
<tr>
<td>
<div style="display:flex;align-items:center;gap:10px">
<i class="<%= menu.icon %>"></i>
<div>
<div style="font-weight:700"><%= menu.labelKey ? t(menu.labelKey, menu.labelDefault) : menu.labelDefault %></div>
<div class="hint mono"><%= menu.href %></div>
</div>
</div>
</td>
<td><span class="hint"><%= menu.sectionLabelKey ? t(menu.sectionLabelKey, menu.sectionLabel) : menu.sectionLabel %></span></td>
<td><span class="hint"><%= menu.roleLabel %></span></td>
<td style="min-width:180px">
<select class="fs" name="menu_state[<%= menu.key %>]">
<option value="visible" <%= menu.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
<option value="hidden" <%= menu.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
<option value="locked" <%= menu.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
</select>
</td>
</tr>
<% (menu.children || []).forEach((ch) => { %>
<tr style="background:rgba(255,255,255,0.02)">
<td>
<div style="display:flex;align-items:center;gap:10px;padding-left:22px">
<i class="<%= ch.icon %>" style="opacity:.8"></i>
<div>
<div style="font-weight:600"><span class="hint" style="margin-right:6px">↳</span><%= ch.labelKey ? t(ch.labelKey, ch.labelDefault) : ch.labelDefault %></div>
<div class="hint mono"><%= ch.href %></div>
</div>
</div>
</td>
<td><span class="hint"><%= ch.sectionLabelKey ? t(ch.sectionLabelKey, ch.sectionLabel) : ch.sectionLabel %></span></td>
<td><span class="hint"><%= ch.roleLabel %></span></td>
<td style="min-width:180px">
<select class="fs" name="menu_state[<%= ch.key %>]">
<option value="visible" <%= ch.state === 'visible' ? 'selected' : '' %>><%= t('admin.sb.st_visible', 'Tampil') %></option>
<option value="hidden" <%= ch.state === 'hidden' ? 'selected' : '' %>><%= t('admin.sb.st_hidden', 'Sembunyikan') %></option>
<option value="locked" <%= ch.state === 'locked' ? 'selected' : '' %>><%= t('admin.sb.st_locked', 'Kunci') %></option>
</select>
</td>
</tr>
<% }) %>
<% }) %>
</tbody>
</table>
</div>
<div class="sidebar-settings-save-grid" style="margin-top:16px">
<div class="fg" style="margin:0">
<label class="fl"><%= t('admin.sb.pass_label', 'Password Aktivasi (Seikhlasnya)') %></label>
<input type="password" class="fc" name="feature_password" placeholder="<%= t('admin.sb.pass_ph', 'Masukkan password aktivasi sebelum menyimpan') %>" required>
<div class="hint" style="margin-top:6px"><%= t('admin.sb.pass_hint', 'Perubahan status menu sidebar hanya akan disimpan jika password aktivasi benar.') %></div>
</div>
<div class="hint" style="align-self:center">
<%= t('admin.sb.no_pass', 'Belum punya kode aktivasi?') %> <a href="/donate" target="_blank" rel="noopener" style="color:var(--primary);font-weight:700;"><i class="bi bi-heart-fill me-1"></i> <%= t('admin.sb.get_donation', 'Dapatkan kode aktivasi GRATIS di halaman Dukungan OPEN-ISP') %></a> <%= t('admin.sb.or_contact', 'atau hubungi') %> <strong><%= featureContactPhone %></strong>.
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:10px;margin-top:16px;flex-wrap:wrap">
<a href="/donate" target="_blank" rel="noopener" class="btn btn-g">
<i class="bi bi-heart-fill"></i> <%= t('admin.sb.donation_page', 'Kode Aktivasi Gratis (Dukungan OPEN-ISP)') %>
</a>
<button type="submit" class="btn btn-p">
<i class="bi bi-save"></i> <%= t('admin.sb.save', 'Simpan Pengaturan Sidebar') %>
</button>
</div>
</form>
<% } else { %>
<div class="alert alert-d">
<i class="bi bi-lock"></i> <%= t('admin.sb.only_admin', 'Hanya admin utama yang dapat mengubah status menu sidebar.') %>
</div>
<% } %>
</div>
</div>
<div class="card">
<div class="card-hd">
<h6 style="margin:0"><i class="bi bi-lightbulb"></i> <%= t('admin.sb.notes', 'Catatan') %></h6>
</div>
<div class="card-bd">
<div class="alert alert-s" style="margin-bottom:12px">
<i class="bi bi-info-circle"></i> <%= t('admin.sb.note_lock', 'Jika menu berstatus Kunci diklik dari sidebar, sistem menampilkan cara mengambil kode aktivasi GRATIS atau menghubungi') %> <strong><%= featureContactPhone %></strong>.
</div>
<ul style="margin:0;padding-left:18px;display:grid;gap:8px">
<li><%= t('admin.sb.note1', 'Status Sembunyikan akan menghilangkan menu dari sidebar dan menolak akses URL langsung.') %></li>
<li><%= t('admin.sb.note2', 'Status Kunci tetap menampilkan menu, tetapi tidak bisa dibuka sampai statusnya diubah di halaman ini.') %></li>
<li><%= t('admin.sb.note3', 'Perubahan status menu hanya dapat disimpan setelah memasukkan password aktivasi yang benar.') %></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+153
View File
@@ -0,0 +1,153 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.nav.technicians', 'Teknisi') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'technicians', 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-person-gear"></i>
<span><%= t('admin.nav.technicians', 'Teknisi') %></span>
</div>
<div class="tb-right">
<span class="adm-badge">
<i class="bi bi-person-fill"></i>
<%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %>
</span>
<button class="btn btn-p btn-sm" onclick="openModal('addTechModal')"><i class="bi bi-plus-lg"></i> <%= t('admin.technicians.add', 'Tambah Teknisi') %></button>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='error'?'d':'s' %>" data-flash="1"><%= msg.text %></div>
<% } %>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-ul"></i> <%= t('admin.technicians.list', 'Daftar Teknisi Lapangan') %></h6>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th><%= t('admin.technicians.table.name', 'Nama') %></th>
<th><%= t('admin.technicians.table.username', 'Username') %></th>
<th><%= t('admin.technicians.table.password', 'Password') %></th>
<th><%= t('admin.technicians.table.whatsapp', 'WhatsApp') %></th>
<th><%= t('admin.technicians.table.area', 'Area Tugas') %></th>
<th><%= t('admin.technicians.table.status', 'Status') %></th>
<th><%= t('admin.technicians.table.actions', 'Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (technicians.length === 0) { %>
<tr><td colspan="7" class="text-center text-muted" style="padding:20px"><%= t('admin.technicians.empty', 'Belum ada data teknisi.') %></td></tr>
<% } else { %>
<% technicians.forEach(tech => { %>
<tr>
<td><strong><%= tech.name %></strong></td>
<td><code><%= tech.username %></code></td>
<td><span class="text-muted"><%= tech.password %></span></td>
<td><%= tech.phone || '-' %></td>
<td><%= tech.area || '-' %></td>
<td>
<span class="badge <%= tech.is_active ? 'bs' : 'bd' %>">
<%= tech.is_active ? t('common.active', 'Aktif') : t('common.inactive', 'Nonaktif') %>
</span>
</td>
<td style="white-space:nowrap">
<form id="tdel-<%= tech.id %>" action="/admin/technicians/<%= tech.id %>/delete" method="POST" style="display:none" onsubmit="return confirm(I18N.confirmDeleteTechnician)"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick='editTech(<%- JSON.stringify(tech).split('<').join('\\u003c') %>)'><i class="bi bi-pencil"></i> Edit Teknisi</button>
<div class="act-sep"></div>
<button type="submit" form="tdel-<%= tech.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Teknisi</button>
</div>
</div>
</td>
</tr>
<% }) %>
<% } %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Tambah -->
<div class="mo" id="addTechModal">
<div class="mb">
<form action="/admin/technicians" method="POST">
<div class="mh"><span class="mt"><%= t('admin.technicians.modal.add_title', 'Tambah Teknisi Baru') %></span><button type="button" class="mc" onclick="closeModal('addTechModal')">&times;</button></div>
<div class="mbody">
<div class="fg"><label class="fl"><%= t('admin.technicians.form.full_name', 'Nama Lengkap') %></label><input type="text" name="name" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.login_username', 'Username Login') %></label><input type="text" name="username" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.password', 'Password') %></label><input type="text" name="password" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.whatsapp', 'No. WhatsApp') %></label><input type="text" name="phone" class="fc" placeholder="<%= t('admin.technicians.form.whatsapp_placeholder', 'Contoh: 08123456789') %>"></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.area', 'Area Tugas') %></label><input type="text" name="area" class="fc" placeholder="<%= t('admin.technicians.form.area_placeholder', 'Contoh: Kelurahan A, Blok B') %>"></div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('addTechModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><%= t('common.save', 'Simpan') %></button>
</div>
</form>
</div>
</div>
<!-- Modal Edit -->
<div class="mo" id="editTechModal">
<div class="mb">
<form id="editForm" method="POST">
<div class="mh"><span class="mt"><%= t('admin.technicians.modal.edit_title', 'Edit Data Teknisi') %></span><button type="button" class="mc" onclick="closeModal('editTechModal')">&times;</button></div>
<div class="mbody">
<div class="fg"><label class="fl"><%= t('admin.technicians.form.full_name', 'Nama Lengkap') %></label><input type="text" name="name" id="e-name" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.login_username', 'Username Login') %></label><input type="text" name="username" id="e-username" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.password', 'Password') %></label><input type="text" name="password" id="e-password" class="fc" required></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.whatsapp', 'No. WhatsApp') %></label><input type="text" name="phone" id="e-phone" class="fc"></div>
<div class="fg"><label class="fl"><%= t('admin.technicians.form.area', 'Area Tugas') %></label><input type="text" name="area" id="e-area" class="fc"></div>
<div class="fg">
<label class="fl"><%= t('admin.technicians.form.account_status', 'Status Akun') %></label>
<select name="is_active" id="e-status" class="fs">
<option value="1"><%= t('common.active', 'Aktif') %></option>
<option value="0"><%= t('common.inactive', 'Nonaktif') %></option>
</select>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('editTechModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><%= t('admin.technicians.save_changes', 'Simpan Perubahan') %></button>
</div>
</form>
</div>
</div>
<script>
const I18N = {
confirmDeleteTechnician: "<%= t('admin.technicians.confirm_delete', 'Hapus teknisi ini?') %>"
};
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function editTech(t) {
document.getElementById('editForm').action = '/admin/technicians/' + t.id + '/update';
document.getElementById('e-name').value = t.name;
document.getElementById('e-username').value = t.username;
document.getElementById('e-password').value = t.password;
document.getElementById('e-phone').value = t.phone || '';
document.getElementById('e-area').value = t.area || '';
document.getElementById('e-status').value = t.is_active;
openModal('editTechModal');
}
</script>
</body>
</html>
+370
View File
@@ -0,0 +1,370 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.nav.customer_tickets', 'Keluhan Pelanggan') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'tickets', 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-headset"></i> <%= t('admin.nav.customer_tickets', 'Keluhan Pelanggan') %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<!-- Summary Cards -->
<div class="stat-grid" style="grid-template-columns:repeat(4,1fr)">
<div class="sc p"><i class="bi bi-ticket-detailed sc-icon"></i><div class="sc-label"><%= t('admin.tickets.stats.total', 'Total Tiket') %></div><div class="sc-val"><%= stats.total %></div></div>
<div class="sc d"><i class="bi bi-exclamation-circle sc-icon"></i><div class="sc-label"><%= t('admin.tickets.status.open', 'Belum Ditangani') %></div><div class="sc-val"><%= stats.open %></div></div>
<div class="sc w"><i class="bi bi-tools sc-icon"></i><div class="sc-label"><%= t('admin.tickets.status.in_progress', 'Sedang Dikerjakan') %></div><div class="sc-val"><%= stats.inProgress %></div></div>
<div class="sc s"><i class="bi bi-check-circle sc-icon"></i><div class="sc-label"><%= t('admin.tickets.status.resolved', 'Selesai') %></div><div class="sc-val"><%= stats.resolved %></div></div>
</div>
<!-- Print Header (Only visible on paper print) -->
<div class="print-only" style="margin-bottom:14px;border-bottom:2px solid #0f172a;padding-bottom:10px">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h2 style="font-size:16pt;font-weight:800;text-transform:uppercase;margin:0"><%= company %></h2>
<div style="font-size:10pt;color:#334155"><%= t('admin.nav.customer_tickets', 'Daftar Tiket Gangguan & Tugas Teknisi') %> &bull; Status: <%= filterStatus ? filterStatus.toUpperCase() : 'SEMUA' %></div>
</div>
<div style="text-align:right;font-size:8.5pt;color:#475569">
<div>Dicetak: <%= typeof getNowLocal === 'function' ? getNowLocal() : (typeof formatDateLocal === 'function' ? formatDateLocal(new Date()) : new Date().toLocaleString('id-ID')) %></div>
<div>Total Tiket: <strong><%= tickets.length %> Kasus</strong></div>
</div>
</div>
</div>
<!-- Filter -->
<div class="card">
<div class="card-hd" style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px;">
<h6><i class="bi bi-list-ul"></i> <%= t('admin.tickets.list_title', 'Daftar Keluhan & Tugas') %> (<%= tickets.length %>)</h6>
<div style="display:flex;gap:8px">
<button type="button" class="btn btn-g btn-sm" onclick="window.print()" title="Cetak Rekap Tiket"><i class="bi bi-printer"></i> Cetak</button>
<button class="btn btn-p btn-sm" onclick="openModal('createTicketModal')">
<i class="bi bi-plus-circle me-1"></i> + Buat Tiket / Tugas Baru
</button>
</div>
</div>
<div class="card-bd" style="padding-bottom:0">
<form method="GET" action="/admin/tickets" class="srow" style="margin-bottom:18px">
<div class="sinput" style="max-width:340px">
<i class="bi bi-search"></i>
<input class="fc" name="search" placeholder="Cari pelanggan / subjek / pesan..." value="<%= (typeof filterSearch!=='undefined'?filterSearch:'') %>">
</div>
<select class="fs" name="tech_id" style="width:180px" onchange="this.form.submit()">
<option value="">Semua Teknisi</option>
<% (typeof technicians!=='undefined'?technicians:[]).forEach(t => { %><option value="<%= t.id %>" <%= (typeof filterTech!=='undefined' && String(filterTech)===String(t.id))?'selected':'' %>><%= t.name %></option><% }) %>
</select>
<select class="fs" name="status" style="width:160px" onchange="this.form.submit()">
<option value="all" <%= filterStatus==='all'?'selected':'' %>><%= t('common.all_status', 'Semua Status') %></option>
<option value="open" <%= filterStatus==='open'?'selected':'' %>><%= t('admin.tickets.status.open', 'Belum Ditangani') %></option>
<option value="in_progress" <%= filterStatus==='in_progress'?'selected':'' %>><%= t('admin.tickets.status.in_progress', 'Sedang Dikerjakan') %></option>
<option value="resolved" <%= filterStatus==='resolved'?'selected':'' %>><%= t('admin.tickets.status.resolved', 'Selesai') %></option>
</select>
<button class="btn btn-g btn-sm" type="submit"><i class="bi bi-search"></i> Cari</button>
<% if ((typeof filterSearch!=='undefined' && filterSearch) || (typeof filterTech!=='undefined' && filterTech) || (filterStatus && filterStatus!=='all')) { %><a href="/admin/tickets" class="btn btn-g btn-sm"><i class="bi bi-x"></i> Reset</a><% } %>
</form>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>#ID</th>
<th><%= t('admin.tickets.table.time', 'Waktu') %></th>
<th><%= t('admin.tickets.table.customer', 'Pelanggan') %></th>
<th><%= t('admin.tickets.table.issue', 'Kendala') %></th>
<th><%= t('admin.tickets.table.technician', 'Teknisi') %></th>
<th><%= t('admin.tickets.table.status', 'Status') %></th>
<th><%= t('common.actions', 'Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (tickets.length===0) { %>
<tr><td colspan="7" class="text-center text-muted" style="padding:32px"><%= t('admin.tickets.empty', 'Tidak ada tiket keluhan') %></td></tr>
<% } %>
<% tickets.forEach((ticket) => { %>
<tr>
<td class="text-muted">#<%= ticket.id %></td>
<td style="font-size:12px;color:var(--muted)">
<%= typeof formatDateLocal === 'function' ? formatDateLocal(ticket.created_at) : (ticket.created_at ? new Date(ticket.created_at).toLocaleString('id-ID', {dateStyle:'short', timeStyle:'short'}) : '-') %>
</td>
<td>
<div class="fw6"><%= ticket.customer_name %></div>
<div style="font-size:11px;color:var(--muted)"><%= ticket.customer_phone || '-' %></div>
</td>
<td style="color:var(--primary)"><%= ticket.subject %></td>
<td>
<% if (ticket.technician_name) { %>
<div class="fw6" style="font-size:12px"><i class="bi bi-person-badge me-1"></i><%= ticket.technician_name %></div>
<% } else { %>
<span class="text-muted small"><%= t('admin.tickets.no_technician', 'Belum ada') %></span>
<% } %>
</td>
<td>
<% if(ticket.status==='open'){%><span class="badge bd"><%= t('admin.tickets.status.open', 'Belum Ditangani') %></span>
<%}else if(ticket.status==='in_progress'){%><span class="badge bw"><%= t('admin.tickets.status.working_short', 'Dikerjakan') %></span>
<%}else{%><span class="badge bs"><%= t('admin.tickets.status.resolved', 'Selesai') %></span><%}%>
</td>
<td style="white-space:nowrap">
<form id="tdel-<%= ticket.id %>" method="POST" action="/admin/tickets/<%= ticket.id %>/delete" style="display:none" onsubmit="return confirm('<%= t('admin.tickets.confirm.delete', 'Hapus keluhan ini?') %>')"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button class="btn act-item update-btn" title="<%= t('admin.tickets.actions.update_status', 'Update Status') %>"
data-id="<%= ticket.id %>" data-status="<%= ticket.status %>" data-message="<%= ticket.message %>"
data-subject="<%= ticket.subject %>" data-customer="<%= ticket.customer_name %>"
data-notes="<%= ticket.technician_notes || '' %>" data-photos="<%= ticket.photos || '' %>"
data-customer-photos="<%= ticket.customer_photos || '' %>" data-tech-id="<%= ticket.technician_id || '' %>"
data-tech="<%= ticket.technician_name || t('admin.tickets.no_technician', 'Belum ada') %>"><i class="bi bi-pencil"></i> Update Status</button>
<% if ((ticket.photos && ticket.photos !== '' && ticket.photos !== '[]') || (ticket.customer_photos && ticket.customer_photos !== '' && ticket.customer_photos !== '[]')) { %>
<button class="btn act-item primary view-photos-btn" title="Lihat Foto"
data-id="<%= ticket.id %>" data-photos="<%= ticket.photos || '' %>"
data-customer-photos="<%= ticket.customer_photos || '' %>"
data-customer="<%= ticket.customer_name %>" data-subject="<%= ticket.subject %>"><i class="bi bi-images"></i> <%= t('admin.common.view_photo','Lihat Foto') %></button>
<% } %>
<div class="act-sep"></div>
<button type="submit" form="tdel-<%= ticket.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Keluhan</button>
</div>
</div>
</td>
</tr>
<% }) %>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Buat Tiket / Tugas Baru -->
<div class="mo" id="createTicketModal">
<div class="mb">
<div class="mh">
<span class="mt"><i class="bi bi-plus-circle me-1"></i> Buat Tiket / Tugas Teknisi Baru</span>
<button class="mc" onclick="closeModal('createTicketModal')">&times;</button>
</div>
<form method="POST" action="/admin/tickets/create">
<div class="mbody">
<div class="fg">
<label class="fl">Pilih Pelanggan (Opsional)</label>
<select class="fs" name="customer_id" id="createCustomerId">
<option value="0">-- Tugas Umum / Non-Pelanggan (Maintenance) --</option>
<% if (typeof customers !== 'undefined' && customers) { %>
<% customers.forEach(c => { %>
<option value="<%= c.id %>"><%= c.name %> (<%= c.phone || '-' %>) - <%= c.address || '' %></option>
<% }) %>
<% } %>
</select>
</div>
<div class="fg">
<label class="fl">Judul / Subjek Kendala</label>
<input class="fc" name="subject" placeholder="Contoh: Perbaikan ODP 02, Tarik Kabel Baru, Sinyal High Drop" required>
</div>
<div class="fg">
<label class="fl">Detail Pesan / Instruksi Tugas</label>
<textarea class="fc" name="message" rows="3" placeholder="Tuliskan detail pekerjaan atau instruksi teknisi secara jelas..." required></textarea>
</div>
<div class="fg">
<label class="fl">Tugaskan ke Teknisi (Opsional)</label>
<select class="fs" name="technician_id">
<option value="">-- Belum Ditugaskan (Bebas) --</option>
<% if (typeof technicians !== 'undefined' && technicians) { %>
<% technicians.forEach(t => { %>
<option value="<%= t.id %>"><%= t.name %> (<%= t.phone || '-' %>)</option>
<% }) %>
<% } %>
</select>
</div>
<div class="fg">
<label class="fl">Status Awal</label>
<select class="fs" name="status">
<option value="open">Belum Ditangani (Open)</option>
<option value="in_progress">Sedang Dikerjakan (In Progress)</option>
</select>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('createTicketModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-send me-1"></i> Simpan & Tugaskan</button>
</div>
</form>
</div>
</div>
<!-- Modal Update -->
<div class="mo" id="updateModal">
<div class="mb">
<div class="mh"><span class="mt"><i class="bi bi-pencil"></i> <%= t('admin.tickets.modal.title', 'Update Status Keluhan') %></span><button class="mc" onclick="closeModal('updateModal')">&times;</button></div>
<form method="POST" id="updateForm">
<div class="mbody">
<div class="fg"><label class="fl"><%= t('admin.tickets.table.customer', 'Pelanggan') %></label><input class="fc" id="upCustomer" readonly></div>
<div class="fg"><label class="fl"><%= t('admin.tickets.table.issue', 'Kendala') %></label><input class="fc" id="upSubject" readonly></div>
<div class="fg">
<label class="fl"><i class="bi bi-person-badge me-1"></i><%= t('admin.tickets.table.technician', 'Tugaskan ke Teknisi') %></label>
<select class="fs" name="technician_id" id="upTechnicianId">
<option value="">-- Belum Ditugaskan (Bebas) --</option>
<% if (typeof technicians !== 'undefined' && technicians) { %>
<% technicians.forEach(t => { %>
<option value="<%= t.id %>"><%= t.name %> (<%= t.phone || '-' %>)</option>
<% }) %>
<% } %>
</select>
</div>
<div class="fg"><label class="fl"><%= t('admin.tickets.modal.message', 'Detail Pesan') %></label><textarea class="fc" id="upMessage" rows="3" readonly></textarea></div>
<div class="fg" id="techNotesContainer" style="display:none;">
<label class="fl"><i class="bi bi-pencil-square me-1"></i>Catatan Teknisi</label>
<textarea class="fc" id="upNotes" rows="3" readonly style="background:#f8f9fa"></textarea>
</div>
<div class="fg">
<label class="fl"><%= t('admin.tickets.actions.update_status', 'Update Status') %></label>
<select class="fs" name="status" id="upStatus">
<option value="open"><%= t('admin.tickets.status.open', 'Belum Ditangani') %></option>
<option value="in_progress"><%= t('admin.tickets.status.in_progress', 'Sedang Dikerjakan') %></option>
<option value="resolved"><%= t('admin.tickets.status.resolved', 'Selesai') %></option>
</select>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('updateModal')"><%= t('common.cancel', 'Batal') %></button>
<button type="submit" class="btn btn-p"><i class="bi bi-check-lg"></i> <%= t('admin.tickets.actions.save_changes', 'Simpan Perubahan') %></button>
</div>
</form>
</div>
</div>
<!-- Modal Photo Gallery -->
<div class="mo" id="photoModal">
<div class="mb" style="max-width:900px">
<div class="mh">
<span class="mt"><i class="bi bi-images"></i> Foto Pekerjaan</span>
<button class="mc" onclick="closeModal('photoModal')">&times;</button>
</div>
<div class="mbody">
<div class="mb-3">
<div class="fw-bold" id="photoCustomer"></div>
<div class="text-muted small" id="photoSubject"></div>
</div>
<div id="photoGallery" class="row g-2"></div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('photoModal')"><%= t('common.close','Tutup') %></button>
</div>
</div>
</div>
<script>
function openModal(id){document.getElementById(id).classList.add('show')}
function closeModal(id){document.getElementById(id).classList.remove('show')}
document.querySelectorAll('.mo').forEach(m=>m.addEventListener('click',e=>{if(e.target===m)m.classList.remove('show')}));
document.addEventListener('click', function(e) {
const btn = e.target.closest('.update-btn');
if (!btn) return;
document.getElementById('updateForm').action = '/admin/tickets/' + btn.dataset.id + '/update';
document.getElementById('upStatus').value = btn.dataset.status;
document.getElementById('upMessage').value = btn.dataset.message;
document.getElementById('upSubject').value = btn.dataset.subject;
document.getElementById('upCustomer').value = btn.dataset.customer;
const upTechSelect = document.getElementById('upTechnicianId');
if (upTechSelect) upTechSelect.value = btn.dataset.techId || '';
// Show technician notes if available
const notes = btn.dataset.notes || '';
const notesContainer = document.getElementById('techNotesContainer');
if (notes && notes.trim() !== '') {
document.getElementById('upNotes').value = notes;
notesContainer.style.display = 'block';
} else {
notesContainer.style.display = 'none';
}
openModal('updateModal');
});
// View photos
document.addEventListener('click', function(e) {
const btn = e.target.closest('.view-photos-btn');
if (!btn) return;
const _mp = (p) => String(p || '').replace('/uploads/tickets/', '/admin/media/ticket/').replace('/uploads/payment_proofs/', '/admin/media/payment-proof/');
const techPhotos = JSON.parse(btn.dataset.photos || '[]').map(_mp);
const customerPhotos = JSON.parse(btn.dataset.customerPhotos || '[]').map(_mp);
const customer = btn.dataset.customer;
const subject = btn.dataset.subject;
document.getElementById('photoCustomer').textContent = customer;
document.getElementById('photoSubject').textContent = subject;
const gallery = document.getElementById('photoGallery');
gallery.innerHTML = '';
const allPhotos = [...customerPhotos, ...techPhotos];
if (allPhotos.length === 0) {
gallery.innerHTML = '<div class="col-12 text-center text-muted">Tidak ada foto</div>';
} else {
// Customer photos first
if (customerPhotos.length > 0) {
const customerSection = document.createElement('div');
customerSection.className = 'col-12 mb-3';
customerSection.innerHTML = '<h6 class="text-primary mb-2"><i class="bi bi-person-circle me-1"></i>Foto dari Pelanggan</h6>';
gallery.appendChild(customerSection);
customerPhotos.forEach((photo, index) => {
const col = document.createElement('div');
col.className = 'col-md-4 col-6';
col.innerHTML = `
<div class="position-relative">
<a href="${photo}" target="_blank">
<img src="${photo}" class="img-fluid rounded" style="width:100%; height:200px; object-fit:cover; border:2px solid #3b82f6; cursor:pointer;">
</a>
<span class="position-absolute top-0 start-0 badge bg-primary m-2">P${index + 1}</span>
</div>
`;
gallery.appendChild(col);
});
}
// Technician photos
if (techPhotos.length > 0) {
const techSection = document.createElement('div');
techSection.className = 'col-12 mb-3 mt-3';
techSection.innerHTML = '<h6 class="text-success mb-2"><i class="bi bi-tools me-1"></i>Foto dari Teknisi</h6>';
gallery.appendChild(techSection);
techPhotos.forEach((photo, index) => {
const col = document.createElement('div');
col.className = 'col-md-4 col-6';
col.innerHTML = `
<div class="position-relative">
<a href="${photo}" target="_blank">
<img src="${photo}" class="img-fluid rounded" style="width:100%; height:200px; object-fit:cover; border:2px solid #10b981; cursor:pointer;">
</a>
<span class="position-absolute top-0 start-0 badge bg-success m-2">T${index + 1}</span>
</div>
`;
gallery.appendChild(col);
});
}
}
openModal('photoModal');
});
setTimeout(()=>document.querySelectorAll('.alert').forEach(a=>a.style.display='none'),5000);
</script>
</body>
</html>
+86
View File
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: activePage || 'update', 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-cloud-arrow-down"></i> <%= t('admin.update.title', 'Update GitHub') %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i> <%= t('common.logout', 'Logout') %></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-git"></i> <%= t('admin.update.status_title', 'Status Versi') %></h6></div>
<div class="card-bd">
<% if (info && info.error) { %>
<div class="alert alert-d"><i class="bi bi-exclamation-circle"></i> <%= info.error %></div>
<% } %>
<div class="form-row" style="align-items:flex-start">
<div class="fg">
<label class="fl"><%= t('admin.update.server_version', 'Versi Server (version.txt)') %></label>
<div class="fc" style="display:flex;align-items:center;gap:8px">
<i class="bi bi-hdd-network"></i>
<span style="font-weight:800"><%= info?.localVersion || '-' %></span>
</div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.update.github_version_prefix', 'Versi GitHub (') %><%= info?.repo || 'origin' %>/<%= info?.branch || '-' %><%= t('admin.update.github_version_suffix', ')') %></label>
<div class="fc" style="display:flex;align-items:center;gap:8px">
<i class="bi bi-github"></i>
<span style="font-weight:800"><%= info?.remoteVersion || '-' %></span>
<% if (info?.needsUpdate) { %>
<span class="badge badge-d" style="margin-left:8px"><i class="bi bi-arrow-up-circle"></i> <%= t('admin.update.has_update', 'Ada update') %></span>
<% } else { %>
<span class="badge badge-s" style="margin-left:8px"><i class="bi bi-check2-circle"></i> <%= t('admin.update.latest', 'Terbaru') %></span>
<% } %>
</div>
</div>
</div>
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:12px">
<form method="GET" action="/admin/update">
<button type="submit" class="btn btn-g"><i class="bi bi-arrow-repeat"></i> <%= t('admin.update.check', 'Cek Versi') %></button>
</form>
<form method="POST" action="/admin/update/run" onsubmit="return confirmUpdate()">
<button type="submit" class="btn btn-s" <%= (info && info.error) ? 'disabled' : '' %>><i class="bi bi-cloud-arrow-down"></i> <%= t('admin.update.run', 'Update Sekarang') %></button>
</form>
</div>
<div style="margin-top:12px;color:var(--muted);font-size:12px;line-height:1.5">
<%= t('admin.update.keep_files', 'File yang tidak ditimpa saat update: settings.json dan folder database. Setelah update selesai, restart aplikasi agar versi baru aktif.') %>
</div>
</div>
</div>
<% if (log) { %>
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-terminal"></i> <%= t('admin.update.log_title', 'Log Update') %></h6></div>
<div class="card-bd">
<pre style="white-space:pre-wrap;word-break:break-word;margin:0;background:rgba(0,0,0,0.08);padding:12px;border-radius:12px"><%= log %></pre>
</div>
</div>
<% } %>
</div>
</div>
<script>
function confirmUpdate() {
return confirm("<%= t('admin.update.confirm', 'Jalankan update dari GitHub sekarang? Pastikan koneksi internet aman dan aplikasi akan direstart setelah selesai.') %>");
}
</script>
</body>
</html>
+634
View File
@@ -0,0 +1,634 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.nav.voucher_hotspot', 'Voucher Hotspot') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.mtabs { display: flex; gap: 10px; margin-bottom: 14px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
.mtab { padding: 8px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; }
.mtab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.mtab.active { background: var(--primary); color: white; }
.kv { display:flex; gap:14px; flex-wrap:wrap; align-items:flex-end; }
.kv .fg { margin:0; min-width: 180px; flex: 1; }
.kv .fg.w120 { min-width: 120px; flex: 0 0 120px; }
.kv .fg.w160 { min-width: 160px; flex: 0 0 160px; }
.hint { font-size: 12px; color: var(--muted); }
.pill { display:inline-flex; align-items:center; gap:6px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); font-size: 12px; color: var(--muted); }
.pill b { color: var(--text); font-weight: 700; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'mikrotik', 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-ticket-perforated"></i> <%= t('admin.nav.voucher_hotspot', 'Voucher Hotspot') %></div>
<div class="tb-right" style="display:flex;align-items:center;gap:15px">
<div class="router-selector" style="display:flex;align-items:center;gap:8px;padding:4px 10px;border-radius:10px;">
<i class="bi bi-hdd-network small" style="color:#60a5fa;"></i>
<select id="global-router-id" class="fs" style="padding:4px 8px;font-size:13px;border-radius:6px;cursor:pointer;" onchange="changeRouter()">
<option value="" <%= (!selectedRouterId) ? 'selected' : '' %>><%= t('admin.vouchers.default_router', '-- Default / Semua Router --') %></option>
<% routers.forEach(r => { %>
<option value="<%= r.id %>" <%= (String(selectedRouterId || '') === String(r.id)) ? 'selected' : '' %>><%= r.name %></option>
<% }) %>
</select>
</div>
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% const mTab = 'vouchers'; %>
<% if (msg) { %>
<div class="alert <%= msg.type === 'success' ? 's' : (msg.type === 'error' ? 'd' : 'w') %>"><%= msg.text %></div>
<% } %>
<div class="mtabs">
<a href="/admin/mikrotik" class="mtab <%= mTab==='monitoring' ? 'active' : '' %>"><i class="bi bi-router"></i> Monitoring</a>
<a href="/admin/vouchers" class="mtab <%= mTab==='vouchers' ? 'active' : '' %>"><i class="bi bi-ticket-perforated"></i> Voucher Hotspot</a>
<a href="/admin/routers" class="mtab <%= mTab==='routers' ? 'active' : '' %>"><i class="bi bi-hdd-network"></i> Router</a>
</div>
<div class="stat-grid" style="grid-template-columns:repeat(4,1fr);margin-bottom:20px">
<div class="sc p"><i class="bi bi-collection sc-icon"></i><div class="sc-label"><%= t('admin.vouchers.stats.batches', 'Batch') %></div><div class="sc-val" id="stat-batches">0</div></div>
<div class="sc s"><i class="bi bi-ticket sc-icon"></i><div class="sc-label"><%= t('admin.vouchers.stats.vouchers', 'Voucher') %></div><div class="sc-val" id="stat-vouchers">0</div></div>
<div class="sc w"><i class="bi bi-check2-circle sc-icon"></i><div class="sc-label"><%= t('admin.vouchers.stats.used', 'Terpakai') %></div><div class="sc-val" id="stat-used">0</div></div>
<div class="sc d"><i class="bi bi-circle sc-icon"></i><div class="sc-label"><%= t('admin.vouchers.stats.unused', 'Belum') %></div><div class="sc-val" id="stat-unused">0</div></div>
</div>
<div class="card" style="margin-bottom:20px">
<div class="card-hd">
<h6><i class="bi bi-plus-circle"></i> <%= t('admin.vouchers.create.title', 'Buat Voucher Batch') %></h6>
<div class="pill"><i class="bi bi-building"></i> <b><%= settings.company_header || company %></b></div>
</div>
<div class="card-bd">
<div class="kv">
<div class="fg">
<label class="fl"><%= t('admin.vouchers.create.profile', 'Hotspot User Profile') %></label>
<select class="fc" id="profileName" onchange="onProfileChanged()">
<option value=""><%= t('common.loading_short', 'Memuat...') %></option>
</select>
<div class="hint" id="profileHint"></div>
</div>
<div class="fg w160">
<label class="fl"><%= t('admin.vouchers.create.qty', 'Jumlah') %></label>
<input class="fc" id="qty" type="number" min="1" max="5000" value="50">
</div>
<div class="fg w160">
<label class="fl"><%= t('admin.vouchers.create.price', 'Harga (Rp)') %></label>
<input class="fc" id="price" type="number" min="0" step="1000" placeholder="<%= t('common.optional','Opsional') %>">
</div>
<div class="fg w160">
<label class="fl"><%= t('admin.vouchers.create.mode', 'Mode') %></label>
<select class="fc" id="mode" onchange="onModeChanged()">
<option value="voucher"><%= t('admin.vouchers.create.mode_voucher', 'Voucher (User=Pass)') %></option>
<option value="member"><%= t('admin.vouchers.create.mode_member', 'Member (User & Pass Beda)') %></option>
</select>
</div>
<div class="fg w160">
<label class="fl"><%= t('admin.vouchers.create.charset', 'Karakter') %></label>
<select class="fc" id="charset">
<option value="numbers"><%= t('admin.vouchers.create.charset_numbers', 'Hanya Angka') %></option>
<option value="letters"><%= t('admin.vouchers.create.charset_letters', 'Hanya Huruf (Kecil)') %></option>
<option value="mixed"><%= t('admin.vouchers.create.charset_mixed', 'Huruf & Angka') %></option>
</select>
</div>
<div class="fg w120">
<label class="fl"><%= t('admin.vouchers.create.length', 'Panjang') %></label>
<input class="fc" id="codeLength" type="number" min="4" max="16" value="6">
</div>
<div class="fg w120">
<label class="fl"><%= t('admin.vouchers.create.prefix', 'Prefix') %></label>
<input class="fc" id="prefix" placeholder="<%= t('common.optional','Opsional') %>">
</div>
<div class="fg w120" style="flex:0 0 auto;min-width:0">
<button class="btn btn-p" onclick="createBatch()" id="btnCreate"><i class="bi bi-magic"></i> <%= t('admin.vouchers.create.create_btn', 'Buat') %></button>
</div>
</div>
<div class="hint" style="margin-top:10px" id="modeHint">
<%= t('admin.vouchers.create.hint_default', 'Durasi diambil dari metadata profile. Harga bisa diisi manual (kalau kosong akan pakai dari profile).') %>
</div>
</div>
</div>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-check"></i> <%= t('admin.vouchers.batches.title', 'Batch Voucher') %></h6>
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
<div style="display:flex;gap:6px;align-items:center">
<span class="muted" style="font-size:12px"><%= t('admin.vouchers.print_style', 'Style Print') %></span>
<select class="fc" id="print_style" style="min-width:220px" onchange="onPrintStyleChanged()">
<option value="cards" <%= (settings.voucher_print_default_style || '') === 'cards' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_cards', 'Kartu (Default)') %></option>
<option value="mks" <%= (settings.voucher_print_default_style || '') === 'mks' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_mks', 'Template Jadi: MKS') %></option>
<option value="simple" <%= (settings.voucher_print_default_style || '') === 'simple' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_simple', 'Template Jadi: Simple') %></option>
<option value="minimal" <%= (settings.voucher_print_default_style || '') === 'minimal' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_minimal', 'Template Jadi: Minimal') %></option>
<option value="template" <%= (settings.voucher_print_default_style || '') === 'template' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_custom', 'Template Custom (Editor)') %></option>
</select>
</div>
<button class="btn btn-g btn-sm" onclick="openModal('voucherTemplateModal')"><i class="bi bi-layout-text-sidebar-reverse"></i> <%= t('admin.vouchers.template.btn', 'Template Print') %></button>
<button class="btn btn-g btn-sm" onclick="reloadBatches()"><i class="bi bi-arrow-clockwise"></i> <%= t('common.refresh', 'Refresh') %></button>
</div>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>ID</th>
<th><%= t('admin.vouchers.table.router', 'Router') %></th>
<th>Profile</th>
<th>Qty</th>
<th><%= t('admin.vouchers.table.used', 'Terpakai') %></th>
<th><%= t('common.status','Status') %></th>
<th><%= t('admin.vouchers.table.created', 'Dibuat') %></th>
<th><%= t('common.actions', 'Aksi') %></th>
</tr>
</thead>
<tbody id="batches-list">
<tr><td colspan="8" class="text-center text-muted"><%= t('common.loading', 'Memuat data...') %></td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="mo" id="batchDetailModal">
<div class="mb" style="max-width:1100px">
<div class="mh">
<span class="mt" id="batchDetailTitle"><%= t('admin.vouchers.detail.title', 'Detail Batch') %></span>
<button class="mc" onclick="closeModal('batchDetailModal')">&times;</button>
</div>
<div class="mbody">
<div id="batchDetailMeta" style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:10px"></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr><th><%= t('admin.vouchers.detail.code', 'Kode') %></th><th><%= t('admin.vouchers.detail.password', 'Password') %></th><th><%= t('common.status','Status') %></th><th><%= t('admin.vouchers.detail.used_at', 'Terpakai') %></th><th><%= t('admin.vouchers.detail.last_seen', 'Last Seen') %></th></tr>
</thead>
<tbody id="batchDetailList">
<tr><td colspan="5" class="text-center text-muted"><%= t('common.loading_short', 'Memuat...') %></td></tr>
</tbody>
</table>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('batchDetailModal')"><%= t('common.close', 'Tutup') %></button>
</div>
</div>
</div>
<div class="mo" id="voucherTemplateModal">
<div class="mb" style="max-width:980px">
<div class="mh">
<span class="mt"><%= t('admin.vouchers.template.title', 'Template Print Voucher') %></span>
<button class="mc" onclick="closeModal('voucherTemplateModal')">&times;</button>
</div>
<div class="mbody">
<div class="fg" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;margin-bottom:12px">
<div style="flex:1;min-width:240px">
<label class="fl"><%= t('admin.vouchers.template.preset', 'Preset') %></label>
<select class="fc" id="tpl_preset" onchange="applyTemplatePreset(this.value)">
<option value=""><%= t('common.choose', '-- Pilih --') %></option>
<option value="mks"><%= t('admin.vouchers.template.preset_mks', 'Mikhmon (MKS)') %></option>
<option value="simple"><%= t('admin.vouchers.template.preset_simple', 'Simple') %></option>
<option value="empty"><%= t('admin.vouchers.template.preset_empty', 'Kosong') %></option>
</select>
</div>
<div class="hint" style="flex:2;min-width:260px">
<%= t('admin.vouchers.template.preset_hint', 'Catatan: template Mikhmon yang masih berisi kode PHP tidak bisa dipakai langsung. Gunakan preset ini lalu edit sesuai kebutuhan.') %>
</div>
</div>
<div class="fg" style="margin-bottom:12px">
<label class="fl"><%= t('admin.vouchers.template.use', 'Gunakan template custom sebagai default print') %></label>
<select class="fc" id="tpl_use">
<option value="0" <%= settings.voucher_print_use_template ? '' : 'selected' %>><%= t('common.off', 'Off') %></option>
<option value="1" <%= settings.voucher_print_use_template ? 'selected' : '' %>><%= t('common.on', 'On') %></option>
</select>
</div>
<div class="fg" style="margin-bottom:12px">
<label class="fl"><%= t('admin.vouchers.template.default_style', 'Default style print') %></label>
<select class="fc" id="tpl_default_style">
<option value="cards" <%= (settings.voucher_print_default_style || '') === 'cards' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_cards', 'Kartu (Default)') %></option>
<option value="mks" <%= (settings.voucher_print_default_style || '') === 'mks' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_mks', 'Template Jadi: MKS') %></option>
<option value="simple" <%= (settings.voucher_print_default_style || '') === 'simple' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_simple', 'Template Jadi: Simple') %></option>
<option value="minimal" <%= (settings.voucher_print_default_style || '') === 'minimal' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_minimal', 'Template Jadi: Minimal') %></option>
<option value="template" <%= (settings.voucher_print_default_style || '') === 'template' ? 'selected' : '' %>><%= t('admin.vouchers.print_style_custom', 'Template Custom (Editor)') %></option>
</select>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vouchers.template.header', 'Header (opsional)') %></label>
<textarea class="fc" id="tpl_header" rows="5" spellcheck="false"><%= settings.voucher_print_template_header || '' %></textarea>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vouchers.template.row', 'Row (wajib, diulang untuk setiap voucher)') %></label>
<textarea class="fc" id="tpl_row" rows="10" spellcheck="false"><%= settings.voucher_print_template_row || '' %></textarea>
<div class="hint">
Variables: %credential% %username% %password% %profile% %validity% %validityText% %price% %priceText% %currency% %company% %phone% %timeStamp% %#%
</div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vouchers.template.footer', 'Footer (opsional)') %></label>
<textarea class="fc" id="tpl_footer" rows="5" spellcheck="false"><%= settings.voucher_print_template_footer || '' %></textarea>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('voucherTemplateModal')"><%= t('common.close', 'Tutup') %></button>
<button type="button" class="btn btn-p" onclick="saveVoucherTemplate()"><%= t('common.save', 'Simpan') %></button>
</div>
</div>
</div>
<script>
const I18N = {
profilePricePrefix: "<%= t('admin.vouchers.js.price_prefix', 'Harga:') %>",
profileDurationPrefix: "<%= t('admin.vouchers.js.duration_prefix', 'Durasi:') %>",
modeVoucherHint: "<%= t('admin.vouchers.js.mode_voucher_hint', 'Mode Voucher: Username dan Password sama. Durasi diambil dari metadata profile. Harga bisa diisi manual.') %>",
modeMemberHint: "<%= t('admin.vouchers.js.mode_member_hint', 'Mode Member: Username dan Password di-generate secara terpisah. Durasi diambil dari metadata profile. Harga bisa diisi manual.') %>",
chooseProfileFirst: "<%= t('admin.vouchers.js.choose_profile_first', 'Pilih profile hotspot terlebih dulu.') %>",
invalidQty: "<%= t('admin.vouchers.js.invalid_qty', 'Jumlah voucher tidak valid.') %>",
processing: "<%= t('admin.vouchers.js.processing', 'Proses...') %>",
createFailed: "<%= t('admin.vouchers.js.create_failed', 'Gagal membuat batch') %>",
batchCreatedPrefix: "<%= t('admin.vouchers.js.batch_created_prefix', 'Batch') %>",
batchCreatedSuffix: "<%= t('admin.vouchers.js.batch_created_suffix', 'dibuat. Sistem sedang membuat voucher di MikroTik.') %>",
createBtn: "<%= t('admin.vouchers.create.create_btn', 'Buat') %>",
noBatches: "<%= t('admin.vouchers.js.no_batches', 'Belum ada batch voucher.') %>",
defaultRouterName: "<%= t('admin.vouchers.js.default_router', 'Default') %>",
actionDetail: "<%= t('common.details', 'Detail') %>",
actionSync: "<%= t('admin.vouchers.actions.sync', 'Sync') %>",
actionPrint: "<%= t('admin.vouchers.actions.print', 'Print') %>",
actionCsv: "<%= t('admin.vouchers.actions.csv', 'CSV') %>",
actionDelete: "<%= t('common.delete', 'Hapus') %>",
detailLoadFailed: "<%= t('admin.vouchers.js.detail_failed', 'Gagal mengambil detail') %>",
detailTitlePrefix: "<%= t('admin.vouchers.js.detail_title_prefix', 'Detail Batch') %>",
noVouchers: "<%= t('admin.vouchers.js.no_vouchers', 'Tidak ada voucher.') %>",
syncFailed: "<%= t('admin.vouchers.js.sync_failed', 'Gagal sync') %>",
syncDonePrefix: "<%= t('admin.vouchers.js.sync_done_prefix', 'Sync selesai. Used baru:') %>",
syncDoneMiddle: "<%= t('admin.vouchers.js.sync_done_middle', 'Missing:') %>",
deleteCreatingWarn: "<%= t('admin.vouchers.js.delete_creating_warn', 'Batch sedang diproses. Tunggu sampai statusnya selesai, lalu coba hapus lagi.') %>",
deleteConfirmPrefix: "<%= t('admin.vouchers.js.delete_confirm_prefix', 'Hapus Batch') %>",
deleteConfirmLine1: "<%= t('admin.vouchers.js.delete_confirm_line1', '- Data voucher batch ini akan dihapus dari sistem.') %>",
deleteConfirmLine2: "<%= t('admin.vouchers.js.delete_confirm_line2', '- Ini TIDAK menghapus user Hotspot yang sudah terbuat di MikroTik.') %>",
deleteConfirmContinue: "<%= t('admin.vouchers.js.delete_confirm_continue', 'Lanjutkan?') %>",
deleteFailed: "<%= t('admin.vouchers.js.delete_failed', 'Gagal menghapus batch') %>",
deleteSuccessPrefix: "<%= t('admin.vouchers.js.delete_success_prefix', 'Batch') %>",
deleteSuccessSuffix: "<%= t('admin.vouchers.js.delete_success_suffix', 'berhasil dihapus.') %>",
deletedVouchersPrefix: "<%= t('admin.vouchers.js.deleted_vouchers_prefix', 'Vouchers terhapus:') %>",
usedPrefix: "<%= t('admin.vouchers.js.used_prefix', 'Terpakai:') %>",
templateSaved: "<%= t('admin.vouchers.template.saved', 'Template tersimpan.') %>",
templateSaveFailed: "<%= t('admin.vouchers.template.save_failed', 'Gagal menyimpan template') %>"
};
function openModal(id){ document.getElementById(id)?.classList.add('show'); }
function closeModal(id){ document.getElementById(id)?.classList.remove('show'); }
function applyTemplatePreset(preset) {
const headerEl = document.getElementById('tpl_header');
const rowEl = document.getElementById('tpl_row');
const footerEl = document.getElementById('tpl_footer');
if (!headerEl || !rowEl || !footerEl) return;
const presets = {
empty: { header: '', row: '', footer: '' },
simple: {
header: `<style>\n@page{size:A4;margin:6mm}\n*{box-sizing:border-box}\nbody{margin:0;font-family:Arial,sans-serif;color:#0f172a}\n.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n.card{border:1.5px solid #334155;border-radius:10px;padding:8px 8px 22px;min-height:110px;position:relative;break-inside:avoid}\n.hd{font-weight:800;font-size:11px}\n.code{font-weight:900;font-size:22px;letter-spacing:2px;margin-top:6px;font-family:Consolas,monospace}\n.meta{font-size:10px;color:#334155;margin-top:4px}\n.wa{position:absolute;left:50%;bottom:6px;transform:translateX(-50%);font-size:9.5px;color:#334155;white-space:nowrap}\n</style>\n<div class="grid">`,
row: `<div class="card">\n <div class="hd">%company%</div>\n <div class="meta">%profile% • %validityText% • %currency% %priceText%</div>\n <div class="code">%username%</div>\n <div class="meta">%password%</div>\n <div class="wa">WA: %phone%</div>\n</div>`,
footer: `</div>`
},
mks: {
header: `<style>\n@page{size:A4;margin:6mm}\n*{box-sizing:border-box}\nbody{margin:0;font-family:Arial,sans-serif;color:#0f172a}\n.v-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}\n.vc{border:1px solid #0f172a;border-radius:10px;min-height:110px;padding:8px;position:relative;break-inside:avoid;overflow:hidden}\n.vc:before{content:\"\";position:absolute;inset:0;background:linear-gradient(120deg,rgba(59,130,246,.03),rgba(16,185,129,.025))}\n.vc>*{position:relative}\n.vh{font-weight:900;font-size:11px;letter-spacing:.2px}\n.vp{position:absolute;top:8px;right:8px;font-size:10.5px;font-weight:800;background:rgba(16,185,129,.16);border:1px solid rgba(16,185,129,.35);padding:2px 7px;border-radius:999px}\n.vm{font-size:10px;color:#334155;margin-top:6px}\n.vu{font-weight:950;font-size:18px;letter-spacing:1px;margin-top:8px;font-family:Consolas,monospace}\n.vf{position:absolute;left:50%;bottom:6px;transform:translateX(-50%);font-size:9.5px;color:#334155;white-space:nowrap}\n</style>\n<div class="v-grid">`,
row: `<div class="vc">\n <div class="vh">%company%</div>\n <div class="vp">%currency% %priceText%</div>\n <div class="vm">%profile% • %validityText%</div>\n <div class="vu">%credential%</div>\n <div class="vf">WA: %phone%</div>\n</div>`,
footer: `</div>`
}
};
const p = presets[preset];
if (!p) return;
headerEl.value = p.header;
rowEl.value = p.row;
footerEl.value = p.footer;
}
function getRouterId() {
const v = document.getElementById('global-router-id')?.value;
return v ? encodeURIComponent(v) : '';
}
function changeRouter() {
const rId = document.getElementById('global-router-id')?.value;
window.location.href = '/admin/set-active-router?router_id=' + (rId || 'all');
}
function parseMikhmonOnLogin(script) {
if (!script) return null;
const s = String(script).trim();
// Cari pattern :put (",rem, ... , ... , ...
const putMatch = s.match(/:\s*put\s*\(\s*[",]rem[",]?\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)/i);
if (putMatch) {
const cost = String(putMatch[1] || '').trim();
const validity = String(putMatch[2] || '').trim();
const priceStr = String(putMatch[3] || '').trim();
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
if (validity && price > 0) {
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
}
}
// Fallback: split by comma
const parts = s.split(',').map(p => String(p).trim());
let remIdx = -1;
for (let i = 0; i < parts.length; i++) {
if (parts[i].includes('rem')) {
remIdx = i;
break;
}
}
if (remIdx >= 0 && remIdx + 3 < parts.length) {
const cost = String(parts[remIdx + 1] || '').trim();
const validity = String(parts[remIdx + 2] || '').trim();
const priceStr = String(parts[remIdx + 3] || '').trim();
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
if (validity && price > 0) {
return { validity, price, cost: Number(cost.replace(/[^\d]/g, '')) || 0 };
}
}
return null;
}
let profilesCache = [];
async function loadProfiles() {
const routerId = getRouterId();
const url = routerId ? `/admin/api/mikrotik/hotspot-user-profiles?routerId=${routerId}` : `/admin/api/mikrotik/hotspot-user-profiles`;
const res = await fetch(url);
const data = await res.json();
profilesCache = Array.isArray(data) ? data : [];
const filtered = profilesCache
.map(p => ({ raw: p, meta: parseMikhmonOnLogin(p.onLogin || p['on-login']) }))
.filter(x => x.meta && x.meta.validity);
const sel = document.getElementById('profileName');
sel.innerHTML = '';
if (filtered.length === 0) {
sel.innerHTML = '<option value="">Tidak ada profile dengan harga/durasi (Mikhmon)</option>';
document.getElementById('profileHint').innerText = '';
return;
}
sel.insertAdjacentHTML('beforeend', '<option value="">-- Pilih Profile --</option>');
for (const x of filtered) {
const label = `${x.raw.name} (Rp ${Number(x.meta.price || 0).toLocaleString('id-ID')} • ${x.meta.validity})`;
sel.insertAdjacentHTML('beforeend', `<option value="${x.raw.name}">${label}</option>`);
}
document.getElementById('profileHint').innerText = '';
}
(function initPrintStyle() {
const ps = document.getElementById('print_style');
if (!ps) return;
const current = String(ps.value || 'cards');
ps.value = current || 'cards';
})();
function onProfileChanged() {
const name = document.getElementById('profileName')?.value;
const raw = profilesCache.find(p => p.name === name);
const meta = raw ? parseMikhmonOnLogin(raw.onLogin || raw['on-login']) : null;
if (!meta) return document.getElementById('profileHint').innerText = '';
document.getElementById('profileHint').innerText = `${I18N.profilePricePrefix} Rp ${Number(meta.price||0).toLocaleString('id-ID')} • ${I18N.profileDurationPrefix} ${meta.validity}`;
const priceEl = document.getElementById('price');
if (priceEl && (priceEl.value === '' || Number(priceEl.value) === 0)) {
priceEl.value = Number(meta.price || 0) ? String(Number(meta.price || 0)) : '';
}
}
function onModeChanged() {
const mode = document.getElementById('mode')?.value;
const hint = document.getElementById('modeHint');
if (mode === 'voucher') {
hint.innerText = I18N.modeVoucherHint;
} else {
hint.innerText = I18N.modeMemberHint;
}
}
async function createBatch() {
const profile = document.getElementById('profileName')?.value;
const qty = Number(document.getElementById('qty')?.value || 0);
const codeLength = Number(document.getElementById('codeLength')?.value || 6);
const prefix = String(document.getElementById('prefix')?.value || '').trim();
const mode = document.getElementById('mode')?.value || 'voucher';
const charset = document.getElementById('charset')?.value || 'numbers';
const price = document.getElementById('price')?.value;
if (!profile) return alert(I18N.chooseProfileFirst);
if (!qty || qty < 1) return alert(I18N.invalidQty);
const routerId = getRouterId();
const url = routerId ? `/admin/api/vouchers/batches?routerId=${routerId}` : `/admin/api/vouchers/batches`;
const btn = document.getElementById('btnCreate');
btn.disabled = true;
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> ' + I18N.processing;
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profile, qty, codeLength, prefix, mode, charset, price })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || I18N.createFailed);
await reloadBatches();
alert(`${I18N.batchCreatedPrefix} #${data.batchId} ${I18N.batchCreatedSuffix}`);
} catch (e) {
alert(e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-magic"></i> ' + I18N.createBtn;
}
}
async function reloadBatches() {
const routerId = getRouterId();
const url = routerId ? `/admin/api/vouchers/batches?routerId=${routerId}` : `/admin/api/vouchers/batches`;
const res = await fetch(url);
const batches = await res.json();
const list = document.getElementById('batches-list');
if (!Array.isArray(batches) || batches.length === 0) {
list.innerHTML = '<tr><td colspan="8" class="text-center text-muted">' + I18N.noBatches + '</td></tr>';
updateStats([]);
return;
}
list.innerHTML = batches.map(b => {
const status = String(b.status || '-');
const stClass = status === 'ready' ? 's' : (status === 'failed' ? 'd' : (status === 'partial' ? 'w' : 'w'));
const created = b.created_at ? new Date(b.created_at).toLocaleString('id-ID') : '-';
const qty = `${b.qty_created || 0}/${b.qty_total || 0}`;
const used = `${b.used_count || 0}/${b.vouchers_count || 0}`;
const routerName = b.router_name || I18N.defaultRouterName;
const selectedStyle = String(document.getElementById('print_style')?.value || 'cards');
const printHref = selectedStyle === 'cards'
? `/admin/vouchers/batches/${b.id}/print`
: `/admin/vouchers/batches/${b.id}/print?style=${encodeURIComponent(selectedStyle)}`;
return `
<tr>
<td class="mono">#${b.id}</td>
<td>${routerName}</td>
<td>${b.profile_name || '-'}</td>
<td>${qty}</td>
<td>${used}</td>
<td><span class="badge ${stClass}">${status}</span></td>
<td>${created}</td>
<td style="white-space:nowrap">
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick="openDetail(${b.id})"><i class="bi bi-eye"></i> ${I18N.actionDetail}</button>
<button type="button" class="act-item" onclick="syncBatch(${b.id})"><i class="bi bi-arrow-repeat"></i> ${I18N.actionSync}</button>
<a class="act-item" target="_blank" href="${printHref}"><i class="bi bi-printer"></i> ${I18N.actionPrint}</a>
<a class="act-item" target="_blank" href="/admin/vouchers/batches/${b.id}/pdf"><i class="bi bi-file-earmark-pdf"></i> PDF</a>
<a class="act-item" href="/admin/vouchers/batches/${b.id}/export.csv"><i class="bi bi-filetype-csv"></i> ${I18N.actionCsv}</a>
<div class="act-sep"></div>
<button type="button" class="act-item danger" onclick="deleteBatch(${b.id}, '${status}')"><i class="bi bi-trash"></i> ${I18N.actionDelete}</button>
</div>
</div>
</td>
</tr>
`;
}).join('');
updateStats(batches);
}
async function saveVoucherTemplate() {
const use_template = document.getElementById('tpl_use')?.value === '1';
const default_style = document.getElementById('tpl_default_style')?.value || '';
const header = document.getElementById('tpl_header')?.value || '';
const row = document.getElementById('tpl_row')?.value || '';
const footer = document.getElementById('tpl_footer')?.value || '';
try {
const res = await fetch('/admin/api/vouchers/template', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ use_template, default_style, header, row, footer })
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || I18N.templateSaveFailed);
alert(I18N.templateSaved);
closeModal('voucherTemplateModal');
const ps = document.getElementById('print_style');
if (ps) ps.value = default_style || 'cards';
await reloadBatches();
} catch (e) {
alert(e.message);
}
}
function onPrintStyleChanged() {
reloadBatches();
}
function updateStats(batches) {
const totalBatches = batches.length;
const totalV = batches.reduce((a, b) => a + (Number(b.vouchers_count || 0)), 0);
const used = batches.reduce((a, b) => a + (Number(b.used_count || 0)), 0);
const unused = Math.max(0, totalV - used);
document.getElementById('stat-batches').innerText = totalBatches;
document.getElementById('stat-vouchers').innerText = totalV;
document.getElementById('stat-used').innerText = used;
document.getElementById('stat-unused').innerText = unused;
}
async function openDetail(id) {
const routerId = getRouterId();
const url = routerId ? `/admin/api/vouchers/batches/${id}?routerId=${routerId}` : `/admin/api/vouchers/batches/${id}`;
const res = await fetch(url);
const data = await res.json();
if (!res.ok) return alert(data.error || I18N.detailLoadFailed);
document.getElementById('batchDetailTitle').innerText = `${I18N.detailTitlePrefix} #${data.batch.id}`;
const meta = document.getElementById('batchDetailMeta');
meta.innerHTML = '';
meta.insertAdjacentHTML('beforeend', `<span class="pill"><i class="bi bi-hdd-network"></i> <b>${data.batch.router_name || I18N.defaultRouterName}</b></span>`);
meta.insertAdjacentHTML('beforeend', `<span class="pill"><i class="bi bi-ticket"></i> <b>${data.batch.profile_name}</b></span>`);
meta.insertAdjacentHTML('beforeend', `<span class="pill"><i class="bi bi-clock"></i> <b>${data.batch.validity || '-'}</b></span>`);
meta.insertAdjacentHTML('beforeend', `<span class="pill"><i class="bi bi-cash-coin"></i> <b>Rp ${Number(data.batch.price||0).toLocaleString('id-ID')}</b></span>`);
meta.insertAdjacentHTML('beforeend', `<span class="pill"><i class="bi bi-collection"></i> <b>${data.batch.qty_created || 0}/${data.batch.qty_total || 0}</b></span>`);
const list = document.getElementById('batchDetailList');
const items = Array.isArray(data.vouchers) ? data.vouchers : [];
if (items.length === 0) {
list.innerHTML = '<tr><td colspan="5" class="text-center text-muted">' + I18N.noVouchers + '</td></tr>';
openModal('batchDetailModal');
return;
}
list.innerHTML = items.map(v => {
const st = String(v.status || '-');
const usedAt = v.used_at ? new Date(v.used_at).toLocaleString('id-ID') : '-';
const seenAt = v.last_seen_at ? new Date(v.last_seen_at).toLocaleString('id-ID') : '-';
const seen = (v.last_seen_uptime || v.last_seen_comment) ? `${(v.last_seen_uptime || '-')}${v.last_seen_comment ? ' • ' + v.last_seen_comment : ''}` : '-';
const stClass = st === 'used' ? 's' : (st === 'created' ? 'w' : (st === 'missing' ? 'd' : 'w'));
return `
<tr>
<td class="mono">${v.code}</td>
<td class="mono">${v.password}</td>
<td><span class="badge ${stClass}">${st}</span></td>
<td>${usedAt}</td>
<td><div class="hint">${seenAt}</div><div class="hint">${seen}</div></td>
</tr>
`;
}).join('');
openModal('batchDetailModal');
}
async function syncBatch(id) {
const routerId = getRouterId();
const url = routerId ? `/admin/api/vouchers/batches/${id}/sync?routerId=${routerId}` : `/admin/api/vouchers/batches/${id}/sync`;
try {
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || I18N.syncFailed);
await reloadBatches();
alert(`${I18N.syncDonePrefix} ${data.usedNew || 0}, ${I18N.syncDoneMiddle} ${data.missing || 0}`);
} catch (e) {
alert(e.message);
}
}
async function deleteBatch(id, status) {
if (String(status) === 'creating') {
return alert(I18N.deleteCreatingWarn);
}
const ok = confirm(
`${I18N.deleteConfirmPrefix} #${id}?\n\n` +
`${I18N.deleteConfirmLine1}\n` +
`${I18N.deleteConfirmLine2}\n\n` +
`${I18N.deleteConfirmContinue}`
);
if (!ok) return;
const routerId = getRouterId();
const url = routerId ? `/admin/api/vouchers/batches/${id}/delete?routerId=${routerId}` : `/admin/api/vouchers/batches/${id}/delete`;
try {
const res = await fetch(url, { method: 'POST' });
const data = await res.json();
if (!res.ok) throw new Error(data.error || I18N.deleteFailed);
await reloadBatches();
alert(`${I18N.deleteSuccessPrefix} #${id} ${I18N.deleteSuccessSuffix} ${I18N.deletedVouchersPrefix} ${data.deletedVouchers || 0}. ${I18N.usedPrefix} ${data.usedCount || 0}.`);
} catch (e) {
alert(e.message);
}
}
async function reloadAll() {
await loadProfiles();
await reloadBatches();
}
reloadAll();
setInterval(() => reloadBatches(), 12000);
</script>
</body>
</html>
+158
View File
@@ -0,0 +1,158 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Voucher Diskon | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
</head>
<body>
<%- include('partials/sidebar', { activePage: 'vouchers_discounts', 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-ticket-perforated"></i> Voucher Diskon &amp; Biaya Pasang</div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session.isCashier) ? 'Kasir' : 'Admin' %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<!-- Biaya pasang global -->
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-tools"></i> Biaya Pasang (Global)</h6></div>
<div class="card-bd">
<form method="POST" action="/admin/vouchers-discounts/settings" style="display:flex;gap:12px;align-items:flex-end;flex-wrap:wrap">
<div class="fg" style="flex:1;min-width:220px">
<label class="fl"><%= t('admin.vd.install_fee','Biaya Pasang Awal (Rp)') %></label>
<input class="fc" type="number" min="0" step="1000" name="registration_install_fee" value="<%= installFee %>">
<div class="hint">Dipakai sebagai komponen "biaya pasang" pada estimasi pendaftaran. Boleh 0.</div>
</div>
<div class="fg" style="min-width:160px"><label class="fl"><%= t('admin.vd.pppoe_domain','Domain PPPoE') %></label><input class="fc" name="pppoe_domain" value="<%= pppoeDomain %>" placeholder="sky.id"></div>
<div class="fg" style="min-width:180px"><label class="fl"><%= t('admin.vd.default_pppoe_pass','Password PPPoE Default') %></label><input class="fc" name="pppoe_default_password" value="<%= pppoeDefaultPassword %>"></div>
<button class="btn btn-p" type="submit"><i class="bi bi-save"></i> <%= t('common.save','Simpan') %></button>
</form>
</div>
</div>
<!-- Form voucher -->
<div class="card mb16">
<div class="card-hd"><h6><i class="bi bi-plus-circle"></i> <%= editVoucher ? ('Edit Voucher: ' + editVoucher.code) : 'Tambah Voucher Diskon' %></h6></div>
<div class="card-bd">
<form method="POST" action="/admin/vouchers-discounts/save">
<% if (editVoucher) { %><input type="hidden" name="id" value="<%= editVoucher.id %>"><% } %>
<div class="form-row">
<div class="fg">
<label class="fl">Kode Voucher *</label>
<input class="fc mono" name="code" value="<%= editVoucher ? editVoucher.code : '' %>" placeholder="mis. HEMAT50" style="text-transform:uppercase" required>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vd.type','Tipe') %></label>
<select class="fs" name="type">
<option value="nominal" <%= (editVoucher && editVoucher.type==='nominal') || !editVoucher ? 'selected' : '' %>><%= t('admin.vd.nominal','Nominal (Rp)') %></option>
<option value="percent" <%= editVoucher && editVoucher.type==='percent' ? 'selected' : '' %>><%= t('admin.vd.percent','Persen (%)') %></option>
</select>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vd.value_rp_pct','Nilai (Rp / %)') %></label>
<input class="fc" type="number" min="0" step="1000" name="value" value="<%= editVoucher ? editVoucher.value : '' %>" required>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vd.max_discount','Maks. Diskon (Rp)') %></label>
<input class="fc" type="number" min="0" step="1000" name="max_discount" value="<%= editVoucher ? editVoucher.max_discount : 0 %>">
<div class="hint">Untuk tipe persen (0 = tanpa batas).</div>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl"><%= t('admin.vd.min_billing','Min. Tagihan (Rp)') %></label>
<input class="fc" type="number" min="0" step="1000" name="min_purchase" value="<%= editVoucher ? editVoucher.min_purchase : 0 %>">
<div class="hint">0 = tanpa minimal.</div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vd.usage_limit','Batas Pemakaian') %></label>
<input class="fc" type="number" min="0" name="max_uses" value="<%= editVoucher ? editVoucher.max_uses : 0 %>">
<div class="hint">0 = tanpa batas (berlaku selamanya).</div>
</div>
<div class="fg">
<label class="fl"><%= t('admin.vd.status','Status') %></label>
<select class="fs" name="active">
<option value="1" <%= !editVoucher || editVoucher.active ? 'selected' : '' %>><%= t('admin.vd.active','Aktif') %></option>
<option value="0" <%= editVoucher && !editVoucher.active ? 'selected' : '' %>><%= t('admin.vd.inactive','Nonaktif') %></option>
</select>
</div>
<div class="fg" style="flex:2;min-width:200px">
<label class="fl"><%= t('admin.vd.note','Catatan') %></label>
<input class="fc" name="note" value="<%= editVoucher ? (editVoucher.note||'') : '' %>" placeholder="opsional">
</div>
</div>
<div style="text-align:right;margin-top:6px">
<% if (editVoucher) { %><a href="/admin/vouchers-discounts" class="btn btn-g"><%= t('admin.vd.cancel','Batal') %></a><% } %>
<button class="btn btn-p" type="submit"><i class="bi bi-save"></i> <%= editVoucher ? 'Simpan Perubahan' : 'Tambah Voucher' %></button>
</div>
</form>
</div>
</div>
<!-- Daftar voucher -->
<div class="card">
<div class="card-hd"><h6><i class="bi bi-list-ul"></i> Daftar Voucher (<%= vouchers.length %>)</h6></div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th style="width:130px"><%= t('admin.vd.code','Kode') %></th>
<th style="width:90px"><%= t('admin.vd.type','Tipe') %></th>
<th class="num" style="width:120px"><%= t('admin.vd.value','Nilai') %></th>
<th class="num" style="width:120px">Maks Diskon</th>
<th class="num" style="width:120px">Min Tagihan</th>
<th class="num" style="width:90px"><%= t('admin.vd.used','Dipakai') %></th>
<th style="width:90px"><%= t('admin.vd.status','Status') %></th>
<th><%= t('admin.vd.note','Catatan') %></th>
<th style="width:120px"><%= t('admin.vd.action','Aksi') %></th>
</tr>
</thead>
<tbody>
<% if (!vouchers.length) { %>
<tr><td colspan="9" class="text-center text-muted" style="padding:24px"><%= t('admin.vd.empty','Belum ada voucher.') %></td></tr>
<% } %>
<% vouchers.forEach(function(v){ %>
<tr>
<td class="mono fw6"><%= v.code %></td>
<td><%= v.type === 'percent' ? 'Persen' : 'Nominal' %></td>
<td class="num"><%= v.type === 'percent' ? (v.value + ' %') : ('Rp ' + Number(v.value).toLocaleString('id-ID')) %></td>
<td class="num"><%= Number(v.max_discount) > 0 ? ('Rp ' + Number(v.max_discount).toLocaleString('id-ID')) : '-' %></td>
<td class="num"><%= Number(v.min_purchase) > 0 ? ('Rp ' + Number(v.min_purchase).toLocaleString('id-ID')) : '-' %></td>
<td class="num"><%= v.used_count %><%= Number(v.max_uses) > 0 ? (' / ' + v.max_uses) : '' %></td>
<td><span class="badge <%= v.active ? 'bs' : 'bd' %>"><%= v.active ? 'Aktif' : 'Nonaktif' %></span></td>
<td style="font-size:12px;color:var(--muted)"><%= v.note || '' %></td>
<td style="white-space:nowrap">
<form id="vtg-<%= v.id %>" method="POST" action="/admin/vouchers-discounts/<%= v.id %>/toggle" style="display:none"></form>
<form id="vdl-<%= v.id %>" method="POST" action="/admin/vouchers-discounts/<%= v.id %>/delete" style="display:none" onsubmit="return confirm('Hapus voucher <%= v.code %>?')"></form>
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<a class="act-item" href="/admin/vouchers-discounts?edit=<%= v.id %>"><i class="bi bi-pencil"></i> Edit Voucher</a>
<button type="submit" form="vtg-<%= v.id %>" class="act-item warn"><i class="bi bi-power"></i> Aktif/Nonaktif</button>
<div class="act-sep"></div>
<button type="submit" form="vdl-<%= v.id %>" class="act-item danger"><i class="bi bi-trash"></i> Hapus Voucher</button>
</div>
</div>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
</div>
</div>
</div>
</body>
</html>
+373
View File
@@ -0,0 +1,373 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Paket Voucher Hotspot | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.mtabs { display: flex; gap: 10px; margin-bottom: 14px; border-bottom: 1px solid var(--border); padding-bottom: 10px; }
.mtab { padding: 8px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; }
.mtab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.mtab.active { background: var(--primary); color: white; }
.pill { display:inline-flex; align-items:center; gap:6px; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border); font-size: 12px; color: var(--muted); }
.pill b { color: var(--text); font-weight: 700; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'voucher_packages', 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-ticket-detailed"></i> Paket Voucher Hotspot (Real-Time)
</div>
<div class="tb-right" style="display:flex;align-items:center;gap:15px">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% if (msg) { %>
<div class="alert <%= msg.type === 'success' ? 's' : 'd' %>"><%= msg.text %></div>
<% } %>
<div class="mtabs">
<a href="/admin/vouchers" class="mtab"><i class="bi bi-ticket-perforated"></i> Pembuatan Voucher (Batch)</a>
<a href="/admin/vouchers/packages" class="mtab active"><i class="bi bi-ticket-detailed"></i> Konfigurasi Paket (Real-Time)</a>
</div>
<div class="card">
<div class="card-hd">
<h6><i class="bi bi-list-stars"></i> Daftar Paket Hotspot Real-Time</h6>
<div style="display:flex;align-items:center;gap:10px">
<% if (typeof routers !== 'undefined' && routers && routers.length > 0) { %>
<select class="fs" style="width:160px" onchange="window.location.href='/admin/set-active-router?router_id=' + this.value">
<option value="all" <%= (!selectedRouterId) ? 'selected' : '' %>> Semua Router</option>
<% routers.forEach(r => { %>
<option value="<%= r.id %>" <%= (selectedRouterId === r.id) ? 'selected' : '' %>> <%= r.name %></option>
<% }) %>
</select>
<% } %>
<button class="btn btn-p btn-sm" onclick="openAddModal()"><i class="bi bi-plus-lg"></i> Tambah Konfigurasi Paket</button>
</div>
</div>
<div class="tbl-wrap">
<table class="dtbl">
<thead>
<tr>
<th>Router</th>
<th>Nama Profile</th>
<th>Harga</th>
<th>Masa Aktif</th>
<th>Prefix</th>
<th>Panjang Kode</th>
<th>Karakter</th>
<th><%= t('common.status','Status') %></th>
<th><%= t('common.actions','Aksi') %></th>
</tr>
</thead>
<tbody id="packages-list">
<tr><td colspan="9" class="text-center text-muted">Memuat data paket...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Modal Tambah / Edit -->
<div class="mo" id="packageModal">
<div class="mb" style="max-width: 600px;">
<div class="mh">
<span class="mt" id="modalTitle">Konfigurasi Paket</span>
<button class="mc" onclick="closeModal('packageModal')">&times;</button>
</div>
<form id="packageForm" onsubmit="savePackage(event)">
<input type="hidden" id="packageId">
<div class="mbody">
<div class="fg">
<label class="fl">Pilih Router MikroTik <span class="text-danger">*</span></label>
<select class="fc" id="form_router_id" required onchange="loadMikrotikProfiles()">
<option value="">-- Pilih Router --</option>
<% routers.forEach(r => { %>
<option value="<%= r.id %>"><%= r.name %></option>
<% }) %>
</select>
<small class="text-muted d-block mt-1">Router harus dipilih untuk paket voucher</small>
</div>
<div class="fg">
<label class="fl">Hotspot User Profile *</label>
<div style="display:flex;gap:8px">
<select class="fs" id="form_profile_select" style="max-width:220px" onchange="document.getElementById('form_profile_name').value=this.value; onProfileSelected();">
<option value="">-- Pilih dari MikroTik --</option>
</select>
<input class="fc" id="form_profile_name" required placeholder="Ketik nama profile manual...">
</div>
<div class="hint" id="profileHint">Pilih router & profil untuk membaca durasi bawaan atau ketik manual jika MikroTik offline.</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Harga Jual (Rp) *</label>
<input class="fc" id="form_price" type="number" min="500" required placeholder="Contoh: 5000">
</div>
<div class="fg">
<label class="fl">Durasi / Masa Aktif *</label>
<input class="fc" id="form_validity" required placeholder="Contoh: 1d, 12h, 3h">
<div class="hint">Format Mikhmon (misal: 1d = 1 hari, 12h = 12 jam)</div>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Prefix Kode Voucher</label>
<input class="fc" id="form_prefix" placeholder="Opsional (misal: ALI-)">
</div>
<div class="fg">
<label class="fl">Panjang Kode (Jumlah Digit) *</label>
<input class="fc" id="form_code_length" type="number" min="4" max="16" value="6" required>
</div>
</div>
<div class="form-row">
<div class="fg">
<label class="fl">Tipe Karakter Kode *</label>
<select class="fc" id="form_charset" required>
<option value="numbers">Hanya Angka (0-9)</option>
<option value="letters">Hanya Huruf Kecil (a-z)</option>
<option value="mixed" selected>Kombinasi Huruf Besar & Angka (Acak)</option>
</select>
</div>
<div class="fg">
<label class="fl">Status Paket *</label>
<select class="fc" id="form_is_active" required>
<option value="1">Aktif (Tampil di portal pelanggan)</option>
<option value="0">Nonaktif (Sembunyikan)</option>
</select>
</div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('packageModal')"><%= t('common.cancel','Batal') %></button>
<button type="submit" class="btn btn-p" id="btnSave"><i class="bi bi-check-lg"></i> Simpan Paket</button>
</div>
</form>
</div>
</div>
<script>
let cachedMikrotikProfiles = [];
let currentPackages = [];
function openModal(id){ document.getElementById(id)?.classList.add('show'); }
function closeModal(id){ document.getElementById(id)?.classList.remove('show'); }
// Load all configurations
async function loadPackages() {
try {
const res = await fetch('/admin/api/vouchers/packages');
const data = await res.json();
currentPackages = data.rows || [];
const list = document.getElementById('packages-list');
if (currentPackages.length === 0) {
list.innerHTML = '<tr><td colspan="9" class="text-center text-muted" style="padding: 24px;">Belum ada paket voucher hotspot real-time terdaftar. Klik tombol Tambah diatas untuk memulai!</td></tr>';
return;
}
list.innerHTML = currentPackages.map((p, idx) => {
const routerName = p.router_name || 'Default';
const charsetText = p.charset === 'numbers' ? 'Angka' : (p.charset === 'letters' ? 'Huruf Kecil' : 'Campuran');
const statusBadge = p.is_active ? '<span class="badge bs"><%= t('common.active','Aktif') %></span>' : '<span class="badge bd"><%= t('common.inactive','Nonaktif') %></span>';
return `
<tr>
<td>${routerName}</td>
<td class="fw6">${p.profile_name}</td>
<td class="fw6" style="color:var(--success)">Rp ${Number(p.price).toLocaleString('id-ID')}</td>
<td><span class="badge bp">${p.validity}</span></td>
<td class="mono">${p.prefix || '<span class="text-muted">—</span>'}</td>
<td class="text-center">${p.code_length} digit</td>
<td>${charsetText}</td>
<td>${statusBadge}</td>
<td style="white-space:nowrap">
<div class="act">
<button type="button" class="act-btn" title="Aksi" onclick="actToggle(event, this)"><i class="bi bi-three-dots-vertical"></i></button>
<div class="act-menu">
<button type="button" class="act-item" onclick="openEditModal(${idx})"><i class="bi bi-pencil"></i> <%= t('common.edit','Edit') %></button>
<div class="act-sep"></div>
<button type="button" class="act-item danger" onclick="deletePackage(${p.id})"><i class="bi bi-trash"></i> <%= t('common.delete','Hapus') %></button>
</div>
</div>
</td>
</tr>
`;
}).join('');
} catch (e) {
alert('Gagal memuat paket voucher: ' + e.message);
}
}
// Load Mikrotik profiles real-time
async function loadMikrotikProfiles() {
const routerId = document.getElementById('form_router_id').value;
const url = routerId ? `/admin/api/mikrotik/hotspot-user-profiles?routerId=${routerId}` : `/admin/api/mikrotik/hotspot-user-profiles`;
const profileSelect = document.getElementById('form_profile_select');
profileSelect.innerHTML = '<option value=""><%= t('common.loading_short','Memuat...') %></option>';
try {
const res = await fetch(url);
const data = await res.json();
if (!res.ok || data.error) {
throw new Error(data.error || `HTTP ${res.status}`);
}
cachedMikrotikProfiles = Array.isArray(data) ? data : [];
profileSelect.innerHTML = '<option value="">-- Pilih dari MikroTik --</option>';
cachedMikrotikProfiles.forEach(p => {
profileSelect.insertAdjacentHTML('beforeend', `<option value="${p.name}">${p.name}</option>`);
});
} catch (e) {
profileSelect.innerHTML = `<option value="">Gagal memuat: ${e.message}</option>`;
}
}
// Parse Mikhmon onLogin metadata for auto pricing/duration
function parseMikhmonOnLogin(script) {
if (!script) return null;
const s = String(script).trim();
const putMatch = s.match(/:\s*put\s*\(\s*[",]rem[",]?\s*,\s*([^,]+)\s*,\s*([^,]+)\s*,\s*([^,]+)/i);
if (putMatch) {
const priceStr = String(putMatch[3] || '').trim();
const price = Number(priceStr.replace(/[^\d]/g, '')) || 0;
const validity = String(putMatch[2] || '').trim();
if (validity && price > 0) return { validity, price };
}
return null;
}
// Auto fill pricing and duration based on selected profile
function onProfileSelected() {
const profileName = document.getElementById('form_profile_select').value;
if (!profileName) return;
const profile = cachedMikrotikProfiles.find(p => p.name === profileName);
const hintEl = document.getElementById('profileHint');
if (!profile) {
hintEl.innerText = '';
return;
}
const meta = parseMikhmonOnLogin(profile.onLogin || profile['on-login']);
if (meta) {
document.getElementById('form_price').value = meta.price;
document.getElementById('form_validity').value = meta.validity;
hintEl.innerHTML = `<span style="color:var(--success)"><b>Terdeteksi:</b> Harga Rp ${meta.price.toLocaleString('id-ID')} • Durasi ${meta.validity}</span>`;
} else {
hintEl.innerHTML = '<span class="text-muted">Profil tidak memiliki metadata Mikhmon. Isi harga dan masa aktif manual.</span>';
}
}
function openAddModal() {
document.getElementById('modalTitle').innerText = 'Tambah Paket Voucher Hotspot';
document.getElementById('packageId').value = '';
document.getElementById('packageForm').reset();
document.getElementById('profileHint').innerText = '';
openModal('packageModal');
loadMikrotikProfiles();
}
function openEditModal(idx) {
const p = currentPackages[idx];
if (!p) return;
document.getElementById('modalTitle').innerText = 'Edit Konfigurasi Paket';
document.getElementById('packageId').value = p.id;
document.getElementById('form_router_id').value = p.router_id || '';
// Trigger loading profiles, then set the selected profile name
loadMikrotikProfiles().then(() => {
document.getElementById('form_profile_select').value = p.profile_name;
document.getElementById('form_profile_name').value = p.profile_name;
});
document.getElementById('form_price').value = p.price;
document.getElementById('form_validity').value = p.validity;
document.getElementById('form_prefix').value = p.prefix || '';
document.getElementById('form_code_length').value = p.code_length;
document.getElementById('form_charset').value = p.charset || 'mixed';
document.getElementById('form_is_active').value = p.is_active ? '1' : '0';
document.getElementById('profileHint').innerText = '';
openModal('packageModal');
}
// Save via API
async function savePackage(e) {
e.preventDefault();
const payload = {
router_id: document.getElementById('form_router_id').value || null,
profile_name: document.getElementById('form_profile_name').value,
price: Number(document.getElementById('form_price').value),
validity: document.getElementById('form_validity').value,
prefix: document.getElementById('form_prefix').value,
code_length: Number(document.getElementById('form_code_length').value),
charset: document.getElementById('form_charset').value,
is_active: document.getElementById('form_is_active').value === '1'
};
const btn = document.getElementById('btnSave');
btn.disabled = true;
btn.innerText = 'Menyimpan...';
try {
const res = await fetch('/admin/api/vouchers/packages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Gagal menyimpan konfigurasi');
closeModal('packageModal');
loadPackages();
} catch (e) {
alert(e.message);
} finally {
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-check-lg"></i> Simpan Paket';
}
}
// Delete package config
async function deletePackage(id) {
if (!confirm('Apakah Anda yakin ingin menghapus konfigurasi paket ini? Pelanggan tidak akan bisa lagi membeli paket ini secara real-time.')) return;
try {
const res = await fetch(`/admin/api/vouchers/packages/${id}/delete`, {
method: 'POST'
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Gagal menghapus');
loadPackages();
} catch (e) {
alert(e.message);
}
}
// Initial load
loadPackages();
</script>
</body>
</html>
+468
View File
@@ -0,0 +1,468 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= t('admin.whatsapp.status_title', 'Status WhatsApp') %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrcode/1.5.1/qrcode.min.js"></script>
<style>
.wa-status-card { text-align: center; padding: 40px 20px; }
.status-indicator { width: 120px; height: 120px; border-radius: 50%; display: flex; align-items: center; justify-content: center; margin: 0 auto 20px; font-size: 48px; }
.status-open { background: rgba(16, 185, 129, 0.1); color: #10b981; border: 4px solid #10b981; }
.status-qr { background: rgba(245, 158, 11, 0.1); color: #f59e0b; border: 4px solid #f59e0b; }
.status-close { background: rgba(239, 68, 68, 0.1); color: #ef4444; border: 4px solid #ef4444; }
#qr-container { background: white; padding: 20px; display: inline-block; border-radius: 12px; margin-top: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.wa-info { margin-top: 20px; color: var(--muted); }
.wa-tabs { display: flex; gap: 8px; margin-bottom: 14px; border-bottom: 1px solid var(--border); padding-bottom: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; white-space: nowrap; }
.wa-tab { padding: 8px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; flex-shrink: 0; }
.wa-tab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.wa-tab.active { background: var(--primary); color: white; }
@media (max-width: 768px) {
.wa-tabs { font-size: 13px; }
.wa-tab { padding: 6px 10px; font-size: 12px; }
.form-row, .grid-2 { grid-template-columns: 1fr !important; }
}
</style>
</head>
<body data-connected-label="<%= t('admin.whatsapp.connected', 'Terhubung') %>" data-need-scan-label="<%= t('admin.whatsapp.need_scan', 'Perlu Pemindaian') %>">
<%- include('partials/sidebar', { activePage: 'whatsapp', 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-whatsapp"></i> <%= t('admin.whatsapp.status_title', 'Status WhatsApp') %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% const waTab = 'status'; %>
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="wa-tabs">
<a href="/admin/whatsapp" class="wa-tab <%= waTab==='status' ? 'active' : '' %>"><i class="bi bi-phone"></i> <%= t('admin.wa.tab_status','Status WhatsApp') %></a>
<a href="/admin/whatsapp/broadcast" class="wa-tab <%= waTab==='broadcast' ? 'active' : '' %>"><i class="bi bi-megaphone"></i> <%= t('admin.wa.tab_broadcast','Broadcast & Pengingat') %></a>
<a href="/admin/whatsapp/templates" class="wa-tab <%= waTab==='templates' ? 'active' : '' %>"><i class="bi bi-chat-left-quote"></i> <%= t('admin.wa.tab_templates','Template Pesan') %></a>
<a href="/admin/whatsapp/preview" class="wa-tab <%= waTab==='preview' ? 'active' : '' %>"><i class="bi bi-eye"></i> <%= t('admin.wa.tab_preview','Preview Pesan') %></a>
<a href="/admin/whatsapp/live-chat" class="wa-tab <%= waTab==='livechat' ? 'active' : '' %>"><i class="bi bi-chat-dots-fill"></i> <%= t('admin.wa.tab_livechat','Live Chat') %></a>
</div>
<!-- PENGATURAN HYBRID GATEWAY (BAILEYS VS FONNTE VS META API) -->
<div class="card mb16" style="padding: 20px;">
<h6 style="margin-bottom: 15px; font-weight: 700; color: var(--primary);"><i class="bi bi-sliders"></i> Mode WhatsApp Gateway (Baileys / Fonnte / GOWA / Meta API)</h6>
<form method="POST" action="/admin/whatsapp/gateway-settings">
<div class="form-row mb16">
<div class="fg">
<label class="fl">Pilihan Provider / Gateway WhatsApp</label>
<select class="fs" name="wa_gateway_type" id="wa_gateway_type" onchange="toggleGatewayFields()">
<option value="fonnte" <%= waGatewayType === 'fonnte' ? 'selected' : '' %>> Fonnte API Gateway (Cloud Resmi & Self-Hosted Ready)</option>
<option value="meta" <%= waGatewayType === 'meta' ? 'selected' : '' %>> Meta Cloud API (Official - Resmi Meta & Anti-Banned)</option>
<option value="gowa" <%= waGatewayType === 'gowa' ? 'selected' : '' %>> GOWA (go-whatsapp-web-multidevice) — Ringan / Self-Hosted</option>
</select>
</div>
<div class="fg">
<label class="fl">Nomor WA Pengirim (Business Phone)</label>
<input class="fc" name="meta_business_phone" value="<%= metaSettings.meta_business_phone %>" placeholder="Contoh: 628123456789">
</div>
</div>
<!-- 1. FONNTE GATEWAY FIELDS -->
<div id="fonnte_fields_container" style="<%= waGatewayType === 'fonnte' ? '' : 'display:none;' %> background: rgba(59, 130, 246, 0.04); padding: 16px; border-radius: 10px; border: 1px solid rgba(59, 130, 246, 0.2); margin-bottom: 16px;">
<div class="fw6 mb12" style="color: #2563eb; font-size: 13px;"><i class="bi bi-send-check"></i> Konfigurasi Fonnte WhatsApp Gateway</div>
<div class="form-row mb12">
<div class="fg" style="flex: 2;">
<label class="fl">Fonnte API Token / Device Key *</label>
<input class="fc" name="fonnte_token" id="fonnte_token" value="<%= fonnteSettings.fonnte_token %>" placeholder="Paste Token Akun / Device Fonnte di sini...">
</div>
<div class="fg" style="flex: 1;">
<label class="fl">Country Code (Kode Negara)</label>
<input class="fc" name="fonnte_country_code" value="<%= fonnteSettings.fonnte_country_code %>" placeholder="62">
</div>
</div>
<div class="fg mb12">
<label class="fl">API Endpoint URL (Default: <code>https://api.fonnte.com/send</code> atau URL Server Self-Hosted)</label>
<input class="fc" name="fonnte_api_url" value="<%= fonnteSettings.fonnte_api_url %>" placeholder="https://api.fonnte.com/send">
<div style="font-size: 11px; color: var(--muted); margin-top: 4px;">
<i class="bi bi-info-circle"></i> Gunakan <code>https://api.fonnte.com/send</code> untuk Fonnte Cloud, atau ubah ke domain/IP server mandiri jika menggunakan <b>Fonnte Self-Hosted</b> (misal: <code>http://192.168.1.100:8000/send</code>).
</div>
</div>
<!-- BOX COPY-PASTE WEBHOOK URL -->
<div style="font-size: 11.5px; margin-top: 12px; background: var(--bg2); padding: 12px 14px; border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<strong style="color: #2563eb;"><i class="bi bi-link-45deg"></i> Webhook Callback URL (Untuk disalin ke Dashboard Fonnte):</strong>
<button type="button" class="btn btn-g btn-sm" onclick="copyWebhookUrl('fonnte_webhook_url')" style="padding: 2px 8px; font-size: 11px;"><i class="bi bi-clipboard"></i> Salin URL</button>
</div>
<div style="display: flex; gap: 6px;">
<input type="text" id="fonnte_webhook_url" class="fc" readonly value="http://<%= host %>/api/webhook/fonnte" style="font-family: monospace; font-size: 12px; background: var(--bg3); cursor: pointer;" onclick="this.select();">
</div>
</div>
<!-- LIVE TEST SENDER FONNTE -->
<div style="margin-top: 14px; padding-top: 12px; border-top: 1px dashed rgba(59, 130, 246, 0.2);">
<label class="fl" style="color: #2563eb; font-weight: 600;"><i class="bi bi-lightning-charge"></i> Tes Kirim Pesan Cepat via Fonnte</label>
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
<input type="text" id="test_fonnte_phone" class="fc" placeholder="Nomor WhatsApp (misal: 08123456789)" style="flex: 1; min-width: 180px;">
<input type="text" id="test_fonnte_msg" class="fc" placeholder="Pesan uji coba..." value="🔔 Tes Notifikasi Billing via Fonnte Gateway Berhasil!" style="flex: 2; min-width: 240px;">
<button type="button" class="btn btn-p" onclick="sendTestFonnte()"><i class="bi bi-send"></i> Tes Kirim</button>
</div>
<div id="test_fonnte_result" style="margin-top: 8px; font-size: 12px; display: none;"></div>
</div>
</div>
<!-- 2. META CLOUD API FIELDS -->
<div id="meta_fields_container" style="<%= waGatewayType === 'meta' ? '' : 'display:none;' %> background: rgba(16, 185, 129, 0.04); padding: 16px; border-radius: 10px; border: 1px solid rgba(16, 185, 129, 0.2); margin-bottom: 16px;">
<div class="fw6 mb12" style="color: var(--success); font-size: 13px;"><i class="bi bi-shield-check"></i> Kredensial Meta WhatsApp Cloud API</div>
<div class="form-row mb12">
<div class="fg">
<label class="fl">Meta Phone Number ID *</label>
<input class="fc" name="meta_phone_number_id" value="<%= metaSettings.meta_phone_number_id %>" placeholder="Contoh: 100654321987654">
</div>
<div class="fg">
<label class="fl">Meta Business Account ID (WABA ID)</label>
<input class="fc" name="meta_waba_id" value="<%= metaSettings.meta_waba_id %>" placeholder="Contoh: 109876543210987">
</div>
</div>
<div class="fg mb12">
<label class="fl">Meta Permanent Access Token *</label>
<textarea class="fc" name="meta_access_token" rows="2" placeholder="Paste Permanent Access Token Meta Graph API di sini..."><%= metaSettings.meta_access_token %></textarea>
</div>
<div class="fg">
<label class="fl">Webhook Verification Token (Verify Token)</label>
<input class="fc" name="meta_verify_token" value="<%= metaSettings.meta_verify_token %>" placeholder="Token Rahasia Webhook">
</div>
<div style="font-size: 11.5px; margin-top: 10px; background: var(--bg2); padding: 10px 14px; border-radius: 8px; border: 1px solid var(--border);">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px;">
<strong style="color: var(--primary);"><i class="bi bi-link-45deg"></i> Callback Webhook URL Meta Developer Portal:</strong>
<button type="button" class="btn btn-g btn-sm" onclick="copyWebhookUrl('meta_webhook_url')" style="padding: 2px 8px; font-size: 11px;"><i class="bi bi-clipboard"></i> Salin URL</button>
</div>
<input type="text" id="meta_webhook_url" class="fc" readonly value="http://<%= host %>/api/meta-webhook" style="font-family: monospace; font-size: 12px; background: var(--bg3); cursor: pointer;" onclick="this.select();">
</div>
</div>
<!-- 3. GOWA (go-whatsapp-web-multidevice) FIELDS -->
<div id="gowa_fields_container" style="<%= waGatewayType === 'gowa' ? '' : 'display:none;' %> background: rgba(173,255,47,0.05); padding: 16px; border-radius: 10px; border: 1px solid var(--primary-glow); margin-bottom: 16px;">
<div class="fw6 mb12" style="color: var(--primary); font-size: 13px;"><i class="bi bi-lightning-charge-fill"></i> Konfigurasi GOWA (go-whatsapp-web-multidevice) — Ringan / Self-Hosted</div>
<div class="form-row mb12">
<div class="fg" style="flex:2;">
<label class="fl">Base URL GOWA *</label>
<input class="fc" name="gowa_base_url" value="<%= gowaSettings.gowa_base_url %>" placeholder="http://127.0.0.1:3000">
<div style="font-size:11px;color:var(--muted);margin-top:4px">Default REST GOWA port <code>3000</code>. Jalankan: <code>whatsapp rest</code>.</div>
</div>
<div class="fg" style="flex:1;">
<label class="fl">Device ID (opsional)</label>
<input class="fc" name="gowa_device_id" value="<%= gowaSettings.gowa_device_id %>" placeholder="multi-device (mis. device1)">
</div>
</div>
<div class="form-row mb12">
<div class="fg">
<label class="fl">Username Basic Auth</label>
<input class="fc" name="gowa_username" value="<%= gowaSettings.gowa_username %>" placeholder="admin">
</div>
<div class="fg">
<label class="fl">Password Basic Auth</label>
<input class="fc" name="gowa_password" value="<%= gowaSettings.gowa_password %>" placeholder="secret">
</div>
</div>
<div style="margin-top:12px;padding:12px;border-radius:10px;background:rgba(173,255,47,.06);border:1px solid var(--primary-glow);">
<div class="fw6" style="font-size:12.5px;color:var(--primary);margin-bottom:6px;"><i class="bi bi-qr-code"></i> Login WhatsApp (Scan QR)</div>
<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px">Klik tombol, lalu buka WhatsApp &rarr; Perangkat Tertaut &rarr; Tautkan perangkat, scan QR. QR berlaku ~30 detik &amp; diperbarui otomatis.</div>
<button type="button" class="btn btn-p btn-sm" onclick="showGowaQr()"><i class="bi bi-qr-code-scan"></i> Tampilkan QR</button>
<a href="<%= gowaUiUrl %>" target="_blank" rel="noopener" class="btn btn-g btn-sm" style="margin-left:6px"><i class="bi bi-box-arrow-up-right"></i> Buka UI GOWA</a>
<div id="gowa_qr_wrap" style="display:none;margin-top:10px;text-align:center">
<img id="gowa_qr_img" alt="QR GOWA" style="max-width:240px;background:#fff;padding:10px;border-radius:12px">
<div id="gowa_qr_status" style="font-size:12px;color:var(--muted);margin-top:6px"></div>
</div>
</div>
<div style="margin-top:12px;padding:12px;border-radius:10px;background:rgba(56,189,248,0.08);border:1px solid rgba(56,189,248,0.25);">
<div class="fw6" style="font-size:12.5px;color:var(--info,#38BDF8);margin-bottom:6px;"><i class="bi bi-broadcast"></i> Webhook Pesan Masuk (Bot Command &amp; Live Chat)</div>
<div style="font-size:11.5px;color:var(--muted);margin-bottom:8px">Set di GOWA: <code>WHATSAPP_WEBHOOK=&lt;URL di bawah&gt;</code> dan <code>WHATSAPP_WEBHOOK_SECRET=&lt;secret&gt;</code>, lalu restart GOWA.</div>
<div class="form-row mb12">
<div class="fg" style="flex:2;">
<label class="fl">Webhook URL (tempel ke GOWA)</label>
<div style="display:flex;gap:8px;">
<input class="fc" id="gowa_webhook_url" readonly value="<%= gowaWebhookUrl %>">
<button type="button" class="btn btn-g" onclick="copyGowaWebhook()"><i class="bi bi-copy"></i></button>
</div>
</div>
<div class="fg" style="flex:1;">
<label class="fl">Webhook Secret (HMAC)</label>
<input class="fc" name="gowa_webhook_secret" value="<%= gowaSettings.gowa_webhook_secret %>" placeholder="secret">
</div>
</div>
</div>
<div style="margin-top:6px;padding-top:12px;border-top:1px dashed var(--border);">
<label class="fl" style="font-weight:600;"><i class="bi bi-lightning-charge"></i> Tes Kirim Pesan via GOWA</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<input type="text" id="test_gowa_phone" class="fc" placeholder="Nomor WhatsApp (mis. 08123456789)" style="flex:1;min-width:180px;">
<input type="text" id="test_gowa_msg" class="fc" placeholder="Pesan uji coba..." value="Tes Notifikasi Billing via GOWA Gateway Berhasil!" style="flex:2;min-width:240px;">
<button type="button" class="btn btn-p" onclick="sendTestGowa()"><i class="bi bi-send"></i> Tes Kirim</button>
</div>
<div id="test_gowa_result" style="margin-top:8px;font-size:12px;display:none;"></div>
</div>
</div>
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> Simpan Pengaturan Gateway</button>
</form>
</div>
<script>
function toggleGatewayFields() {
const val = document.getElementById('wa_gateway_type').value;
document.getElementById('fonnte_fields_container').style.display = val === 'fonnte' ? 'block' : 'none';
document.getElementById('meta_fields_container').style.display = val === 'meta' ? 'block' : 'none';
document.getElementById('gowa_fields_container').style.display = val === 'gowa' ? 'block' : 'none';
}
// Cooldown QR disinkronkan dengan GoWA: interval mengikuti qr_duration dari server.
var _gowaQrRefresh = null; // penjadwal ambil QR berikutnya (timeout)
var _gowaQrTick = null; // interval hitung mundur 1 detik
function showGowaQr() {
var wrap = document.getElementById('gowa_qr_wrap');
if (wrap) wrap.style.display = 'block';
var st = document.getElementById('gowa_qr_status');
if (st) st.textContent = 'Memuat QR...';
gowaQrCycle();
}
function gowaQrCycle() {
fetch('/admin/whatsapp/gowa/qr', { cache: 'no-store' }).then(function (r) { return r.json(); }).then(function (d) {
var st = document.getElementById('gowa_qr_status');
if (!d || !d.ok) { if (st) st.textContent = (d && d.message) || 'Gagal memuat QR'; return; }
var img = document.getElementById('gowa_qr_img');
if (img) img.src = d.image;
// durasi (detik) dari GOWA -> qr_duration; fallback 30s
var dur = Number(d.duration) > 0 ? Number(d.duration) : 30;
gowaQrCountdown(dur);
if (_gowaQrRefresh) clearTimeout(_gowaQrRefresh);
// ambil QR baru sedikit sebelum kedaluwarsa, supaya selalu ada QR valid
_gowaQrRefresh = setTimeout(gowaQrCycle, Math.max(5, dur - 2) * 1000);
}).catch(function (e) { var st = document.getElementById('gowa_qr_status'); if (st) st.textContent = 'Error: ' + e.message; });
}
function gowaQrCountdown(dur) {
var st = document.getElementById('gowa_qr_status');
if (_gowaQrTick) clearInterval(_gowaQrTick);
var remaining = dur;
function render() { if (st) st.innerHTML = 'Scan QR dengan WhatsApp &mdash; QR baru dalam <b>' + remaining + '</b> detik.'; }
render();
_gowaQrTick = setInterval(function () { remaining--; if (remaining < 0) remaining = 0; render(); }, 1000);
}
function copyGowaWebhook() {
var el = document.getElementById('gowa_webhook_url');
if (!el) return;
el.select();
try { document.execCommand('copy'); } catch (e) {}
alert('Webhook URL disalin:\n' + el.value);
}
async function sendTestGowa() {
const phone = (document.getElementById('test_gowa_phone') || {}).value || '';
const msg = (document.getElementById('test_gowa_msg') || {}).value || '';
const resDiv = document.getElementById('test_gowa_result');
if (resDiv) { resDiv.style.display = 'block'; resDiv.style.color = 'var(--muted)'; resDiv.textContent = 'Mengirim...'; }
try {
const r = await fetch('/admin/whatsapp/test-gowa', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ phone, message: msg }) });
const d = await r.json();
if (resDiv) { resDiv.style.color = d.success ? 'var(--success)' : 'var(--danger)'; resDiv.innerHTML = (d.success ? '<b><%= t('common.success_excl','Berhasil!') %></b> ' : '<b><%= t('common.failed_colon','Gagal:') %></b> ') + (d.message || d.error || ''); }
} catch (e) { if (resDiv) { resDiv.style.color = 'var(--danger)'; resDiv.textContent = 'Error: ' + e.message; } }
}
function copyWebhookUrl(elementId) {
const el = document.getElementById(elementId);
if (el) {
el.select();
navigator.clipboard.writeText(el.value).then(() => {
alert(' Webhook URL berhasil disalin ke clipboard:\n' + el.value);
}).catch(() => {
document.execCommand('copy');
alert(' Webhook URL berhasil disalin ke clipboard:\n' + el.value);
});
}
}
async function sendTestFonnte() {
const phone = document.getElementById('test_fonnte_phone').value.trim();
const msg = document.getElementById('test_fonnte_msg').value.trim();
const resDiv = document.getElementById('test_fonnte_result');
if (!phone) {
alert('Harap masukkan nomor WhatsApp tujuan tes!');
document.getElementById('test_fonnte_phone').focus();
return;
}
resDiv.style.display = 'block';
resDiv.style.color = 'var(--muted)';
resDiv.innerHTML = '<i class="bi bi-hourglass-split"></i> Mengirim pesan tes via Fonnte...';
try {
const resp = await fetch('/admin/whatsapp/test-fonnte', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, message: msg })
});
const data = await resp.json();
if (data.success) {
resDiv.style.color = 'var(--success)';
resDiv.innerHTML = '<i class="bi bi-check-circle-fill"></i> <b><%= t('common.success_excl','Berhasil!') %></b> Pesan tes telah dikirim via Fonnte. Silakan cek WhatsApp penerima.';
} else {
resDiv.style.color = 'var(--danger)';
resDiv.innerHTML = '<i class="bi bi-x-circle-fill"></i> <b><%= t('common.failed_colon','Gagal:') %></b> ' + (data.error || 'Terjadi kesalahan saat menghubungi API Fonnte.');
}
} catch (err) {
resDiv.style.color = 'var(--danger)';
resDiv.innerHTML = '<i class="bi bi-x-circle-fill"></i> <b><%= t('common.error_colon','Error:') %></b> ' + err.message;
}
}
</script>
<div class="card">
<div class="wa-status-card">
<div id="status-icon" class="status-indicator status-close">
<i class="bi bi-whatsapp"></i>
</div>
<h2 id="status-text"><%= t('admin.whatsapp.checking', 'Mengecek status...') %></h2>
<p id="status-desc" class="text-muted"><%= t('admin.whatsapp.please_wait', 'Harap tunggu sebentar.') %></p>
<% if (waGatewayType === 'baileys') { %>
<div id="qr-section" style="display:none">
<p class="mb-3"><%= t('admin.whatsapp.qr_instruction', 'Silakan pindai QR Code ini dengan aplikasi WhatsApp Anda:') %></p>
<div id="qr-container"></div>
<p class="mt-3 text-sm text-muted"><%= t('admin.whatsapp.qr_auto_refresh', 'QR Code akan diperbarui secara otomatis.') %></p>
</div>
<% } %>
<div id="info-section" style="display:none" class="wa-info">
<div class="alert alert-s">
<i class="bi bi-check-circle-fill"></i> <%= t('admin.whatsapp.connected_title', 'WhatsApp Terhubung') %>
</div>
<p><%= t('admin.whatsapp.connected_as', 'Terhubung sebagai:') %> <strong id="wa-user">-</strong></p>
<p><%= t('admin.whatsapp.last_updated', 'Terakhir diperbarui:') %> <span id="wa-time">-</span></p>
</div>
<div class="mt-5" style="display:flex; justify-content:center; flex-wrap:wrap; gap:10px">
<button class="btn btn-g" onclick="checkStatus()"><i class="bi bi-arrow-clockwise"></i> <%= t('admin.whatsapp.refresh_status', 'Refresh Status') %></button>
<form method="POST" action="/admin/whatsapp/test-notification" onsubmit="return confirm('<%= t('admin.whatsapp.confirm_test', 'Kirim test notifikasi WhatsApp ke admin sekarang?') %>');">
<button class="btn btn-p"><i class="bi bi-send-check"></i> <%= t('admin.whatsapp.test_notification', 'Kirim Test Notif') %></button>
</form>
<% if (waGatewayType === 'baileys') { %>
<form method="POST" action="/admin/whatsapp/repair-keys" onsubmit="return confirm('Bersihkan cache pre-keys & sesi usang untuk mengatasi masalah pesan pending / Bad-MAC tanpa perlu scan QR ulang?');">
<button class="btn btn-g" style="border-color: #3b82f6; color: #2563eb;"><i class="bi bi-wrench-adjustable"></i> Perbaiki Sesi (Anti Waiting)</button>
</form>
<form method="POST" action="/admin/whatsapp/reset" onsubmit="return confirm('<%= t('admin.whatsapp.confirm_reset', 'Apakah Anda yakin ingin menghapus sesi WhatsApp? Anda harus scan QR ulang setelah ini.') %>')">
<button class="btn btn-d"><i class="bi bi-trash"></i> <%= t('admin.whatsapp.reset_session', 'Hapus Sesi / Reset') %></button>
</form>
<% } %>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.3/build/qrcode.min.js"></script>
<script>
const I18N = {
descOpen: "<%= t('admin.whatsapp.desc_open', 'Bot WhatsApp aktif dan siap mengirim notifikasi.') %>",
descQr: "<%= t('admin.whatsapp.desc_qr', 'Sesi tidak ditemukan atau kedaluwarsa.') %>",
descOffPrefix: "<%= t('admin.whatsapp.disconnected_prefix', 'Terputus') %>",
descOff: "<%= t('admin.whatsapp.desc_off', 'Bot WhatsApp sedang offline atau sedang mencoba menghubungkan ulang.') %>",
deviceFallback: "<%= t('admin.whatsapp.device_fallback', 'Device') %>",
waitQrLib: "<%= t('admin.whatsapp.wait_qr_lib', 'Menunggu library QR Code...') %>",
qrFail: "<%= t('admin.whatsapp.qr_fail', 'Gagal memproses QR Code. Silakan cek konsol browser.') %>"
};
async function checkStatus() {
try {
const res = await fetch('/admin/api/whatsapp/status');
if (!res.ok) {
console.error('API Status Error:', res.statusText);
return;
}
const status = await res.json();
console.log('[WA Status]', status);
const icon = document.getElementById('status-icon');
const text = document.getElementById('status-text');
const desc = document.getElementById('status-desc');
const qrSection = document.getElementById('qr-section');
const infoSection = document.getElementById('info-section');
const qrContainer = document.getElementById('qr-container');
if (icon) icon.className = 'status-indicator';
if (qrSection) qrSection.style.display = 'none';
if (infoSection) infoSection.style.display = 'none';
const connectedLabel = document.body.dataset.connectedLabel || 'Terhubung';
const needScanLabel = document.body.dataset.needScanLabel || 'Perlu Pemindaian';
if (status.connection === 'open') {
icon.classList.add('status-open');
text.innerText = connectedLabel;
desc.innerText = I18N.descOpen;
infoSection.style.display = 'block';
document.getElementById('wa-user').innerText = status.user?.id || status.user?.name || I18N.deviceFallback;
document.getElementById('wa-time').innerText = new Date(status.lastUpdate).toLocaleTimeString();
} else if (status.connection === 'qr' && (status.qr || status.qrImage)) {
console.log('Generating QR Code Canvas...');
icon.classList.add('status-qr');
text.innerText = needScanLabel;
desc.innerText = I18N.descQr;
if (qrSection) qrSection.style.display = 'block';
if (qrContainer) qrContainer.innerHTML = '';
if (status.qrImage) {
const img = document.createElement('img');
img.src = status.qrImage;
img.style.width = '256px';
img.style.height = '256px';
img.style.borderRadius = '8px';
img.alt = 'Scan WhatsApp QR';
qrContainer.appendChild(img);
} else if (typeof QRCode !== 'undefined' && status.qr) {
QRCode.toCanvas(status.qr, { width: 256, margin: 2 }, function (error, canvas) {
if (error) {
console.error('QR Generate Error:', error);
qrContainer.innerText = I18N.qrFail;
} else {
console.log('QR Code Canvas generated successfully.');
qrContainer.appendChild(canvas);
}
});
} else {
qrContainer.innerText = I18N.waitQrLib;
}
} else if (status.connection === 'connecting') {
icon.classList.add('status-qr');
text.innerText = 'Menghubungkan...';
desc.innerText = status.loadingProgress || 'Autentikasi berhasil, sedang menyinkronkan chat WhatsApp Web... Harap tunggu beberapa detik.';
} else {
icon.classList.add('status-close');
if (status.gateway === 'gowa') {
text.innerText = 'Belum Terhubung';
desc.innerText = 'Klik Tampilkan QR untuk login WhatsApp via GOWA.';
} else {
text.innerText = I18N.descOffPrefix + ' (' + (status.connection || 'off') + ')';
desc.innerText = I18N.descOff;
}
}
} catch (e) {
console.error('Failed to check WA status', e);
}
}
// Check every 5 seconds
checkStatus();
setInterval(checkStatus, 5000);
</script>
</body>
</html>
+374
View File
@@ -0,0 +1,374 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Live Chat WhatsApp | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.chat-layout { display: grid; grid-template-columns: 340px 1fr; gap: 16px; height: calc(100vh - 120px); min-height: 520px; }
.chat-inbox { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.chat-inbox-header { padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 700; display: flex; align-items: center; justify-content: space-between; }
.chat-inbox-search { padding: 10px 14px; border-bottom: 1px solid var(--border); }
.chat-user-list { flex: 1; overflow-y: auto; }
.chat-user-item { padding: 12px 16px; border-bottom: 1px solid var(--border); cursor: pointer; transition: background .15s; display: flex; gap: 12px; align-items: center; }
.chat-user-item:hover { background: rgba(255,255,255,0.04); }
.chat-user-item.active { background: var(--pdim) !important; border-left: 3px solid var(--primary); }
.chat-user-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--primary); color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 16px; flex-shrink: 0; }
.chat-user-info { flex: 1; min-width: 0; }
.chat-user-name { font-size: 13px; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.chat-user-last { font-size: 11px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.chat-user-time { font-size: 10px; color: var(--muted); flex-shrink: 0; }
.chat-main { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.chat-main-header { padding: 14px 18px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; background: var(--bg); }
.chat-main-title { font-weight: 700; font-size: 15px; }
.chat-messages { flex: 1; padding: 18px; overflow-y: auto; display: flex; flex-direction: column; gap: 12px; background: rgba(0,0,0,0.02); }
.msg-bubble { max-width: 75%; padding: 10px 14px; border-radius: 12px; font-size: 13px; line-height: 1.4; position: relative; word-wrap: break-word; }
.msg-bubble.inbound { align-self: flex-start; background: var(--bg3); color: var(--text); border-bottom-left-radius: 2px; }
.msg-bubble.outbound { align-self: flex-end; background: var(--primary); color: #ffffff; border-bottom-right-radius: 2px; }
.msg-meta { font-size: 10px; opacity: 0.75; text-align: right; margin-top: 4px; display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
.msg-gateway { font-size: 9px; padding: 1px 4px; border-radius: 4px; background: rgba(255,255,255,0.2); }
.chat-input-area { padding: 12px 16px; border-top: 1px solid var(--border); display: flex; gap: 10px; align-items: center; background: var(--bg); }
.chat-input-area textarea { flex: 1; height: 46px; border-radius: 8px; resize: none; padding: 10px 12px; font-size: 13px; line-height: 1.3; }
@media (max-width: 768px) {
.chat-layout { grid-template-columns: 1fr; height: auto; }
.chat-inbox { height: 350px; }
.chat-main { height: 480px; }
}
.wa-tabs { display: flex; gap: 8px; margin-bottom: 14px; border-bottom: 1px solid var(--border); padding-bottom: 10px; overflow-x: auto; white-space: nowrap; }
.wa-tab { padding: 8px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; flex-shrink: 0; }
.wa-tab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.wa-tab.active { background: var(--primary); color: white; }
@media (max-width: 768px) { .wa-tabs { font-size: 13px; } .wa-tab { padding: 6px 10px; font-size: 12px; } }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'whatsapp', 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-chat-dots-fill"></i> Live Chat WhatsApp
</div>
<div class="tb-right">
<%
var GW = {
meta: { cls: 'bs', icon: 'meta', label: 'Meta Cloud API (Resmi)' },
gowa: { cls: 'bp', icon: 'whatsapp', label: 'GoWA (WhatsApp Multidevice)' },
fonnte: { cls: 'bp', icon: 'send-check', label: 'Fonnte API Gateway' },
baileys: { cls: 'bp', icon: 'whatsapp', label: 'Baileys Web (Gratis)' }
};
var gw = GW[gatewayType] || { cls: 'bp', icon: 'whatsapp', label: 'WhatsApp Gateway' };
%>
<span class="badge <%= gw.cls %>" title="Gateway WhatsApp aktif">
<i class="bi bi-<%= gw.icon %>"></i> <%= gw.label %>
</span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% const waTab = 'livechat'; %>
<div class="wa-tabs">
<a href="/admin/whatsapp" class="wa-tab <%= waTab==='status' ? 'active' : '' %>"><i class="bi bi-phone"></i> <%= t('admin.wa.tab_status','Status WhatsApp') %></a>
<a href="/admin/whatsapp/broadcast" class="wa-tab <%= waTab==='broadcast' ? 'active' : '' %>"><i class="bi bi-megaphone"></i> <%= t('admin.wa.tab_broadcast','Broadcast & Pengingat') %></a>
<a href="/admin/whatsapp/templates" class="wa-tab <%= waTab==='templates' ? 'active' : '' %>"><i class="bi bi-chat-left-quote"></i> <%= t('admin.wa.tab_templates','Template Pesan') %></a>
<a href="/admin/whatsapp/preview" class="wa-tab <%= waTab==='preview' ? 'active' : '' %>"><i class="bi bi-eye"></i> <%= t('admin.wa.tab_preview','Preview Pesan') %></a>
<a href="/admin/whatsapp/live-chat" class="wa-tab <%= waTab==='livechat' ? 'active' : '' %>"><i class="bi bi-chat-dots-fill"></i> <%= t('admin.wa.tab_livechat','Live Chat') %></a>
</div>
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %> mb16" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-triangle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="chat-layout">
<!-- INBOX LEFT PANEL -->
<div class="chat-inbox">
<div class="chat-inbox-header">
<span><i class="bi bi-inbox-fill"></i> Percakapan</span>
<div style="display:flex;gap:6px">
<button class="btn btn-p btn-sm" onclick="openNewChatModal()" title="Mulai Chat Baru"><i class="bi bi-plus-lg"></i> Chat Baru</button>
<button class="btn btn-g btn-sm" onclick="loadConversations()" title="Refresh Chat"><i class="bi bi-arrow-clockwise"></i></button>
</div>
</div>
<div class="chat-inbox-search">
<input type="text" id="chatSearch" class="fc" placeholder="Cari nama atau nomor..." onkeyup="filterUserList()">
</div>
<div class="chat-user-list" id="userList">
<div class="text-center text-muted py-4" style="padding:20px;">Memuat percakapan...</div>
</div>
</div>
<!-- MAIN CHAT RIGHT PANEL -->
<div class="chat-main">
<div class="chat-main-header">
<div class="chat-main-title" id="chatHeaderTitle">
<i class="bi bi-person-circle"></i> Memuat chat...
</div>
<div id="chatHeaderSub" style="font-size:11px;" class="text-muted"></div>
</div>
<div class="chat-messages" id="chatMessages">
<div style="margin:auto;text-align:center;color:var(--muted)">
<i class="bi bi-chat-text" style="font-size:48px;display:block;margin-bottom:10px;"></i>
Pilih pelanggan dari daftar di sebelah kiri atau klik <strong>+ Chat Baru</strong> untuk membalas/mengirim WhatsApp.
</div>
</div>
<form class="chat-input-area" id="chatForm" onsubmit="sendChatMessage(event)">
<input type="hidden" id="activePhone" value="">
<textarea id="chatInput" class="fc" placeholder="Tulis pesan WhatsApp... (Tekan Enter untuk kirim, Shift+Enter untuk baris baru)" disabled required onkeydown="handleKeyDown(event)"></textarea>
<button type="submit" class="btn btn-p" id="sendBtn" disabled>
<i class="bi bi-send-fill"></i> Kirim
</button>
</form>
</div>
</div>
</div>
</div>
<!-- MODAL CHAT BARU -->
<div class="mo" id="newChatModal">
<div class="mb" style="max-width:520px;">
<div class="mh">
<span class="mt"><i class="bi bi-chat-plus-fill"></i> Mulai Percakapan WhatsApp Baru</span>
<button type="button" class="mc" onclick="closeModal('newChatModal')">&times;</button>
</div>
<div class="mbody">
<div class="fg mb12">
<label class="fl">Pilih Pelanggan Terdaftar:</label>
<select class="fs" id="selectCustomerPhone" onchange="document.getElementById('manualPhoneInput').value=this.value">
<option value="">-- Pilih dari Daftar Pelanggan --</option>
<% if (typeof customers !== 'undefined' && customers) { %>
<% customers.forEach(c => { %>
<% if (c.phone) { %>
<option value="<%= c.phone %>"><%= c.name %> (<%= c.phone %>) - <%= c.area || 'Area Default' %></option>
<% } %>
<% }) %>
<% } %>
</select>
</div>
<div class="fg">
<label class="fl">Atau Ketik Nomor WA Manual:</label>
<input type="text" id="manualPhoneInput" class="fc" placeholder="Contoh: 08123456789 atau 628123456789">
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('newChatModal')"><%= t('common.cancel','Batal') %></button>
<button type="button" class="btn btn-p" onclick="startNewChatFromModal()"><i class="bi bi-chat-dots-fill"></i> Buka Chat</button>
</div>
</div>
</div>
<script>
let conversationsData = [];
let activePhone = '';
async function loadConversations() {
try {
const res = await fetch('/admin/api/whatsapp/conversations');
const data = await res.json();
if (data.ok) {
conversationsData = data.conversations || [];
renderUserList(conversationsData);
// Auto open first conversation if available and not selected yet
if (!activePhone && conversationsData.length > 0) {
const first = conversationsData[0];
const name = first.customer_name || first.phone;
openChat(first.phone, name);
}
}
} catch (e) {
console.error('Error loading conversations:', e);
}
}
function renderUserList(list) {
const container = document.getElementById('userList');
if (!list || list.length === 0) {
container.innerHTML = `<div class="text-center text-muted" style="padding:24px;">Belum ada percakapan WhatsApp. Klik <b>+ Chat Baru</b>.</div>`;
return;
}
container.innerHTML = list.map(c => {
const name = c.customer_name || c.phone;
const initial = name.charAt(0).toUpperCase();
const isActive = c.phone === activePhone ? 'active' : '';
const dateStr = c.created_at ? new Date(c.created_at).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) : '';
return `
<div class="chat-user-item ${isActive}" onclick="openChat('${c.phone}', '${escapeHtml(name)}')">
<div class="chat-user-avatar">${initial}</div>
<div class="chat-user-info">
<div class="chat-user-name">${escapeHtml(name)}</div>
<div class="chat-user-last">${escapeHtml(c.message_text || '')}</div>
</div>
<div class="chat-user-time">${dateStr}</div>
</div>
`;
}).join('');
}
function filterUserList() {
const q = document.getElementById('chatSearch').value.toLowerCase();
const filtered = conversationsData.filter(c =>
(c.customer_name && c.customer_name.toLowerCase().includes(q)) ||
(c.phone && c.phone.includes(q))
);
renderUserList(filtered);
}
function openNewChatModal() {
document.getElementById('newChatModal').classList.add('show');
}
function closeModal(id) {
document.getElementById(id).classList.remove('show');
}
function startNewChatFromModal() {
const phoneVal = document.getElementById('manualPhoneInput').value.trim();
if (!phoneVal) {
alert('Harap pilih pelanggan atau ketik nomor WhatsApp.');
return;
}
closeModal('newChatModal');
// Find customer name if exists
const selectEl = document.getElementById('selectCustomerPhone');
const selectedOption = Array.from(selectEl.options).find(o => o.value === phoneVal);
const name = selectedOption ? selectedOption.text.split(' (')[0] : phoneVal;
openChat(phoneVal, name);
}
async function openChat(phone, name) {
activePhone = phone;
document.getElementById('activePhone').value = phone;
document.getElementById('chatHeaderTitle').innerHTML = `<i class="bi bi-person-fill"></i> ${escapeHtml(name)}`;
document.getElementById('chatHeaderSub').innerText = ` ${phone}`;
const inputEl = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
inputEl.disabled = false;
sendBtn.disabled = false;
// Highlight active user in inbox
renderUserList(conversationsData);
// Load chat messages
loadChatMessages(phone, true);
}
async function loadChatMessages(phone, showSpinner = false) {
const container = document.getElementById('chatMessages');
if (showSpinner) {
container.innerHTML = `<div class="text-center text-muted" style="margin:auto;"><i class="bi bi-arrow-repeat spin"></i> Memuat pesan...</div>`;
}
try {
const res = await fetch(`/admin/api/whatsapp/messages?phone=${encodeURIComponent(phone)}`);
const data = await res.json();
if (data.ok) {
const messages = data.messages || [];
if (messages.length === 0) {
container.innerHTML = `<div class="text-center text-muted" style="margin:auto;">Belum ada pesan dengan pelanggan ini. Ketik pesan di bawah dan tekan Enter untuk memulai.</div>`;
return;
}
container.innerHTML = messages.map(m => {
const isOutbound = m.direction === 'outbound';
const timeStr = m.created_at ? new Date(m.created_at).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }) : '';
const gatewayBadge = m.gateway === 'meta' ? 'Meta API'
: m.gateway === 'gowa' ? 'GoWA'
: m.gateway === 'fonnte' ? 'Fonnte'
: m.gateway === 'baileys' ? 'Baileys'
: 'WhatsApp';
const statusIcon = m.status === 'read' ? '<i class="bi bi-check-all"></i>' : m.status === 'delivered' ? '<i class="bi bi-check-all"></i>' : '<i class="bi bi-check-lg"></i>';
return `
<div class="msg-bubble ${isOutbound ? 'outbound' : 'inbound'}">
<div>${escapeHtml(m.message_text).replace(/\n/g, '<br>')}</div>
<div class="msg-meta">
<span class="msg-gateway">${gatewayBadge}</span>
<span>${timeStr}</span>
${isOutbound ? `<span>${statusIcon}</span>` : ''}
</div>
</div>
`;
}).join('');
container.scrollTop = container.scrollHeight;
}
} catch (e) {
if (showSpinner) {
container.innerHTML = `<div class="text-center text-danger" style="margin:auto;">Gagal memuat pesan: ${e.message}</div>`;
}
}
}
function handleKeyDown(event) {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendChatMessage(event);
}
}
async function sendChatMessage(e) {
if (e) e.preventDefault();
const phone = document.getElementById('activePhone').value;
const inputEl = document.getElementById('chatInput');
const text = inputEl.value.trim();
if (!phone) {
alert('Silakan pilih percakapan atau nomor tujuan terlebih dahulu.');
return;
}
if (!text) return;
const sendBtn = document.getElementById('sendBtn');
sendBtn.disabled = true;
try {
const res = await fetch('/admin/api/whatsapp/send-direct', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone, message: text })
});
const data = await res.json();
if (data.success) {
inputEl.value = '';
await loadChatMessages(phone, false);
await loadConversations();
inputEl.focus();
} else {
const errDetail = typeof data.error === 'object' ? (data.error.message || JSON.stringify(data.error)) : String(data.error || 'Terjadi kesalahan');
alert('Gagal mengirim pesan WA: ' + errDetail);
}
} catch (err) {
alert('Gagal membuat request: ' + err.message);
} finally {
sendBtn.disabled = false;
}
}
function escapeHtml(str) {
return String(str || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
document.addEventListener('DOMContentLoaded', () => {
loadConversations();
setInterval(() => {
loadConversations();
if (activePhone) loadChatMessages(activePhone, false);
}, 8000);
});
</script>
</body>
</html>
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Preview Pesan WhatsApp | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.wa-tabs { display: flex; gap: 8px; margin-bottom: 14px; border-bottom: 1px solid var(--border); padding-bottom: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; white-space: nowrap; }
.wa-tab { padding: 8px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; flex-shrink: 0; }
.wa-tab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.wa-tab.active { background: var(--primary); color: #0A0D14; }
@media (max-width: 768px) { .wa-tabs { font-size: 13px; } .wa-tab { padding: 6px 10px; font-size: 12px; } }
.wp-grid { display: grid; grid-template-columns: repeat(auto-fill,minmax(340px,1fr)); gap: 14px; }
.wp-card { border: 1px solid var(--border); border-radius: 14px; background: var(--bg2); overflow: hidden; display: flex; flex-direction: column; }
.wp-head { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.wp-title { font-weight: 800; font-size: 13.5px; }
.wp-src { font-size: 10.5px; color: var(--muted); font-family: monospace; }
.wp-body { padding: 14px; background: #0b141a; flex: 1; }
.wp-bubble { background: #005c4b; color: #e9edef; border-radius: 12px; padding: 12px 14px; font-size: 13px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
.wp-note { font-size: 11px; color: var(--muted); margin-top: 8px; }
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'whatsapp', 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-eye"></i> Preview Pesan WhatsApp
</div>
<div class="tb-right">
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% const waTab = 'preview'; %>
<div class="wa-tabs">
<a href="/admin/whatsapp" class="wa-tab <%= waTab==='status' ? 'active' : '' %>"><i class="bi bi-phone"></i> <%= t('admin.wa.tab_status','Status WhatsApp') %></a>
<a href="/admin/whatsapp/broadcast" class="wa-tab <%= waTab==='broadcast' ? 'active' : '' %>"><i class="bi bi-megaphone"></i> <%= t('admin.wa.tab_broadcast','Broadcast & Pengingat') %></a>
<a href="/admin/whatsapp/templates" class="wa-tab <%= waTab==='templates' ? 'active' : '' %>"><i class="bi bi-chat-left-quote"></i> <%= t('admin.wa.tab_templates','Template Pesan') %></a>
<a href="/admin/whatsapp/preview" class="wa-tab active"><i class="bi bi-eye"></i> <%= t('admin.wa.tab_preview','Preview Pesan') %></a>
<a href="/admin/whatsapp/live-chat" class="wa-tab <%= waTab==='livechat' ? 'active' : '' %>"><i class="bi bi-chat-dots-fill"></i> <%= t('admin.wa.tab_livechat','Live Chat') %></a>
</div>
<div class="alert alert-s" style="margin-bottom:14px">
<i class="bi bi-info-circle"></i> Preview semua <b>pesan otomatis</b> dengan data contoh — <b><%= sample.nama %></b> (<%= sample.id %>), paket <%= sample.paket %>. Variabel <span class="mono">{{...}}</span> diisi otomatis saat benar-benar dikirim.
</div>
<div class="wp-grid">
<% messages.forEach(function(m, i){ %>
<div class="wp-card">
<div class="wp-head">
<div class="wp-title"><i class="bi bi-chat-left-text"></i> <%= m.title %></div>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<div class="wp-src"><%= m.key %></div>
<button type="button" class="btn btn-g btn-sm wp-send" data-idx="<%= i %>" title="Kirim contoh ke WA admin"><i class="bi bi-send"></i> Kirim contoh</button>
</div>
</div>
<div class="wp-body">
<div class="wp-bubble"><%= m.text %></div>
<% if (m.note) { %><div class="wp-note"><i class="bi bi-info-circle"></i> <%= m.note %></div><% } %>
</div>
</div>
<% }); %>
</div>
</div>
</div>
<script>
document.querySelectorAll('.wp-send').forEach(function(b){
b.addEventListener('click', async function(){
var old = b.innerHTML; b.disabled = true; b.innerHTML = '<i class="bi bi-hourglass-split"></i> Mengirim...';
try {
var r = await fetch('/admin/whatsapp/preview/send', { method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'index=' + encodeURIComponent(b.getAttribute('data-idx')) });
var j = await r.json().catch(function(){ return {}; });
alert(j.message || 'Selesai.');
} catch(e){ alert('Gagal: ' + e.message); }
finally { b.disabled = false; b.innerHTML = old; }
});
});
</script>
</body>
</html>
+280
View File
@@ -0,0 +1,280 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/admin.css">
<style>
.wa-tabs { display: flex; gap: 8px; margin-bottom: 14px; border-bottom: 1px solid var(--border); padding-bottom: 10px; overflow-x: auto; -webkit-overflow-scrolling: touch; white-space: nowrap; }
.wa-tab { padding: 8px 14px; border-radius: 8px; cursor: pointer; color: var(--muted); transition: all 0.2s; font-weight: 700; text-decoration: none; flex-shrink: 0; }
.wa-tab:hover { background: rgba(255,255,255,0.06); color: var(--text); }
.wa-tab.active { background: var(--primary); color: white; }
.template-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; }
@media (max-width: 768px) {
.template-grid { grid-template-columns: 1fr; }
.wa-tab { padding: 6px 10px; font-size: 12px; }
}
</style>
</head>
<body>
<%- include('partials/sidebar', { activePage: 'whatsapp', 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-whatsapp"></i> <%= title %></div>
<div class="tb-right">
<span class="adm-badge"><i class="bi bi-person-fill"></i> <%= (typeof session !== 'undefined' && session && session.isCashier) ? t('admin.role.cashier', 'Kasir') : t('admin.role.admin', 'Admin') %></span>
<a href="/admin/logout" class="btn btn-g btn-sm"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<div class="page">
<% const waTab = 'templates'; %>
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='success'?'s':'d' %>" data-flash="1">
<i class="bi bi-<%= msg.type==='success'?'check-circle':'exclamation-circle' %>"></i> <%= msg.text %>
</div>
<% } %>
<div class="wa-tabs">
<a href="/admin/whatsapp" class="wa-tab <%= waTab==='status' ? 'active' : '' %>"><i class="bi bi-phone"></i> <%= t('admin.wa.tab_status','Status WhatsApp') %></a>
<a href="/admin/whatsapp/broadcast" class="wa-tab <%= waTab==='broadcast' ? 'active' : '' %>"><i class="bi bi-megaphone"></i> <%= t('admin.wa.tab_broadcast','Broadcast & Pengingat') %></a>
<a href="/admin/whatsapp/templates" class="wa-tab <%= waTab==='templates' ? 'active' : '' %>"><i class="bi bi-chat-left-quote"></i> <%= t('admin.wa.tab_templates','Template Pesan') %></a>
<a href="/admin/whatsapp/preview" class="wa-tab <%= waTab==='preview' ? 'active' : '' %>"><i class="bi bi-eye"></i> <%= t('admin.wa.tab_preview','Preview Pesan') %></a>
<a href="/admin/whatsapp/live-chat" class="wa-tab <%= waTab==='livechat' ? 'active' : '' %>"><i class="bi bi-chat-dots-fill"></i> <%= t('admin.wa.tab_livechat','Live Chat') %></a>
</div>
<form method="POST" action="/admin/whatsapp/templates">
<div class="card mb16">
<div class="card-hd">
<h6><i class="bi bi-chat-left-quote-fill"></i> Pengaturan Template Notifikasi WhatsApp</h6>
</div>
<div class="card-bd template-grid">
<div>
<div class="fg">
<label class="fl">Template Pengingat Tagihan (Bulanan / Manual)</label>
<textarea class="fc" id="tmpl_auto_billing" name="whatsapp_auto_billing_message" rows="8" required style="width: 100%;"><%= templates.whatsapp_auto_billing_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px; display:flex; justify-between; align-items:center;">
<span><i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b> <span style="color:var(--primary)"><%= t('common.required','(wajib)') %></span>, <b style="color:var(--primary)">{{pppoe}}</b> <span style="color:var(--muted)">(Username PPPoE, opsional)</span>, <b>{{nama}}</b>, <b>{{tagihan}}</b>, <b>{{rincian}}</b>, <b>{{paket}}</b>, <b>{{link}}</b>.</span>
</div>
</div>
<div class="fg" style="margin-top: 20px;">
<label class="fl">Template Tagihan QRIS (Manual)</label>
<textarea class="fc" id="tmpl_billing_qris" name="whatsapp_billing_qris_message" rows="8" required style="width: 100%;"><%= templates.whatsapp_billing_qris_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;">
<i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b> <span style="color:var(--primary)"><%= t('common.required','(wajib)') %></span>, <b style="color:var(--primary)">{{pppoe}}</b> <span style="color:var(--muted)">(Username PPPoE, opsional)</span>, <b>{{nama}}</b>, <b>{{periode}}</b>, <b>{{paket}}</b>, <b>{{qris_nominal}}</b>, <b>{{qris_kode}}</b>, <b>{{qris_qr}}</b>.
</div>
</div>
</div>
<div>
<div class="fg">
<label class="fl">Template Pembayaran Sukses (Lunas)</label>
<textarea class="fc" id="tmpl_payment_success" name="whatsapp_payment_success_message" rows="8" required style="width: 100%;"><%= templates.whatsapp_payment_success_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;">
<i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b> <span style="color:var(--primary)"><%= t('common.required','(wajib)') %></span>, <b style="color:var(--primary)">{{pppoe}}</b> <span style="color:var(--muted)">(Username PPPoE, opsional)</span>, <b>{{nama}}</b>, <b>{{no_invoice}}</b>, <b>{{paket}}</b>, <b>{{periode}}</b>, <b>{{waktu}}</b>, <b>{{metode}}</b>, <b>{{total}}</b>, <b>{{company}}</b>, <b>{{company_phone}}</b>, <b>{{link_portal}}</b>, <b>{{link_pdf}}</b> (link PDF invoice).
</div>
</div>
<div class="fg" style="margin-top: 20px;">
<label class="fl">Template Pemberitahuan Isolir (Otomatis / Manual)</label>
<textarea class="fc" id="tmpl_isolir" name="whatsapp_isolir_message" rows="8" required style="width: 100%;"><%= templates.whatsapp_isolir_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;">
<i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b> <span style="color:var(--primary)"><%= t('common.required','(wajib)') %></span>, <b style="color:var(--primary)">{{pppoe}}</b> <span style="color:var(--muted)">(Username PPPoE, opsional)</span>, <b>{{nama}}</b>, <b>{{paket}}</b>, <b>{{tagihan}}</b>, <b>{{link}}</b>.
</div>
</div>
<div class="fg" style="margin-top: 20px;">
<label class="fl">Template Peringatan Tunggakan (menuju nonaktif)</label>
<div class="fg" style="margin-top: 20px;">
<label class="fl">Template Auto-Lunas (Rp 0 / auto_isolate=0)</label>
<textarea class="fc" id="tmpl_autolunas" name="whatsapp_autolunas_message" rows="7" required style="width: 100%;"><%= templates.whatsapp_autolunas_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;"><i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b>, <b>{{nama}}</b>, <b>{{tagihan}}</b>, <b>{{rincian}}</b>, <b>{{company}}</b>.</div>
</div>
<div class="fg" style="margin-top: 20px;">
<label class="fl">Template Auto-Nonaktif (menunggak)</label>
<textarea class="fc" id="tmpl_deactivated" name="whatsapp_deactivated_message" rows="7" required style="width: 100%;"><%= templates.whatsapp_deactivated_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;"><i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b>, <b>{{nama}}</b>, <b>{{bulan_nunggak}}</b>, <b>{{batas}}</b>, <b>{{link}}</b>, <b>{{company}}</b>.</div>
</div>
<div class="fg" style="margin-top: 20px;">
<label class="fl">Template Auto-Hapus (bulan ke-4)</label>
<textarea class="fc" id="tmpl_deleted" name="whatsapp_deleted_message" rows="7" required style="width: 100%;"><%= templates.whatsapp_deleted_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;"><i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b>, <b>{{nama}}</b>, <b>{{bulan_nunggak}}</b>, <b>{{company}}</b>.</div>
</div>
<textarea class="fc" id="tmpl_arrears" name="whatsapp_arrears_warning_message" rows="8" required style="width: 100%;"><%= templates.whatsapp_arrears_warning_message %></textarea>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;">
<i class="bi bi-info-circle"></i> Variabel: <b style="color:var(--primary)">{{id_pelanggan}}</b> <span style="color:var(--primary)"><%= t('common.required','(wajib)') %></span>, <b>{{nama}}</b>, <b>{{paket}}</b>, <b>{{tagihan}}</b>, <b>{{rincian}}</b>, <b>{{bulan_nunggak}}</b>, <b>{{batas}}</b>, <b>{{link}}</b>, <b>{{company}}</b>.
</div>
</div>
</div>
</div>
<div class="card-bd" style="border-top: 1px solid var(--border); background: var(--bg2); display: flex; flex-wrap:wrap; gap:10px; justify-content: space-between; align-items: center;">
<div style="display:flex; gap:8px; flex-wrap:wrap;">
<button type="button" class="btn btn-primary btn-sm" onclick="fillOfficialReceiptPreset()"><i class="bi bi-receipt-cutoff"></i> Muat Template Struk Resmi Lengkap</button>
<button type="button" class="btn btn-g btn-sm" onclick="fillSpintaxPreset()"><i class="bi bi-magic"></i> Muat Template Ringkas (Spintax Anti-Ban)</button>
<button type="button" class="btn btn-w btn-sm" onclick="showMetaConverter()"><i class="bi bi-meta"></i> <i class="bi bi-magic"></i> Konversi Format Meta Cloud API</button>
<div style="flex-basis:100%; font-size:11.5px; color:var(--muted); margin-top:4px"><i class="bi bi-exclamation-circle" style="color:var(--primary)"></i> Setiap template <b>wajib menyertakan variabel ID Pelanggan</b> <b style="color:var(--primary)">{{id_pelanggan}}</b>.</div>
<div id="presetStatus" style="flex-basis:100%; font-size:12px; display:none"></div>
</div>
<button type="submit" class="btn btn-p"><i class="bi bi-save"></i> Simpan Template</button>
</div>
</div>
</form>
<!-- MODAL META CONVERTER -->
<div class="mo" id="metaConverterModal">
<div class="mb" style="max-width: 680px;">
<div class="mh">
<span class="mt"><i class="bi bi-meta"></i> Tool Konversi Template Meta WhatsApp Cloud API</span>
<button type="button" class="mc" onclick="closeModal('metaConverterModal')">&times;</button>
</div>
<div class="mbody" style="max-height:70vh; overflow-y:auto;">
<div class="fg mb12">
<label class="fl">Pilih Template yang Akan Dikonversi ke Format Meta:</label>
<select class="fs" id="metaSourceSelect" onchange="convertCurrentTemplate()">
<option value="tmpl_auto_billing">Template Pengingat Tagihan</option>
<option value="tmpl_billing_qris">Template Tagihan QRIS</option>
<option value="tmpl_payment_success">Template Pembayaran Sukses</option>
<option value="tmpl_isolir">Template Pemberitahuan Isolir</option>
</select>
</div>
<div class="fg mb12">
<label class="fl">Hasil Format Teks Template untuk Meta Business Manager (Placeholder {{1}}, {{2}}...):</label>
<textarea class="fc" id="metaFormattedResult" rows="6" readonly style="background:var(--bg3); font-family:monospace;"></textarea>
</div>
<div class="fg mb12">
<label class="fl">Daftar Variabel Urutan Parameter (Components Payload):</label>
<div id="metaParamMapping" style="background:var(--bg3); padding:10px 14px; border-radius:8px; font-size:12px; font-family:monospace;"></div>
</div>
</div>
<div class="mf">
<button type="button" class="btn btn-g" onclick="closeModal('metaConverterModal')"><%= t('common.close','Tutup') %></button>
<button type="button" class="btn btn-p" onclick="copyMetaTemplateResult()"><i class="bi bi-clipboard-check"></i> Salin Teks Meta</button>
</div>
</div>
</div>
</div>
</div>
<script>
function setPresetStatus(msg, ok) {
var el = document.getElementById('presetStatus');
if (!el) return;
el.style.display = 'block';
el.style.color = ok === false ? 'var(--danger)' : 'var(--success, #10b981)';
el.innerHTML = (ok === false ? '<i class="bi bi-x-circle-fill"></i> ' : '<i class="bi bi-check-circle-fill"></i> ') + msg;
}
function highlightField(el) {
if (!el) return;
try {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.style.transition = 'box-shadow .2s ease';
el.style.boxShadow = '0 0 0 3px var(--primary-glow)';
setTimeout(function () { el.style.boxShadow = ''; }, 1600);
} catch (e) {}
}
function openModal(id){ var el = document.getElementById(id); if (el) el.classList.add('show'); }
function closeModal(id){ var el = document.getElementById(id); if (el) el.classList.remove('show'); }
/* ===== VERSI LENGKAP (Profesional) ===== */
var TMPL_AB_LENGKAP = '👋 Yth. *{{nama}}*,\n\n📢 Berikut kami sampaikan rincian tagihan internet Anda.\n\n📋 *RINCIAN TAGIHAN*\n• 🆔 ID Pelanggan : *{{id_pelanggan}}*\n• 📦 Paket : {{paket}}\n• 💰 Total Tagihan : *Rp {{tagihan}}*\n• 🗓️ Periode : {{rincian}}\n\n💳 Mohon melakukan pembayaran sebelum tanggal jatuh tempo melalui portal pelanggan:\n🔗 {{link}}\n\n🙏 Terima kasih atas kepercayaan Anda.\n🏢 {{company}}';
var TMPL_QR_LENGKAP = '👋 Yth. *{{nama}}*,\n\n📢 Berikut tagihan internet Anda beserta kode pembayaran QRIS.\n\n📋 *RINCIAN TAGIHAN*\n• 🆔 ID Pelanggan : *{{id_pelanggan}}*\n• 📦 Paket : {{paket}}\n• 💰 Total Tagihan : *Rp {{tagihan}}*\n• 🗓️ Periode : {{rincian}}\n\n📱 Silakan scan QRIS terlampir untuk melakukan pembayaran:\n🔗 {{link}}\n\n🙏 Terima kasih.\n🏢 {{company}}';
var TMPL_PS_LENGKAP = '🧾 *BUKTI PEMBAYARAN RESMI — LUNAS* ✅\n🏢 *{{company}}*\n────────────────────────────\n👋 Yth. *{{nama}}*,\n\n✅ Pembayaran tagihan internet Anda telah kami terima & verifikasi.\n\n📋 *RINCIAN PEMBAYARAN*\n• 🆔 ID Pelanggan : *{{id_pelanggan}}*\n• 🧾 No. Invoice : #INV-{{no_invoice}}\n• 📦 Paket Layanan : {{paket}}\n• 🗓️ Periode : {{periode}}\n• 🕒 Waktu Bayar : {{waktu}}\n• 💳 Metode Bayar : {{metode}}\n• 💰 Total Bayar : *Rp {{total}}*\n• 📌 Status : *LUNAS* ✅\n\n🌐 Status layanan: *AKTIF* dan dapat digunakan dengan nyaman.\n\n────────────────────────────\n📄 Unduh Invoice (PDF): {{link_pdf}}\n\n────────────────────────────\n🔗 Cek Tagihan / Riwayat:\n{{link_portal}}\n\n📞 Bantuan: WhatsApp {{company_phone}}\n_Simpan pesan ini sebagai bukti pembayaran yang sah dari {{company}}._';
var TMPL_IS_LENGKAP = '⚠️ Yth. *{{nama}}*,\n\n🚫 Layanan internet Anda (📦 Paket {{paket}}) saat ini *DITANGGUHKAN* karena tagihan belum dilunasi.\n\n📋 *RINCIAN TAGIHAN*\n• 🆔 ID Pelanggan : *{{id_pelanggan}}*\n• 💰 Total Tagihan : *Rp {{tagihan}}*\n\n💳 Mohon segera melakukan pembayaran melalui portal pelanggan:\n🔗 {{link}}\n\n✅ Setelah pembayaran diterima, layanan akan aktif kembali otomatis.\n\n🙏 Terima kasih.\n🏢 {{company}}';
/* ===== VERSI SPINTAX ANTI-BAN ===== */
var TMPL_AB_SPINTAX = '{Halo|Hai|Salam} *{{nama}}*,\n\n{Ini adalah|Berikut} pengingat tagihan internet Anda.\n\n📋 *RINGKASAN TAGIHAN*\n• ID Pelanggan : *{{id_pelanggan}}*\n• Paket : {{paket}}\n• Total Tagihan : *Rp {{tagihan}}*\n• Periode : {{rincian}}\n\n{Mohon|Silakan} {segera|lakukan} pembayaran melalui portal pelanggan:\n{{link}}\n\n{Terima kasih atas perhatian & kerja samanya.|Terima kasih.}\n— Admin {{company}}';
var TMPL_QR_SPINTAX = '{Halo|Hai|Salam} *{{nama}}*,\n\n{Berikut|Ini adalah} tagihan internet Anda:\n\n📋 *RINGKASAN TAGIHAN*\n• ID Pelanggan : *{{id_pelanggan}}*\n• Paket : {{paket}}\n• Total Tagihan : *Rp {{tagihan}}*\n• Periode : {{rincian}}\n\n{Silakan scan|Mohon scan} QRIS terlampir (atau buka link): {{link}}\n\nTerima kasih.\n— {{company}}';
var TMPL_PS_SPINTAX = '{Halo|Hai|Salam} *{{nama}}*,\n\n{Terima kasih|Terima kasih banyak}, pembayaran tagihan internet Anda telah kami terima.\n\n📋 *RINCIAN PEMBAYARAN*\n• ID Pelanggan : *{{id_pelanggan}}*\n• No. Invoice : #INV-{{no_invoice}}\n• Paket Layanan : {{paket}}\n• Periode : {{periode}}\n• Waktu Bayar : {{waktu}}\n• Metode Bayar : {{metode}}\n• Total Bayar : *Rp {{total}}*\n• Status : *LUNAS* ✅\n\n📄 Unduh Invoice (PDF): {{link_pdf}}\n🔗 Cek Tagihan: {{link_portal}}\n— {{company}}';
var TMPL_IS_SPINTAX = '{Halo|Hai|Salam} *{{nama}}*,\n\nLayanan internet Anda (Paket {{paket}}) {ditangguhkan|terisolir sementara} karena belum melunasi tagihan.\n\n📋 *RINGKASAN TAGIHAN*\n• ID Pelanggan : *{{id_pelanggan}}*\n• Total Tagihan : *Rp {{tagihan}}*\n\n{Mohon segera|Silakan} lakukan pembayaran via portal pelanggan:\n{{link}}\n\nTerima kasih.\n— {{company}}';
var TMPL_AL_LENGKAP = '✅ *Tagihan LUNAS*\n\nYth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),\n\nTerima kasih, tagihan layanan internet Anda telah kami tandai *LUNAS* secara otomatis.\n\n💵 Nominal: *Rp {{tagihan}}*\n📅 Periode: {{rincian}}\n\nPesan ini kami kirim sebagai validasi & pencatatan. Terima kasih 🙏\n— {{company}}';
var TMPL_DE_LENGKAP = '⚠️ *Layanan Dinonaktifkan*\n\nYth. Bapak/Ibu *{{nama}}* (ID Pelanggan: {{id_pelanggan}}),\n\nLayanan internet Anda untuk sementara kami *nonaktifkan* karena tagihan belum diselesaikan selama *{{bulan_nunggak}} bulan berturut-turut*.\n\nApabila bulan depan masih belum dibayar, akun Anda akan *DIHAPUS OTOMATIS* dan untuk berlangganan kembali harus *mendaftar dari awal*.\n\nBayar: {{link}}\n— {{company}}';
var TMPL_DL_LENGKAP = '🗑️ *Akun Dihapus*\n\nYth. *{{nama}}*,\n\nAkun layanan internet Anda telah *DIHAPUS OTOMATIS* karena tagihan tidak dibayar *{{bulan_nunggak}} bulan berturut-turut*.\n\nUntuk berlangganan kembali, silakan *mendaftar dari awal*. Terima kasih.\n— {{company}}';
var TMPL_AL_SPINTAX = '{Halo|Salam} *{{nama}}*,\n\n{Terima kasih|Alhamdulillah}, tagihan internet Anda *LUNAS* otomatis.\n\n💰 Nominal: *Rp {{tagihan}}*\n📅 Periode: {{rincian}}\n\n{Terima kasih atas kerja samanya.|Terima kasih.}\n— {{company}}';
var TMPL_DE_SPINTAX = '{Halo|Salam} *{{nama}}*,\n\nLayanan internet Anda {dinonaktifkan|kami nonaktifkan sementara} karena menunggak *{{bulan_nunggak}} bulan* berturut-turut.\n\n{Mohon segera|Silakan} lunasi, jika tidak akun akan *DIHAPUS OTOMATIS* dan harus daftar ulang:\n{{link}}\n— {{company}}';
var TMPL_DL_SPINTAX = '{Halo|Salam} *{{nama}}*,\n\nAkun Anda *DIHAPUS OTOMATIS* karena menunggak *{{bulan_nunggak}} bulan* berturut-turut. {Silakan|Mohon} mendaftar dari awal untuk berlangganan kembali.\n— {{company}}';
function applyPreset(set, msg) {
var ok = 0;
set.forEach(function (pair) {
var el = document.querySelector('[name="' + pair[0] + '"]');
if (el) { el.value = pair[1]; ok++; }
});
highlightField(document.querySelector('[name="whatsapp_auto_billing_message"]'));
var names = set.map(function (p) { return p[2]; }).join(' / ');
setPresetStatus('Dimuat ' + ok + ' template (' + names + '). Klik "Simpan Template" untuk menyimpan.\n' + (msg || ''));
}
function fillOfficialReceiptPreset() {
applyPreset([
['whatsapp_auto_billing_message', TMPL_AB_LENGKAP, 'Pengingat'],
['whatsapp_billing_qris_message', TMPL_QR_LENGKAP, 'QRIS'],
['whatsapp_payment_success_message', TMPL_PS_LENGKAP, 'Pembayaran Sukses'],
['whatsapp_isolir_message', TMPL_IS_LENGKAP, 'Isolir'],
['whatsapp_autolunas_message', TMPL_AL_LENGKAP, 'Auto-Lunas'],
['whatsapp_deactivated_message', TMPL_DE_LENGKAP, 'Auto-Nonaktif'],
['whatsapp_deleted_message', TMPL_DL_LENGKAP, 'Auto-Hapus']
], 'Versi: LENGKAP (profesional).');
}
function fillSpintaxPreset() {
applyPreset([
['whatsapp_auto_billing_message', TMPL_AB_SPINTAX, 'Pengingat'],
['whatsapp_billing_qris_message', TMPL_QR_SPINTAX, 'QRIS'],
['whatsapp_payment_success_message', TMPL_PS_SPINTAX, 'Pembayaran Sukses'],
['whatsapp_isolir_message', TMPL_IS_SPINTAX, 'Isolir'],
['whatsapp_autolunas_message', TMPL_AL_SPINTAX, 'Auto-Lunas'],
['whatsapp_deactivated_message', TMPL_DE_SPINTAX, 'Auto-Nonaktif'],
['whatsapp_deleted_message', TMPL_DL_SPINTAX, 'Auto-Hapus']
], 'Versi: SPINTAX ANTI-BAN.');
}
function showMetaConverter() { openModal('metaConverterModal'); convertCurrentTemplate(); }
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal('metaConverterModal'); });
(function () { var m = document.getElementById('metaConverterModal'); if (m) m.addEventListener('click', function (e) { if (e.target === m) closeModal('metaConverterModal'); }); })();
function convertCurrentTemplate() {
var selEl = document.getElementById('metaSourceSelect');
var selectId = selEl ? selEl.value : 'tmpl_auto_billing';
var srcEl = document.getElementById(selectId);
var sourceText = srcEl ? (srcEl.value || '') : '';
// Strip spintax hanya grup ber-pipe {A|B} (aman, tidak menyentuh {{variabel}})
var convertedText = sourceText.replace(/\{[^{}]*\|[^{}]*\}/g, function (full) {
return full.slice(1, -1).split('|')[0];
});
var varMatches = [];
var mapping = {};
var regex = /\{\{([^}]+)\}\}/g;
var match, counter = 1;
while ((match = regex.exec(sourceText)) !== null) {
var varName = match[1].trim();
if (!mapping[varName]) { mapping[varName] = '{{' + counter + '}}'; varMatches.push({ index: counter, name: varName }); counter++; }
}
var keys = Object.keys(mapping);
for (var k = 0; k < keys.length; k++) {
convertedText = convertedText.split('{{' + keys[k] + '}}').join(mapping[keys[k]]);
}
var res = document.getElementById('metaFormattedResult');
if (res) res.value = convertedText;
var map2 = document.getElementById('metaParamMapping');
if (map2) {
if (!varMatches.length) {
map2.innerHTML = '<span class="text-muted">Tidak ada variabel {{variabel}} dalam template ini.</span>';
} else {
map2.innerHTML = varMatches.map(function (v) {
return '<b>{{' + v.index + '}}</b> <i class="bi bi-arrow-right"></i> ' + v.name + (v.name === 'id_pelanggan' ? ' <span style="color:var(--primary)">(ID Pelanggan)</span>' : '');
}).join('<br>');
}
}
}
function copyMetaTemplateResult() {
var resultEl = document.getElementById('metaFormattedResult');
if (!resultEl) return;
resultEl.select();
navigator.clipboard.writeText(resultEl.value).then(function () {
alert('Teks format Meta API berhasil disalin! Silakan paste di Meta Business Manager untuk pengajuan template.');
}).catch(function () { try { document.execCommand('copy'); } catch (e) {} });
}
</script>
</body>
</html>
+66
View File
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="id" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.app_connect.title', 'Portal Pelanggan') %> — <%= companyName %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/isolated.css?v=3">
<link rel="icon" href="/img/icons/icon-192.png">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
</head>
<body>
<main class="iso-wrap">
<div class="iso-card" style="max-width:460px;text-align:center">
<div style="display:flex;justify-content:center;gap:8px;margin-bottom:8px">
<% (typeof availableLangs !== 'undefined' && availableLangs ? availableLangs : ['id','en']).forEach(function(L){ %>
<a href="/lang/<%= L %>" style="font-size:11px;font-weight:800;text-decoration:none;<%= (lang===L)?'color:var(--primary)':'color:var(--muted);opacity:.7' %>"><%= String(L).toUpperCase() %></a>
<% }); %>
</div>
<div class="iso-brand" style="flex-direction:column;gap:8px">
<% if (settings.company_logo) { %>
<img src="<%= settings.company_logo %>" alt="Logo" style="height:44px;width:auto">
<% } else { %>
<img src="/img/icons/icon-192.png" alt="Logo" style="height:52px;width:52px">
<% } %>
<div class="t" style="text-transform:uppercase;letter-spacing:.04em"><%= companyName %></div>
<span class="iso-badge success" style="font-size:11px"><%= t('customer.app_connect.badge', 'Koneksi Resmi Modem Pelanggan') %></span>
</div>
<% if (cid) { %>
<div class="iso-info" style="margin:14px 0"><div class="row"><span class="label"><%= t('customer.app_connect.id_label', 'ID Pelanggan') %></span><span class="val mono"><%= cid %></span></div></div>
<% } %>
<p class="iso-sub" style="margin:12px 0 18px"><%= t('customer.app_connect.thanks', 'Terima kasih telah menggunakan layanan internet kami. Silakan akses akun dan layanan Anda:') %></p>
<a href="/downloads/billing-rtrw.apk" class="iso-btn success"><i class="bi bi-download"></i> <%= t('customer.app_connect.download', 'Unduh Aplikasi Pelanggan (APK)') %></a>
<a href="/customer/login<%= cid ? ('?cid=' + encodeURIComponent(cid)) : '' %>" class="iso-btn primary iso-space"><i class="bi bi-box-arrow-in-right"></i> <%= t('customer.app_connect.login', 'Masuk ke Portal Web Pelanggan') %></a>
<% if (companyPhone) { %>
<a href="https://wa.me/<%= companyPhone.replace(/[^0-9]/g, '') %>" target="_blank" rel="noopener" class="iso-btn wa iso-space"><i class="bi bi-whatsapp"></i> <%= t('customer.app_connect.contact', 'Hubungi Bantuan CS WhatsApp') %></a>
<% } %>
<div class="iso-info" style="text-align:left;margin-top:18px">
<div class="row" style="align-items:flex-start;gap:10px">
<span class="label" style="flex:0 0 auto"><i class="bi bi-lightning-charge-fill" style="color:var(--warning)"></i></span>
<span class="val" style="font-weight:400;text-align:left;color:var(--muted)"><strong style="color:var(--text)"><%= t('customer.app_connect.tip1_title', 'Waspada Petir:') %></strong> <%= t('customer.app_connect.tip1_text', 'Cabut adaptor daya &amp; kabel saat hujan lebat demi keselamatan modem Anda.') %></span>
</div>
<div class="row" style="align-items:flex-start;gap:10px">
<span class="label" style="flex:0 0 auto"><i class="bi bi-arrow-repeat" style="color:var(--info)"></i></span>
<span class="val" style="font-weight:400;text-align:left;color:var(--muted)"><strong style="color:var(--text)"><%= t('customer.app_connect.tip2_title', 'Cara Reboot:') %></strong> <%= t('customer.app_connect.tip2_text', 'Matikan tombol power modem 10 detik lalu nyalakan kembali jika koneksi melambat.') %></span>
</div>
</div>
<% if (settings.company_address) { %>
<div class="iso-foot"><%= settings.company_address %></div>
<% } %>
</div>
</main>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow,noarchive">
<title><%= title %> | <%= company %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/theme-toggle.css">
<style>
.bh-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}
.bh-card{width:min(560px,100%);background:var(--glass-2,rgba(17,22,34,.85));border:1px solid var(--border);border-radius:var(--radius,16px);padding:28px;box-shadow:var(--shadow-2,0 10px 30px rgba(0,0,0,.4))}
.bh-title{font-family:var(--font-display,'Space Grotesk');font-size:24px;font-weight:700;display:flex;align-items:center;gap:10px;letter-spacing:-.02em}
.bh-sub{color:var(--muted,#8A96A6);font-size:13px;margin-top:8px}
.bh-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:22px}
.bh-item{display:flex;align-items:center;gap:12px;padding:16px;border-radius:14px;background:var(--ghost,rgba(242,245,241,.06));border:1px solid var(--border);color:var(--text);text-decoration:none;transition:.2s var(--ease,cubic-bezier(.4,0,.2,1))}
.bh-item:hover{border-color:var(--primary-glow);background:var(--pdim);transform:translateY(-2px)}
.bh-item .ic{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;font-size:20px;background:var(--pdim);color:var(--primary,#ADFF2F);flex:0 0 auto}
.bh-item .tx b{display:block;font-size:14px;font-weight:800}
.bh-item .tx span{display:block;font-size:11.5px;color:var(--muted,#8A96A6)}
.bh-back{display:inline-flex;align-items:center;gap:6px;margin-top:22px;color:var(--muted,#8A96A6);font-size:12.5px;text-decoration:none}
.bh-back:hover{color:var(--text)}
@media(max-width:520px){.bh-grid{grid-template-columns:1fr}}
</style>
</head>
<body>
<div class="bh-wrap">
<div class="bh-card">
<div class="bh-title"><i class="bi bi-router-fill" style="color:var(--primary,#ADFF2F)"></i> Blackhole</div>
<div class="bh-sub"><%= t('portal.staff_login','Pintu masuk staf') %> <%= company %>. <%= t('portal.choose','Pilih portal untuk masuk.') %></div>
<div class="bh-grid">
<a class="bh-item" href="/admin/login"><span class="ic"><i class="bi bi-shield-lock-fill"></i></span><span class="tx"><b><%= t('portal.admin','Admin') %></b><span><%= t('portal.admin_desc','Panel administrator') %></span></span></a>
<a class="bh-item" href="/agent/login"><span class="ic"><i class="bi bi-person-badge-fill"></i></span><span class="tx"><b><%= t('portal.agent','Agen') %></b><span><%= t('portal.agent_desc','Portal agen / reseller') %></span></span></a>
<a class="bh-item" href="/collector/login"><span class="ic"><i class="bi bi-cash-coin"></i></span><span class="tx"><b><%= t('portal.collector','Kolektor') %></b><span><%= t('portal.collector_desc','Penagihan lapangan') %></span></span></a>
<a class="bh-item" href="/tech/login"><span class="ic"><i class="bi bi-tools"></i></span><span class="tx"><b><%= t('portal.tech','Teknisi') %></b><span><%= t('portal.tech_desc','Portal teknisi') %></span></span></a>
</div>
<a class="bh-back" href="/customer/login"><i class="bi bi-arrow-left"></i> <%= t('portal.back_customer','Kembali ke Portal Pelanggan') %></a>
</div>
</div>
<script src="/js/theme-toggle.js" defer></script>
</body>
</html>
+823
View File
@@ -0,0 +1,823 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1,user-scalable=no">
<title><%= title %> | <%= company %></title>
<link href="/vendor/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="/vendor/bootstrap-icons/bootstrap-icons.min.css" rel="stylesheet">
<style>
:root {
--bg: #0f172a;
--panel: rgba(30, 41, 59, 0.92);
--panel2: rgba(15, 23, 42, 0.55);
--muted: #94a3b8;
--text: #e2e8f0;
--border: rgba(148, 163, 184, 0.18);
}
body {
background-color: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding-bottom: 96px;
}
.cardx {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
}
.form-control {
background: var(--panel2);
border: 1px solid var(--border);
color: var(--text);
}
.form-control:focus {
box-shadow: none;
border-color: rgba(99, 102, 241, 0.75);
}
.muted {
color: var(--muted);
}
.badge-status {
padding: 4px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
display: inline-block;
}
.bn {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 2000;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.92);
border-top: 1px solid rgba(148, 163, 184, 0.14);
backdrop-filter: blur(8px);
}
.bnw {
max-width: 980px;
margin: 0 auto;
display: flex;
gap: 8px;
justify-content: space-between;
}
.bni {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 3px;
padding: 8px 6px;
border-radius: 14px;
text-decoration: none;
color: var(--muted);
border: 1px solid rgba(148, 163, 184, 0.08);
background: rgba(30, 41, 59, 0.35);
}
.bni i {
font-size: 18px;
line-height: 1;
}
.bni span {
font-size: 10px;
font-weight: 800;
letter-spacing: .02em;
}
.bni.active {
color: #e0e7ff;
border-color: rgba(99, 102, 241, 0.35);
background: rgba(99, 102, 241, 0.12);
}
.bni.active i {
color: #a5b4fc;
}
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
margin-bottom: 20px;
}
@media (max-width: 576px) {
.stat-grid {
grid-template-columns: 1fr 1fr;
}
}
.stat-box {
background: var(--panel);
border-radius: 16px;
padding: 12px;
border: 1px solid var(--border);
text-align: center;
}
.stat-box .val {
font-size: 20px;
font-weight: 700;
}
.stat-box .lbl {
font-size: 11px;
color: var(--muted);
}
</style>
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/portal.css?v=4">
<link rel="stylesheet" href="/css/portal-theme.css?v=3">
<style>:root{--bg:#0A0D14;--panel:rgba(17,22,34,.72);--panel2:#0A0D14;--panel2b:#0A0D14;--border:rgba(242,245,241,.1);--muted:#8A96A6;--text:#F2F5F1;--brand1:#ADFF2F;--brand2:#9CE62A;--primary:#ADFF2F;--success:#34D399;--danger:#FB7185}</style>
<link rel="stylesheet" href="/css/theme-toggle.css">
</head>
<body>
<div class="container" style="max-width:980px;padding-top:14px;">
<%- include('../partials/service-notice') %>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<div style="font-weight:900;letter-spacing:-.02em"><%= t('collector.attendance_title','Absensi Karyawan') %></div>
<div class="muted" style="font-size:12px"><%= collectorName %></div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-light btn-sm" id="topbarThemeBtn" title="Ganti tema"><i class="bi bi-circle-half"></i></button>
<a class="btn btn-outline-light btn-sm" href="/collector"><i class="bi bi-house-door-fill"></i> <%= t('collector.home','Beranda') %></a>
<a class="btn btn-outline-danger btn-sm" href="/collector/logout"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<% if (typeof msg !== 'undefined' && msg) { %>
<div class="alert alert-<%= msg.type === 'error' ? 'danger' : 'success' %> alert-dismissible fade show rounded-4" role="alert" style="font-size: 13px;">
<i class="bi bi-<%= msg.type === 'error' ? 'exclamation-circle' : 'check-circle' %>-fill me-2"></i>
<%= msg.text %>
<button type="button" class="btn-close btn-close-white" style="font-size: 10px;" data-bs-dismiss="alert"></button>
</div>
<% } %>
<!-- Status Hari Ini -->
<div class="cardx p-3 mb-3" style="background: linear-gradient(135deg, var(--pdim), transparent 60%), var(--bg2); border: 1px solid var(--primary-glow);">
<h6 class="fw-bold mb-3"><i class="bi bi-clock-history me-2"></i><%= t('collector.today_status','Status Hari Ini') %></h6>
<% if (todayAttendance) { %>
<% if (todayAttendance.status === 'checked_in') { %>
<span class="badge-status" style="background: rgba(16, 185, 129, 0.3); color: #10b981; border: 1px solid #10b981;">
<i class="bi bi-check-circle-fill"></i> <%= t('collector.checked_in','Sudah Check-in') %>
</span>
<% } else { %>
<span class="badge-status" style="background: var(--panel2); color: var(--muted); border: 1px solid var(--border);">
<i class="bi bi-check-all"></i> <%= t('collector.checked_out','Sudah Check-out') %>
</span>
<% } %>
<div style="background: var(--panel2); border-radius: 12px; padding: 15px; margin-top: 15px;">
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border);">
<span style="color: var(--muted); font-size: 13px;"><%= t('collector.checkin','Check-in') %></span>
<span style="color: white; font-weight: 600; font-size: 13px;">
<%= formatTimeLocal(todayAttendance.check_in_time) %>
</span>
</div>
<% if (todayAttendance.check_out_time) { %>
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid var(--border);">
<span style="color: var(--muted); font-size: 13px;"><%= t('collector.checkout','Check-out') %></span>
<span style="color: white; font-weight: 600; font-size: 13px;">
<%= formatTimeLocal(todayAttendance.check_out_time) %>
</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 8px 0;">
<span style="color: var(--muted); font-size: 13px;"><%= t('collector.work_duration','Durasi Kerja') %></span>
<span style="color: white; font-weight: 600; font-size: 13px;">
<%= Math.floor(todayAttendance.work_duration_minutes / 60) %> jam <%= todayAttendance.work_duration_minutes % 60 %> menit
</span>
</div>
<% } %>
<% if (todayAttendance.check_in_note) { %>
<div style="padding: 8px 0; border-top: 1px solid var(--border); margin-top: 8px;">
<span style="color: var(--muted); font-size: 12px;"><%= t('collector.note_checkin','Catatan Check-in:') %></span>
<div style="color: white; font-size: 13px; margin-top: 4px;"><%= todayAttendance.check_in_note %></div>
</div>
<% } %>
<% if (todayAttendance.check_out_note) { %>
<div style="padding: 8px 0; border-top: 1px solid var(--border); margin-top: 8px;">
<span style="color: var(--muted); font-size: 12px;"><%= t('collector.note_checkout','Catatan Check-out:') %></span>
<div style="color: white; font-size: 13px; margin-top: 4px;"><%= todayAttendance.check_out_note %></div>
</div>
<% } %>
<% if (todayAttendance.check_in_photo || todayAttendance.check_out_photo) { %>
<div style="padding: 8px 0; border-top: 1px solid var(--border); margin-top: 8px;">
<span style="color: var(--muted); font-size: 12px;"><%= t('collector.attendance_photo','Foto Absensi:') %></span>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;">
<% if (todayAttendance.check_in_photo) { %>
<a href="<%= todayAttendance.check_in_photo %>" target="_blank" style="color: white; text-decoration: none; font-size: 12px;">
<img src="<%= todayAttendance.check_in_photo %>" alt="Foto check-in" style="width: 72px; height: 72px; object-fit: cover; border-radius: 10px; display: block; margin-bottom: 4px;">
<%= t('collector.checkin','Check-in') %>
</a>
<% } %>
<% if (todayAttendance.check_out_photo) { %>
<a href="<%= todayAttendance.check_out_photo %>" target="_blank" style="color: white; text-decoration: none; font-size: 12px;">
<img src="<%= todayAttendance.check_out_photo %>" alt="Foto check-out" style="width: 72px; height: 72px; object-fit: cover; border-radius: 10px; display: block; margin-bottom: 4px;">
<%= t('collector.checkout','Check-out') %>
</a>
<% } %>
</div>
</div>
<% } %>
</div>
<% if (todayAttendance.status === 'checked_in') { %>
<textarea id="checkoutNote" class="form-control mt-3" placeholder="Catatan check-out (opsional)" rows="2" style="background: var(--panel2); border: 1px solid var(--border); color: var(--text); font-size: 13px;"></textarea>
<div class="mt-3 text-center">
<label style="display: block; color: var(--text); font-size: 12px; margin-bottom: 8px;"><%= t('collector.selfie_checkout','Foto selfie check-out (wajib)') %></label>
<input id="checkoutPhoto" type="file" accept="image/*" capture="user" onchange="updatePhotoInfo('checkoutPhoto', 'checkoutPhotoInfo', 'checkoutPhotoPreviewWrap', 'checkoutPhotoPreview')" style="display: none;">
<div style="margin: 12px 0;">
<button type="button" onclick="document.getElementById('checkoutPhoto').click()" class="btn d-inline-flex align-items-center justify-content-center" style="width: 64px; height: 64px; border-radius: 50%; background: #7c3aed; color: white; font-size: 24px; box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); border: none; transition: transform 0.2s;" onmousedown="this.style.transform='scale(0.92)'" onmouseup="this.style.transform='scale(1)'">
<i class="bi bi-camera-fill"></i>
</button>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.tap_camera','Ketuk tombol kamera untuk mengambil foto selfie') %></div>
</div>
<div id="checkoutPhotoInfo" style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.no_selfie_out','Belum ada foto selfie dipilih. Foto wajib untuk check-out.') %></div>
<div id="checkoutPhotoStatus" style="display: inline-block; margin-top: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11px; background: var(--ddim); color: var(--danger); border: 1px solid var(--danger-glow);"><%= t('collector.selfie_required','Foto wajib') %></div>
<div style="margin-top: 8px;">
<button type="button" onclick="clearSelectedPhoto('checkoutPhoto')" class="btn btn-sm btn-outline-danger" id="checkoutPhotoClearBtn" style="display: none; border-radius: 8px; font-size: 11px;"><%= t('collector.delete_photo','Hapus Foto') %></button>
</div>
<div id="checkoutPhotoPreviewWrap" style="display: none; margin-top: 8px;">
<div style="font-size: 11px; color: var(--muted); margin-bottom: 6px;"><%= t('collector.photo_preview','Preview foto final yang akan dikirim:') %></div>
<img id="checkoutPhotoPreview" alt="Preview foto check-out" style="width: 100%; max-width: 240px; border-radius: 12px; border: 1px solid var(--border); object-fit: cover;">
</div>
</div>
<button id="checkoutSubmitBtn" class="btn btn-danger btn-action mt-2 w-100" onclick="checkout(event)" disabled style="opacity: 0.55; cursor: not-allowed; border-radius: 12px; padding: 10px; font-weight: 600;">
<i class="bi bi-box-arrow-right me-2"></i><%= t('collector.checkout_now','Check-out Sekarang') %>
</button>
<% } %>
<% } else { %>
<span class="badge-status" style="background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 1px solid #ef4444;">
<i class="bi bi-x-circle-fill"></i> <%= t('collector.not_checked_in','Belum Check-in') %>
</span>
<p style="color: var(--text); margin: 15px 0 0 0; font-size: 13px;">
<%= t('collector.start_day','Silakan lakukan check-in untuk memulai hari kerja Anda.') %>
</p>
<textarea id="checkinNote" class="form-control mt-3" placeholder="Catatan check-in (opsional)" rows="2" style="background: var(--panel2); border: 1px solid var(--border); color: var(--text); font-size: 13px;"></textarea>
<div class="mt-3 text-center">
<label style="display: block; color: var(--text); font-size: 12px; margin-bottom: 8px;"><%= t('collector.selfie_checkin','Foto selfie check-in (wajib)') %></label>
<input id="checkinPhoto" type="file" accept="image/*" capture="user" onchange="updatePhotoInfo('checkinPhoto', 'checkinPhotoInfo', 'checkinPhotoPreviewWrap', 'checkinPhotoPreview')" style="display: none;">
<div style="margin: 12px 0;">
<button type="button" onclick="document.getElementById('checkinPhoto').click()" class="btn d-inline-flex align-items-center justify-content-center" style="width: 64px; height: 64px; border-radius: 50%; background: #10b981; color: white; font-size: 24px; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); border: none; transition: transform 0.2s;" onmousedown="this.style.transform='scale(0.92)'" onmouseup="this.style.transform='scale(1)'">
<i class="bi bi-camera-fill"></i>
</button>
<div style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.tap_camera','Ketuk tombol kamera untuk mengambil foto selfie') %></div>
</div>
<div id="checkinPhotoInfo" style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.no_selfie_in','Belum ada foto selfie dipilih. Foto wajib untuk check-in.') %></div>
<div id="checkinPhotoStatus" style="display: inline-block; margin-top: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11px; background: var(--ddim); color: var(--danger); border: 1px solid var(--danger-glow);"><%= t('collector.selfie_required','Foto wajib') %></div>
<div style="margin-top: 8px;">
<button type="button" onclick="clearSelectedPhoto('checkinPhoto')" class="btn btn-sm btn-outline-danger" id="checkinPhotoClearBtn" style="display: none; border-radius: 8px; font-size: 11px;"><%= t('collector.delete_photo','Hapus Foto') %></button>
</div>
<div id="checkinPhotoPreviewWrap" style="display: none; margin-top: 8px;">
<div style="font-size: 11px; color: var(--muted); margin-bottom: 6px;"><%= t('collector.photo_preview','Preview foto final yang akan dikirim:') %></div>
<img id="checkinPhotoPreview" alt="Preview foto check-in" style="width: 100%; max-width: 240px; border-radius: 12px; border: 1px solid var(--border); object-fit: cover;">
</div>
</div>
<button id="checkinSubmitBtn" class="btn btn-success btn-action mt-2 w-100" onclick="checkin(event)" disabled style="opacity: 0.55; cursor: not-allowed; border-radius: 12px; padding: 10px; font-weight: 600;">
<i class="bi bi-box-arrow-in-right me-2"></i><%= t('collector.checkin_now','Check-in Sekarang') %>
</button>
<% } %>
<div id="locationInfo" style="display: none; font-size: 11px; color: var(--muted); margin-top: 10px; padding: 8px; background: rgba(0, 0, 0, 0.2); border-radius: 8px;">
<i class="bi bi-geo-alt-fill"></i> <%= t('collector.getting_gps','Mengambil lokasi GPS...') %>
</div>
</div>
<!-- Ringkasan Bulan Ini -->
<h6 class="mb-3 text-secondary fw-bold text-uppercase" style="font-size: 13px; letter-spacing: 1px;">
<i class="bi bi-calendar3 me-1"></i><%= t('collector.month_summary','Ringkasan Bulan Ini') %>
</h6>
<div class="stat-grid">
<div class="stat-box" style="border-left: 3px solid #3b82f6;">
<div class="val text-primary"><%= summary.total_days || 0 %></div>
<div class="lbl"><%= t('collector.total_days','Total Hari') %></div>
</div>
<div class="stat-box" style="border-left: 3px solid #10b981;">
<div class="val text-success"><%= summary.completed_days || 0 %></div>
<div class="lbl"><%= t('collector.full_days','Hari Lengkap') %></div>
</div>
<div class="stat-box" style="border-left: 3px solid #f59e0b;">
<div class="val text-warning"><%= summary.total_minutes ? Math.floor(summary.total_minutes / 60) : 0 %></div>
<div class="lbl">Total Jam</div>
</div>
<div class="stat-box" style="border-left: 3px solid #8b5cf6;">
<div class="val" style="color: #a78bfa;"><%= summary.avg_minutes ? Math.floor(summary.avg_minutes / 60) : 0 %></div>
<div class="lbl"><%= t('collector.avg_hours','Rata-rata Jam') %></div>
</div>
</div>
<!-- Riwayat Terakhir -->
<h6 class="mb-3 text-secondary fw-bold text-uppercase" style="font-size: 13px; letter-spacing: 1px;">
<i class="bi bi-clock-history me-1"></i><%= t('collector.recent_history','Riwayat Terakhir') %>
</h6>
<% if (history && history.length > 0) { %>
<% history.forEach(item => { %>
<div class="cardx p-3 mb-2">
<div class="fw-bold mb-2" style="font-size: 14px; color: #f8fafc;">
<%= parseDateInTimezone(item.check_in_time).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long'
}) %>
</div>
<div class="text-secondary mb-1" style="font-size: 12px;">
<i class="bi bi-box-arrow-in-right text-success me-1"></i>
Check-in: <%= formatTimeLocal(item.check_in_time) %>
</div>
<% if (item.check_out_time) { %>
<div class="text-secondary mb-1" style="font-size: 12px;">
<i class="bi bi-box-arrow-right text-danger me-1"></i>
Check-out: <%= formatTimeLocal(item.check_out_time) %>
</div>
<div class="text-secondary" style="font-size: 12px;">
<i class="bi bi-clock text-primary me-1"></i>
<%= t('collector.duration_label','Durasi:') %> <%= Math.floor(item.work_duration_minutes / 60) %> <%= t('common.hours','jam') %> <%= item.work_duration_minutes % 60 %> <%= t('collector.minutes','menit') %>
</div>
<% } else { %>
<div class="text-danger" style="font-size: 12px;">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<%= t('collector.not_checked_out','Belum check-out') %>
</div>
<% } %>
</div>
<% }) %>
<% } else { %>
<div class="text-center py-5">
<i class="bi bi-inbox text-secondary" style="font-size: 40px;"></i>
<p class="text-secondary mt-2 small"><%= t('collector.no_history','Belum ada riwayat absensi') %></p>
</div>
<% } %>
</div> <!-- End container -->
<!-- Bottom Navigation -->
<div class="bn">
<div class="bnw">
<a class="bni" href="/collector">
<i class="bi bi-grid-3x3-gap"></i><span>Semua</span>
</a>
<a class="bni" href="/collector?scope=today#data-tagihan">
<i class="bi bi-calendar-check"></i><span>Hari Ini</span>
</a>
<a class="bni" href="/collector?scope=unpaid#data-tagihan">
<i class="bi bi-exclamation-circle"></i><span>Belum</span>
</a>
<a class="bni" href="/collector?scope=isolir#data-tagihan">
<i class="bi bi-shield-exclamation"></i><span>Isolir</span>
</a>
<a class="bni" href="/collector?scope=multi#data-tagihan">
<i class="bi bi-layers"></i><span>&gt;1 Bln</span>
</a>
<a class="bni active" href="/collector/attendance">
<i class="bi bi-calendar-check-fill"></i><span>Absensi</span>
</a>
</div>
</div>
<script>
let currentLocation = null;
const processedAttendancePhotos = {};
const photoFieldConfig = {
checkinPhoto: { infoId: 'checkinPhotoInfo', previewWrapId: 'checkinPhotoPreviewWrap', previewImgId: 'checkinPhotoPreview', statusId: 'checkinPhotoStatus', submitBtnId: 'checkinSubmitBtn' },
checkoutPhoto: { infoId: 'checkoutPhotoInfo', previewWrapId: 'checkoutPhotoPreviewWrap', previewImgId: 'checkoutPhotoPreview', statusId: 'checkoutPhotoStatus', submitBtnId: 'checkoutSubmitBtn' }
};
function setPhotoStatus(statusId, text, mode = 'idle') {
const el = document.getElementById(statusId);
if (!el) return;
const palette = {
idle: ['var(--ddim)', 'var(--danger)'],
waiting: ['var(--wdim)', 'var(--warning)'],
processing: ['var(--idim)', 'var(--info)'],
ready: ['var(--sdim)', 'var(--success)'],
error: ['var(--ddim)', 'var(--danger)']
};
const [bg, color] = palette[mode] || palette.idle;
el.style.background = bg;
el.style.color = color;
el.textContent = text;
}
function setSubmitButtonState(buttonId, enabled) {
const button = document.getElementById(buttonId);
if (!button) return;
button.disabled = !enabled;
button.style.opacity = enabled ? '1' : '0.55';
button.style.cursor = enabled ? 'pointer' : 'not-allowed';
}
function resetPhotoPreview(previewWrapId, previewImgId) {
const previewWrap = document.getElementById(previewWrapId);
const previewImg = document.getElementById(previewImgId);
if (previewImg && previewImg.dataset.objectUrl) {
URL.revokeObjectURL(previewImg.dataset.objectUrl);
delete previewImg.dataset.objectUrl;
}
if (previewImg) previewImg.removeAttribute('src');
if (previewWrap) previewWrap.style.display = 'none';
}
function setPhotoPreview(previewWrapId, previewImgId, file) {
const previewWrap = document.getElementById(previewWrapId);
const previewImg = document.getElementById(previewImgId);
if (!previewWrap || !previewImg) return;
resetPhotoPreview(previewWrapId, previewImgId);
const previewSource = file && file.blob ? file.blob : file;
const objectUrl = URL.createObjectURL(previewSource);
previewImg.src = objectUrl;
previewImg.dataset.objectUrl = objectUrl;
previewWrap.style.display = 'block';
}
function getOriginalPhotoFile(inputId) {
const input = document.getElementById(inputId);
return input && input.files && input.files[0] ? input.files[0] : null;
}
function updatePhotoInfo(inputId, infoId, previewWrapId, previewImgId) {
const input = document.getElementById(inputId);
const info = document.getElementById(infoId);
const config = getPhotoState(inputId);
if (!input || !info) return;
const file = input.files && input.files[0];
delete processedAttendancePhotos[inputId];
if (!file) {
info.textContent = 'Belum ada foto selfie dipilih.';
resetPhotoPreview(previewWrapId, previewImgId);
if (config) setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
if (config) setSubmitButtonState(config.submitBtnId, false);
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'none';
return;
}
info.textContent = `Foto dipilih: ${file.name} (${Math.round(file.size / 1024)} KB)`;
if (config) setPhotoStatus(config.statusId, 'Menyiapkan preview', 'processing');
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'inline-block';
refreshPhotoPreview(inputId).catch((error) => {
info.textContent = error.message;
resetPhotoPreview(previewWrapId, previewImgId);
if (config) setPhotoStatus(config.statusId, 'Preview gagal', 'error');
});
}
function getPhotoState(inputId) {
return photoFieldConfig[inputId];
}
function clearSelectedPhoto(inputId) {
const config = getPhotoState(inputId);
const input = document.getElementById(inputId);
if (!config || !input) return;
input.value = '';
delete processedAttendancePhotos[inputId];
const info = document.getElementById(config.infoId);
if (info) info.textContent = 'Belum ada foto selfie dipilih. Foto wajib untuk absensi.';
resetPhotoPreview(config.previewWrapId, config.previewImgId);
setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
setSubmitButtonState(config.submitBtnId, false);
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'none';
}
async function refreshPhotoPreview(inputId, force = false) {
const config = getPhotoState(inputId);
const input = document.getElementById(inputId);
const info = config ? document.getElementById(config.infoId) : null;
const file = input && input.files && input.files[0];
if (!config || !info) return null;
if (!file) {
info.textContent = 'Belum ada foto selfie dipilih. Foto wajib untuk absensi.';
resetPhotoPreview(config.previewWrapId, config.previewImgId);
delete processedAttendancePhotos[inputId];
setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
setSubmitButtonState(config.submitBtnId, false);
return null;
}
if (!currentLocation) {
info.textContent = `Foto dipilih: ${file.name} (${Math.round(file.size / 1024)} KB). Preview menunggu lokasi GPS.`;
resetPhotoPreview(config.previewWrapId, config.previewImgId);
setPhotoStatus(config.statusId, 'Menunggu GPS', 'waiting');
setSubmitButtonState(config.submitBtnId, false);
return null;
}
const cacheKey = [file.name, file.size, file.lastModified, currentLocation.lat, currentLocation.lng].join('|');
const cached = processedAttendancePhotos[inputId];
if (!force && cached && cached.cacheKey === cacheKey && cached.file) {
setPhotoPreview(config.previewWrapId, config.previewImgId, cached.file);
info.textContent = `Preview siap: ${(cached.file.filename || cached.file.name)} (${Math.round(cached.file.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Preview siap dikirim', 'ready');
setSubmitButtonState(config.submitBtnId, true);
return cached.file;
}
info.textContent = 'Memproses foto untuk preview...';
setPhotoStatus(config.statusId, 'Memproses foto', 'processing');
setSubmitButtonState(config.submitBtnId, false);
let processedFile;
try {
processedFile = await processAttendancePhotoFile(file, currentLocation);
} catch (error) {
processedFile = file;
info.textContent = `Foto asli siap dikirim: ${file.name} (${Math.round(file.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Foto asli siap dikirim', 'ready');
}
processedAttendancePhotos[inputId] = { cacheKey, file: processedFile };
setPhotoPreview(config.previewWrapId, config.previewImgId, processedFile);
if (processedFile === file) {
info.textContent = `Foto asli siap dikirim: ${file.name} (${Math.round(file.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Foto asli siap dikirim', 'ready');
} else {
info.textContent = `Preview siap: ${processedFile.filename} (${Math.round(processedFile.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Preview siap dikirim', 'ready');
}
setSubmitButtonState(config.submitBtnId, true);
return processedFile;
}
function refreshAllPhotoPreviews() {
Object.keys(photoFieldConfig).forEach((inputId) => {
refreshPhotoPreview(inputId, true).catch(() => {});
});
}
function buildAttendanceFormData(location, note, photoInputId) {
const formData = new FormData();
formData.append('lat', location.lat);
formData.append('lng', location.lng);
formData.append('note', note);
return getProcessedAttendancePhoto(photoInputId, location).then((processedPhoto) => {
if (processedPhoto && processedPhoto.blob) {
formData.append('photo', processedPhoto.blob, processedPhoto.filename);
} else if (processedPhoto) {
formData.append('photo', processedPhoto, processedPhoto.name || 'attendance-selfie.jpg');
} else {
const originalFile = getOriginalPhotoFile(photoInputId);
if (originalFile) {
formData.append('photo', originalFile, originalFile.name);
}
}
return formData;
});
}
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Gagal membaca foto yang dipilih'));
};
img.src = objectUrl;
});
}
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
const fallback = () => {
try {
const dataUrl = canvas.toDataURL(type, quality);
const parts = dataUrl.split(',');
if (parts.length < 2) throw new Error('Gagal memproses foto absensi');
const mime = (parts[0].match(/data:(.*?);base64/) || [])[1] || type || 'image/jpeg';
const binary = atob(parts[1]);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
resolve(new Blob([bytes], { type: mime }));
} catch (e) {
reject(new Error('Gagal memproses foto absensi'));
}
};
if (typeof canvas.toBlob !== 'function') return fallback();
canvas.toBlob((blob) => {
if (blob) return resolve(blob);
fallback();
}, type, quality);
});
}
function buildWatermarkLines(location) {
const timestamp = new Date().toLocaleString('id-ID', {
timeZone: '<%= settings.timezone %>',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return [
'<%= settings.companyName %>'.toUpperCase(),
'KOLEKTOR PORTAL - ABSENSI',
`Waktu: ${timestamp}`,
`GPS: ${location.lat}, ${location.lng}`
];
}
async function processAttendancePhotoFile(originalFile, location) {
const image = await loadImageFromFile(originalFile);
const maxWidth = 1280;
const scale = Math.min(1, maxWidth / image.width);
const width = Math.max(1, Math.round(image.width * scale));
const height = Math.max(1, Math.round(image.height * scale));
const watermarkLines = buildWatermarkLines(location);
const fontSize = Math.max(20, Math.round(width * 0.028));
const lineGap = Math.round(fontSize * 0.35);
const padding = Math.round(fontSize * 0.8);
const overlayHeight = Math.round((fontSize + lineGap) * watermarkLines.length + padding * 1.6);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0, width, height);
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
ctx.fillRect(0, height - overlayHeight, width, overlayHeight);
ctx.fillStyle = '#ffffff';
ctx.font = `600 ${fontSize}px Arial, sans-serif`;
ctx.textBaseline = 'top';
let textY = height - overlayHeight + padding;
watermarkLines.forEach((line, index) => {
if (index > 0) {
ctx.font = `500 ${Math.max(16, Math.round(fontSize * 0.88))}px Arial, sans-serif`;
} else {
ctx.font = `600 ${fontSize}px Arial, sans-serif`;
}
ctx.fillText(line, padding, textY);
textY += (index > 0 ? Math.max(16, Math.round(fontSize * 0.88)) : fontSize) + lineGap;
});
const blob = await canvasToBlob(canvas, 'image/jpeg', 0.82);
const baseName = (originalFile.name || 'attendance-photo').replace(/\.[^.]+$/, '');
return {
blob,
filename: `${baseName}-wm.jpg`,
size: blob.size
};
}
async function getProcessedAttendancePhoto(photoInputId, location) {
currentLocation = location;
try {
const processed = await refreshPhotoPreview(photoInputId);
if (processed) return processed;
const originalFile = getOriginalPhotoFile(photoInputId);
if (!originalFile) return null;
return await processAttendancePhotoFile(originalFile, location);
} catch (error) {
return getOriginalPhotoFile(photoInputId);
}
}
// Get current location
function getLocation() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation tidak didukung browser Anda'));
return;
}
const locationInfo = document.getElementById('locationInfo');
locationInfo.style.display = 'block';
locationInfo.innerHTML = '<i class="bi bi-geo-alt-fill"></i> Mengambil lokasi GPS...';
navigator.geolocation.getCurrentPosition(
(position) => {
currentLocation = {
lat: position.coords.latitude.toString(),
lng: position.coords.longitude.toString()
};
locationInfo.innerHTML = `<i class="bi bi-geo-alt-fill text-success"></i> Lokasi berhasil didapat`;
refreshAllPhotoPreviews();
resolve(currentLocation);
},
(error) => {
locationInfo.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-danger"></i> Gagal mendapatkan lokasi GPS';
reject(error);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
});
}
async function checkin(evt) {
const btn = evt.currentTarget;
if (!document.getElementById('checkinPhoto')?.files?.[0]) {
alert('Foto check-in wajib diunggah sebelum absensi.');
return;
}
try {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Memproses...';
const location = await getLocation();
const note = document.getElementById('checkinNote').value.trim();
const response = await fetch('/collector/attendance/checkin', {
method: 'POST',
body: await buildAttendanceFormData(location, note, 'checkinPhoto')
});
const result = await response.json();
if (result.success) {
alert(' ' + result.message);
window.location.reload();
} else {
alert(' ' + result.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Check-in Sekarang';
}
} catch (error) {
alert('Gagal check-in: ' + error.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Check-in Sekarang';
}
}
async function checkout(evt) {
const btn = evt.currentTarget;
if (!document.getElementById('checkoutPhoto')?.files?.[0]) {
alert('Foto check-out wajib diunggah sebelum absensi.');
return;
}
try {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Memproses...';
const location = await getLocation();
const note = document.getElementById('checkoutNote').value.trim();
const response = await fetch('/collector/attendance/checkout', {
method: 'POST',
body: await buildAttendanceFormData(location, note, 'checkoutPhoto')
});
const result = await response.json();
if (result.success) {
alert(' ' + result.message);
window.location.reload();
} else {
alert(' ' + result.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-right me-2"></i>Check-out Sekarang';
}
} catch (error) {
alert('Gagal check-out: ' + error.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-right me-2"></i>Check-out Sekarang';
}
}
// Auto-get location on page load
window.addEventListener('load', () => {
getLocation().catch(() => {});
});
</script>
<script src="/js/theme-toggle.js" defer></script>
</body>
</html>
+335
View File
@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title><%= title %> | <%= company %></title>
<link href="/vendor/bootstrap/bootstrap.min.css" rel="stylesheet">
<link href="/vendor/bootstrap-icons/bootstrap-icons.min.css" rel="stylesheet">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/portal.css?v=4">
<link rel="stylesheet" href="/css/portal-theme.css?v=3">
<link rel="stylesheet" href="/css/theme-toggle.css">
<style>
:root{--bg:#0A0D14;--panel:rgba(17,22,34,.72);--panel2:#0A0D14;--muted:#8A96A6;--text:#F2F5F1;--border:rgba(242,245,241,.1)}
body{background:var(--bg);color:var(--text)}
.cardx{background:var(--panel);border:1px solid var(--border);border-radius:16px}
.form-control{background:var(--panel2);border:1px solid var(--border);color:var(--text)}
.form-control:focus{box-shadow:none;border-color:rgba(99,102,241,.75)}
.muted{color:var(--muted)}
.badgey{display:inline-flex;align-items:center;gap:6px;padding:6px 10px;border-radius:999px;font-size:11px;font-weight:800;border:1px solid var(--border);background:var(--panel2)}
.badgey.s{color:#bbf7d0;border-color:rgba(34,197,94,.35);background:rgba(34,197,94,.1)}
.badgey.d{color:#fecaca;border-color:rgba(239,68,68,.35);background:rgba(239,68,68,.1)}
.badgey.w{color:#fde68a;border-color:rgba(245,158,11,.35);background:rgba(245,158,11,.1)}
.tbl td{border-top:1px solid rgba(148,163,184,.12);padding:8px 0;font-size:12px;vertical-align:top}
.tbl td:first-child{color:var(--muted);width:120px;padding-right:12px}
.bn{position:fixed;left:0;right:0;bottom:0;z-index:2000;padding:10px 12px;background:rgba(15,23,42,.92);border-top:1px solid rgba(148,163,184,.14);backdrop-filter:blur(8px)}
.bnw{max-width:980px;margin:0 auto;display:flex;gap:8px;justify-content:space-between}
.bni{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;padding:8px 6px;border-radius:14px;text-decoration:none;color:var(--muted);border:1px solid rgba(148,163,184,.08);background:rgba(30,41,59,.35)}
.bni i{font-size:18px;line-height:1}
.bni span{font-size:10px;font-weight:800;letter-spacing:.02em}
.bni.active{color:var(--primary);border-color:var(--primary-glow);background:var(--pdim)}
.bni.active i{color:var(--primary)}
</style>
</head>
<body>
<div class="container" style="max-width:980px;padding-top:14px;padding-bottom:96px">
<div class="collector-top d-flex justify-content-between align-items-center">
<div>
<div style="font-weight:900;letter-spacing:-.02em"><%= t('collector.role','Kolektor') %>: <%= session.collectorName || t('collector.fallback_name','Penagih') %></div>
<div class="muted" style="font-size:12px">
<%= company %> · v<%= appVersion %> (<%= appVersionName %>)
<% if (session.collectorArea) { %>
<span class="badge bg-indigo ms-1" style="background:rgba(99,102,241,0.2);color:#a5b4fc;border:1px solid rgba(99,102,241,0.3)"><i class="bi bi-geo-alt"></i> <%= t('collector.col_area_short','Area:') %> <%= session.collectorArea %></span>
<% } else { %>
<span class="badge bg-secondary ms-1" style="font-weight:normal;opacity:0.7"><i class="bi bi-globe"></i> <%= t('collector.filter_all_area','Semua Area') %></span>
<% } %>
</div>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-light btn-sm" id="topbarThemeBtn" title="Ganti tema"><i class="bi bi-circle-half"></i></button>
<a href="/lang/<%= lang === 'en' ? 'id' : 'en' %>" class="btn btn-outline-light btn-sm" style="text-decoration:none" title="<%= lang === 'en' ? 'Bahasa Indonesia' : 'English' %>"><i class="bi bi-translate"></i> <%= lang === 'en' ? 'ID' : 'EN' %></a>
<a class="btn btn-outline-primary btn-sm d-flex align-items-center gap-1" href="/collector/attendance">
<i class="bi bi-calendar-check"></i> <%= t('collector.absensi','Absensi') %>
</a>
<a class="btn btn-outline-light btn-sm" href="/collector/logout"><i class="bi bi-box-arrow-left"></i></a>
</div>
</div>
<%- include('../partials/service-notice') %>
<% if (msg) { %>
<div class="alert alert-<%= msg.type==='error'?'danger':'success' %> d-flex align-items-center gap-2 flex-wrap">
<span><%- msg.text %></span>
</div>
<% } %>
<div class="row g-2 mb-3">
<div class="col-6 col-md-3">
<a class="cardx p-3 d-block text-decoration-none" href="/collector?<%= new URLSearchParams({ month: String(month), year: String(year), status: 'unpaid', scope: 'today', search: String(search||''), jump: 'data' }).toString() %>#data-tagihan">
<div class="muted" style="font-size:12px"><%= t('collector.tag_hari_ini_tgl','Tagihan Hari Ini (tgl') %> <%= todayDay %>)</div>
<div style="font-weight:900;font-size:20px"><%= Number(summary?.today_count || 0) %></div>
<div class="muted" style="font-size:12px">Rp <%= Number(summary?.today_total || 0).toLocaleString('id-ID') %></div>
</a>
</div>
<div class="col-6 col-md-3">
<a class="cardx p-3 d-block text-decoration-none" href="/collector?<%= new URLSearchParams({ month: String(month), year: String(year), status: 'unpaid', scope: 'unpaid', search: String(search||''), jump: 'data' }).toString() %>#data-tagihan">
<div class="muted" style="font-size:12px"><%= t('collector.tag_belum_bayar','Tagihan Belum Bayar') %></div>
<div style="font-weight:900;font-size:20px"><%= Number(summary?.unpaid_count || 0) %></div>
<div class="muted" style="font-size:12px">Rp <%= Number(summary?.unpaid_total || 0).toLocaleString('id-ID') %></div>
</a>
</div>
<div class="col-6 col-md-3">
<a class="cardx p-3 d-block text-decoration-none" href="/collector?<%= new URLSearchParams({ month: String(month), year: String(year), status: 'unpaid', scope: 'isolir', search: String(search||''), jump: 'data' }).toString() %>#data-tagihan">
<div class="muted" style="font-size:12px"><%= t('collector.tag_isolir','Tagihan Isolir') %></div>
<div style="font-weight:900;font-size:20px"><%= Number(summary?.isolir_count || 0) %></div>
<div class="muted" style="font-size:12px">Rp <%= Number(summary?.isolir_total || 0).toLocaleString('id-ID') %></div>
</a>
</div>
<div class="col-6 col-md-3">
<a class="cardx p-3 d-block text-decoration-none" href="/collector?<%= new URLSearchParams({ month: String(month), year: String(year), status: 'unpaid', scope: 'multi', search: String(search||''), jump: 'data' }).toString() %>#data-tagihan">
<div class="muted" style="font-size:12px"><%= t('collector.tag_menunggak','Menunggak > 1 Bulan') %></div>
<div style="font-weight:900;font-size:20px"><%= Number(summary?.multi_customer_count || 0) %></div>
<div class="muted" style="font-size:12px">Rp <%= Number(summary?.multi_total || 0).toLocaleString('id-ID') %></div>
</a>
</div>
</div>
<div class="cardx p-3 mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<div style="font-weight:800"><i class="bi bi-receipt"></i> <%= t('collector.bill_list','Daftar Tagihan') %></div>
<span class="badgey"><i class="bi bi-shield-check"></i><%= t('collector.read_only','Read Only') %></span>
</div>
<form method="GET" action="/collector" class="d-flex gap-2 flex-wrap">
<select class="form-control" name="month" style="max-width:110px">
<% for (let m=1; m<=12; m++) { %>
<option value="<%= m %>" <%= Number(month)===m ? 'selected' : '' %>><%= String(m).padStart(2,'0') %></option>
<% } %>
</select>
<select class="form-control" name="year" style="max-width:120px">
<% const baseY = Number(year) || new Date().getFullYear(); %>
<% for (let y=baseY-2; y<=baseY+1; y++) { %>
<option value="<%= y %>" <%= Number(year)===y ? 'selected' : '' %>><%= y %></option>
<% } %>
</select>
<select class="form-control" name="status" style="max-width:170px">
<option value="unpaid" <%= String(status)==='unpaid' ? 'selected' : '' %>><%= t('collector.filter_belum','Belum Bayar') %></option>
<option value="paid" <%= String(status)==='paid' ? 'selected' : '' %>><%= t('collector.lunas','Lunas') %></option>
<option value="all" <%= String(status)==='all' ? 'selected' : '' %>><%= t('collector.filter_all','Semua') %></option>
</select>
<select class="form-control" name="scope" style="max-width:200px">
<option value="" <%= !scope ? 'selected' : '' %>><%= t('collector.filter_all_cust','Semua Pelanggan') %></option>
<option value="today" <%= String(scope)==='today' ? 'selected' : '' %>><%= t('collector.tag_hari_ini','Tagihan Hari Ini') %></option>
<option value="unpaid" <%= String(scope)==='unpaid' ? 'selected' : '' %>><%= t('collector.filter_unpaid','Belum Bayar (Filter)') %></option>
<option value="isolir" <%= String(scope)==='isolir' ? 'selected' : '' %>><%= t('collector.btn_isolir','Isolir') %></option>
<option value="multi" <%= String(scope)==='multi' ? 'selected' : '' %>><%= t('collector.tag_menunggak','Menunggak > 1 Bulan') %></option>
</select>
<input class="form-control" name="search" value="<%= search || '' %>" placeholder="<%= t('collector.filter_search','Cari (opsional): nama / HP / PPPoE / TAG') %>">
<button class="btn btn-primary" type="submit" style="min-width:90px"><i class="bi bi-funnel"></i></button>
</form>
<div class="muted mt-2" style="font-size:12px">
<%= t('collector.count_prefix','Menampilkan maks 500 tagihan periode') %><span style="font-weight:900"><%= String(month).padStart(2,'0') %>/<%= year %></span>.
</div>
</div>
<div class="cardx p-3 mb-3" id="data-tagihan">
<div style="font-weight:800" class="mb-2"><i class="bi bi-list-ul"></i> <%= t('collector.data_title','Data Pelanggan & Tagihan Area') %> (<%= (invoices || []).length %>)</div>
<% if (!invoices || invoices.length === 0) { %>
<div class="muted" style="font-size:12px"><%= t('collector.no_data','Tidak ada data pelanggan untuk filter ini.') %></div>
<% } else { %>
<div class="d-flex flex-column gap-2">
<% invoices.forEach(inv => {
const invId = inv.invoice_id;
const isPaid = String(inv.invoice_status||'').toLowerCase()==='paid';
const reqRow = pendingMap && pendingMap.get ? (pendingMap.get('inv_' + invId) || pendingMap.get('cust_' + inv.customer_id)) : null;
const reqStatus = reqRow ? String(reqRow.status||'') : '';
const isPending = reqStatus === 'pending';
const noInvoice = !invId;
const amount = Number(inv.invoice_amount || inv.package_price || 0);
%>
<div style="background:var(--panel2);border:1px solid rgba(148,163,184,.14);border-radius:14px;padding:12px">
<div class="d-flex justify-content-between align-items-start gap-2">
<div style="min-width:0">
<div style="font-weight:900">
<%= inv.customer_name || '-' %>
<span class="muted" style="font-size:12px"><%= inv.customer_phone ? '('+inv.customer_phone+')' : '' %></span>
<% if (inv.customer_status === 'suspended') { %>
<span class="badge bg-danger ms-1" style="font-size:10px"><i class="bi bi-pause-circle"></i> <%= t('collector.status_isolir','ISOLIR') %></span>
<% } else if (inv.customer_status === 'active') { %>
<span class="badge bg-success ms-1" style="font-size:10px;opacity:0.8"><i class="bi bi-check-circle"></i> <%= t('collector.status_active','Aktif') %></span>
<% } else { %>
<span class="badge bg-secondary ms-1" style="font-size:10px;opacity:0.8"><%= inv.customer_status %></span>
<% } %>
</div>
<div class="muted" style="font-size:12px"><%= inv.package_name || 'Internet' %> <%= invId ? ('• Invoice #' + invId) : '• Belum Ada Invoice' %></div>
</div>
<div class="text-end">
<div style="font-weight:900">Rp <%= amount.toLocaleString('id-ID') %></div>
<% if (isPaid) { %>
<span class="badgey s"><i class="bi bi-check-circle"></i> <%= t('collector.status_lunas','LUNAS') %></span>
<% } else if (isPending) { %>
<span class="badgey w"><i class="bi bi-hourglass"></i> <%= t('collector.waiting_approval','MENUNGGU APPROVAL') %></span>
<% } else if (noInvoice) { %>
<span class="badgey w" style="background:rgba(99,102,241,0.15);color:#a5b4fc;border-color:rgba(99,102,241,0.3)"><i class="bi bi-file-earmark-plus"></i> <%= t('collector.status_belum_tagihan','BELUM TAGIHAN') %></span>
<% } else { %>
<span class="badgey d"><i class="bi bi-exclamation-circle"></i> <%= t('collector.status_belum_bayar','BELUM BAYAR') %></span>
<% } %>
</div>
</div>
<table class="tbl mt-2" style="width:100%;border-collapse:collapse">
<tr><td><%= t('collector.col_period','Periode') %></td><td><%= month %>/<%= year %></td></tr>
<tr><td><%= t('collector.col_area','Area / Wilayah') %></td><td><strong style="color:#a5b4fc"><%= inv.customer_area || '-' %></strong></td></tr>
<tr><td><%= t('collector.col_due','Jatuh Tempo') %></td><td><%= inv.isolate_day ? ('Tgl ' + inv.isolate_day) : '-' %></td></tr>
<tr><td><%= t('collector.col_cust_id','ID Pelanggan') %></td><td>#<%= inv.customer_id %></td></tr>
<tr><td><%= t('collector.col_pppoe','PPPoE') %></td><td><%= inv.pppoe_username ? inv.pppoe_username : '-' %></td></tr>
<tr><td><%= t('collector.col_onu_tag','Tag ONU') %></td><td><%= inv.genieacs_tag ? inv.genieacs_tag : '-' %></td></tr>
<tr><td><%= t('collector.col_address','Alamat') %></td><td><%= inv.customer_address ? inv.customer_address : '-' %></td></tr>
</table>
<div class="d-flex flex-wrap gap-2 mt-2">
<% if (inv.lat && inv.lng) { %>
<a class="btn btn-outline-light btn-sm" target="_blank" href="https://www.google.com/maps?q=<%= encodeURIComponent(inv.lat + ',' + inv.lng) %>"><i class="bi bi-geo-alt"></i> <%= t('collector.map','Map') %></a>
<% } %>
<% if (isPaid && inv.invoice_id) { %>
<a class="btn btn-primary btn-sm fw-bold shadow-sm" target="_blank" href="/collector/invoice/<%= inv.invoice_id %>/print-thermal">
<i class="bi bi-printer"></i> <%= t('collector.print_receipt','Cetak Struk (Thermal)') %>
</a>
<a class="btn btn-outline-info btn-sm fw-bold" target="_blank" href="/customer/invoice/<%= inv.invoice_id %>/pdf">
<i class="bi bi-file-earmark-pdf"></i> PDF
</a>
<button type="button" class="btn btn-outline-success btn-sm fw-bold" onclick="sendWaPdf('<%= inv.invoice_id %>')">
<i class="bi bi-whatsapp"></i> <%= t('collector.send_wa_pdf','Kirim WA PDF') %>
</button>
<% } else if (!isPaid && !isPending) { %>
<form method="POST" action="/collector/payment-request" class="d-flex gap-2" style="flex:1;min-width:260px">
<input type="hidden" name="customer_id" value="<%= inv.customer_id %>">
<input type="hidden" name="invoice_id" value="<%= inv.invoice_id || '' %>">
<input type="hidden" name="month" value="<%= month %>">
<input type="hidden" name="year" value="<%= year %>">
<input type="hidden" name="status" value="<%= status %>">
<input type="hidden" name="search" value="<%= search || '' %>">
<input class="form-control form-control-sm" name="note" placeholder="Catatan (opsional)">
<button class="btn btn-warning btn-sm fw-bold" type="submit">
<i class="bi bi-cash-stack"></i> <%= noInvoice ? 'Buat & Bayar Tagihan' : 'Ajukan Pembayaran' %>
</button>
</form>
<% } %>
</div>
</div>
<% }) %>
</div>
<% } %>
</div>
<div class="cardx p-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<div style="font-weight:800"><i class="bi bi-list-ul"></i> <%= t('collector.request_history','Riwayat Pengajuan Saya') %></div>
<span class="badgey"><%= (myReqs || []).length %></span>
</div>
<% if (!myReqs || myReqs.length === 0) { %>
<div class="muted" style="font-size:12px"><%= t('collector.no_requests','Belum ada pengajuan.') %></div>
<% } else { %>
<div class="table-responsive">
<table class="table table-dark table-sm align-middle" style="margin:0">
<thead>
<tr>
<th><%= t('collector.col_time','Waktu') %></th>
<th><%= t('collector.customer','Pelanggan') %></th>
<th><%= t('collector.col_period','Periode') %></th>
<th class="text-end"><%= t('collector.col_amount','Nominal') %></th>
<th><%= t('collector.col_status_action','Status / Aksi') %></th>
</tr>
</thead>
<tbody>
<% myReqs.forEach(r => { %>
<% const st = String(r.status||''); %>
<tr>
<td style="font-size:12px"><%= r.created_at ? new Date(r.created_at).toLocaleString('id-ID') : '-' %></td>
<td style="font-size:12px"><%= r.customer_name || '-' %> <span class="muted"><%= r.customer_phone ? '('+r.customer_phone+')' : '' %></span></td>
<td style="font-size:12px"><%= r.period_month %>/<%= r.period_year %></td>
<td class="text-end" style="font-size:12px">Rp <%= Number(r.amount||0).toLocaleString('id-ID') %></td>
<td style="font-size:12px">
<% if (st === 'approved') { %>
<span class="badgey s"><%= t('collector.approved','APPROVED') %></span>
<a href="/collector/invoice/<%= r.invoice_id %>/print-thermal" target="_blank" class="btn btn-sm btn-outline-light ms-1" style="font-size:11px;padding:2px 6px;" title="Cetak Struk Thermal"><i class="bi bi-printer"></i> <%= t('collector.struk','Struk') %></a>
<% } else if (st === 'rejected') { %>
<span class="badgey d"><%= t('collector.rejected','REJECTED') %></span>
<% } else { %>
<span class="badgey w"><%= t('collector.status_pending','PENDING') %></span>
<% } %>
</td>
</tr>
<% }) %>
</tbody>
<tfoot>
<tr>
<td colspan="3" class="muted" style="font-size:12px"><%= t('collector.total_requests','Total pengajuan (halaman ini)') %></td>
<td class="text-end" style="font-size:12px;font-weight:900">Rp <%= (myReqs || []).reduce((s, r) => s + (Number(r.amount||0) || 0), 0).toLocaleString('id-ID') %></td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<% } %>
</div>
</div>
<%
const qsBase = { month: String(month), year: String(year), search: String(search || '') };
const qsAll = new URLSearchParams({ ...qsBase, status: String(status || 'all'), scope: '' }).toString();
const qsToday = new URLSearchParams({ ...qsBase, status: 'unpaid', scope: 'today' }).toString();
const qsUnpaid = new URLSearchParams({ ...qsBase, status: 'unpaid', scope: 'unpaid' }).toString();
const qsIsolir = new URLSearchParams({ ...qsBase, status: 'unpaid', scope: 'isolir' }).toString();
const qsMulti = new URLSearchParams({ ...qsBase, status: 'unpaid', scope: 'multi' }).toString();
const activeScope = String(scope || '');
const activeStatus = String(status || '');
%>
<div class="bn">
<div class="bnw">
<a class="bni <%= (!activeScope && (activeStatus === 'all' || activeStatus === 'paid' || activeStatus === 'unpaid')) ? 'active' : '' %>" href="/collector?<%= qsAll %>#data-tagihan">
<i class="bi bi-grid-3x3-gap"></i><span><%= t('collector.filter_all','Semua') %></span>
</a>
<a class="bni <%= activeScope === 'today' ? 'active' : '' %>" href="/collector?<%= qsToday %>#data-tagihan">
<i class="bi bi-calendar-check"></i><span><%= t('collector.today','Hari Ini') %></span>
</a>
<a class="bni <%= activeScope === 'unpaid' ? 'active' : '' %>" href="/collector?<%= qsUnpaid %>#data-tagihan">
<i class="bi bi-exclamation-circle"></i><span><%= t('collector.btn_belum','Belum') %></span>
</a>
<a class="bni <%= activeScope === 'isolir' ? 'active' : '' %>" href="/collector?<%= qsIsolir %>#data-tagihan">
<i class="bi bi-shield-exclamation"></i><span><%= t('collector.btn_isolir','Isolir') %></span>
</a>
<a class="bni <%= activeScope === 'multi' ? 'active' : '' %>" href="/collector?<%= qsMulti %>#data-tagihan">
<i class="bi bi-layers"></i><span>&gt;1 <%= t('collector.month_short','Bln') %></span>
</a>
<a class="bni" href="/collector/attendance">
<i class="bi bi-calendar-check-fill"></i><span><%= t('collector.absensi','Absensi') %></span>
</a>
</div>
</div>
<script>
(function () {
try {
const u = new URL(window.location.href);
if (u.hash === '#data-tagihan') {
const el = document.getElementById('data-tagihan');
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
} catch (e) {}
})();
async function sendWaPdf(invId) {
if (!confirm('Kirimkan dokumen Invoice PDF ke WhatsApp pelanggan sekarang?')) return;
try {
const res = await fetch('/collector/invoice/' + invId + '/send-pdf-wa', { method: 'POST' });
const data = await res.json();
alert(data.message || (data.success ? 'Berhasil dikirim!' : 'Gagal mengirim'));
} catch (e) {
alert('Error: ' + e.message);
}
}
</script>
<script src="/js/theme-toggle.js" defer></script>
</body>
</html>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= title %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/theme-toggle.css">
<link rel="icon" href="/img/icons/icon-192.png">
<script>document.documentElement.classList.add('js');</script>
</head>
<body>
<main class="otp-wrap">
<div class="auth-card">
<div class="auth-logo"><i class="bi bi-cash-coin" style="font-size:26px;color:var(--primary)"></i></div>
<h2><%= t('collector.login_title','Portal Kolektor') %></h2>
<p class="sub"><%= company %></p>
<% if (error) { %><div class="auth-alert err" style="justify-content:center"><i class="bi bi-exclamation-triangle"></i> <%= error %></div><% } %>
<form method="POST" action="/collector/login">
<div class="field">
<label for="username"><i class="bi bi-person-badge"></i> Username</label>
<div class="wrap"><i class="bi bi-person-badge lead-ic"></i>
<input type="text" id="username" name="username" class="inp" required placeholder="Masukkan username" autocomplete="username" autofocus></div>
</div>
<div class="field">
<label for="password"><i class="bi bi-key"></i> Password</label>
<div class="wrap"><i class="bi bi-key lead-ic"></i>
<input type="password" id="password" name="password" class="inp" required placeholder="Masukkan password" autocomplete="current-password"></div>
</div>
<button type="submit" class="lbtn lbtn-primary btn-auth"><i class="bi bi-box-arrow-in-right"></i> Masuk</button>
</form>
</div>
</main>
<script src="/js/theme-toggle.js" defer></script>
</body>
</html>
+245
View File
@@ -0,0 +1,245 @@
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><%= t('thermal.invoice_prefix','Struk Thermal #INV-') %><%= String(invoice.id).padStart(4, '0') %></title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Courier New', Courier, monospace, 'Lucida Console', sans-serif;
font-size: 12px;
line-height: 1.35;
color: #000000;
background: #e2e8f0;
padding: 15px 10px;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
.ticket {
width: 58mm;
max-width: 100%;
margin: 0 auto;
background: #ffffff;
padding: 12px 8px;
box-shadow: 0 4px 15px rgba(0,0,0,0.15);
border-radius: 4px;
transition: width 0.2s;
}
.ticket.size-80mm {
width: 80mm;
font-size: 13px;
padding: 16px 12px;
}
.text-center { text-align: center; }
.text-end { text-align: right; }
.fw-bold { font-weight: bold; }
.title { font-size: 14px; font-weight: bold; text-transform: uppercase; margin-bottom: 2px; }
.subtitle { font-size: 10px; margin-bottom: 3px; color: #222; }
.divider { border-top: 1px dashed #000000; margin: 6px 0; }
.row-item { display: flex; justify-content: space-between; margin-bottom: 3px; font-size: 11px; }
.size-80mm .row-item { font-size: 12.5px; }
.row-item .label { color: #333; }
.row-item .val { font-weight: bold; text-align: right; word-break: break-word; }
.total-box {
border: 1.5px dashed #000000;
padding: 8px 4px;
margin: 8px 0;
text-align: center;
}
.total-amount { font-size: 16px; font-weight: bold; margin-top: 2px; }
.badge-status {
display: inline-block;
padding: 2px 10px;
font-weight: bold;
font-size: 12px;
border: 1.5px solid #000;
margin-top: 5px;
background: #ffffff;
}
.no-print {
margin-top: 16px;
text-align: center;
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
justify-content: center;
}
.btn-group-size {
display: flex;
gap: 6px;
margin-bottom: 4px;
}
.btn-size {
background: #ffffff;
border: 1px solid #94a3b8;
padding: 4px 10px;
border-radius: 6px;
font-size: 11px;
cursor: pointer;
font-weight: 600;
}
.btn-size.active {
background: #0f172a;
color: #ffffff;
border-color: #0f172a;
}
.btn-row {
display: flex;
gap: 8px;
}
.btn-print {
background: #4f46e5;
color: white;
border: none;
padding: 9px 16px;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
font-size: 12px;
box-shadow: 0 2px 6px rgba(79,70,229,0.3);
}
.btn-close {
background: #64748b;
color: white;
border: none;
padding: 9px 16px;
border-radius: 8px;
font-weight: bold;
cursor: pointer;
font-size: 12px;
}
@media print {
@page {
size: auto;
margin: 0;
}
body {
width: 100%;
padding: 0;
margin: 0;
background: #ffffff;
}
.ticket {
width: 100% !important;
max-width: 100% !important;
padding: 4px 2px;
box-shadow: none;
border-radius: 0;
}
.no-print {
display: none !important;
}
}
</style>
</head>
<body>
<div class="ticket" id="ticketBox">
<div class="text-center">
<div class="title"><%= settings.company_name || company || 'INTERNET SERVICE' %></div>
<div class="subtitle"><%= settings.company_address || 'Pusat Layanan Internet Terpercaya' %></div>
<% if (settings.company_phone) { %>
<div class="subtitle"><%= t('thermal.contact','Telp/WA:') %> <%= settings.company_phone %></div>
<% } %>
</div>
<div class="divider"></div>
<div class="text-center fw-bold" style="font-size: 11px; margin: 4px 0;"><%= t('thermal.receipt_title','STRUK BUKTI PEMBAYARAN') %></div>
<div class="divider"></div>
<div class="row-item"><span class="label"><%= t('thermal.invoice_no','No. Faktur:') %></span><span class="val">#INV-<%= String(invoice.id).padStart(4, '0') %></span></div>
<div class="row-item"><span class="label"><%= t('thermal.time','Waktu:') %></span><span class="val"><%= formatDateLocal(getNowLocal()) %></span></div>
<div class="row-item"><span class="label"><%= t('thermal.officer','Petugas:') %></span><span class="val"><%= collectorName || 'Kasir / Admin' %></span></div>
<div class="divider"></div>
<div class="row-item"><span class="label"><%= t('thermal.cust_id','ID Pelg:') %></span><span class="val"><%= customer.customer_code || ('#' + customer.id) %></span></div>
<div class="row-item"><span class="label"><%= t('thermal.name','Nama:') %></span><span class="val"><%= customer.name %></span></div>
<% if (customer.phone) { %>
<div class="row-item"><span class="label"><%= t('thermal.phone','No. HP:') %></span><span class="val"><%= customer.phone %></span></div>
<% } %>
<% if (customer.pppoe_username) { %>
<div class="row-item"><span class="label">PPPoE:</span><span class="val"><%= customer.pppoe_username %></span></div>
<% } %>
<div class="row-item"><span class="label"><%= t('thermal.package','Paket:') %></span><span class="val"><%= invoice.package_name || customer.package_name || 'Internet Broadband' %></span></div>
<div class="row-item"><span class="label"><%= t('thermal.period','Periode:') %></span><span class="val"><%= invoice.period_month %>/<%= invoice.period_year %></span></div>
<div class="divider"></div>
<% if (Number(invoice.discount_amount||0) > 0) { %>
<div class="row-item"><span class="label"><%= t('thermal.discount','Diskon:') %></span><span class="val">- Rp <%= Number(invoice.discount_amount).toLocaleString('id-ID') %></span></div>
<% } %>
<div class="total-box">
<div style="font-size: 10px; font-weight: bold;"><%= t('thermal.total_payment','TOTAL PEMBAYARAN') %></div>
<% const amt = invoice.amount != null ? invoice.amount : (customer.package_price != null ? customer.package_price : 0); %>
<div class="total-amount">Rp <%= parseInt(amt || 0).toLocaleString('id-ID') %></div>
<div class="badge-status"><%= t('thermal.paid','*** LUNAS ***') %></div>
</div>
<div class="text-center subtitle" style="margin-top: 8px;">
<%= t('thermal.receipt_note','Simpan struk ini sebagai bukti pembayaran yang sah.') %><br>
<strong><%= t('thermal.thanks','Terima Kasih atas Pembayaran Anda!') %></strong>
</div>
<div class="no-print">
<div class="btn-group-size">
<button type="button" class="btn-size active" id="btn58" onclick="setPaperSize('58mm')"><%= t('thermal.size58','Thermal 58mm') %></button>
<button type="button" class="btn-size" id="btn80" onclick="setPaperSize('80mm')"><%= t('thermal.size80','Thermal 80mm') %></button>
</div>
<div class="btn-row">
<button class="btn-print" onclick="window.print()"><i class="bi bi-printer-fill"></i> <%= t('thermal.print_receipt','Cetak Struk') %></button>
<button class="btn-close" onclick="window.close()"><i class="bi bi-x-lg"></i> <%= t('common.close','Tutup') %></button>
</div>
</div>
</div>
<script>
function setPaperSize(size) {
const box = document.getElementById('ticketBox');
const b58 = document.getElementById('btn58');
const b80 = document.getElementById('btn80');
if (size === '80mm') {
box.classList.add('size-80mm');
b80.classList.add('active');
b58.classList.remove('active');
localStorage.setItem('thermal_paper_size', '80mm');
} else {
box.classList.remove('size-80mm');
b58.classList.add('active');
b80.classList.remove('active');
localStorage.setItem('thermal_paper_size', '58mm');
}
}
// Restore saved size
const savedSize = localStorage.getItem('thermal_paper_size');
if (savedSize === '80mm') {
setPaperSize('80mm');
}
// Otomatis buka dialog pemilih printer saat halaman dimuat
window.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
if (!window.location.search.includes('noprint')) {
window.print();
}
}, 350);
});
</script>
</body>
</html>
+81
View File
@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.pages.contact.meta_title', 'Kontak Support') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/pages.css?v=3">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="Beranda"><img class="bmark" src="<%= brandLogo %>" alt="<%= company %>"><span class="bname"><%= brandName %></span></a>
<div class="header-actions" style="margin-left:auto">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill"></i></button>
<a class="lbtn lbtn-ghost lbtn-sm" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-box-arrow-in-right"></i> <%= isLoggedIn ? 'Dashboard' : 'Masuk' %></a>
</div>
</div>
</header>
<main class="container">
<section class="page-hero">
<h1><%= t('customer.pages.contact.title', 'Hubungi Kami') %></h1>
<p><%= t('customer.pages.contact.subtitle', 'Kami siap membantu setiap kendala internet Anda.') %></p>
</section>
<div class="prose">
<p class="lead" style="text-align:center"><%= t('customer.pages.contact.p1', 'Tim support kami tersedia untuk melayani pertanyaan teknis, bantuan pembayaran, atau informasi pemasangan baru.') %></p>
<% let waContact = settings.company_phone || (settings.whatsapp_admin_numbers ? settings.whatsapp_admin_numbers[0] : ''); let waLink = waContact ? `https://wa.me/${waContact.replace(/\D/g, '')}` : '#'; %>
<div class="contact-list">
<a href="<%= waLink %>" class="contact-item">
<div class="contact-ic" style="background:rgba(37,211,102,.15);color:#25D366"><i class="bi bi-whatsapp"></i></div>
<div><h6><%= t('customer.pages.contact.whatsapp_title', 'WhatsApp Support') %></h6><p><%= waContact || t('customer.pages.contact.whatsapp_fallback', 'Hubungi WhatsApp Kami') %> (<%= t('customer.pages.contact.fast_response', 'Respon Cepat') %>)</p></div>
</a>
<div class="contact-item">
<div class="contact-ic"><i class="bi bi-envelope"></i></div>
<div><h6><%= t('customer.pages.contact.email_title', 'Email Support') %></h6><p><%= settings.company_email || `support@${company.toLowerCase().replace(/\s+/g, '')}.com` %></p></div>
</div>
<div class="contact-item">
<div class="contact-ic" style="background:rgba(192,132,252,.15);color:#c084fc"><i class="bi bi-clock"></i></div>
<div><h6><%= t('customer.pages.contact.hours_title', 'Jam Operasional') %></h6><p><%= settings.operational_hours || 'Setiap Hari: 08.00 - 22.00 WIB' %></p></div>
</div>
<div class="contact-item">
<div class="contact-ic" style="background:var(--idim);color:var(--info)"><i class="bi bi-geo-alt"></i></div>
<div><h6><%= t('customer.pages.contact.address_title', 'Alamat Kantor') %></h6><p><%= settings.company_address || 'Jl. Utama ISP No. 123, Indonesia' %></p></div>
</div>
</div>
<div style="text-align:center;margin-top:30px">
<a class="lbtn lbtn-primary" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-arrow-left"></i> <%= t('common.back_to', 'Kembali ke') %> <%= isLoggedIn ? t('customer.dashboard_title', 'Dashboard Pelanggan') : t('common.portal', 'Portal') %></a>
</div>
</div>
<div class="page-foot">&copy; <%= new Date().getFullYear() %> <%= company %> • <%= t('customer.pages.contact.footer_tagline', 'Kepuasan Pelanggan adalah Kebanggaan Kami') %></div>
</main>
<script>
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); try{localStorage.setItem('landing-theme',t);}catch(e){} }
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
<%- include('partials/customer_bottom_nav', { activeNav: 'ticket' }) %>
</body>
</html>
+766
View File
@@ -0,0 +1,766 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.dashboard_title', 'Dashboard Pelanggan') %> | <%= settings.company_header || 'OPEN-ISP' %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/dashboard.css?v=3">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');</script>
<link rel="stylesheet" href="/css/theme-toggle.css">
</head>
<body>
<!-- ═══ APP BAR ═══ -->
<header class="app-bar">
<div class="container inner">
<a class="app-brand" href="/customer/dashboard">
<img src="/img/icons/icon-192.png" alt="<%= brandName %>"> <%= t('customer.dashboard.portal', 'Portal Pelanggan') %>
</a>
<div class="app-actions">
<div class="notif-bell-btn" onclick="openNotifDrawer()" title="Notifikasi">
<i class="bi bi-bell-fill"></i>
<span class="notif-badge-count" id="notifCountBadge" style="display:none">0</span>
</div>
<% if (pppoeOnline === true) { %>
<span class="status-pill online"><span class="pulse-dot green"></span> PPPoE Online</span>
<% } else if (pppoeOnline === false) { %>
<span class="status-pill offline"><span class="pulse-dot red"></span> PPPoE Offline</span>
<% } else { %>
<span class="status-pill offline">PPPoE —</span>
<% } %>
<form method="POST" action="/customer/reconnect" style="margin:0">
<button type="submit" class="chip-btn ok" title="Reconnect internet"><i class="bi bi-arrow-repeat"></i> Re-Connect</button>
</form>
<form method="POST" action="/customer/logout" style="margin:0">
<button type="submit" class="chip-btn danger" title="Keluar"><i class="bi bi-box-arrow-right"></i></button>
</form>
</div>
</div>
</header>
<main class="container dash-wrap">
<!-- NOTIF -->
<% if (typeof notif !== 'undefined' && notif && notif.text) { %>
<div class="auth-alert <%= notif.type === 'danger' ? 'err' : notif.type === 'warning' ? 'warn' : notif.type === 'info' ? 'info' : 'ok' %>" id="notif-alert">
<i class="bi bi-info-circle"></i> <%= notif.text %>
</div>
<script>setTimeout(function(){var a=document.getElementById('notif-alert');if(a)a.style.display='none';},4000);</script>
<% } %>
<!-- PROMO ROTATOR -->
<div class="promo-rot" id="promoRot">
<div class="welcome"><i class="bi bi-wifi"></i><span>Selamat Datang di Portal Pelanggan</span></div>
</div>
<script>
fetch('/customer/api/promo-slides')
.then(function(r){return r.json();})
.then(function(slides){
if (!slides || !slides.length) return;
var box = document.getElementById('promoRot');
box.innerHTML = slides.map(function(s,i){
var img = '<img src="'+s.image_path+'" alt="'+(s.title||'Promo')+'" loading="lazy">';
var inner = s.url ? '<a href="'+s.url+'"'+(s.open_in_new_tab?' target="_blank" rel="noopener"':'')+'>'+img+'</a>' : img;
return '<div class="promo-slide" style="display:'+(i===0?'block':'none')+'">'+inner+'</div>';
}).join('') + '<div class="dots">'+slides.map(function(_,i){return '<span class="'+(i===0?'on':'')+'"></span>';}).join('')+'</div>';
var idx=0, items=box.querySelectorAll('.promo-slide'), dots=box.querySelectorAll('.dots span');
if(items.length>1){
setInterval(function(){
items[idx].style.display='none'; dots[idx].classList.remove('on');
idx=(idx+1)%items.length;
items[idx].style.display='block'; dots[idx].classList.add('on');
},5000);
}
}).catch(function(){});
</script>
<!-- ═══ SERVICE NOTICE ═══ -->
<%- include('partials/service-notice') %>
<!-- HERO -->
<section class="hero-banner" id="home-section">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:14px;flex-wrap:wrap">
<div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-bottom:6px">
<span class="id-badge"><i class="bi bi-person-badge"></i> ID: <b id="custIdText" class="mono"><%= profile ? (profile.customer_code || profile.genieacs_tag || profile.pppoe_username || profile.phone) : customer.phone %></b><button class="copy-mini" type="button" onclick="copyId()" title="Salin ID pelanggan"><i class="bi bi-clipboard"></i></button></span>
<% if (onuMissing) { %>
<button type="button" class="onu-chip" onclick="showModal('onuMissingModal')"><i class="bi bi-exclamation-triangle-fill"></i> ONU belum terdeteksi</button>
<% } %>
<% if (profile) { %>
<span class="status-pill <%= profile.status === 'active' ? 'online' : 'suspended' %>">
<span class="pulse-dot <%= profile.status === 'active' ? 'green' : 'red' %>"></span>
Layanan: <%= profile.status === 'active' ? 'AKTIF' : 'ISOLIR' %>
</span>
<% } %>
</div>
<h3><%= t('customer.dashboard.hello', 'Halo') %>, <%= profile ? profile.name : customer.phone %></h3>
<p class="muted" style="font-size:13px;margin-top:6px;display:flex;gap:14px;flex-wrap:wrap">
<span><i class="bi bi-reception-4" style="color:var(--success)"></i> Sinyal Optik: <strong style="color:var(--text)"><%= customer.rxPower || '-20 dBm' %></strong></span>
<span><i class="bi bi-hdd-network" style="color:var(--info)"></i> <span class="mono"><%= (profile && profile.pppoe_username) || customer.pppoeUsername || 'Belum ada akun PPPoE' %></span></span>
</p>
<% if (!((profile && profile.pppoe_username) || customer.pppoeUsername)) { %>
<div style="margin-top:10px;padding:10px 12px;border-radius:12px;background:rgba(251,191,36,.12);border:1px solid rgba(251,191,36,.35);color:var(--warning,#fbbf24);font-size:12.5px;display:flex;gap:8px;align-items:center">
<i class="bi bi-hourglass-split"></i> Akun PPPoE Anda belum dibuat — menunggu pemasangan/aktivasi oleh teknisi.
</div>
<% } %>
</div>
</div>
</section>
<!-- BILL HERO -->
<%
const paidInvoices = (invoices || []).filter(function(i){return i.status === 'paid';});
const unpaidInvoices = (invoices || []).filter(function(i){return i.status !== 'paid';});
const hasUnpaid = unpaidInvoices.length > 0;
const latestUnpaid = hasUnpaid ? unpaidInvoices[0] : null;
const totalUnpaidAmount = latestUnpaid ? Number(latestUnpaid.amount != null ? latestUnpaid.amount : (latestUnpaid.package_price != null ? latestUnpaid.package_price : 0)) : 0;
const unpaidPeriods = latestUnpaid ? (latestUnpaid.period_month + '/' + latestUnpaid.period_year) : '';
const unpaidCount = unpaidInvoices.length;
const isolateDay = (typeof profile !== 'undefined' && profile && profile.isolate_day) ? Number(profile.isolate_day) : ((typeof settings !== 'undefined' && settings && settings.isolir_day) ? Number(settings.isolir_day) : 10);
const bulanNames = ['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'];
const fmtDue = function(m, y){ return isolateDay + ' ' + (bulanNames[Number(m) - 1] || '') + ' ' + y; };
let dueText = '';
let dueDiffDays = null;
if (latestUnpaid) {
dueText = fmtDue(latestUnpaid.period_month, latestUnpaid.period_year);
const d0 = new Date(Number(latestUnpaid.period_year), Number(latestUnpaid.period_month) - 1, isolateDay);
d0.setHours(0, 0, 0, 0);
const t0 = new Date(); t0.setHours(0, 0, 0, 0);
dueDiffDays = Math.round((d0.getTime() - t0.getTime()) / 86400000);
}
const nowD = new Date();
const nextD = new Date(nowD.getFullYear(), nowD.getMonth() + 1, 1);
const nextDueText = fmtDue(nextD.getMonth() + 1, nextD.getFullYear());
%>
<section class="bill-hero-card <%= hasUnpaid ? 'unpaid' : 'paid' %>">
<div style="display:flex;align-items:center;gap:14px">
<div class="bico"><i class="bi bi-<%= hasUnpaid ? 'exclamation-triangle-fill' : 'shield-check' %>"></i></div>
<div>
<% if (hasUnpaid) { %>
<span class="badge bw" style="font-size:10px"><i class="bi bi-exclamation-circle"></i> TAGIHAN BELUM DIBAYAR <%= unpaidCount > 1 ? '('+unpaidCount+' BULAN)' : '' %></span>
<h5 style="margin-top:6px;color:var(--warning)">Rp <%= parseInt(totalUnpaidAmount || 0).toLocaleString('id-ID') %></h5>
<div class="muted" style="font-size:12.5px;margin-top:2px">Periode <%= unpaidPeriods %></div>
<div style="font-size:13px;margin-top:6px;display:flex;align-items:center;gap:7px;flex-wrap:wrap">
<i class="bi bi-calendar-event" style="color:var(--warning)"></i>
<span>Jatuh tempo: <strong style="color:var(--text)"><%= dueText %></strong></span>
<% if (dueDiffDays !== null) { %>
<span class="badge <%= dueDiffDays < 0 ? 'bd' : (dueDiffDays <= 2 ? 'bw' : 'bm') %>" style="font-size:10px">
<%= dueDiffDays > 0 ? ('Sisa ' + dueDiffDays + ' hari') : (dueDiffDays === 0 ? 'Hari ini!' : ('Lewat ' + Math.abs(dueDiffDays) + ' hari')) %>
</span>
<% } %>
</div>
<% } else { %>
<span class="badge bs" style="font-size:10px"><i class="bi bi-check-circle"></i> SEMUA TAGIHAN LUNAS</span>
<h5 style="margin-top:6px">Layanan Internet Aktif</h5>
<div class="muted" style="font-size:12.5px;margin-top:2px">Terima kasih telah membayar tepat waktu!</div>
<div style="font-size:13px;margin-top:6px;display:flex;align-items:center;gap:7px">
<i class="bi bi-calendar-event" style="color:var(--success)"></i>
<span>Jatuh tempo berikutnya: <strong style="color:var(--text)"><%= nextDueText %></strong></span>
</div>
<% } %>
</div>
</div>
<% if (hasUnpaid) { %>
<button type="button" class="lbtn lbtn-primary lbtn-lg" onclick="openPaymentModal('<%= latestUnpaid.id %>', '<%= totalUnpaidAmount %>')"><i class="bi bi-wallet2"></i> Bayar Sekarang</button>
<% } %>
</section>
<!-- INFO LAYANAN -->
<section class="svc-grid">
<div class="svc-card">
<div class="svc-ic"><i class="bi bi-box-seam"></i></div>
<div class="svc-tx"><span>Paket Berjalan</span>
<b><%= profile && profile.package_name ? profile.package_name : 'Belum ada paket' %></b>
<small><% if (profile && Number(profile.speed_down) > 0) { %><%= Math.round(Number(profile.speed_down)/1000) %> Mbps<%= profile.speed_up ? ' / ' + Math.round(Number(profile.speed_up)/1000) + ' Mbps' : '' %><% } else { %>—<% } %></small>
</div>
</div>
<div class="svc-card">
<div class="svc-ic"><i class="bi bi-person-check"></i></div>
<div class="svc-tx"><span>Status Akun</span>
<% if (profile && profile.status === 'active') { %><b class="ok">Aktif</b><% } else { %><b class="warn">Isolir</b><% } %>
<small><%= profile ? profile.name : '—' %></small>
</div>
</div>
<div class="svc-card">
<div class="svc-ic"><i class="bi bi-hdd-network"></i></div>
<div class="svc-tx"><span>Internet (PPPoE)</span>
<% if (pppoeOnline === true) { %><b class="ok">Online</b><% } else if (pppoeOnline === false) { %><b class="off">Offline</b><% } else { %><b>Tidak diketahui</b><% } %>
<small class="mono"><%= (profile ? profile.pppoe_username : '') || customer.pppoeUsername || 'Belum dibuat' %></small>
</div>
</div>
<div class="svc-card">
<div class="svc-ic"><i class="bi bi-router"></i></div>
<div class="svc-tx"><span>Perangkat (ONU)</span>
<% if (onuMissing) { %><b class="warn">Tidak ditemukan</b><% } else { %><b class="ok">Terhubung</b><% } %>
<small><%= customer.rxPower || '-' %> • <%= customer.model || 'ONU' %></small>
</div>
</div>
</section>
<!-- QUICK ACTIONS -->
<div class="action-grid">
<% const navPayAmt = hasUnpaid ? totalUnpaidAmount : 0; %>
<div class="action-item" onclick="<%= hasUnpaid ? "openPaymentModal('"+latestUnpaid.id+"','"+navPayAmt+"')" : "location.href='#billing-section'" %>">
<div class="action-icon ai-pay"><i class="bi bi-credit-card-2-front-fill"></i></div><div class="action-label">Bayar Tagihan</div>
</div>
<div class="action-item" data-ssid-change onclick="editSsid('<%= (customer.tags && customer.tags[0]) || customer.id %>', '<%= customer.ssid %>')">
<div class="action-icon ai-ssid"><i class="bi bi-wifi"></i></div><div class="action-label">Nama WiFi</div>
</div>
<div class="action-item" onclick="editPassword('<%= (customer.tags && customer.tags[0]) || customer.id %>')">
<div class="action-icon ai-pass"><i class="bi bi-key-fill"></i></div><div class="action-label">Sandi WiFi</div>
</div>
<div class="action-item" onclick="rebootDevice('<%= (customer.tags && customer.tags[0]) || customer.id %>')">
<div class="action-icon ai-reboot"><i class="bi bi-arrow-clockwise"></i></div><div class="action-label">Restart Modem</div>
</div>
<div class="action-item" onclick="openNotifDrawer()"><div class="action-icon ai-ppob"><i class="bi bi-bell-fill"></i></div><div class="action-label">Notifikasi</div></div>
<div class="action-item" onclick="showModal('ticketModal')"><div class="action-icon ai-ticket"><i class="bi bi-headset"></i></div><div class="action-label">Bantuan / Tiket</div></div>
</div>
<!-- DEVICE + TRAFFIC -->
<div class="dash-grid" style="margin-bottom:18px">
<!-- Router card -->
<div class="glass-card">
<div class="glass-header">
<h5><i class="bi bi-router" style="color:var(--primary)"></i> Kontrol Perangkat Router</h5>
<span class="badge-soft"><%= customer.model !== '-' ? customer.model : 'ONT / ONU' %></span>
</div>
<div class="glass-body">
<div id="deviceSyncPanel" data-sync-in-progress="<%= customer.syncInProgress ? 'true' : 'false' %>" style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:12px;flex-wrap:wrap">
<span class="muted" id="deviceSyncNotice" style="font-size:11px"></span>
<button type="button" id="btnDeviceRefresh" class="lbtn lbtn-ghost lbtn-sm"><i class="bi bi-arrow-repeat"></i> Poll ONU</button>
</div>
<div class="stats-grid" style="margin-bottom:14px">
<div class="stat-card"><div class="stat-icon <%= customer.status === 'Online' ? 'online' : 'offline' %>"><i class="bi bi-power"></i></div><div class="stat-label">Status Router</div><div class="stat-value"><%= customer.status %></div></div>
<div class="stat-card"><div class="stat-icon activity"><i class="bi bi-reception-4"></i></div><div class="stat-label">Rx Optical</div><div class="stat-value"><%= customer.rxPower %></div></div>
<div class="stat-card"><div class="stat-icon people"><i class="bi bi-laptop"></i></div><div class="stat-label">User Terhubung</div><div class="stat-value"><%= customer.totalAssociations || 0 %> User</div></div>
</div>
<div class="wifi-box" style="margin-bottom:14px">
<div class="wifi-row"><span class="wifi-label" style="color:var(--info)"><i class="bi bi-wifi"></i> Nama SSID WiFi</span>
<button class="link-btn" data-ssid-change onclick="editSsid('<%= (customer.tags && customer.tags[0]) || customer.id %>', '<%= customer.ssid %>')">Ubah <i class="bi bi-pencil"></i></button></div>
<div class="wifi-val" style="margin-bottom:12px"><%= customer.ssid || '-' %></div>
<div class="wifi-row"><span class="wifi-label" style="color:var(--success)"><i class="bi bi-shield-lock"></i> Password WiFi</span>
<button class="link-btn" onclick="editPassword('<%= (customer.tags && customer.tags[0]) || customer.id %>')">Ubah <i class="bi bi-pencil"></i></button></div>
<div style="display:flex;gap:8px">
<input type="password" id="wifiPassInput" class="inp inp-plain" value="••••••••" readonly style="padding-left:14px">
<button class="chip-btn" type="button" onclick="togglePassVisibility()"><i class="bi bi-eye" id="eyeIcon"></i></button>
</div>
</div>
<div class="muted" style="font-size:12px;font-weight:700;margin-bottom:8px"><i class="bi bi-people"></i> Perangkat Terhubung saat ini:</div>
<% if (connectedUsers && connectedUsers.length > 0) { %>
<div class="dev-list">
<% connectedUsers.forEach(function(u){ %>
<div class="dev-item">
<div style="display:flex;align-items:center;gap:9px">
<i class="bi bi-laptop" style="color:var(--primary)"></i>
<div>
<div style="font-weight:700"><%= u.hostname || 'Device' %></div>
<div class="mono muted" style="font-size:10px"><%= u.ip || '-' %> | <%= u.mac || '-' %></div>
</div>
</div>
<span class="badge bs" style="font-size:9px">Active</span>
</div>
<% }); %>
</div>
<% } else { %>
<div class="dev-empty">Tidak ada perangkat aktif terdeteksi</div>
<% } %>
</div>
</div>
<!-- Traffic card -->
<div class="glass-card">
<div class="glass-header">
<h5><i class="bi bi-speedometer2" style="color:var(--info)"></i> Pemantauan Trafik Real-Time</h5>
<span class="badge-soft">Live Mbps</span>
</div>
<div class="glass-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:14px;text-align:center">
<div>
<div class="stat-label" style="margin-bottom:6px">Trafik Download</div>
<div class="traffic-gauge">
<canvas id="pppoe_rx_gauge" width="220" height="140" data-max="<%= typeof trafficMaxDownMbps !== 'undefined' ? trafficMaxDownMbps : 10 %>"></canvas>
<div class="traffic-gauge-center"><div class="traffic-gauge-value" id="pppoe_rx_value">-</div><div class="traffic-gauge-unit" id="pppoe_rx_unit">&nbsp;</div></div>
<div class="traffic-gauge-scale"><span>0</span><span id="pppoe_rx_max"><%= typeof trafficMaxDownMbps !== 'undefined' ? trafficMaxDownMbps : 10 %> Mbps</span></div>
</div>
</div>
<div>
<div class="stat-label" style="margin-bottom:6px">Trafik Upload</div>
<div class="traffic-gauge">
<canvas id="pppoe_tx_gauge" width="220" height="140" data-max="<%= typeof trafficMaxUpMbps !== 'undefined' ? trafficMaxUpMbps : 10 %>"></canvas>
<div class="traffic-gauge-center"><div class="traffic-gauge-value" id="pppoe_tx_value">-</div><div class="traffic-gauge-unit" id="pppoe_tx_unit">&nbsp;</div></div>
<div class="traffic-gauge-scale"><span>0</span><span id="pppoe_tx_max"><%= typeof trafficMaxUpMbps !== 'undefined' ? trafficMaxUpMbps : 10 %> Mbps</span></div>
</div>
</div>
</div>
<div class="auth-alert info" style="margin-top:16px;justify-content:center"><i class="bi bi-info-circle"></i> Kecepatan disesuaikan dengan paket berlangganan Anda.</div>
</div>
</div>
</div>
<!-- TAGIHAN -->
<details class="acc" id="billing-section" open>
<summary><span><i class="bi bi-receipt" style="color:var(--warning)"></i> Status Tagihan &amp; Riwayat Pembayaran</span><i class="bi bi-chevron-down chev"></i></summary>
<div class="acc-body">
<% if (invoices && invoices.length > 0) { %>
<% invoices.forEach(function(inv){ %>
<% const itemAmt = inv.amount != null ? inv.amount : (inv.package_price != null ? inv.package_price : 0); %>
<div class="inv-card">
<div class="inv-top">
<% if (inv.status === 'paid') { %><span class="badge bs" style="font-size:10px">LUNAS</span><% } else { %><span class="badge bd" style="font-size:10px">BELUM BAYAR</span><% } %>
<span class="muted" style="font-size:12px"><i class="bi bi-calendar-event"></i> Periode <%= inv.period_month %>/<%= inv.period_year %></span>
</div>
<div style="display:flex;justify-content:space-between;align-items:flex-end;gap:10px;flex-wrap:wrap">
<div><div class="inv-name"><%= inv.package_name || 'Paket Internet' %></div><div class="inv-amt">Rp <%= parseInt(itemAmt || 0).toLocaleString('id-ID') %></div></div>
</div>
<div class="inv-actions">
<% if (inv.status !== 'paid') { %>
<button class="lbtn lbtn-primary lbtn-sm" onclick="openPaymentModal('<%= inv.id %>', '<%= itemAmt %>')"><i class="bi bi-wallet2"></i> Bayar</button>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/invoice/<%= inv.id %>/pdf" target="_blank" rel="noopener"><i class="bi bi-file-earmark-pdf-fill"></i> Tagihan PDF</a>
<button class="lbtn lbtn-ghost lbtn-sm" onclick="sendInvoiceWa(<%= inv.id %>, this)"><i class="bi bi-whatsapp"></i> Kirim ke WA</button>
<% } else { %>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/invoice/<%= inv.id %>/print-thermal" target="_blank" rel="noopener"><i class="bi bi-receipt"></i> Struk Thermal</a>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/invoice/<%= inv.id %>/print" target="_blank" rel="noopener"><i class="bi bi-printer-fill"></i> Faktur A4</a>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/invoice/<%= inv.id %>/pdf" target="_blank" rel="noopener"><i class="bi bi-file-earmark-pdf-fill"></i> PDF</a>
<button class="lbtn lbtn-ghost lbtn-sm" onclick="sendInvoiceWa(<%= inv.id %>, this)"><i class="bi bi-whatsapp"></i> Kirim ke WA</button>
<% } %>
</div>
</div>
<% }); %>
<% } else { %>
<div class="dev-empty">Belum ada riwayat tagihan</div>
<% } %>
</div>
</details>
<!-- TIKET -->
<details class="acc" id="ticket-section">
<summary><span><i class="bi bi-headset" style="color:#c084fc"></i> Tiket Keluhan &amp; Bantuan</span><i class="bi bi-chevron-down chev"></i></summary>
<div class="acc-body">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:12px">
<span style="font-weight:700">Daftar Tiket Anda</span>
<button class="lbtn lbtn-primary lbtn-sm" onclick="showModal('ticketModal')"><i class="bi bi-plus-lg"></i> Buat Tiket</button>
</div>
<% if (tickets && tickets.length > 0) { %>
<div class="srch-wrap">
<table class="btbl">
<thead><tr><th>ID</th><th>Jenis Kendala</th><th>Tanggal</th><th>Status</th></tr></thead>
<tbody>
<% tickets.forEach(function(t){ %>
<tr>
<td class="mono" style="color:var(--info)">#TK-<%= t.id %></td>
<td><%= t.subject %></td>
<td class="muted"><%= t.created_at %></td>
<td><span class="badge <%= t.status === 'open' ? 'bw' : (t.status === 'in_progress' ? 'bi2' : 'bs') %>" style="font-size:10px"><%= t.status === 'open' ? 'TERKIRIM' : (t.status === 'in_progress' ? 'DIPROSES' : 'SELESAI') %></span></td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } else { %>
<div class="dev-empty">Belum ada tiket keluhan yang dibuat.</div>
<% } %>
</div>
</details>
</main>
<!-- ═══ MODAL PEMBAYARAN ═══ -->
<div class="mo" id="paymentModal" onclick="if(event.target===this)hideModal('paymentModal')">
<div class="mb">
<div class="mh"><h3><i class="bi bi-credit-card-2-front" style="color:var(--warning)"></i> Pembayaran</h3>
<button class="mc" onclick="hideModal('paymentModal')"><i class="bi bi-x-lg"></i></button></div>
<div class="mbody">
<div style="text-align:center;padding:14px;border-radius:14px;background:var(--bg);border:1px solid var(--warning-glow);margin-bottom:16px">
<div class="muted" style="font-size:12px;text-transform:uppercase;letter-spacing:.5px">Total Tagihan</div>
<h2 id="pay_amount_text" style="font-family:var(--font-display);color:var(--warning);font-size:26px;margin-top:4px">Rp 0</h2>
</div>
<div class="stack">
<%
const _payMode = String(settings.payment_mode || 'selfservice').toLowerCase();
const _gwActive = (_payMode === 'gateway') && String(settings.mayar_api_key || '').trim();
const _channels = (typeof paymentChannels !== 'undefined' && paymentChannels) ? paymentChannels : [];
%>
<% if (_gwActive && _channels.length > 0) { %>
<% _channels.forEach(function(ch){ %>
<button class="pay-item" style="text-align:left;width:100%" onclick="submitPayment('<%= ch.code %>')">
<div class="pay-icon"><i class="bi bi-credit-card-2-front"></i></div>
<div class="pay-info"><span class="pay-name"><%= ch.name %></span><span class="pay-sub"><span class="pay-chip"><%= ch.group || 'Gateway' %></span></span></div>
<i class="bi bi-chevron-right" style="color:var(--primary)"></i>
</button>
<% }); %>
<% } else { %>
<button class="pay-item" style="text-align:left;width:100%" onclick="submitPayment('<%= _gwActive ? 'gateway' : 'qris' %>')">
<div class="pay-icon"><i class="bi <%= _gwActive ? 'bi-credit-card-2-front' : 'bi-wallet2' %>"></i></div>
<div class="pay-info"><span class="pay-name">Bayar Sekarang</span><span class="pay-sub"><span class="pay-chip"><%= _gwActive ? 'Gateway Mayar' : 'QRIS / Transfer Bank / E-Wallet' %></span></span></div>
<i class="bi bi-chevron-right" style="color:var(--primary)"></i>
</button>
<% } %>
</div>
</div>
</div>
</div>
<!-- ═══ MODAL NOTIFIKASI ═══ -->
<div class="mo" id="notifDrawerModal" onclick="if(event.target===this)hideModal('notifDrawerModal')">
<div class="mb" style="max-width:460px">
<div class="mh"><h3><i class="bi bi-bell-fill" style="color:var(--warning)"></i> Pusat Notifikasi</h3>
<button class="mc" onclick="hideModal('notifDrawerModal')"><i class="bi bi-x-lg"></i></button></div>
<div class="mbody" id="notifListContainer"></div>
</div>
</div>
<!-- ═══ MODAL SSID ═══ -->
<div class="mo" id="editSsidModal" onclick="if(event.target===this)hideModal('editSsidModal')">
<div class="mb" style="max-width:460px">
<div class="mh"><h3><i class="bi bi-wifi" style="color:var(--info)"></i> Ubah Nama WiFi (SSID)</h3>
<button class="mc" onclick="hideModal('editSsidModal')"><i class="bi bi-x-lg"></i></button></div>
<form action="/customer/change-ssid" method="POST">
<div class="mbody">
<div class="field" style="margin-top:0">
<label for="modalSsidInput">Nama WiFi (SSID) Baru</label>
<input type="text" name="ssid" id="modalSsidInput" class="inp inp-plain" placeholder="Contoh: WiFi_Rumah_Keluarga" required>
<div class="form-note">Perangkat akan terputus sesaat setelah nama WiFi diubah.</div>
</div>
</div>
<div class="mf"><button type="button" class="lbtn lbtn-ghost" onclick="hideModal('editSsidModal')">Batal</button><button type="submit" class="lbtn lbtn-primary">Simpan Nama WiFi</button></div>
</form>
</div>
</div>
<!-- ═══ MODAL PASSWORD ═══ -->
<div class="mo" id="editPasswordModal" onclick="if(event.target===this)hideModal('editPasswordModal')">
<div class="mb" style="max-width:460px">
<div class="mh"><h3><i class="bi bi-shield-lock" style="color:var(--success)"></i> Ubah Password WiFi</h3>
<button class="mc" onclick="hideModal('editPasswordModal')"><i class="bi bi-x-lg"></i></button></div>
<form action="/customer/change-password" method="POST">
<div class="mbody">
<div class="field" style="margin-top:0">
<label for="modalPassInput">Password WiFi Baru (Minimal 8 Karakter)</label>
<input type="password" name="password" id="modalPassInput" class="inp inp-plain" minlength="8" placeholder="Contoh: Rahasia12345" required>
<div class="form-note">Gunakan minimal 8 kombinasi huruf dan angka. Simpan password ini baik-baik.</div>
</div>
</div>
<div class="mf"><button type="button" class="lbtn lbtn-ghost" onclick="hideModal('editPasswordModal')">Batal</button><button type="submit" class="lbtn lbtn-primary">Simpan Password</button></div>
</form>
</div>
</div>
<!-- ═══ MODAL REBOOT ═══ -->
<div class="mo" id="rebootModal" onclick="if(event.target===this)hideModal('rebootModal')">
<div class="mb" style="max-width:460px">
<div class="mh"><h3><i class="bi bi-arrow-clockwise" style="color:var(--warning)"></i> Reboot Router / Modem</h3>
<button class="mc" onclick="hideModal('rebootModal')"><i class="bi bi-x-lg"></i></button></div>
<form action="/customer/reboot" method="POST">
<div class="mbody" style="text-align:center">
<div style="width:64px;height:64px;border-radius:50%;margin:0 auto 12px;display:grid;place-items:center;background:var(--wdim);color:var(--warning);font-size:28px"><i class="bi bi-exclamation-triangle-fill"></i></div>
<h4 style="font-size:15px">Yakin ingin menyalakan ulang modem?</h4>
<p class="muted" style="font-size:13px;margin-top:8px">Proses reboot ±1–2 menit. Koneksi internet akan terputus sementara.</p>
</div>
<div class="mf"><button type="button" class="lbtn lbtn-ghost" onclick="hideModal('rebootModal')">Batal</button><button type="submit" class="lbtn lbtn-primary">Ya, Reboot Sekarang</button></div>
</form>
</div>
</div>
<!-- ═══ MODAL TIKET ═══ -->
<div class="mo" id="ticketModal" onclick="if(event.target===this)hideModal('ticketModal')">
<div class="mb" style="max-width:520px">
<div class="mh"><h3><i class="bi bi-headset" style="color:#c084fc"></i> Buat Tiket Keluhan Baru</h3>
<button class="mc" onclick="hideModal('ticketModal')"><i class="bi bi-x-lg"></i></button></div>
<form action="/customer/tickets/create" method="POST" enctype="multipart/form-data">
<input type="hidden" name="customerId" value="<%= profile ? profile.id : '' %>">
<div class="mbody">
<div class="field" style="margin-top:0">
<label for="tk_subject">Jenis Kendala / Subjek</label>
<select name="subject" id="tk_subject" class="inp inp-plain" required>
<option value="" disabled selected>-- Pilih Jenis Kendala --</option>
<option>Koneksi Terputus / Tidak Bisa Konek</option>
<option>Koneksi Lambat / Lemot</option>
<option>Lampu PON Merah / Los Sinyal</option>
<option>Kendala Lupa Password WiFi</option>
<option>Pertanyaan Tagihan &amp; Pembayaran</option>
<option>Lainnya</option>
</select>
</div>
<div class="field">
<label for="tk_msg">Detail Pesan / Penjelasan</label>
<textarea name="message" id="tk_msg" class="inp inp-plain" rows="3" placeholder="Jelaskan kendala Anda…" required></textarea>
</div>
<div class="field">
<label for="tk_photo">Foto Lampiran (Opsional)</label>
<input type="file" name="photos" id="tk_photo" class="inp inp-plain" accept="image/*" multiple style="padding:10px">
</div>
</div>
<div class="mf"><button type="button" class="lbtn lbtn-ghost" onclick="hideModal('ticketModal')">Batal</button><button type="submit" class="lbtn lbtn-primary">Kirim Tiket</button></div>
</form>
</div>
</div>
<!-- ═══ POPUP AWAL: ONU TIDAK TERDETEKSI ═══ -->
<div class="mo" id="onuMissingModal" onclick="if(event.target===this)hideModal('onuMissingModal')">
<div class="mb" style="max-width:440px">
<div class="mh"><h3><i class="bi bi-exclamation-triangle-fill" style="color:var(--warning)"></i> ONU Belum Terdeteksi</h3>
<button class="mc" onclick="hideModal('onuMissingModal')"><i class="bi bi-x-lg"></i></button></div>
<div class="mbody">
<div class="onu-pop-ic"><i class="bi bi-router"></i></div>
<p style="font-size:13.5px;text-align:center">Perangkat ONU/ONT Anda belum terbaca di sistem, sehingga data sinyal optik, WiFi, dan trafik mungkin belum akurat.</p>
<div class="auth-alert warn" style="margin-top:14px"><i class="bi bi-info-circle"></i> Coba tekan “Poll ONU” pada kartu Kontrol Perangkat, atau buat tiket bantuan bila koneksi bermasalah.</div>
</div>
<div class="mf"><button class="lbtn lbtn-ghost" onclick="hideModal('onuMissingModal')">Nanti</button><button class="lbtn lbtn-primary" onclick="hideModal('onuMissingModal');showModal('ticketModal')"><i class="bi bi-headset"></i> Buat Tiket</button></div>
</div>
</div>
<!-- ═══ POPUP STATUS WIFI (loading → hasil) ═══ -->
<div class="mo" id="wifiStatusModal">
<div class="mb" style="max-width:400px">
<div class="mbody" style="text-align:center">
<div class="wifi-status-ic" id="wifiStatusIc"><i class="bi bi-arrow-repeat spin"></i></div>
<h4 id="wifiStatusTitle" style="font-size:16px">Memproses…</h4>
<p class="muted" id="wifiStatusMsg" style="font-size:13px;margin-top:6px">Mohon tunggu sebentar.</p>
<div id="wifiStatusActions" style="margin-top:16px;display:none"><button class="lbtn lbtn-primary" onclick="hideModal('wifiStatusModal')">Tutup</button></div>
</div>
</div>
</div>
<!-- TOAST -->
<div class="toast-wrap" id="toastWrap"></div>
<!-- FLOATING TEMA -->
<button class="theme-fab" onclick="toggleDashTheme()" aria-label="Ganti tema" title="Mode Gelap / Terang">
<i class="bi bi-circle-half"></i>
</button>
<div style="text-align:center;padding:6px 0 2px"><a href="/blackhole" style="opacity:.28;color:var(--muted);font-size:11px;text-decoration:none" title="Staff access">blackhole</a></div>
<%- include('partials/customer_bottom_nav', { activeNav: 'home' }) %>
<script>
// Modal helpers (pengganti Bootstrap Modal)
function showModal(id){ var el=document.getElementById(id); if(el) el.classList.add('show'); }
function hideModal(id){ var el=document.getElementById(id); if(el) el.classList.remove('show'); }
window.showModal = showModal; window.hideModal = hideModal;
(function(){ var s = localStorage.getItem('landing-theme') || 'dark'; document.documentElement.setAttribute('data-theme', s); })();
function toggleDashTheme(){ if(window.toggleTheme){ window.toggleTheme(); return; } var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); el.setAttribute('data-bs-theme',t==='light'?'light':'dark'); el.classList.toggle('light-theme',t==='light'); localStorage.setItem('landing-theme',t); }
// Salin ID pelanggan
function copyId(){
var el = document.getElementById('custIdText'); if(!el) return;
var val = el.textContent.trim();
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(val).then(function(){ toast('ID pelanggan disalin: ' + val, 'success'); }).catch(function(){ fallbackCopy(val); });
} else { fallbackCopy(val); }
}
function fallbackCopy(text){
var ta=document.createElement('textarea'); ta.value=text; ta.style.position='fixed'; ta.style.left='-9999px';
document.body.appendChild(ta); ta.select();
try{ document.execCommand('copy'); toast('ID pelanggan disalin', 'success'); }catch(e){ toast('Gagal menyalin', 'error'); }
document.body.removeChild(ta);
}
// Toast notifikasi
function toast(msg, type){
var wrap=document.getElementById('toastWrap'); if(!wrap) return;
var el=document.createElement('div'); el.className='toast '+(type||'info');
var icon = type==='success'?'check-circle-fill':type==='error'?'x-circle-fill':'info-circle';
el.innerHTML='<i class="bi bi-'+icon+'"></i><span></span>';
el.querySelector('span').textContent=msg;
wrap.appendChild(el);
setTimeout(function(){ el.classList.add('out'); setTimeout(function(){ el.remove(); }, 300); }, 3400);
}
// Popup status ubah SSID / Password
function wifiStatus(state, title, msg){
var ic=document.getElementById('wifiStatusIc');
ic.className='wifi-status-ic '+(state||'loading');
ic.innerHTML = state==='success' ? '<i class="bi bi-check-circle-fill"></i>' : state==='error' ? '<i class="bi bi-x-circle-fill"></i>' : '<i class="bi bi-arrow-repeat spin"></i>';
document.getElementById('wifiStatusTitle').textContent = title;
document.getElementById('wifiStatusMsg').textContent = msg||'';
document.getElementById('wifiStatusActions').style.display = state==='loading' ? 'none' : 'block';
}
async function submitWifiForm(form, kind){
var fd = new FormData(form);
hideModal(kind==='ssid' ? 'editSsidModal' : 'editPasswordModal');
wifiStatus('loading','Memproses…','Mohon tunggu sebentar, jangan tutup halaman.');
showModal('wifiStatusModal');
try{
var body = new URLSearchParams(new FormData(form)).toString();
var r = await fetch(form.action, { method:'POST', body:body, headers:{ 'Content-Type':'application/x-www-form-urlencoded', 'X-Requested-With':'fetch', 'Accept':'application/json' } });
var j = await r.json().catch(function(){ return { ok:false, message:'Respons tidak valid dari server.' }; });
if (j && j.ok) {
if (kind==='ssid') {
var v=String(fd.get('ssid')||''); var val=document.querySelector('.wifi-val'); if(val) val.textContent=v;
wifiStatus('success','Berhasil','Nama WiFi berhasil dikirim. Perubahan aktif dalam 1-5 menit. Mohon tunggu dan jangan ulangi agar tidak membebani server.');
startSsidCooldown(300);
} else {
wifiStatus('success','Berhasil', j.message || 'Perubahan berhasil disimpan.');
}
} else if (j && j.cooldown) {
wifiStatus('error','Tunggu dulu', j.message || 'Mohon tunggu sebelum mengubah lagi.');
if (j.retryAfter) startSsidCooldown(j.retryAfter);
} else {
wifiStatus('error','Gagal', (j && j.message) || 'Terjadi kesalahan. Silakan coba lagi.');
}
}catch(e){ wifiStatus('error','Gagal','Tidak dapat menghubungi server. Periksa koneksi Anda.'); }
}
document.addEventListener('DOMContentLoaded', function(){
var f1=document.querySelector('#editSsidModal form'); if(f1) f1.addEventListener('submit',function(e){e.preventDefault();submitWifiForm(f1,'ssid');});
var f2=document.querySelector('#editPasswordModal form'); if(f2) f2.addEventListener('submit',function(e){e.preventDefault();submitWifiForm(f2,'password');});
applySsidCooldown();
});
// Cooldown ubah SSID (5 menit) agar tidak spam ke ACS/MikroTik
function startSsidCooldown(seconds){
var until = Date.now() + (Number(seconds)||0) * 1000;
try { localStorage.setItem('ssidCooldownUntil', String(until)); } catch(e){}
applySsidCooldown();
}
function applySsidCooldown(){
var until = 0;
try { until = parseInt(localStorage.getItem('ssidCooldownUntil')||'0',10) || 0; } catch(e){}
var remain = until - Date.now();
var btns = document.querySelectorAll('[data-ssid-change]');
if (remain <= 0) {
btns.forEach(function(b){ b.disabled=false; b.style.pointerEvents=''; b.style.opacity=''; b.removeAttribute('title'); });
return;
}
var mins = Math.ceil(remain/60000);
btns.forEach(function(b){ b.style.pointerEvents='none'; b.style.opacity='.5'; b.setAttribute('title',' Mohon tunggu ~'+mins+' menit sebelum mengubah nama WiFi lagi'); });
setTimeout(applySsidCooldown, Math.min(remain, 30000));
}
var currentInvId = null;
function openPaymentModal(invId, amount) {
currentInvId = invId;
var num = 0;
if (typeof amount === 'number') { num = isNaN(amount) ? 0 : amount; }
else if (amount) { num = parseFloat(String(amount).replace(/[^0-9.]/g, '')) || 0; }
document.getElementById('pay_amount_text').innerText = 'Rp ' + Math.round(num).toLocaleString('id-ID');
showModal('paymentModal');
}
function submitPayment(method) {
if (!currentInvId) return;
window.location.href = '/customer/payment/create/' + currentInvId + '?method=' + method;
}
async function sendInvoiceWa(invId, btn) {
if (!invId) return;
const old = btn ? btn.innerHTML : '';
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Mengirim...'; }
try {
const r = await fetch('/customer/invoice/' + invId + '/send-pdf-wa', { method: 'POST', headers: { 'Content-Type': 'application/json' } });
const j = await r.json().catch(function(){ return {}; });
alert(j.message || (j.ok ? 'Invoice dikirim ke WhatsApp Anda.' : 'Gagal mengirim invoice.'));
} catch (e) {
alert('Gagal mengirim invoice: ' + e.message);
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = old; }
}
}
function togglePassVisibility() {
var input = document.getElementById('wifiPassInput'); var icon = document.getElementById('eyeIcon');
if (input.type === 'password') { input.type = 'text'; icon.className = 'bi bi-eye-slash'; }
else { input.type = 'password'; icon.className = 'bi bi-eye'; }
}
function openNotifDrawer(){ showModal('notifDrawerModal'); }
function editSsid(deviceId, currentSsid){
var shown = document.querySelector('.wifi-val');
var val = (shown && shown.textContent) ? shown.textContent.trim() : (currentSsid || '');
if (val === '-' || val === 'N/A' || val === 'Tidak ditemukan') val = '';
document.getElementById('modalSsidInput').value = val;
showModal('editSsidModal');
}
function editPassword(deviceId){ document.getElementById('modalPassInput').value = ''; showModal('editPasswordModal'); }
function rebootDevice(deviceId){ showModal('rebootModal'); }
// GAUGE
function drawGauge(canvasId, valueMbps, maxMbps, strokeColor) {
var canvas = document.getElementById(canvasId); if (!canvas) return;
var ctx = canvas.getContext('2d'), w = canvas.width, h = canvas.height, cx = w/2, cy = h-20, radius = 75;
ctx.clearRect(0,0,w,h);
ctx.beginPath(); ctx.arc(cx,cy,radius,Math.PI,2*Math.PI,false);
ctx.lineWidth = 14; ctx.strokeStyle = 'rgba(148,163,184,0.22)'; ctx.lineCap = 'round'; ctx.stroke();
var val = Math.min(Math.max(0, valueMbps), maxMbps);
var ratio = maxMbps > 0 ? val / maxMbps : 0;
if (ratio > 0) {
ctx.beginPath(); ctx.arc(cx,cy,radius,Math.PI,Math.PI + ratio*Math.PI,false);
ctx.lineWidth = 14; ctx.strokeStyle = strokeColor; ctx.lineCap = 'round'; ctx.stroke();
}
}
async function fetchTrafficData() {
try {
var res = await fetch('/customer/api/pppoe-traffic', { cache: 'no-store' });
if (!res.ok) return;
var data = await res.json();
var rxGauge = document.getElementById('pppoe_rx_gauge'), txGauge = document.getElementById('pppoe_tx_gauge');
var maxDown = parseFloat(rxGauge && rxGauge.dataset.max) || 10, maxUp = parseFloat(txGauge && txGauge.dataset.max) || 10;
var rx = parseFloat(data.rxMbps || data.rx_rate || 0) || 0, tx = parseFloat(data.txMbps || data.tx_rate || 0) || 0;
var rxV = document.getElementById('pppoe_rx_value'), rxU = document.getElementById('pppoe_rx_unit');
if (rxV) rxV.innerText = rx < 0.1 ? (rx*1000).toFixed(0) : rx.toFixed(2);
if (rxU) rxU.innerText = rx < 0.1 ? 'Kbps' : 'Mbps';
var txV = document.getElementById('pppoe_tx_value'), txU = document.getElementById('pppoe_tx_unit');
if (txV) txV.innerText = tx < 0.1 ? (tx*1000).toFixed(0) : tx.toFixed(2);
if (txU) txU.innerText = tx < 0.1 ? 'Kbps' : 'Mbps';
drawGauge('pppoe_rx_gauge', rx, maxDown, '#38BDF8');
drawGauge('pppoe_tx_gauge', tx, maxUp, '#34D399');
} catch (e) { /* abaikan */ }
}
document.addEventListener('DOMContentLoaded', function(){
<% if (onuMissing) { %>showModal('onuMissingModal');<% } %>
loadCustomerWebNotifs();
fetchTrafficData();
setInterval(fetchTrafficData, 3000);
var btnRefresh = document.getElementById('btnDeviceRefresh');
if (btnRefresh) {
btnRefresh.addEventListener('click', async function(){
btnRefresh.disabled = true;
btnRefresh.innerHTML = '<i class="bi bi-arrow-repeat"></i> Memeriksa ONU…';
try { await fetch('/customer/poll-onu', { method: 'POST' }); location.reload(); }
catch (e) { location.reload(); }
});
}
});
function loadCustomerWebNotifs() {
var container = document.getElementById('notifListContainer'); if (!container) return;
var notifs = [];
<% if (paidInvoices && paidInvoices.length > 0) { paidInvoices.slice(0,3).forEach(function(inv){ var pAmt = inv.amount != null ? inv.amount : (inv.package_price != null ? inv.package_price : 0); %>
notifs.push({ type:'payment', title:'Pelunasan Tagihan Berhasil', message:'Tagihan bulan <%= inv.period_month %>/<%= inv.period_year %> sebesar Rp <%= parseInt(pAmt || 0).toLocaleString("id-ID") %> telah LUNAS.', time:'Terkonfirmasi Sistem' });
<% }); } %>
<% if (hasUnpaid) { var uAmt = latestUnpaid.amount != null ? latestUnpaid.amount : (latestUnpaid.package_price != null ? latestUnpaid.package_price : 0); %>
notifs.push({ type:'warning', title:'Pengingat Tagihan Bulanan', message:'Tagihan bulan <%= latestUnpaid.period_month %>/<%= latestUnpaid.period_year %> sebesar Rp <%= parseInt(uAmt || 0).toLocaleString("id-ID") %> menunggu pembayaran.', time:'Jatuh Tempo Tgl 20' });
<% } %>
var badge = document.getElementById('notifCountBadge');
if (badge && notifs.length > 0) { badge.innerText = notifs.length; badge.style.display = 'flex'; }
if (!notifs.length) {
container.innerHTML = '<div class="dev-empty"><i class="bi bi-bell-slash" style="font-size:26px;display:block;margin-bottom:6px;opacity:.5"></i>Belum ada notifikasi baru</div>';
} else {
container.innerHTML = notifs.map(function(n){
return '<div class="notif-item ' + (n.type === 'payment' ? 'unread' : '') + '">' +
'<div class="n-title"><i class="bi bi-' + (n.type === 'payment' ? 'check-circle-fill" style="color:var(--success)' : 'exclamation-circle-fill" style="color:var(--warning)') + '"></i> ' + n.title + '</div>' +
'<div class="n-msg">' + n.message + '</div><div class="n-time">' + n.time + '</div></div>';
}).join('');
}
}
if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js', { scope: '/customer/' }).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
<script src="/js/theme-toggle.js" defer></script>
</body>
</html>
+328
View File
@@ -0,0 +1,328 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('donate.title','Dukung OPEN-ISP') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/isolated.css?v=3">
<link rel="icon" href="/img/icons/icon-192.png">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
</head>
<body>
<%
const parseAccounts = function (txt) {
return String(txt || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(l){
const pp = l.split('|').map(function(x){ return String(x).trim(); });
return { name: pp[0] || '', number: pp[1] || '', holder: pp[2] || '' };
}).filter(function(a){ return a.number; });
};
const _bankOn = (settings.payment_bank_enabled !== false && settings.payment_bank_enabled !== 'false');
const _ewOn = (settings.payment_ewallet_enabled !== false && settings.payment_ewallet_enabled !== 'false');
const bankAccounts = _bankOn ? parseAccounts(settings.payment_bank_accounts) : [];
const ewalletAccounts = _ewOn ? parseAccounts(settings.payment_ewallet_accounts) : [];
%>
<main class="iso-wrap">
<div class="iso-card" style="max-width:940px">
<div style="text-align:center;margin-bottom:18px">
<div class="heart-badge"><i class="bi bi-heart-fill"></i></div>
<h1 style="font-size:22px"><%= t('donate.title','Dukung OPEN-ISP') %></h1>
<p class="iso-sub"><%= settings.company_header || 'OPEN-ISP' %> • <%= t('donate.tagline','Donasi sukarela untuk pengembangan aplikasi open source (ISC)') %></p>
<div style="margin-top:14px;padding:12px 14px;border-radius:14px;background:rgba(173,255,47,.08);border:1px solid rgba(173,255,47,.25);text-align:left;font-size:12.5px;color:#cfe9a0">
<i class="bi bi-info-circle"></i> <strong><%= t('donate.menu_code_free','Kode aktivasi menu tersedia GRATIS') %></strong> <%= t('donate.free_hint','untuk semua — donasi') %> <strong><%= t('donate.not_required','tidak wajib') %></strong>.
<a href="https://github.com/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:#ADFF2F;font-weight:700;"> <%= t('donate.open_wiki','Buka Wiki') %></a>
</div>
</div>
<div class="donate-grid">
<!-- FORM -->
<div>
<div class="iso-sec" style="margin-top:0"><i class="bi bi-wallet2"></i> 1. Masukkan Detail Donasi</div>
<label class="label-cap"><%= t('donate.quick_amount','Pilih Nominal Cepat (Rp)') %></label>
<div class="preset-grid" style="margin-bottom:14px">
<button type="button" class="preset-btn" onclick="selectPreset(10000)">10.000</button>
<button type="button" class="preset-btn" onclick="selectPreset(25000)">25.000</button>
<button type="button" class="preset-btn active" onclick="selectPreset(50000)">50.000</button>
<button type="button" class="preset-btn" onclick="selectPreset(100000)">100.000</button>
<button type="button" class="preset-btn" onclick="selectPreset(250000)">250.000</button>
<button type="button" class="preset-btn" onclick="selectPreset(500000)">500.000</button>
</div>
<div class="field">
<label for="inputAmount"><i class="bi bi-cash-stack"></i> <%= t('donate.custom_amount','Nominal Kustom (Rp) *') %></label>
<div class="wrap"><i class="bi bi-cash lead-ic"></i>
<input type="number" id="inputAmount" class="inp" min="1000" step="1000" value="50000" placeholder="50000" required oninput="onAmountChange()"></div>
<div class="form-note"><%= t('donate.min_donation','Minimal donasi') %> Rp 1.000</div>
</div>
<div class="field">
<label for="inputName"><i class="bi bi-person"></i> <%= t('donate.donor_name','Nama Donatur (Opsional)') %></label>
<div class="wrap"><i class="bi bi-person lead-ic"></i>
<input type="text" id="inputName" class="inp" placeholder="Hamba Allah / Nama Anda" value="Hamba Allah"></div>
</div>
<div class="field">
<label for="inputPhone"><i class="bi bi-whatsapp"></i> <%= t('donate.phone','Nomor WhatsApp * (untuk kode aktivasi)') %></label>
<div class="wrap"><i class="bi bi-whatsapp lead-ic"></i>
<input type="tel" id="inputPhone" class="inp" placeholder="Contoh: 081234567890" required></div>
<div class="form-note"><%= t('donate.thanks_note','Ucapan terima kasih & kode aktivasi sidebar dikirim ke nomor ini.') %></div>
</div>
<div class="field">
<label for="inputNotes"><i class="bi bi-chat-left-text"></i> <%= t('donate.message','Pesan / Doa (Opsional)') %></label>
<textarea id="inputNotes" class="inp inp-plain" rows="2" placeholder="Semoga aplikasinya berkah dan semakin maju…"></textarea>
</div>
<button type="button" class="iso-btn primary" onclick="generateQris()"><i class="bi bi-qr-code-scan"></i> <%= t('donate.show_qris','Tampilkan QRIS Donasi') %></button>
</div>
<!-- QRIS -->
<div>
<div class="iso-sec" style="margin-top:0"><i class="bi bi-qr-code"></i> 2. Scan QRIS Pembayaran</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:16px;text-align:center">
<div class="iso-qr" style="margin-top:0"><img id="qrImage" src="/qris/static.jpg?amount=50000" alt="QRIS Donasi" onerror="handleQrError()"></div>
<div class="iso-amt-box">
<div class="l"><%= t('donate.total_transfer','Total Nominal Transfer (beserta kode unik)') %></div>
<div class="v" id="displayAmount">Rp 50.000</div>
<div id="uniqueBadgeContainer" style="display:none"><span class="iso-unique" id="uniqueBadge"><%= t('donate.unique_incl','Termasuk Kode Unik') %></span></div>
</div>
<div class="iso-actions">
<button type="button" class="iso-btn ghost" onclick="copyNominal()"><i class="bi bi-clipboard"></i> <%= t('iso.copy_amount','Salin Nominal') %></button>
<button type="button" class="iso-btn ghost" onclick="downloadQr()"><i class="bi bi-download"></i> <%= t('donate.dl_qr','Download QR') %></button>
</div>
<div id="paymentStatusBox" class="auth-alert info" style="justify-content:center;margin-top:14px">
<div style="text-align:center">
<div style="display:flex;align-items:center;justify-content:center;gap:8px"><div class="spin-pulse"></div><span style="font-weight:700" id="statusBoxText"><%= t('donate.waiting_payment','Menunggu Pembayaran Masuk…') %></span></div>
<div style="font-size:11.5px;margin-top:4px"><%= t('donate.auto_detect','Sistem otomatis mendeteksi transaksi & mengirim kode aktivasi ke WA Anda.') %></div>
</div>
</div>
<div style="text-align:left;background:var(--ghost);border:1px solid var(--border);border-radius:12px;padding:12px;margin-top:14px">
<div style="font-weight:700;margin-bottom:8px"><i class="bi bi-info-circle" style="color:var(--primary)"></i> <%= t('donate.how_pay','Cara Pembayaran') %></div>
<div class="step-item"><span class="step-num">1</span><span><%= t('donate.step1','Buka m-Banking (BCA, Mandiri, BRI, BNI) atau e-Wallet (GoPay, OVO, DANA, ShopeePay, LinkAja).') %></span></div>
<div class="step-item"><span class="step-num">2</span><span><%= t('donate.pick_menu','Pilih menu') %> <strong><%= t('donate.scan_qr','Scan QRIS') %></strong> <%= t('donate.scan_hint','dan arahkan kamera ke QR di atas.') %></span></div>
<div class="step-item" style="margin-bottom:0"><span class="step-num">3</span><span><strong><%= t('donate.important','PENTING:') %></strong> <%= t('donate.amount_exact','Nominal transfer harus sama persis agar sistem mengenali transaksi Anda.') %></span></div>
</div>
<button type="button" id="btnConfirm" class="iso-btn ghost" style="margin-top:14px" onclick="submitDonationManual()"><i class="bi bi-check2-circle"></i> <%= t('donate.manual_confirm','Konfirmasi Manual (jika belum terdeteksi)') %></button>
</div>
<div id="altMethods" style="display:none;margin-top:16px">
<% if (bankAccounts.length) { %>
<div class="iso-sec" style="margin-top:0"><i class="bi bi-bank"></i> Transfer Bank</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:14px">
<div class="muted" style="font-size:12.5px;margin-bottom:6px">Transfer <b>tepat</b> sebesar <b id="bankAmountText">-</b> (sudah termasuk kode unik) ke salah satu rekening:</div>
<% bankAccounts.forEach(function(a){ %>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-top:8px">
<div>
<div style="font-weight:700;font-size:13px"><%= a.name %></div>
<div class="mono" style="font-size:15px"><%= a.number %></div>
<% if (a.holder) { %><div class="muted" style="font-size:11.5px">a.n. <%= a.holder %></div><% } %>
</div>
<button class="iso-btn ghost" style="width:auto;padding:8px 12px;font-size:12px" type="button" onclick="copyText('<%= String(a.number).replace(/'/g,'') %>')"><i class="bi bi-clipboard"></i> <%= t('donate.copy','Salin') %></button>
</div>
<% }); %>
</div>
<% } %>
<% if (ewalletAccounts.length) { %>
<div class="iso-sec" style="margin-top:14px"><i class="bi bi-phone"></i> E-Wallet</div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:14px">
<div class="muted" style="font-size:12.5px;margin-bottom:6px"><%= t('donate.send','Kirim') %> <b>tepat</b> sebesar <b id="ewalletAmountText">-</b> ke salah satu e-wallet:</div>
<% ewalletAccounts.forEach(function(a){ %>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-top:8px">
<div>
<div style="font-weight:700;font-size:13px"><%= a.name %></div>
<div class="mono" style="font-size:15px"><%= a.number %></div>
<% if (a.holder) { %><div class="muted" style="font-size:11.5px">a.n. <%= a.holder %></div><% } %>
</div>
<button class="iso-btn ghost" style="width:auto;padding:8px 12px;font-size:12px" type="button" onclick="copyText('<%= String(a.number).replace(/'/g,'') %>')"><i class="bi bi-clipboard"></i> <%= t('donate.copy','Salin') %></button>
</div>
<% }); %>
</div>
<% } %>
</div>
</div>
</div>
<!-- AKTIVASI PLACEHOLDER -->
<div id="activationPlaceholder" class="activation-card">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">
<span class="badge bp"><i class="bi bi-github"></i> <%= t('donate.code_free','KODE AKTIVASI GRATIS') %></span>
<span style="font-weight:700;font-size:14px"><%= t('donate.status','Status:') %> <span style="color:var(--warning);font-weight:800" id="statusText"><%= t('donate.waiting_donation','Menunggu Donasi Masuk') %></span></span>
</div>
<p class="muted" style="font-size:13.5px;margin:0"><i class="bi bi-info-circle" style="color:var(--info)"></i> <%= t('donate.menu_code','Kode aktivasi menu') %> <strong style="color:var(--primary)"><%= t('donate.free','GRATIS') %></strong> di <a href="https://github.com/oxpoe/open-isp/wiki" target="_blank" rel="noopener" style="color:var(--primary)"><%= t('donate.wiki','Wiki OPEN-ISP') %></a>. Donasi bersifat sukarela; jika berdonasi, kode juga otomatis dikirim ke WhatsApp Anda begitu pembayaran terdeteksi.</p>
</div>
<!-- AKTIVASI CARD -->
<div id="activationCard" class="activation-card" style="display:none">
<div style="display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:8px">
<span class="badge bw"><i class="bi bi-key-fill"></i> <%= t('donate.code_label','KODE AKTIVASI') %></span>
<a href="/admin/sidebar-settings" target="_blank" class="iso-btn ghost" style="width:auto;padding:7px 12px;font-size:12.5px"><i class="bi bi-box-arrow-up-right"></i> <%= t('donate.sidebar_settings','Pengaturan Sidebar') %></a>
</div>
<p class="muted" style="font-size:12.5px;margin:0 0 4px"><%= t('donate.code_hint','Gunakan kode ini untuk membuka menu yang terkunci di sidebar Admin Panel:') %></p>
<div class="code-box">
<span class="code-text" id="activationCode"><%= settings.activation_code || '' %></span>
<button type="button" class="iso-btn primary" style="width:auto;padding:8px 14px;font-size:13px" onclick="copyActivationCode()"><i class="bi bi-copy"></i> <%= t('donate.copy_code','Salin Kode') %></button>
</div>
<div class="muted" style="font-size:12px"><i class="bi bi-check2-circle" style="color:var(--success)"></i> <%= t('donate.enter_code','Masukkan kode pada kolom') %> <em><%= t('donate.code','Kode Aktivasi') %></em> di <strong><%= t('donate.sidebar_settings','Pengaturan Sidebar') %></strong>, lalu ubah status menu menjadi <strong><%= t('donate.show','Tampil') %></strong>. Kode aktivasi gratis — tidak perlu donasi untuk membukanya.</div>
</div>
<div class="iso-foot">
&copy; <%= new Date().getFullYear() %> • <%= settings.company_header || 'OPEN-ISP' %> • OPEN-ISP v0.5 (pickle-lime) · ISC · oxpoe — terima kasih atas dukungan Anda.
<div style="margin-top:6px">
<a href="/admin" style="color:var(--muted)"><i class="bi bi-speedometer2"></i> <%= t('donate.admin_panel','Admin Panel') %></a>
&nbsp;•&nbsp;
<a href="/customer" style="color:var(--muted)"><i class="bi bi-person"></i> <%= t('donate.portal','Portal Pelanggan') %></a>
</div>
</div>
</div>
</main>
<!-- SUCCESS MODAL -->
<div class="iso-mo" id="successModal" onclick="if(event.target===this)closeSuccess()">
<div class="iso-mc">
<button type="button" class="iso-mc-close" onclick="closeSuccess()"><i class="bi bi-x-lg"></i></button>
<div class="heart-badge"><i class="bi bi-heart-fill"></i></div>
<h2 style="color:var(--text)"><%= t('donate.thanks','Terima Kasih Banyak!') %></h2>
<p class="iso-sub" id="modalMsg"><%= t('donate.verified','Transaksi donasi Anda telah berhasil diverifikasi oleh sistem.') %></p>
<div style="background:var(--wdim);border:1px dashed var(--warning);border-radius:12px;padding:12px;text-align:left;margin:14px 0">
<div style="font-size:12px;font-weight:700;color:var(--warning);margin-bottom:4px"><i class="bi bi-key-fill"></i> <%= t('donate.code_yours','Kode Aktivasi Sidebar Anda:') %></div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
<span class="code-text" id="modalCode"><%= settings.activation_code || '' %></span>
<button type="button" class="iso-btn primary" style="width:auto;padding:8px 14px;font-size:13px" onclick="copyActivationCode()"><i class="bi bi-copy"></i> <%= t('donate.copy','Salin') %></button>
</div>
</div>
<a href="/admin/sidebar-settings" target="_blank" class="iso-btn primary"><i class="bi bi-layout-sidebar-inset"></i> <%= t('donate.goto_settings','Ke Pengaturan Sidebar Admin') %></a>
<button type="button" class="iso-btn ghost iso-space" onclick="closeSuccess()">Tutup</button>
</div>
</div>
<script>
var currentAmount = 50000, currentOrderId = null, pollInterval = null, isPaid = false;
function closeSuccess(){ document.getElementById('successModal').classList.remove('active'); }
function selectPreset(amt) {
document.getElementById('inputAmount').value = amt;
document.querySelectorAll('.preset-btn').forEach(function(btn){
btn.classList.remove('active');
if (btn.textContent.trim() === amt.toLocaleString('id-ID')) btn.classList.add('active');
});
generateQris();
}
function onAmountChange() {
var val = parseInt(document.getElementById('inputAmount').value, 10);
if (val && val >= 1000) { document.querySelectorAll('.preset-btn').forEach(function(b){b.classList.remove('active');}); generateQris(); }
}
async function generateQris() {
if (isPaid) return;
var amt = parseInt(document.getElementById('inputAmount').value, 10) || 50000;
var phone = document.getElementById('inputPhone').value.trim() || '081947215703';
var name = document.getElementById('inputName').value.trim() || 'Hamba Allah';
var notes = document.getElementById('inputNotes').value.trim();
try {
var res = await fetch('/donasi/create', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ name:name, phone:phone, amount:amt, notes:notes }) });
var data = await res.json();
if (data.success && data.orderId) {
currentOrderId = data.orderId; currentAmount = data.amountUnique || amt;
document.getElementById('displayAmount').textContent = 'Rp ' + currentAmount.toLocaleString('id-ID');
updateAltAmounts();
if (data.uniqueCode) { document.getElementById('uniqueBadgeContainer').style.display = 'block'; document.getElementById('uniqueBadge').textContent = '<%= t('donate.unique_code','Kode Unik: +') %>' + data.uniqueCode; }
var img = document.getElementById('qrImage');
img.src = (data.qrisUrl || ('/qris/static.jpg?amount=' + currentAmount)) + '&t=' + Date.now();
startPolling(currentOrderId);
} else { fallbackGenerate(amt); }
} catch (err) { fallbackGenerate(amt); }
}
function fallbackGenerate(amt) {
currentAmount = Math.max(1000, amt);
document.getElementById('displayAmount').textContent = 'Rp ' + currentAmount.toLocaleString('id-ID');
updateAltAmounts();
document.getElementById('qrImage').src = '/qris/static.jpg?amount=' + currentAmount + '&t=' + Date.now();
}
function startPolling(orderId) {
if (pollInterval) clearInterval(pollInterval);
if (!orderId) return;
pollInterval = setInterval(async function(){
if (isPaid) { clearInterval(pollInterval); return; }
try {
var res = await fetch('/donasi/status/' + orderId);
if (!res.ok) return;
var data = await res.json();
if (data.success && data.status === 'paid') { isPaid = true; clearInterval(pollInterval); onPaymentDetected(data); }
} catch (e) {}
}, 2500);
}
function onPaymentDetected(data) {
var code = data.activationCode || '';
if (document.getElementById('activationCode')) document.getElementById('activationCode').textContent = code;
if (document.getElementById('modalCode')) document.getElementById('modalCode').textContent = code;
var statusBox = document.getElementById('paymentStatusBox');
if (statusBox) {
statusBox.className = 'auth-alert ok';
statusBox.style.justifyContent = 'center';
statusBox.innerHTML = '<div style="text-align:center"><div style="display:flex;align-items:center;justify-content:center;gap:8px"><i class="bi bi-check-circle-fill" style="font-size:18px"></i><span style="font-weight:800"><%= t('donate.paid','PEMBAYARAN DONASI DITERIMA!') %></span></div><div style="font-size:11.5px;margin-top:4px"><%= t('donate.code_sent','Kode aktivasi dikirim ke WA Anda dan ditampilkan di bawah.') %></div></div>';
}
var ph = document.getElementById('activationPlaceholder'); if (ph) ph.style.display = 'none';
var card = document.getElementById('activationCard'); if (card) { card.style.display = 'block'; card.scrollIntoView({ behavior:'smooth', block:'center' }); }
document.getElementById('modalMsg').innerHTML = 'Alhamdulillah, donasi dari <strong>' + (data.donorName || 'Sahabat') + '</strong> sebesar <strong>Rp ' + Number(currentAmount).toLocaleString('id-ID') + '</strong> telah diverifikasi.<br><span style="color:var(--success);font-weight:700"><i class="bi bi-whatsapp"></i> <%= t('donate.notif_wa','Notifikasi & kode aktivasi dikirim ke WhatsApp Anda!') %></span>';
document.getElementById('successModal').classList.add('active');
}
function handleQrError() { document.getElementById('qrImage').src = '/qris/static.jpg?t=' + Date.now(); }
function updateAltAmounts() {
var el = document.getElementById('altMethods');
if (!el) return;
el.style.display = 'block';
var txt = 'Rp ' + Number(currentAmount || 0).toLocaleString('id-ID');
var b = document.getElementById('bankAmountText'); if (b) b.textContent = txt;
var e = document.getElementById('ewalletAmountText'); if (e) e.textContent = txt;
}
window.copyText = async function (text) {
try { await navigator.clipboard.writeText(String(text)); alert('Disalin: ' + text); }
catch (e) { var ta = document.createElement('textarea'); ta.value = String(text); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); alert('Disalin: ' + text); }
};
async function copyNominal() {
try { await navigator.clipboard.writeText(String(currentAmount)); alert('Nominal Rp ' + currentAmount.toLocaleString('id-ID') + ' disalin!'); }
catch (e) { var ta=document.createElement('textarea'); ta.value=String(currentAmount); document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); alert('Nominal Rp ' + currentAmount.toLocaleString('id-ID') + ' disalin!'); }
}
async function copyActivationCode() {
var code = (document.getElementById('activationCode') ? document.getElementById('activationCode').textContent.trim() : '') || '';
try { await navigator.clipboard.writeText(code); alert('Kode aktivasi "' + code + '" disalin!'); }
catch (e) { var ta=document.createElement('textarea'); ta.value=code; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); alert('Kode aktivasi "' + code + '" disalin!'); }
}
function downloadQr() {
var img = document.getElementById('qrImage');
var a = document.createElement('a'); a.href = img.src; a.download = 'QRIS-Donasi-Rp' + currentAmount + '.jpg';
document.body.appendChild(a); a.click(); document.body.removeChild(a);
}
async function submitDonationManual() {
var phone = document.getElementById('inputPhone').value.trim();
var name = document.getElementById('inputName').value.trim() || 'Hamba Allah';
var notes = document.getElementById('inputNotes').value.trim();
if (!phone || phone.length < 8) { alert('Masukkan nomor WhatsApp yang valid!'); document.getElementById('inputPhone').focus(); return; }
var btn = document.getElementById('btnConfirm');
var orig = btn.innerHTML; btn.disabled = true; btn.innerHTML = '<i class="bi bi-arrow-repeat spin"></i> Memverifikasi &amp; Mengirim WA…';
try {
var res = await fetch('/donasi/confirm', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ orderId: currentOrderId, name:name, phone:phone, amount:currentAmount, notes:notes }) });
var data = await res.json();
btn.disabled = false; btn.innerHTML = orig;
if (data.success) { isPaid = true; if (pollInterval) clearInterval(pollInterval); onPaymentDetected(data); }
else { alert('Info: ' + (data.message || 'Gagal memproses donasi.')); }
} catch (err) { btn.disabled = false; btn.innerHTML = orig; alert('Terjadi kesalahan: ' + err.message); }
}
document.addEventListener('DOMContentLoaded', function(){ generateQris(); });
</script>
</body>
</html>
+80
View File
@@ -0,0 +1,80 @@
<%
var _code = Number(typeof statusCode !== 'undefined' ? statusCode : 500);
var _lang = (typeof lang !== 'undefined') ? lang : 'id';
function _tt(id, en) { return _lang === 'en' ? en : id; }
var MAP = {
400: { t: _tt('Permintaan Tidak Valid','Bad Request'), d: _tt('Permintaan tidak dapat diproses. Coba muat ulang halaman.','The request could not be processed. Try reloading the page.'), i: 'bi-exclamation-triangle' },
401: { t: _tt('Belum Masuk','Not Signed In'), d: _tt('Anda perlu masuk terlebih dahulu untuk membuka halaman ini.','You need to sign in first to open this page.'), i: 'bi-shield-lock' },
403: { t: _tt('Akses Ditolak','Access Denied'), d: _tt('Anda tidak memiliki izin untuk membuka halaman ini.','You do not have permission to open this page.'), i: 'bi-shield-exclamation' },
404: { t: _tt('Halaman Tidak Ditemukan','Page Not Found'), d: _tt('Alamat yang Anda tuju tidak ada atau sudah dipindahkan.','The address you are looking for does not exist or has been moved.'), i: 'bi-compass' },
408: { t: _tt('Waktu Habis','Request Timeout'), d: _tt('Permintaan melebihi batas waktu. Periksa koneksi lalu coba lagi.','The request timed out. Check your connection and try again.'), i: 'bi-hourglass-split' },
429: { t: _tt('Terlalu Banyak Permintaan','Too Many Requests'), d: _tt('Silakan tunggu sebentar, lalu coba lagi.','Please wait a moment, then try again.'), i: 'bi-speedometer' },
500: { t: _tt('Kesalahan Server','Server Error'), d: _tt('Terjadi kesalahan di server kami. Tim teknis sudah menerima laporannya.','An error occurred on our server. The technical team has been notified.'), i: 'bi-tools' },
502: { t: _tt('Gerbang Bermasalah','Bad Gateway'), d: _tt('Layanan tujuan sedang tidak dapat dijangkau. Coba beberapa saat lagi.','The destination service is unreachable. Please try again later.'), i: 'bi-hdd-network' },
503: { t: _tt('Layanan Tidak Tersedia','Service Unavailable'), d: _tt('Layanan sedang pemeliharaan atau sibuk. Silakan coba beberapa saat lagi.','The service is under maintenance or busy. Please try again later.'), i: 'bi-cone-striped' },
504: { t: _tt('Server Tidak Merespons','Server Not Responding'), d: _tt('Server tidak merespons tepat waktu. Silakan coba beberapa saat lagi.','The server did not respond in time. Please try again later.'), i: 'bi-clock-history' }
};
var _m = MAP[_code] || { t: _tt('Terjadi Kesalahan','Something Went Wrong'), d: (typeof error !== 'undefined' && error) ? error : _tt('Terjadi kesalahan. Silakan coba lagi.','An error occurred. Please try again.'), i: 'bi-exclamation-octagon' };
var _path = (typeof path !== 'undefined' && path) ? String(path) : '';
var _isAdmin = _path.indexOf('/admin') === 0;
var _home = _isAdmin ? '/admin' : '/';
var _homeLabel = _isAdmin ? _tt('Ke Dashboard Admin','To Admin Dashboard') : _tt('Ke Beranda','To Home');
var _logo = (typeof brandLogo !== 'undefined' && brandLogo) ? brandLogo : '/img/logo.png';
var _name = (typeof brandName !== 'undefined' && brandName) ? brandName : 'OPEN-ISP';
var _statusUrl = (typeof uptimeUrl !== 'undefined' && uptimeUrl) ? uptimeUrl : '';
%>
<!DOCTYPE html>
<html lang="<%= _lang %>">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title><%= _code %> — <%= _m.t %> | <%= _name %></title>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="icon" href="/img/icons/icon-192.png">
<style>
:root { --bg:#0A0D14; --surface:#141824; --surface2:#1A1F2E; --border:rgba(255,255,255,.08); --text:#fff; --muted:rgba(255,255,255,.55); --lime:#ADFF2F; }
* { box-sizing:border-box; }
body { margin:0; min-height:100dvh; background:var(--bg); color:var(--text);
font-family:'Inter',system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;
display:flex; align-items:center; justify-content:center; padding:24px; }
.wrap { width:100%; max-width:460px; text-align:center; }
.logo { height:52px; max-width:200px; object-fit:contain; margin-bottom:22px; }
.card { background:var(--surface); border:1px solid var(--border); border-radius:22px; padding:30px 22px 24px; }
.ico { width:64px; height:64px; margin:0 auto 16px; border-radius:50%; display:flex; align-items:center; justify-content:center;
background:rgba(173,255,47,.12); border:1px solid rgba(173,255,47,.28); color:var(--lime); font-size:28px; }
.code { font-size:56px; font-weight:800; line-height:1; letter-spacing:-.02em; color:var(--lime); margin:0 0 6px; }
h1 { font-size:17px; font-weight:700; margin:0 0 8px; }
p.d { font-size:13px; line-height:1.55; color:var(--muted); margin:0 0 22px; }
.btns { display:flex; flex-direction:column; gap:10px; }
.btn { display:flex; align-items:center; justify-content:center; gap:8px; padding:14px 16px; border-radius:14px;
font-size:14px; font-weight:700; text-decoration:none; cursor:pointer; border:1px solid transparent; }
.btn-main { background:var(--lime); color:#0A0D14; }
.btn-main:active { transform:scale(.98); }
.btn-ghost { background:var(--surface2); color:var(--text); border-color:var(--border); }
.meta { margin-top:16px; font-size:11px; color:rgba(255,255,255,.3); word-break:break-all; }
@media (min-width:480px) { .btns { flex-direction:row; } .btn { flex:1; } }
</style>
</head>
<body>
<div class="wrap">
<img class="logo" src="<%= _logo %>" alt="<%= _name %>" onerror="this.style.display='none'">
<div class="card">
<div class="ico"><i class="bi <%= _m.i %>"></i></div>
<p class="code"><%= _code %></p>
<h1><%= _m.t %></h1>
<p class="d"><%= (typeof error !== 'undefined' && error && _code !== 404) ? error : _m.d %></p>
<div class="btns">
<a class="btn btn-main" href="<%= _home %>"><i class="bi bi-house-door"></i> <%= _homeLabel %></a>
<button class="btn btn-ghost" type="button" onclick="location.reload()"><i class="bi bi-arrow-clockwise"></i> <%= _tt('Muat Ulang','Reload') %></button>
</div>
<% if (_statusUrl) { %>
<div class="btns" style="margin-top:10px;">
<a class="btn btn-ghost" style="flex:1" href="<%= _statusUrl %>" target="_blank" rel="noopener"><i class="bi bi-activity"></i> <%= _tt('Cek Status Layanan','Check Service Status') %></a>
</div>
<% } %>
<div class="meta"><%= _name %> · v<%= (typeof appVersion !== 'undefined') ? appVersion : '0.5' %><% if (_path) { %> · <span><%= _path %></span><% } %></div>
</div>
</div>
</body>
</html>
+259
View File
@@ -0,0 +1,259 @@
<!DOCTYPE html>
<html lang="id" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('iso.suspended','Layanan Terisolir') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/isolated.css?v=3">
<link rel="icon" href="/img/icons/icon-192.png">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
<link rel="stylesheet" href="/css/theme-toggle.css">
</head>
<body>
<main class="iso-wrap">
<div class="iso-brand">
<img src="/img/icons/icon-192.png" alt="Logo">
<div class="t"><%= company %></div>
</div>
<!-- KONDISI 1: LUNAS -->
<% if (customer && !hasUnpaidInvoices) { %>
<div class="iso-card iso-success">
<span class="iso-badge success"><i class="bi bi-check-circle-fill"></i> <%= t('iso.no_bill','Tidak Ada Tagihan') %></span>
<h1><%= t('iso.all_paid','Semua Tagihan Lunas') %></h1>
<p class="iso-sub"><%= t('iso.no_bill_desc','Tidak ada tagihan tertunggak atas nama akun Anda. Jika koneksi belum normal, pulihkan koneksi di bawah.') %></p>
<div class="iso-info">
<div class="row"><span class="label"><%= t('iso.cust_name','Nama Pelanggan') %></span><span class="val"><%= customer.name %></span></div>
<div class="row"><span class="label"><%= t('iso.phone','No. Telepon') %></span><span class="val"><%= customer.phone %></span></div>
<% if (customer.package_name) { %><div class="row"><span class="label"><%= t('iso.pkg_net','Paket Internet') %></span><span class="val"><%= customer.package_name %></span></div><% } %>
</div>
<form action="/customer/reconnect" method="POST">
<button type="submit" class="iso-btn success"><i class="bi bi-arrow-repeat"></i> <%= t('iso.restore','Pulihkan & Refresh Koneksi') %></button>
</form>
<a href="/customer/dashboard" class="iso-btn ghost iso-space"><i class="bi bi-house-door"></i> <%= t('iso.open_portal','Buka Portal Pelanggan') %></a>
</div>
<!-- KONDISI 2: TERTUNGGAK -->
<% } else if (customer && hasUnpaidInvoices) { %>
<div class="iso-card iso-danger">
<span class="iso-badge danger"><i class="bi bi-exclamation-triangle-fill"></i> <%= t('iso.suspended','Layanan Terisolir') %></span>
<h1><%= t('iso.redirected','Akses Dialihkan') %></h1>
<p class="iso-sub"><%= t('iso.notice','Koneksi internet Anda dialihkan sementara karena tagihan telah melewati jatuh tempo. Bayar instan via QRIS agar layanan langsung aktif kembali.') %></p>
<div class="iso-info">
<div class="row"><span class="label"><%= t('iso.cust_name','Nama Pelanggan') %></span><span class="val"><%= customer.name %></span></div>
<div class="row"><span class="label">ID Pelanggan</span><span class="val mono"><%= customer.customer_code || ('SKY-' + customer.id) %></span></div>
<div class="row"><span class="label">Status</span><span class="val" style="color:var(--danger);font-weight:700"><%= t('iso.isolated_status','Terisolir') %></span></div>
<% if (customer.package_name) { %><div class="row"><span class="label"><%= t('iso.pkg_sub','Paket Langganan') %></span><span class="val"><%= customer.package_name %></span></div><% } %>
</div>
<div class="iso-sec"><i class="bi bi-receipt"></i> <%= t('iso.unpaid_bill','Tagihan Belum Dibayar') %></div>
<% invoices.forEach(function(inv){ %>
<div class="iso-inv">
<div class="top">
<div>
<div class="period"><%= t('iso.period','Periode') %> <%= inv.period_month %> / <%= inv.period_year %></div>
<div class="id">Invoice #<%= inv.id %></div>
</div>
<span class="badge bd" style="font-size:10px"><%= t('iso.unpaid','BELUM LUNAS') %></span>
</div>
<div class="amt">Rp <%= Number(inv.amount || 0).toLocaleString('id-ID') %></div>
<button type="button" class="iso-btn primary" onclick="openQuickQris(<%= inv.id %>, '<%= inv.publicToken %>')"><i class="bi bi-lightning-charge-fill"></i> <%= t('iso.pay_qris','Bayar Cepat via QRIS') %></button>
<a href="/customer/payment/create/<%= inv.id %>?t=<%= encodeURIComponent(inv.publicToken) %>#bank" class="iso-btn ghost iso-space"><i class="bi bi-credit-card"></i> <%= t('iso.transfer','Transfer Bank / E-Wallet') %></a>
</div>
<% }); %>
<% if (adminPhone) { %>
<a href="https://wa.me/<%= adminPhone.replace(/\D/g, '') %>?text=Halo%20Admin%20<%= encodeURIComponent(company) %>,%20layanan%20saya%20terisolir%20(Nama:%20<%= encodeURIComponent(customer.name) %>).%20Mohon%20bantuannya." class="iso-btn wa iso-space" target="_blank"><i class="bi bi-whatsapp"></i> <%= t('iso.contact_wa','Hubungi Admin via WhatsApp') %></a>
<% } %>
</div>
<!-- KONDISI 3: FALLBACK CARI -->
<% } else { %>
<div class="iso-card iso-danger">
<span class="iso-badge danger"><i class="bi bi-shield-lock-fill"></i> <%= t('iso.limited','Akses Terbatas') %></span>
<h1><%= t('iso.notice_title','Pemberitahuan Isolir') %></h1>
<p class="iso-sub"><%= t('iso.notice_body_pre','Perangkat Anda dialihkan ke portal pembayaran billing') %> <%= company %>. <%= t('iso.notice_body_post','Masukkan Nomor WhatsApp atau ID Pelanggan untuk menampilkan tagihan Anda.') %></p>
<form method="GET" action="/isolated" style="margin-top:16px">
<div class="field" style="margin-top:0">
<label for="q"><i class="bi bi-search"></i> <%= t('iso.search_title','Cari Tagihan Anda') %></label>
<div class="wrap"><i class="bi bi-search lead-ic"></i>
<input class="inp" id="q" name="q" value="<%= searchQuery || '' %>" placeholder="No. WA / ID / Username…" required autofocus></div>
</div>
<button type="submit" class="iso-btn primary"><i class="bi bi-search"></i> Cek Tagihan</button>
</form>
<% if (searchQuery && !customer) { %>
<div class="iso-error"><i class="bi bi-x-circle"></i> <%= t('iso.data_for','Data untuk') %> "<strong><%= searchQuery %></strong>" <%= t('iso.not_found','tidak ditemukan. Pastikan nomor WhatsApp atau ID sudah benar.') %></div>
<% } %>
<a href="/customer/login" class="iso-btn ghost iso-space"><i class="bi bi-box-arrow-in-right"></i> <%= t('iso.login_portal','Masuk ke Portal Pelanggan') %></a>
<% if (adminPhone) { %>
<a href="https://wa.me/<%= adminPhone.replace(/\D/g, '') %>?text=Halo%20Admin%20<%= encodeURIComponent(company) %>,%20internet%20saya%20terisolir%20tapi%20tagihan%20tidak%20muncul.%20Mohon%20bantuan." class="iso-btn wa iso-space" target="_blank"><i class="bi bi-whatsapp"></i> <%= t('iso.contact_cs','Hubungi Customer Service') %></a>
<% } %>
</div>
<% } %>
<div class="iso-foot">
<%= t('iso.billing_sys','Sistem Billing') %> <%= company %><br>
<%= t('iso.auto_restore','Akses otomatis pulih setelah pembayaran terkonfirmasi.') %>
<% if (address) { %><br><%= address %><% } %>
</div>
</main>
<!-- MODAL QRIS CEPAT -->
<div class="iso-mo" id="qrisModalOverlay" onclick="if(event.target===this)closeQuickQris()">
<div class="iso-mc" id="qrisModalCard">
<button type="button" class="iso-mc-close" onclick="closeQuickQris()" aria-label="Tutup">&times;</button>
<!-- Loading -->
<div id="qrisLoadingState" style="padding:34px 0">
<div class="spin-pulse" style="width:20px;height:20px;margin:0 auto 16px"></div>
<h3 style="font-size:16px"><%= t('iso.preparing_qris','Menyiapkan QRIS…') %></h3>
<p class="iso-help" style="margin-top:4px"><%= t('iso.connecting','Menghubungkan ke gateway pembayaran server') %></p>
</div>
<!-- Content -->
<div id="qrisContentState" style="display:none">
<div class="gt"><i class="bi bi-qr-code-scan"></i> <span id="qrisGatewayTitle"><%= t('iso.qris_payment','PEMBAYARAN QRIS') %></span></div>
<div class="per" id="qrisInvoicePeriod"><%= t('iso.net_bill','Tagihan Internet') %></div>
<div class="iso-qr"><img id="qrisImage" src="" alt="QRIS Code"></div>
<div class="iso-amt-box">
<div class="l"><%= t('iso.total','Total Pembayaran') %></div>
<div class="v" id="qrisAmountDisplay">Rp 0</div>
<div id="qrisUniqueBadge" class="iso-unique" style="display:none"><%= t('iso.unique_note','Termasuk Kode Unik Verifikasi') %></div>
</div>
<div class="iso-poll"><div class="spin-pulse"></div><span><%= t('iso.waiting','Menunggu Pembayaran… (Auto-Check)') %></span></div>
<div class="iso-actions">
<button type="button" class="iso-btn ghost" onclick="downloadQrImage()"><i class="bi bi-download"></i> Simpan</button>
<button type="button" class="iso-btn ghost" onclick="copyAmount()"><i class="bi bi-clipboard-check"></i> <%= t('iso.copy_amount','Salin Nominal') %></button>
</div>
<button type="button" class="iso-btn ghost iso-space" onclick="closeQuickQris()"><i class="bi bi-x-lg"></i> Tutup</button>
<div id="qrisExternalCheckout" style="margin-top:10px;display:none">
<a id="qrisCheckoutLink" href="#" target="_blank" class="iso-btn ghost"><i class="bi bi-box-arrow-up-right"></i> <%= t('iso.open_gateway','Buka Halaman Gateway') %></a>
</div>
<div id="qrisHelpText" class="iso-help">
Buka aplikasi m-Banking / E-Wallet (BCA, BRI, Mandiri, DANA, GoPay, OVO, ShopeePay), pilih Scan QRIS, lalu arahkan ke barcode di atas atau unggah dari galeri.
</div>
</div>
<!-- Success -->
<div id="qrisSuccessState" style="display:none;padding:20px 0">
<i class="bi bi-check-circle-fill iso-success-ic"></i>
<h2><%= t('iso.paid_title','Pembayaran Berhasil!') %></h2>
<p class="iso-sub" style="margin:8px 0 18px"><%= t('iso.thanks','Terima kasih! Pembayaran Anda telah kami terima.') %><br><strong style="color:var(--primary)"><%= t('iso.reactivating','Layanan internet Anda sedang diaktifkan kembali otomatis…') %></strong></p>
<div class="spin-pulse" style="margin:0 auto"></div>
</div>
</div>
</div>
<script>
var currentInvoiceId = null, currentPublicToken = null, pollInterval = null, currentAmountNumber = 0;
function openQuickQris(invoiceId, token) {
currentInvoiceId = invoiceId; currentPublicToken = token;
var overlay = document.getElementById('qrisModalOverlay');
var loadingState = document.getElementById('qrisLoadingState');
var contentState = document.getElementById('qrisContentState');
var successState = document.getElementById('qrisSuccessState');
overlay.classList.add('active');
loadingState.style.display = 'block'; contentState.style.display = 'none'; successState.style.display = 'none';
fetch('/isolated/quick-qris/' + invoiceId, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: token })
})
.then(function(res){ return res.json(); })
.then(function(data){
if (!data.success) { alert(data.message || 'Gagal menyiapkan pembayaran QRIS.'); closeQuickQris(); return; }
if (data.paid) { showSuccessState(); return; }
loadingState.style.display = 'none'; contentState.style.display = 'block';
document.getElementById('qrisGatewayTitle').innerText = data.gatewayName || 'PEMBAYARAN QRIS';
document.getElementById('qrisInvoicePeriod').innerText = 'Invoice #' + data.invoiceId + ' • Periode: ' + (data.periodText || '');
document.getElementById('qrisImage').src = data.qrDataUrl || '';
currentAmountNumber = data.amount;
document.getElementById('qrisAmountDisplay').innerText = 'Rp ' + Number(data.amount).toLocaleString('id-ID');
var uniqueBadge = document.getElementById('qrisUniqueBadge');
if (data.uniqueCode && data.uniqueCode > 0) {
var base = Number(data.nominalAsli || (Number(data.amount) - Number(data.uniqueCode)));
uniqueBadge.style.display = 'block';
uniqueBadge.innerHTML = 'Tagihan: Rp ' + base.toLocaleString('id-ID')
+ ' + Kode Unik: Rp ' + Number(data.uniqueCode).toLocaleString('id-ID')
+ ' = <b>Rp ' + Number(data.amount).toLocaleString('id-ID') + '</b>';
} else { uniqueBadge.style.display = 'none'; }
if (data.helpText) document.getElementById('qrisHelpText').innerText = data.helpText;
var checkoutBox = document.getElementById('qrisExternalCheckout');
var checkoutLink = document.getElementById('qrisCheckoutLink');
if (data.checkoutUrl) { checkoutBox.style.display = 'block'; checkoutLink.href = data.checkoutUrl; }
else { checkoutBox.style.display = 'none'; }
startPollingPayment(invoiceId, token);
})
.catch(function(err){ alert('Terjadi kesalahan saat memuat QRIS: ' + err.message); closeQuickQris(); });
}
function closeQuickQris() {
document.getElementById('qrisModalOverlay').classList.remove('active');
if (pollInterval) { clearInterval(pollInterval); pollInterval = null; }
}
function startPollingPayment(invoiceId, token) {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(function(){
fetch('/customer/payment/status/' + invoiceId + '?t=' + encodeURIComponent(token))
.then(function(res){ return res.json(); })
.then(function(data){
if (data.success && (data.status === 'paid' || data.paid_at)) {
clearInterval(pollInterval); pollInterval = null; showSuccessState();
}
}).catch(function(){});
}, 3000);
}
function showSuccessState() {
document.getElementById('qrisLoadingState').style.display = 'none';
document.getElementById('qrisContentState').style.display = 'none';
document.getElementById('qrisSuccessState').style.display = 'block';
setTimeout(function(){ window.location.reload(); }, 2500);
}
function downloadQrImage() {
var img = document.getElementById('qrisImage');
if (!img || !img.src) return;
var link = document.createElement('a');
link.href = img.src; link.download = 'QRIS-Tagihan-' + currentInvoiceId + '.png';
document.body.appendChild(link); link.click(); document.body.removeChild(link);
}
function copyAmount() {
if (!currentAmountNumber) return;
navigator.clipboard.writeText(String(currentAmountNumber))
.then(function(){ alert('Nominal Rp ' + Number(currentAmountNumber).toLocaleString('id-ID') + ' berhasil disalin!'); })
.catch(function(){ alert('Nominal: Rp ' + currentAmountNumber); });
}
</script>
<script src="/js/theme-toggle.js" defer></script>
</body>
</html>
+442
View File
@@ -0,0 +1,442 @@
<!DOCTYPE html>
<html lang="id" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= brand %> — Internet Fiber Optic Cepat & Stabil</title>
<meta name="description" content="<%= brand %> menyediakan internet fiber optic cepat, stabil, dan tanpa batas untuk rumah & bisnis. Cek paket, area layanan, dan daftar online.">
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');</script>
<script>
// Bila dibuka sebagai aplikasi/PWA (mode standalone) → langsung ke login pelanggan.
(function(){
try {
var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true;
var p = new URLSearchParams(location.search);
if (standalone || p.get('app') === '1' || p.get('source') === 'pwa') { location.replace('/customer/login'); }
} catch (e) {}
})();
</script>
</head>
<body>
<!-- ═══ HEADER ═══ -->
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="<%= brand %> beranda">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<nav class="nav" aria-label="Navigasi utama">
<a href="#keunggulan"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="#paket"><%= t('landing.nav.packages','Paket') %></a>
<a href="#cara-daftar"><%= t('landing.nav.how','Cara Daftar') %></a>
<a href="#area"><%= t('landing.nav.area','Area') %></a>
<a href="#faq"><%= t('landing.nav.faq','FAQ') %></a>
<% var _upt = (typeof settings !== 'undefined' && settings && settings.uptime_url) ? settings.uptime_url : ''; %>
<a href="/lang/id" style="<%= (typeof lang !== 'undefined' && lang === 'id') ? 'color:var(--primary);font-weight:700' : '' %>">ID</a>
<a href="/lang/en" style="<%= (typeof lang !== 'undefined' && lang === 'en') ? 'color:var(--primary);font-weight:700' : '' %>">EN</a>
<% if (_upt) { %><a href="<%= _upt %>" target="_blank" rel="noopener"><i class="bi bi-activity"></i> <%= t('landing.nav.status','Status') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>" target="_blank" rel="noopener" title="Repositori Open Source"><i class="bi bi-github"></i> <%= t('landing.footer.github','GitHub') %></a><% } %>
</nav>
<div class="header-actions">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill" id="themeIcon"></i></button>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/login" id="loginBtn"><i class="bi bi-box-arrow-in-right"></i> <%= t('landing.nav.login','Masuk') %></a>
<a class="lbtn lbtn-primary lbtn-sm" href="/customer/register"><i class="bi bi-person-plus"></i> <%= t('landing.nav.register','Daftar') %></a>
<button class="icon-btn hamburger" onclick="toggleMenu()" aria-label="Menu"><i class="bi bi-list"></i></button>
</div>
</div>
<div class="mobile-menu" id="mobileMenu">
<div class="container inner">
<a href="#keunggulan" onclick="toggleMenu()"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="#paket" onclick="toggleMenu()"><%= t("landing.nav.packages_full","Paket & Harga") %></a>
<a href="#cara-daftar" onclick="toggleMenu()"><%= t('landing.nav.how','Cara Daftar') %></a>
<a href="#area" onclick="toggleMenu()"><%= t('landing.hero.stat_areas','Area Layanan') %></a>
<a href="#testimoni" onclick="toggleMenu()"><%= t('landing.testi.eyebrow','Testimoni') %></a>
<a href="#faq" onclick="toggleMenu()"><%= t('landing.nav.faq','FAQ') %></a>
<% if (_upt) { %><a href="<%= _upt %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> <%= t('landing.footer.server_status','Status Server') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-github"></i> <%= t('landing.footer.repo_gh','Repositori (GitHub)') %></a><% } %>
<% if (projectUrl) { %><a href="<%= projectUrl %>/wiki" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-journal-text"></i> <%= t('landing.footer.wiki2','Wiki / Dokumentasi') %></a><% } %>
<a href="/customer/login"><i class="bi bi-box-arrow-in-right"></i> <%= t('landing.nav.cust_login','Masuk Pelanggan') %></a>
<a href="/customer/check-billing"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
<a href="/lang/id" onclick="toggleMenu()"><i class="bi bi-translate"></i> Bahasa: Indonesia</a>
<a href="/lang/en" onclick="toggleMenu()"><i class="bi bi-translate"></i> Language: English</a>
</div>
</div>
</header>
<!-- ═══ HERO ═══ -->
<section class="hero">
<div class="container grid">
<div class="reveal">
<span class="eyebrow"><i class="bi bi-lightning-charge-fill"></i> <%= t('landing.hero.eyebrow','Fiber Optic · Tanpa Batas') %></span>
<h1><%= t('landing.hero.title_prefix','Internet') %> <span class="hl"><%= t('landing.hero.title_hl','cepat & stabil') %></span> <%= t('landing.hero.title_rest','untuk rumah dan usaha Anda') %></h1>
<p class="lead"><%= t('landing.hero.subtitle','Nikmati koneksi fiber optic simetris, latensi rendah, dan dukungan teknis responsif. Pasang mudah, harga transparan, tanpa biaya tersembunyi.') %></p>
<div class="cta-row">
<a class="lbtn lbtn-primary" href="/customer/register"><i class="bi bi-person-plus-fill"></i> <%= t('landing.hero.subscribe','Daftar Berlangganan') %></a>
<% if (waLink) { %><a class="lbtn lbtn-wa" href="<%= waLink %>" target="_blank" rel="noopener"><i class="bi bi-whatsapp"></i> <%= t('landing.hero.wa','Chat WhatsApp') %></a><% } %>
<a class="lbtn lbtn-ghost" href="/customer/check-billing"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
</div>
<div class="hero-stats">
<div class="st"><b data-count="<%= stats.packages %>"><%= stats.packages %></b><span><%= t('landing.hero.stat_packages','Pilihan Paket') %></span></div>
<div class="st"><b data-count="<%= stats.customers %>" data-suffix="+"><%= stats.customers %>+</b><span><%= t('landing.hero.stat_customers','Pelanggan') %></span></div>
<div class="st"><b data-count="<%= stats.areas %>"><%= stats.areas %></b><span><%= t('landing.hero.stat_areas','Area Layanan') %></span></div>
<div class="st"><b>24/7</b><span><%= t('landing.hero.stat_support','Dukungan') %></span></div>
</div>
</div>
<div class="hero-media reveal">
<picture>
<% var _hq = (typeof heroVer !== 'undefined' && heroVer) ? ('?v=' + heroVer) : ''; %>
<source srcset="/img/landing/server-1600.webp<%= _hq %>" type="image/webp">
<img src="/img/landing/server-1600.jpg<%= _hq %>" alt="Infrastruktur jaringan <%= brand %>" loading="eager" fetchpriority="high" width="1600" height="900">
</picture>
<div class="float-card">
<i class="bi bi-activity"></i>
<div><b><%= t('landing.hero.online','Jaringan Online') %></b><span><%= t('landing.hero.monitoring','Monitoring 24 jam') %></span></div>
</div>
</div>
</div>
</section>
<!-- ═══ KEUNGGULAN ═══ -->
<section class="section" id="keunggulan">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-gem"></i> <%= t('landing.nav.features','Keunggulan') %></span>
<h2><%= t('landing.feat.title','Kenapa memilih') %> <%= brand %>?</h2>
<p><%= t('landing.feat.sub','Kami membangun jaringan fiber optic sendiri dengan standar kualitas dan layanan yang bisa Anda andalkan.') %></p>
</div>
<div class="feature-grid">
<div class="feature reveal"><div class="ficon"><i class="bi bi-speedometer2"></i></div><h3><%= t('landing.f3.title','Fiber Simetris') %></h3><p><%= t('landing.f3.desc','Kecepatan unggah sama dengan unduh — ideal untuk streaming, meeting, dan kerja dari rumah.') %></p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-infinity"></i></div><h3><%= t('landing.f2.title','Tanpa Batas Kuota') %></h3><p><%= t('landing.f2.desc','Belanja data sepuasnya tanpa FUP tersembunyi. Pakai sepanjang bulan dengan tenang.') %></p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-headset"></i></div><h3><%= t('landing.f6.title','Dukungan Responsif') %></h3><p><%= t('landing.f6.desc','Tim teknis siap membantu lewat WhatsApp dan tiket. Gangguan ditangani cepat.') %></p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-shield-check"></i></div><h3><%= t('landing.f4.title','Koneksi Stabil') %></h3><p><%= t('landing.f4.desc','Latensi rendah dan jitter kecil untuk gaming, video call, dan semua aktivitas daring.') %></p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-router"></i></div><h3><%= t('landing.f1.title','Perangkat Modern') %></h3><p><%= t('landing.f1.desc','Perangkat ONU/ONT berkualitas yang dipantau langsung dari sistem kami.') %></p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-geo-alt"></i></div><h3><%= t('landing.f5.title','Jangkauan Luas') %></h3><p><%= t('landing.f5.desc','Terus memperluas jaringan ke lebih banyak titik di wilayah layanan kami.') %></p></div>
</div>
</div>
</section>
<!-- ═══ PAKET & HARGA ═══ -->
<section class="section" id="paket">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-tags"></i> <%= t("landing.nav.packages_full","Paket & Harga") %></span>
<h2><%= t('landing.pkg.title','Pilih paket sesuai kebutuhan') %></h2>
<p><%= t('landing.pkg.sub','Harga jujur per bulan. Semua paket sudah termasuk perangkat dan dukungan teknis.') %></p>
</div>
<div class="pricing-grid">
<% packages.forEach(function(p, i){ %>
<div class="price-card reveal <%= i === featuredIndex ? 'featured' : '' %>">
<% if (i === featuredIndex) { %><span class="tag"><%= t('landing.pkg.popular','Terpopuler') %></span><% } %>
<h3><%= p.name %></h3>
<div class="speed"><%= p.speedText %> <small>Mbps</small></div>
<div class="price"><b>Rp <%= p.priceText %></b> / <%= t('landing.pkg.permonth','bulan') %></div>
<ul>
<li><i class="bi bi-check-circle-fill"></i> <%= t('landing.pkg.symmetric','Kecepatan simetris') %> <%= p.speedText %> Mbps</li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('landing.pkg.f_unlimited','Tanpa batas kuota') %></li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('landing.pkg.device','Perangkat ONU/ONT disediakan') %></li>
<% if (p.description) { %><li><i class="bi bi-check-circle-fill"></i> <%= p.description %></li><% } %>
</ul>
<a class="lbtn <%= i === featuredIndex ? 'lbtn-primary' : 'lbtn-ghost' %> lbtn-block" href="/customer/register?package=<%= p.id %>"><%= t('landing.pkg.choose','Pilih Paket') %></a>
</div>
<% }); %>
<% if (!packages.length) { %>
<div class="card-surface" style="padding:26px;grid-column:1/-1;text-align:center">
<p class="muted"><%= t('landing.pkg.empty','Daftar paket belum tersedia. Silakan hubungi kami untuk informasi.') %></p>
</div>
<% } %>
</div>
</div>
</section>
<!-- ═══ CARA DAFTAR ═══ -->
<section class="section" id="cara-daftar">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-signpost-split"></i> <%= t('landing.nav.how','Cara Daftar') %></span>
<h2><%= t('landing.how.title','Berlangganan cuma 3 langkah') %></h2>
<p><%= t('landing.cta.note','Proses cepat, tim kami akan menghubungi Anda setelah pendaftaran.') %></p>
</div>
<div class="steps">
<div class="step reveal"><div class="num">1</div><h3><%= t('landing.how.s1t2','Isi Formulir') %></h3><p><%= t('landing.how.s1d','Daftar online lewat halaman pendaftaran. Cukup nama, nomor WhatsApp, dan alamat.') %></p></div>
<div class="step reveal"><div class="num">2</div><h3><%= t('landing.how.s2t','Verifikasi & Survei') %></h3><p><%= t('landing.how.s2d','Tim kami memverifikasi ketersediaan jaringan di lokasi Anda dan menyepakati jadwal pasang.') %></p></div>
<div class="step reveal"><div class="num">3</div><h3><%= t('landing.pkg.install','Pemasangan') %></h3><p><%= t('landing.how.s3d','Teknisi memasang perangkat dan mengaktifkan layanan. Internet siap digunakan.') %></p></div>
</div>
<div class="cta-row reveal" style="justify-content:center;margin-top:28px">
<a class="lbtn lbtn-primary" href="/customer/register"><i class="bi bi-person-plus-fill"></i> <%= t('landing.how.start','Mulai Daftar Sekarang') %></a>
<% if (waLink) { %><a class="lbtn lbtn-ghost" href="<%= waLink %>" target="_blank" rel="noopener"><i class="bi bi-whatsapp"></i> <%= t('landing.ads.wa','Tanya via WhatsApp') %></a><% } %>
</div>
</div>
</section>
<!-- ═══ AREA ═══ -->
<section class="section" id="area">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-map"></i> <%= t('landing.hero.stat_areas','Area Layanan') %></span>
<h2><%= t('landing.area.title','Jangkauan jaringan kami') %></h2>
<p><%= t('landing.area.sub','Layanan kami mencakup beberapa wilayah. Hubungi kami untuk memastikan ketersediaan di lokasi Anda.') %></p>
</div>
<div class="area-wrap">
<div class="card-surface reveal" style="padding:22px">
<h3 style="font-size:15px;margin-bottom:14px"><i class="bi bi-geo-alt-fill" style="color:var(--primary)"></i> <%= t('landing.area.covered','Wilayah terjangkau') %></h3>
<div class="area-list">
<% areas.forEach(function(a,i){ %><span class="chip" style="--i:<%= i %>"><i class="bi bi-check2"></i> <%= a %></span><% }); %>
<% if (!areas.length) { %><span class="muted"><%= t('landing.area.empty','Informasi area belum tersedia.') %></span><% } %>
</div>
<% if (settings.company_address) { %>
<div style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border);font-size:13.5px;color:var(--muted)">
<i class="bi bi-building" style="color:var(--primary)"></i> <%= settings.company_address %>
</div>
<% } %>
</div>
<div class="card-surface reveal" style="padding:8px">
<div id="coverage-map" data-lat="<%= settings.office_lat || '' %>" data-lng="<%= settings.office_lng || '' %>">
<div class="map-fallback"><div><i class="bi bi-map" style="font-size:28px;opacity:.5"></i><p style="margin-top:8px"><%= t('landing.area.loading','Memuat peta…') %></p></div></div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ TESTIMONI ═══ -->
<section class="section" id="testimoni">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-chat-quote"></i> <%= t('landing.testi.eyebrow','Testimoni') %></span>
<h2><%= t('landing.testi.title','Kata pelanggan kami') %></h2>
<p><%= t('landing.testi.sub','Cerita nyata dari pelanggan yang memakai layanan') %> <%= brand %>.</p>
</div>
<div class="testi-grid">
<% testimonials.forEach(function(t){ %>
<div class="testi reveal">
<div class="stars"><i class="bi bi-star-fill"></i><i class="bi bi-star-fill"></i><i class="bi bi-star-fill"></i><i class="bi bi-star-fill"></i><i class="bi bi-star-fill"></i></div>
<p>"<%= t.text %>"</p>
<div class="who">
<div class="av"><%= t.name.charAt(0).toUpperCase() %></div>
<div><b><%= t.name %></b><span><%= t.role %></span></div>
</div>
</div>
<% }); %>
</div>
</div>
</section>
<!-- ═══ FAQ ═══ -->
<section class="section" id="faq">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-patch-question"></i> <%= t('landing.nav.faq','FAQ') %></span>
<h2><%= t('landing.faq.title','Pertanyaan yang sering diajukan') %></h2>
</div>
<div class="faq">
<details class="reveal"><summary><%= t('landing.faq.q3','Berapa lama proses pemasangan?') %><i class="bi bi-chevron-down chev"></i></summary><div class="ans"><%= t('landing.faq.a3','Setelah pendaftaran diverifikasi dan lokasi Anda terjangkau jaringan, pemasangan umumnya dapat dijadwalkan dalam 1–3 hari kerja.') %></div></details>
<details class="reveal"><summary><%= t('landing.faq.q1','Apakah ada biaya pemasangan?') %><i class="bi bi-chevron-down chev"></i></summary><div class="ans"><%= t('landing.faq.a1','Biaya pemasangan serta ketentuan lainnya akan dijelaskan tim kami saat verifikasi. Harga paket bulanan bersifat transparan tanpa biaya tersembunyi.') %></div></details>
<details class="reveal"><summary><%= t('landing.faq.q5','Apakah kecepatan simetris (upload = download)?') %><i class="bi bi-chevron-down chev"></i></summary><div class="ans"><%= t('landing.faq.a5','Ya. Jaringan fiber optic kami menyediakan kecepatan simetris sehingga unggah dan unduh sama cepat, cocok untuk meeting dan unggah konten.') %></div></details>
<details class="reveal"><summary><%= t('landing.faq.q4','Bagaimana jika ada gangguan?') %><i class="bi bi-chevron-down chev"></i></summary><div class="ans"><%= t('landing.faq.a4','Hubungi tim teknis via WhatsApp atau buat tiket dari portal pelanggan. Kami memantau jaringan 24 jam dan menangani gangguan secepat mungkin.') %></div></details>
<details class="reveal"><summary><%= t('landing.faq.q2','Bagaimana cara cek tagihan?') %><i class="bi bi-chevron-down chev"></i></summary><div class="ans"><%= t('landing.faq.a2','Anda bisa mengecek tagihan tanpa login melalui halaman Cek Tagihan, atau masuk ke portal pelanggan untuk melihat riwayat dan melakukan pembayaran.') %></div></details>
</div>
</div>
</section>
<!-- ═══ CTA BAND ═══ -->
<section class="section" style="padding-top:0">
<div class="container">
<div class="cta-band reveal">
<h2><%= t('landing.cta.title','Siap beralih ke internet fiber yang lebih baik?') %></h2>
<p><%= t('landing.cta.sub','Daftar sekarang atau tanyakan ketersediaan jaringan di lokasi Anda. Tim kami siap membantu.') %></p>
<div class="cta-row">
<a class="lbtn lbtn-primary" href="/customer/register"><i class="bi bi-person-plus-fill"></i> <%= t('landing.hero.subscribe','Daftar Berlangganan') %></a>
<% if (waLink) { %><a class="lbtn lbtn-wa" href="<%= waLink %>" target="_blank" rel="noopener"><i class="bi bi-whatsapp"></i> <%= t('landing.hero.wa','Chat WhatsApp') %></a><% } %>
<a class="lbtn lbtn-ghost" href="/customer/login"><i class="bi bi-box-arrow-in-right"></i> <%= t('landing.nav.cust_login','Masuk Pelanggan') %></a>
</div>
</div>
</div>
</section>
<!-- ═══ IKLAN / SPONSOR (COMING SOON) ═══ -->
<section class="section" id="iklan" style="padding-top:0">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-megaphone"></i> <%= t('landing.ads.eyebrow','Iklan & Sponsor') %></span>
<h2><%= (promos && promos.length) ? 'Promo & penawaran' : 'Ruang promosi mitra' %></h2>
</div>
<% if (promos && promos.length) { %>
<div class="promo-grid">
<% promos.forEach(function(p){ %>
<% if (p.url) { %><a class="promo-card reveal" href="<%= p.url %>"<% if (Number(p.open_in_new_tab) === 1) { %> target="_blank" rel="noopener"<% } %>><% } else { %><div class="promo-card reveal"><% } %>
<img src="<%= p.image_path %>" alt="<%= p.title || 'Promo' %>" loading="lazy">
<% if (p.title || p.description) { %>
<div class="promo-cap">
<% if (p.title) { %><b><%= p.title %></b><% } %>
<% if (p.description) { %><span><%= p.description %></span><% } %>
</div>
<% } %>
<% if (p.url) { %></a><% } else { %></div><% } %>
<% }); %>
</div>
<% } else { %>
<div class="adsbox reveal">
<span class="badge-soon"><i class="bi bi-hourglass-split"></i> <%= t('landing.coming_soon','Coming Soon') %></span>
<h3><%= t('landing.ads.title','Halaman Iklan Segera Hadir') %></h3>
<p><%= t('landing.ads.desc','Slot promosi untuk mitra dan pelaku usaha lokal sedang disiapkan. Ingin memasang iklan Anda di sini? Hubungi kami untuk informasi lebih lanjut.') %></p>
<% if (waLink) { %>
<div class="cta-row" style="justify-content:center;margin-top:20px">
<a class="lbtn lbtn-ghost" href="<%= waLink %>" target="_blank" rel="noopener"><i class="bi bi-whatsapp"></i> <%= t('landing.ads.ask','Tanya Slot Iklan') %></a>
</div>
<% } %>
</div>
<% } %>
</div>
</section>
<!-- ═══ SERVICE NOTICE ═══ -->
<% if (typeof serviceNotice !== 'undefined' && serviceNotice && serviceNotice.active) { %>
<section class="container" style="padding-top:28px;padding-bottom:0"><%- include('partials/service-notice') %></section>
<% } %>
<!-- ═══ FOOTER ═══ -->
<footer class="site-footer">
<div class="container">
<div class="foot-grid">
<div>
<a class="brand" href="/" style="margin-bottom:14px">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<p class="muted" style="font-size:14px;max-width:40ch"><%= t('landing.footer.tagline','Micro Internet Provider dan Server Platform.') %></p>
<p class="muted" style="font-size:13px;margin-top:8px;max-width:40ch">Internet Freedom — We Are Open-Source.</p>
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
</div>
<div>
<h4><%= t('landing.footer.nav','Navigasi') %></h4>
<div class="foot-links">
<a href="#keunggulan"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="#paket"><%= t("landing.nav.packages_full","Paket & Harga") %></a>
<a href="#cara-daftar"><%= t('landing.nav.how','Cara Daftar') %></a>
<a href="#area"><%= t('landing.hero.stat_areas','Area Layanan') %></a>
<a href="#faq"><%= t('landing.nav.faq','FAQ') %></a>
<% if (_upt) { %><a href="<%= _upt %>" target="_blank" rel="noopener"><%= t('landing.footer.server_status','Status Server') %></a><% } %>
</div>
</div>
<% if (projectUrl) { %>
<div>
<h4><%= t('landing.footer.opensource','Open Source') %></h4>
<div class="foot-links">
<a href="<%= projectUrl %>" target="_blank" rel="noopener"><i class="bi bi-github"></i> <%= t('landing.footer.repo','Repositori') %></a>
<a href="<%= projectUrl %>/issues/new/choose" target="_blank" rel="noopener"><i class="bi bi-bug"></i> <%= t('landing.footer.issues','Laporkan Masalah / Form') %></a>
<a href="<%= projectUrl %>/wiki" target="_blank" rel="noopener"><i class="bi bi-journal-text"></i> <%= t('landing.footer.wiki','Wiki & Dokumentasi') %></a>
<a href="<%= projectUrl %>/blob/main/LICENSE" target="_blank" rel="noopener"><i class="bi bi-shield-check"></i> <%= t('landing.footer.license','Lisensi ISC') %></a>
</div>
</div>
<% } %>
<div>
<h4><%= t('landing.footer.contact','Kontak') %></h4>
<div class="foot-contact">
<% if (settings.company_address) { %><div class="ci"><i class="bi bi-geo-alt-fill"></i><span><%= settings.company_address %></span></div><% } %>
<% if (settings.company_phone) { %><div class="ci"><i class="bi bi-telephone-fill"></i><span><%= settings.company_phone %></span></div><% } %>
<% if (settings.company_email) { %><div class="ci"><i class="bi bi-envelope-fill"></i><span><%= settings.company_email %></span></div><% } %>
<div class="ci"><i class="bi bi-box-arrow-in-right"></i><a href="/customer/login"><%= t('landing.footer.portal','Portal Pelanggan') %></a></div>
<div class="ci"><i class="bi bi-receipt"></i><a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a></div>
</div>
</div>
</div>
<div class="foot-bottom">
<span>&copy; <span id="year"></span> <%= brand %>. Seluruh hak cipta dilindungi.</span>
<span class="mono">OPEN-ISP v<%= (typeof appVersion !== 'undefined' ? appVersion : '0.5') %> (<%= (typeof appVersionName !== 'undefined' ? appVersionName : 'pickle-lime') %>) · ISC · oxpoe</span>
</div>
</div>
</footer>
<script>
// Tema
(function(){
var saved = localStorage.getItem('landing-theme') || 'dark';
document.documentElement.setAttribute('data-theme', saved);
})();
function toggleTheme(){
var el = document.documentElement;
var t = el.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
el.setAttribute('data-theme', t);
localStorage.setItem('landing-theme', t);
}
// Menu mobile
function toggleMenu(){ document.getElementById('mobileMenu').classList.toggle('open'); }
// Tahun
document.getElementById('year').textContent = new Date().getFullYear();
// Reveal on scroll
(function(){
var els = document.querySelectorAll('.reveal');
if(!('IntersectionObserver' in window)){ els.forEach(function(e){e.classList.add('in')}); return; }
var io = new IntersectionObserver(function(entries){
entries.forEach(function(en){ if(en.isIntersecting){ en.target.classList.add('in'); io.unobserve(en.target); } });
}, {threshold:0.12, rootMargin:'0px 0px -40px 0px'});
els.forEach(function(e){ io.observe(e); });
})();
// Header bayangan saat discroll
(function(){
var h = document.querySelector('.site-header');
function onScroll(){ h.classList.toggle('scrolled', window.scrollY > 8); }
window.addEventListener('scroll', onScroll, {passive:true}); onScroll();
})();
// Count-up statistik hero
(function(){
var els = document.querySelectorAll('.hero-stats b[data-count]');
Array.prototype.forEach.call(els, function(el){
var target = parseInt(el.dataset.count, 10) || 0;
var suffix = el.dataset.suffix || '';
if(!('IntersectionObserver' in window)){ el.textContent = target + suffix; return; }
var io = new IntersectionObserver(function(en){
if(!en[0].isIntersecting) return; io.disconnect();
var start = performance.now(), dur = 1100;
function step(t){
var p = Math.min(1, (t - start) / dur);
var v = Math.round(target * (1 - Math.pow(1 - p, 3)));
el.textContent = v + suffix;
if(p < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}, {threshold:0.4});
io.observe(el);
});
})();
// Peta Leaflet — dimuat hanya ketika bagian peta terlihat (hemat beban awal)
(function(){
var el = document.getElementById('coverage-map'); if(!el) return;
var lat = parseFloat(el.dataset.lat), lng = parseFloat(el.dataset.lng);
if(isNaN(lat) || isNaN(lng)) { el.innerHTML = '<div class="map-fallback"><div><i class="bi bi-geo-alt" style="font-size:28px;opacity:.5"></i><p style="margin-top:8px"><%= t('landing.map.no_office','Lokasi kantor belum diatur.') %></p></div></div>'; return; }
var loaded = false;
function load(){
if(loaded) return; loaded = true;
var css = document.createElement('link'); css.rel='stylesheet'; css.href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(css);
var js = document.createElement('script'); js.src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
js.onload = function(){
var map = L.map('coverage-map', {scrollWheelZoom:false, attributionControl:true}).setView([lat,lng], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {maxZoom:19, attribution:'&copy; OpenStreetMap'}).addTo(map);
L.circleMarker([lat,lng], {radius:9, color:'#ADFF2F', fillColor:'#ADFF2F', fillOpacity:.9, weight:2}).addTo(map)
.bindPopup('<b><%= (settings.company_header || "OPEN-ISP") %> HQ</b><br>Kantor Layanan').openPopup();
setTimeout(function(){ map.invalidateSize(); }, 200);
};
document.body.appendChild(js);
}
if('IntersectionObserver' in window){
var io = new IntersectionObserver(function(en){ if(en[0].isIntersecting){ load(); io.disconnect(); } }, {rootMargin:'200px'});
io.observe(el);
} else { load(); }
})();
</script>
</body>
</html>
+307
View File
@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= settings.company_header || 'OPEN-ISP' %> — <%= t('customer.login_title', 'Portal Pelanggan') %></title>
<meta name="description" content="Portal pelanggan internet fiber optic — masuk, cek tagihan, bayar, dan kelola WiFi Anda.">
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');</script>
<script>
// Mode aplikasi (PWA/standalone) → tandai <html class="is-pwa">
(function(){
try {
var standalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true;
var p = new URLSearchParams(location.search);
if (standalone || p.get('source') === 'pwa') { document.documentElement.classList.add('is-pwa'); }
} catch (e) {}
})();
</script>
</head>
<body>
<!-- ═══ HEADER ═══ -->
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="Beranda">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<nav class="nav" aria-label="Navigasi">
<a href="#packages">Paket</a>
<a href="#features"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a>
<a href="/customer/voucher"><%= t('login.voucher_nav','Voucher') %></a>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener">Status</a><% } %>
</nav>
<div class="header-actions">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill" id="themeIcon"></i></button>
<button class="lbtn lbtn-primary lbtn-sm" id="btnInstall" style="display:none" onclick="triggerPwaInstall()"><i class="bi bi-download"></i> Instal</button>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/register"><i class="bi bi-person-plus"></i> <%= t('landing.nav.register','Daftar') %></a>
<button class="icon-btn hamburger" onclick="toggleMenu()" aria-label="Menu"><i class="bi bi-list"></i></button>
</div>
</div>
<div class="mobile-menu" id="mobileMenu">
<div class="container inner">
<a href="#packages" onclick="toggleMenu()"><%= t('landing.nav.packages_full','Paket & Harga') %></a>
<a href="#features" onclick="toggleMenu()"><%= t('landing.nav.features','Keunggulan') %></a>
<a href="/customer/check-billing" onclick="toggleMenu()"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
<a href="/customer/voucher" onclick="toggleMenu()"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a>
<a href="/customer/register" onclick="toggleMenu()"><i class="bi bi-person-plus"></i> <%= t('login.register_new','Daftar Baru') %></a>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %>
</div>
</div>
</header>
<!-- ═══ HERO + LOGIN ═══ -->
<section class="auth-hero">
<div class="container auth-grid">
<div class="auth-copy reveal">
<span class="eyebrow"><i class="bi bi-shield-lock-fill"></i> <%= t('landing.footer.portal','Portal Pelanggan') %></span>
<h1><%= t('login.manage','Kelola layanan internet Anda') %> <span class="hl"><%= t('login.manage_hl','dalam satu tempat') %></span></h1>
<p class="lead"><%= t('login.manage_desc','Masuk untuk melihat tagihan, melakukan pembayaran, memantau koneksi, dan mengatur WiFi perangkat Anda.') %></p>
<ul class="auth-points">
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.chk_desc','Cek & bayar tagihan kapan saja') %></li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.mon_desc','Pantau trafik dan status koneksi') %></li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.wifi_desc','Ubah SSID & password WiFi') %></li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.rbt_desc','Reboot perangkat & buat laporan gangguan') %></li>
</ul>
<div class="auth-stats">
<div class="st"><b><%= (typeof packages !== 'undefined' ? packages.length : 0) %></b><span><%= t('landing.hero.stat_packages','Pilihan Paket') %></span></div>
<div class="st"><b>24/7</b><span><%= t('login.f4t','Dukungan') %></span></div>
<div class="st"><b>Fiber</b><span>Optic</span></div>
</div>
</div>
<!-- Kartu Login -->
<div class="auth-card reveal" id="login-section">
<div class="auth-logo"><img src="<%= brandLogo %>" alt="Logo" onerror="this.style.display='none'"></div>
<h2><%= t('customer.portal_title', 'Portal Pelanggan') %></h2>
<p class="sub"><%= settings.company_header || 'OPEN-ISP' %></p>
<form method="POST" action="/customer/login" style="margin-top:6px">
<div class="field">
<label for="phone"><i class="bi bi-person-badge"></i> <%= t('customer.id_tag', 'ID Pelanggan / Nomor WhatsApp') %></label>
<div class="wrap">
<i class="bi bi-person-badge lead-ic"></i>
<input type="text" class="inp" id="phone" name="phone"
placeholder="<%= t('customer.id_tag_placeholder', 'cth: SKY-48213 atau 08xxxxxxxxxx') %>"
oninput="this.value=this.value.toUpperCase()" autocapitalize="characters"
required autofocus autocomplete="username">
</div>
</div>
<div class="hint-line"><i class="bi bi-info-circle"></i> <%= t('login.hint','Hanya ID Pelanggan (SKY-xxxxx) atau Nomor WhatsApp terdaftar') %></div>
<% if (settings.login_otp_enabled) { %>
<div class="auth-alert info"><i class="bi bi-whatsapp"></i> <%= t('customer.otp_sent_via_wa', 'Kode OTP akan dikirim via WhatsApp') %></div>
<% } %>
<button type="submit" class="lbtn lbtn-primary btn-auth">
<i class="bi bi-box-arrow-in-right"></i> <%= t('customer.sign_in', 'Masuk Ke Portal') %>
</button>
</form>
<% if (error) { %>
<div class="auth-alert err">
<i class="bi bi-exclamation-triangle"></i>
<%= error === 'Nomor HP tidak valid atau belum terdaftar.' ? 'ID Pelanggan / Nomor tidak valid atau belum terdaftar.' : error %>
</div>
<% } %>
<div class="quick-tools">
<a href="/customer/check-billing" class="qtool"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
<a href="/customer/voucher" class="qtool"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a>
</div>
<div class="auth-foot">
<%= t('customer.not_subscribed', 'Belum berlangganan?') %>
<a href="/customer/register"><%= t('customer.register_new', 'Daftar Pemasangan Baru') %> <i class="bi bi-arrow-right"></i></a>
</div>
</div>
</div>
</section>
<!-- ═══ PAKET ═══ -->
<section class="section" id="packages" style="padding-top:10px">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-tags"></i> <%= t('landing.hero.stat_packages','Pilihan Paket') %></span>
<h2><%= t('login.pkg_title','Paket & tarif layanan') %></h2>
<p><%= t('login.pkg_sub','Pilih kecepatan yang sesuai kebutuhan Anda, dari rumah hingga kantor.') %></p>
</div>
<% const pkgs = (typeof packages !== 'undefined' && Array.isArray(packages)) ? packages : []; %>
<% if (pkgs.length === 0) { %>
<div class="card-surface" style="padding:26px;text-align:center">
<i class="bi bi-info-circle" style="font-size:26px;color:var(--info)"></i>
<h3 style="margin:10px 0 4px"><%= t('login.pkg_empty_title','Belum ada paket dipublikasikan') %></h3>
<p class="muted"><%= t('login.pkg_empty','Silakan hubungi customer service untuk informasi paket terkini.') %></p>
</div>
<% } else { %>
<% const list = pkgs.slice().sort((a,b) => Number(a.price||0) - Number(b.price||0)); %>
<div class="pricing-grid">
<% list.forEach(function(p, index){ %>
<div class="price-card reveal <%= index === Math.floor(list.length/2) ? 'featured' : '' %>">
<% if (index === Math.floor(list.length/2)) { %><span class="tag">Terpopuler</span><% } %>
<h3><%= p.name %></h3>
<div class="speed">
<% if (Number(p.speed_down||0) > 0) { %>
<%= (Math.round(Number(p.speed_down||0)/1000) >= 1) ? Math.round(Number(p.speed_down||0)/1000) : Number(p.speed_down||0) %>
<% } else { %>—<% } %>
<small>Mbps</small>
</div>
<div class="price"><b>Rp <%= Number(p.price||0).toLocaleString('id-ID') %></b> / bulan</div>
<ul>
<li><i class="bi bi-check-circle-fill"></i> Unlimited tanpa FUP</li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.f1d','Fiber optic dedicated') %></li>
<li><i class="bi bi-check-circle-fill"></i> Gratis sewa modem WiFi</li>
<li><i class="bi bi-check-circle-fill"></i> <%= t('login.support247','Dukungan teknisi 24/7') %></li>
<% if (p.description) { %><li><i class="bi bi-info-circle-fill"></i> <%= p.description %></li><% } %>
</ul>
<a class="lbtn <%= index === Math.floor(list.length/2) ? 'lbtn-primary' : 'lbtn-ghost' %> lbtn-block" href="/customer/register?package=<%= encodeURIComponent(p.name) %>">
<i class="bi bi-cart-plus"></i> <%= t('login.pkg_choose','Pilih Paket Ini') %>
</a>
</div>
<% }); %>
</div>
<% } %>
</div>
</section>
<!-- ═══ KEUNGGULAN ═══ -->
<section class="section" id="features">
<div class="container">
<div class="section-head reveal">
<span class="eyebrow"><i class="bi bi-star"></i> <%= t('landing.nav.features','Keunggulan') %></span>
<h2>Layanan yang bisa Anda andalkan</h2>
</div>
<div class="feature-grid">
<div class="feature reveal"><div class="ficon"><i class="bi bi-speedometer2"></i></div><h3><%= t('login.f1t','Fiber Simetris') %></h3><p>Unggah dan unduh sama cepat untuk kerja, streaming, dan gaming.</p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-infinity"></i></div><h3><%= t('login.f2t','Tanpa Batas') %></h3><p><%= t('login.f2d','Nikmati internet tanpa FUP tersembunyi sepanjang bulan.') %></p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-headset"></i></div><h3>Support 24/7</h3><p><%= t('login.f4d','Tim teknis siap membantu melalui WhatsApp dan tiket.') %></p></div>
<div class="feature reveal"><div class="ficon"><i class="bi bi-shield-check"></i></div><h3><%= t('login.f3t','Stabil') %></h3><p><%= t('login.f3d','Latensi rendah dengan jaringan yang dipantau 24 jam.') %></p></div>
</div>
</div>
</section>
<!-- ═══ FOOTER ═══ -->
<footer class="site-footer">
<div class="container">
<div class="foot-grid">
<div>
<a class="brand" href="/" style="margin-bottom:14px">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<p class="muted" style="font-size:14px;max-width:40ch">Micro Internet Provider dan Server Platform.</p>
<p class="muted" style="font-size:13px;margin-top:8px">Internet Freedom — We Are Open-Source.</p>
<p class="muted" style="font-size:12.5px;margin-top:12px"><i class="bi bi-building"></i> PT Artha Mega Data</p>
</div>
<div>
<h4>Portal</h4>
<div class="foot-links">
<a href="/customer/check-billing"><%= t('login.check_full','Cek & Bayar Tagihan') %></a>
<a href="/customer/voucher"><%= t('login.voucher','Beli Voucher Wi-Fi') %></a>
<a href="/customer/register"><%= t('login.new_install','Pasang Baru') %></a>
<a href="/customer/dashboard"><%= t('login.dashboard','Dashboard Pelanggan') %></a>
</div>
</div>
<div>
<h4><%= t('login.info','Informasi') %></h4>
<div class="foot-links">
<a href="/customer/about"><%= t('customer.footer.about', 'Tentang Kami') %></a>
<a href="/customer/contact"><%= t('customer.footer.contact', 'Kontak Kami') %></a>
<a href="/customer/tos"><%= t('customer.footer.tos', 'Syarat & Ketentuan') %></a>
<a href="/customer/privacy"><%= t('customer.footer.privacy', 'Kebijakan Privasi') %></a>
<a href="/customer/tos#refund"><%= t('customer.footer.refund', 'Kebijakan Refund') %></a>
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener"><%= t('login.status_full','Status Server & Uptime') %></a><% } %>
</div>
</div>
</div>
<div class="foot-bottom">
<span>&copy; <span id="year"></span> <%= settings.company_header || 'OPEN-ISP' %>. Seluruh hak cipta dilindungi. <span style="opacity:.6">· v<%= appVersion %> (<%= appVersionName %>)</span></span>
<span class="mono">Fiber Optic Internet Service &middot; <a href="/blackhole" style="opacity:.32;color:inherit;text-decoration:none" title="Staff access">blackhole</a> &middot; <a href="#" onclick="hardResetApp();return false" style="opacity:.32;color:inherit;text-decoration:none" title="Bersihkan cache aplikasi">muat ulang aplikasi</a></span>
</div>
</div>
</footer>
<!-- PWA install banner -->
<div class="pwa-banner" id="pwaBanner">
<img src="<%= brandLogo %>" alt="icon" style="width:40px;height:40px;border-radius:10px">
<div class="txt"><b><%= t('login.install_app','Pasang aplikasi') %> <%= brandName %></b><span><%= t('login.pwa_desc','Akses lebih cepat dari layar utama') %></span></div>
<button class="lbtn lbtn-primary lbtn-sm" onclick="triggerPwaInstall()"><%= t('login.install','Pasang') %></button>
<button class="icon-btn" onclick="dismissPwaBanner()" aria-label="Tutup"><i class="bi bi-x-lg"></i></button>
</div>
<script>
// Tema
(function(){ var s = localStorage.getItem('landing-theme') || 'dark'; document.documentElement.setAttribute('data-theme', s); })();
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); el.setAttribute('data-bs-theme', t==='light'?'light':'dark'); localStorage.setItem('landing-theme',t); var ic=document.getElementById('themeIcon'); if(ic) ic.className = t==='light'?'bi bi-sun-fill':'bi bi-moon-stars-fill'; }
function toggleMenu(){ document.getElementById('mobileMenu').classList.toggle('open'); }
document.getElementById('year').textContent = new Date().getFullYear();
// Reveal
(function(){
var els=document.querySelectorAll('.reveal');
if(!('IntersectionObserver' in window)){ els.forEach(function(e){e.classList.add('in')}); return; }
var io=new IntersectionObserver(function(en){ en.forEach(function(x){ if(x.isIntersecting){ x.target.classList.add('in'); io.unobserve(x.target); } }); },{threshold:.12});
els.forEach(function(e){ io.observe(e); });
})();
// ── PWA install ──
var deferredPrompt = null;
var isStandalone = (window.matchMedia && window.matchMedia('(display-mode: standalone)').matches) || window.navigator.standalone === true;
window.addEventListener('beforeinstallprompt', function(e){
e.preventDefault(); deferredPrompt = e;
document.getElementById('btnInstall').style.display = 'inline-flex';
if(!localStorage.getItem('pwaBannerDismissed_v2')) document.getElementById('pwaBanner').classList.add('show');
});
function triggerPwaInstall(){
if(deferredPrompt){ deferredPrompt.prompt(); deferredPrompt.userChoice.then(function(){ deferredPrompt=null; }); }
else { alert('Buka menu browser (⋮) lalu pilih "Instal aplikasi" / "Tambahkan ke Layar utama".'); }
}
function dismissPwaBanner(){ localStorage.setItem('pwaBannerDismissed_v2','1'); document.getElementById('pwaBanner').classList.remove('show'); }
window.addEventListener('appinstalled', function(){ document.getElementById('pwaBanner').classList.remove('show'); });
// Service Worker (scope pelanggan) — update + auto-reload saat berganti
if('serviceWorker' in navigator){
var swRefreshing = false;
navigator.serviceWorker.addEventListener('controllerchange', function(){
if(swRefreshing) return; swRefreshing = true; window.location.reload();
});
navigator.serviceWorker.register('/sw.js', { scope: '/customer/' })
.then(function(reg){ try { reg.update(); } catch(e){} })
.catch(function(){});
}
</script>
<script>
/* Pembersih sekali per versi: hapus cache lama + paksa update Service Worker */
function hardResetApp(){
try {
if ('caches' in window) { caches.keys().then(function(ks){ ks.forEach(function(k){ try{ caches.delete(k); }catch(e){} }); }); }
if (navigator.serviceWorker) { navigator.serviceWorker.getRegistrations().then(function(rs){ rs.forEach(function(r){ try{ r.unregister(); }catch(e){} }); }); }
try { localStorage.removeItem('pwaVer'); } catch(e){}
} catch (e) {}
setTimeout(function(){ window.location.href = '/customer/login'; }, 600);
}
(function(){
try {
var V = 'customer-pwa-v5';
if (localStorage.getItem('pwaVer') !== V) {
if ('caches' in window) { caches.keys().then(function(ks){ ks.forEach(function(k){ try{ caches.delete(k); }catch(e){} }); }); }
if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then(function(rs){ rs.forEach(function(r){ try{ r.update(); }catch(e){} }); }); }
localStorage.setItem('pwaVer', V);
}
} catch (e) {}
})();
</script>
</body>
</html>
+114
View File
@@ -0,0 +1,114 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.otp_title', 'Verifikasi OTP') %> | <%= settings.company_header || 'OPEN-ISP' %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="Beranda">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<div class="header-actions" style="margin-left:auto">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill"></i></button>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/login"><i class="bi bi-arrow-left"></i> Login</a>
</div>
</div>
</header>
<main class="otp-wrap">
<div class="otp-card reveal">
<div class="otp-icon"><i class="bi bi-shield-lock-fill"></i></div>
<h1><%= t('customer.otp_title', 'Verifikasi OTP') %></h1>
<p class="sub">
<%= t('customer.otp_sent_to', 'Kode verifikasi telah dikirim ke WhatsApp Anda') %>:<br>
<strong style="color:var(--text)"><%= phone %></strong>
</p>
<% if (error) { %>
<div class="auth-alert err" style="justify-content:center"><i class="bi bi-exclamation-triangle"></i> <%= error %></div>
<% } %>
<form action="/customer/login-otp" method="POST">
<div class="field" style="text-align:left">
<label for="otp" style="justify-content:center"><i class="bi bi-123"></i> <%= t('customer.otp_label', 'Masukkan Kode OTP') %></label>
<input type="text" class="inp otp-input" id="otp" name="otp" maxlength="4" inputmode="numeric"
pattern="\d{4}" required autocomplete="one-time-code" autofocus placeholder="0000">
</div>
<button type="submit" class="lbtn lbtn-primary btn-auth"><i class="bi bi-check-circle-fill"></i> <%= t('customer.otp_verify', 'Verifikasi & Masuk') %></button>
</form>
<div class="otp-back">
<a href="/customer/login" class="muted"><i class="bi bi-arrow-left"></i> <%= t('customer.back_to_login', 'Kembali ke Login') %></a>
</div>
</div>
</main>
<script>
(function(){ var s = localStorage.getItem('landing-theme') || 'dark'; document.documentElement.setAttribute('data-theme', s); })();
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); el.setAttribute('data-bs-theme',t==='light'?'light':'dark'); el.classList.toggle('light-theme',t==='light'); localStorage.setItem('landing-theme',t); }
(function(){ var els=document.querySelectorAll('.reveal'); if(!('IntersectionObserver' in window)){els.forEach(function(e){e.classList.add('in')});return;} var io=new IntersectionObserver(function(en){en.forEach(function(x){if(x.isIntersecting){x.target.classList.add('in');io.unobserve(x.target);}});},{threshold:.1}); els.forEach(function(e){io.observe(e);}); })();
// Angka saja + auto-submit saat 4 digit (SEKALI saja, anti submit-ganda)
(function(){
var inp = document.getElementById('otp');
if(!inp) return;
var submitted = false;
function submitOnce(){
if (submitted) return;
submitted = true;
try {
var b = inp.form.querySelector('button[type=submit]');
if (b) { b.disabled = true; b.innerHTML = '<i class="bi bi-hourglass-split"></i> Memverifikasi…'; }
inp.readOnly = true;
} catch(e){}
inp.form.submit();
}
inp.addEventListener('input', function(){
inp.value = inp.value.replace(/\D/g,'').slice(0,4);
if(inp.value.length === 4) setTimeout(submitOnce, 90);
});
inp.form.addEventListener('submit', function(e){ if (submitted) { e.preventDefault(); return; } submitted = true; });
})();
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
<script>
/* Pembersih sekali per versi: hapus cache lama + paksa update Service Worker */
function hardResetApp(){
try {
if ('caches' in window) { caches.keys().then(function(ks){ ks.forEach(function(k){ try{ caches.delete(k); }catch(e){} }); }); }
if (navigator.serviceWorker) { navigator.serviceWorker.getRegistrations().then(function(rs){ rs.forEach(function(r){ try{ r.unregister(); }catch(e){} }); }); }
try { localStorage.removeItem('pwaVer'); } catch(e){}
} catch (e) {}
setTimeout(function(){ window.location.href = '/customer/login'; }, 600);
}
(function(){
try {
var V = 'customer-pwa-v5';
if (localStorage.getItem('pwaVer') !== V) {
if ('caches' in window) { caches.keys().then(function(ks){ ks.forEach(function(k){ try{ caches.delete(k); }catch(e){} }); }); }
if ('serviceWorker' in navigator) { navigator.serviceWorker.getRegistrations().then(function(rs){ rs.forEach(function(r){ try{ r.update(); }catch(e){} }); }); }
localStorage.setItem('pwaVer', V);
}
} catch (e) {}
})();
</script>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!-- views/partials/customer_bottom_nav.ejs -->
<% if (typeof isLoggedIn !== 'undefined' && isLoggedIn) { %>
<style>
.customer-bottom-nav {
position: fixed;
bottom: 0; left: 0; right: 0;
height: 68px;
background: var(--glass, rgba(17,22,34,0.95));
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border-top: 1px solid var(--border, rgba(242,245,241,0.1));
display: flex;
justify-content: space-around;
align-items: center;
z-index: 1050;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -12px 35px rgba(0, 0, 0, 0.45);
}
.customer-bottom-nav .nav-item {
display: flex; flex-direction: column; align-items: center; justify-content: center;
color: var(--muted, #8A96A6);
text-decoration: none; font-size: 11px; font-weight: 700;
transition: color .25s ease, transform .2s ease;
flex: 1; height: 100%; cursor: pointer; position: relative;
}
.customer-bottom-nav .nav-item i { font-size: 20px; margin-bottom: 2px; transition: transform .2s ease, color .2s ease; }
.customer-bottom-nav .nav-item:hover { color: var(--text, #F2F5F1); }
.customer-bottom-nav .nav-item.active { color: var(--primary, #ADFF2F); }
.customer-bottom-nav .nav-item.active i { transform: translateY(-2px); color: var(--primary, #ADFF2F); }
.customer-bottom-nav .nav-item.active::after {
content: ''; position: absolute; bottom: 6px; width: 5px; height: 5px;
background: var(--primary, #ADFF2F); border-radius: 50%; box-shadow: 0 0 8px var(--primary, #ADFF2F);
}
@media (min-width: 992px) {
.customer-bottom-nav { display: none; }
body { padding-bottom: 0 !important; }
}
</style>
<nav class="customer-bottom-nav">
<a href="/customer/dashboard#home-section" class="nav-item <%= activeNav === 'home' ? 'active' : '' %>">
<i class="bi bi-house-door-fill"></i><span><%= t('customer.nav.home','Beranda') %></span>
</a>
<div class="nav-item" onclick="triggerEditSsidNav()" title="<%= t('customer.nav.edit_wifi_name','Ubah Nama WiFi') %>">
<i class="bi bi-wifi"></i><span><%= t('customer.nav.wifi_name','Nama WiFi') %></span>
</div>
<div class="nav-item" onclick="triggerEditPasswordNav()" title="<%= t('customer.nav.edit_wifi_pass','Ubah Sandi WiFi') %>">
<i class="bi bi-key-fill"></i><span><%= t('customer.nav.wifi_pass','Sandi WiFi') %></span>
</div>
<a href="/customer/dashboard#billing-section" class="nav-item <%= activeNav === 'billing' ? 'active' : '' %>">
<i class="bi bi-receipt-cutoff"></i><span><%= t('customer.nav.billing','Tagihan') %></span>
</a>
<div class="nav-item" onclick="triggerTicketNav()" title="<%= t('customer.nav.help_ticket','Bantuan Keluhan') %>">
<i class="bi bi-headset"></i><span><%= t('customer.nav.help','Bantuan') %></span>
</div>
</nav>
<script>
function triggerEditSsidNav() {
if (typeof editSsid === 'function') {
var devId = (typeof customer !== 'undefined' && customer && customer.id) ? customer.id : '';
var ssid = (typeof customer !== 'undefined' && customer && customer.ssid) ? customer.ssid : '';
editSsid(devId, ssid);
} else { window.location.href = '/customer/dashboard#home-section'; }
}
function triggerEditPasswordNav() {
if (typeof editPassword === 'function') {
var devId = (typeof customer !== 'undefined' && customer && customer.id) ? customer.id : '';
editPassword(devId);
} else { window.location.href = '/customer/dashboard#home-section'; }
}
function triggerTicketNav() {
if (typeof window.showModal === 'function') { window.showModal('ticketModal'); }
else { window.location.href = '/customer/dashboard#ticket-section'; }
}
function updateActiveNav() {
var hash = window.location.hash;
document.querySelectorAll('.customer-bottom-nav .nav-item').forEach(function(item){
item.classList.remove('active');
if (item.getAttribute('href') && item.getAttribute('href').indexOf(hash) !== -1 && hash !== '') item.classList.add('active');
});
if (hash === '' || hash === '#home-section') {
var homeNav = document.querySelector('.customer-bottom-nav a[href*="home-section"]');
if (homeNav) homeNav.classList.add('active');
}
}
window.addEventListener('hashchange', updateActiveNav);
window.addEventListener('load', updateActiveNav);
</script>
<% } %>
+15
View File
@@ -0,0 +1,15 @@
<% if (typeof serviceNotice !== 'undefined' && serviceNotice && serviceNotice.active) { %>
<div class="service-notice" data-tone="<%= serviceNotice.tone || 'primary' %>">
<div class="sn-ico"><i class="bi <%= serviceNotice.mode === 'GANGGUAN_MASAL' ? 'bi-exclamation-triangle-fill' : 'bi-tools' %>"></i></div>
<div class="sn-main">
<div class="sn-title"><%= serviceNotice.label %></div>
<div class="sn-text"><%= serviceNotice.text %></div>
<% if (serviceNotice.estimateText) { %>
<div class="sn-eta">
<i class="bi bi-clock-history"></i> <%= t('common.estimated_done','Estimasi selesai') %>: <b><%= serviceNotice.estimateText %></b>
<% if (serviceNotice.startText) { %><span class="sn-since">· <%= t('common.since','sejak') %> <%= serviceNotice.startText %></span><% } %>
</div>
<% } %>
</div>
</div>
<% } %>
+2736
View File
File diff suppressed because it is too large. Load diff
+89
View File
@@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.pages.privacy.meta_title', 'Kebijakan Privasi') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/pages.css?v=3">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="Beranda"><img class="bmark" src="<%= brandLogo %>" alt="<%= company %>"><span class="bname"><%= brandName %></span></a>
<div class="header-actions" style="margin-left:auto">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill"></i></button>
<a class="lbtn lbtn-ghost lbtn-sm" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-box-arrow-in-right"></i> <%= isLoggedIn ? 'Dashboard' : 'Masuk' %></a>
</div>
</div>
</header>
<main class="container">
<section class="page-hero">
<h1><%= t('customer.pages.privacy.title', 'Kebijakan Privasi') %></h1>
<p><%= t('customer.pages.privacy.subtitle', 'Bagaimana kami menjaga dan melindungi data pribadi Anda.') %></p>
</section>
<div class="prose">
<p><%- String(t('customer.pages.privacy.p1', 'Di <strong>{company}</strong>, privasi pelanggan kami adalah prioritas utama. Dokumen Kebijakan Privasi ini menjelaskan jenis informasi pribadi yang kami kumpulkan dan bagaimana kami menggunakannya.')).replace('{company}', company) %></p>
<h4><i class="bi bi-database-check"></i> 1. <%= t('customer.pages.privacy.s1_title', 'Informasi yang Kami Kumpulkan') %></h4>
<p><%= t('customer.pages.privacy.s1_body', 'Kami mengumpulkan informasi terbatas yang diperlukan untuk penyediaan layanan, termasuk:') %></p>
<ul>
<li><strong>Data Akun & Kontak:</strong> Nama lengkap, nomor telepon/WhatsApp, dan alamat pemasangan layanan internet.</li>
<li><strong>Izin Akses Lokasi (GPS):</strong> Data titik koordinat digunakan saat absensi kehadiran karyawan berbasis radius kantor (Geofencing), pemetaan lokasi ODP/tiang jaringan, dan navigasi penagihan kolektor lapangan.</li>
<li><strong>Izin Kamera & Foto:</strong> Digunakan untuk pengambilan foto selfie presensi kerja ber-watermark Geo-Tagging, pemindaian QR Code voucher/QRIS, serta lampiran foto bukti kendala pada pelaporan tiket gangguan pelanggan.</li>
<li><strong>Izin Bluetooth:</strong> Digunakan untuk menghubungkan aplikasi ke printer thermal Bluetooth portabel saat pencetakan struk pembayaran oleh petugas kolektor dan kasir.</li>
<li><strong>Data Transaksi:</strong> Rincian tagihan bulanan dan status pembayaran melalui saluran payment gateway resmi.</li>
</ul>
<h4><i class="bi bi-gear-wide-connected"></i> 2. <%= t('customer.pages.privacy.s2_title', 'Penggunaan Informasi') %></h4>
<p><%= t('customer.pages.privacy.s2_body', 'Informasi yang kami kumpulkan digunakan untuk:') %></p>
<ul>
<li><%= t('customer.pages.privacy.s2_li1', 'Memproses pendaftaran dan aktivasi layanan internet Anda.') %></li>
<li><%= t('customer.pages.privacy.s2_li2', 'Mengirimkan informasi tagihan dan pengingat pembayaran otomatis.') %></li>
<li><%= t('customer.pages.privacy.s2_li3', 'Memberikan dukungan teknis dan menanggapi keluhan Anda.') %></li>
<li>Memvalidasi presensi kerja resmi karyawan dan penugasan lapangan.</li>
<li><%= t('customer.pages.privacy.s2_li4', 'Meningkatkan kualitas jaringan dan pengalaman pengguna.') %></li>
</ul>
<h4><i class="bi bi-shield-check"></i> 3. <%= t('customer.pages.privacy.s3_title', 'Keamanan Data') %></h4>
<p><%= t('customer.pages.privacy.s3_body', 'Kami menerapkan standar keamanan teknis dan enkripsi HTTPS/TLS untuk melindungi data Anda dari akses yang tidak sah. Data sensitif seperti kata sandi WiFi dan detail login disimpan dengan enkripsi di server kami.') %></p>
<h4><i class="bi bi-people"></i> 4. <%= t('customer.pages.privacy.s4_title', 'Pihak Ketiga') %></h4>
<p><%= t('customer.pages.privacy.s4_body', 'Kami tidak menjual atau menyewakan data pribadi Anda kepada pihak ketiga mana pun. Kami hanya berbagi data transaksi dengan mitra payment gateway resmi (seperti Tripay/Xendit/Midtrans/Duitku) untuk memproses pembayaran Anda secara aman.') %></p>
<h4><i class="bi bi-person-x"></i> 5. Hak Pengguna & Penghapusan Data</h4>
<p>Pengguna berhak meminta informasi mengenai data pribadi yang tersimpan atau mengajukan permohonan penghapusan akun beserta riwayat datanya dengan menghubungi kontak customer support resmi kami.</p>
<h4><i class="bi bi-patch-check"></i> 6. <%= t('customer.pages.privacy.s5_title', 'Persetujuan') %></h4>
<p><%= t('customer.pages.privacy.s5_body', 'Dengan menggunakan layanan kami, Anda dengan ini menyetujui Kebijakan Privasi kami dan menyetujui ketentuan-ketentuannya.') %></p>
<div style="text-align:center;margin-top:30px">
<a class="lbtn lbtn-primary" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-arrow-left"></i> <%= t('common.back_to', 'Kembali ke') %> <%= isLoggedIn ? t('customer.dashboard_title', 'Dashboard Pelanggan') : t('common.portal', 'Portal') %></a>
</div>
</div>
<div class="page-foot">&copy; <%= new Date().getFullYear() %> <%= company %> • <%= t('customer.pages.privacy.footer_tagline', 'Keamanan Data Anda adalah Komitmen Kami') %></div>
</main>
<script>
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); try{localStorage.setItem('landing-theme',t);}catch(e){} }
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
<%- include('partials/customer_bottom_nav', { activeNav: 'none' }) %>
</body>
</html>
+226
View File
@@ -0,0 +1,226 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.check_billing.title', 'Cek Tagihan') %> | <%= settings.company_header || 'OPEN-ISP' %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<script>document.documentElement.classList.add('js');</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="<%= t('customer.aria_home', 'Beranda') %>">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<div class="header-actions" style="margin-left:auto;display:flex;align-items:center;gap:6px">
<button class="icon-btn" onclick="toggleTheme()" aria-label="<%= t('customer.aria_theme', 'Ganti tema') %>"><i class="bi bi-moon-stars-fill"></i></button>
<% (typeof availableLangs !== 'undefined' && availableLangs ? availableLangs : ['id','en']).forEach(function(L){ %>
<a href="/lang/<%= L %>" class="lbtn lbtn-ghost lbtn-sm" style="padding:4px 8px;<%= (lang === L) ? 'font-weight:800;color:var(--primary);opacity:1' : 'opacity:.65' %>"><%= String(L).toUpperCase() %></a>
<% }); %>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/login"><i class="bi bi-box-arrow-in-right"></i> <%= t('customer.header_login', 'Login') %></a>
</div>
</div>
</header>
<main class="bill-wrap">
<div class="bill-card reveal">
<div class="bill-head">
<div>
<h1><i class="bi bi-receipt" style="color:var(--primary)"></i> <%= t('customer.check_billing.title', 'Cek Tagihan') %></h1>
<div class="sub"><%= settings.company_header || 'OPEN-ISP' %> — <%= t('customer.check_billing.subtitle', 'tanpa perlu login') %></div>
</div>
</div>
<div class="bill-body">
<form method="GET" action="/customer/check-billing">
<div class="search-row">
<div class="field">
<label for="q"><i class="bi bi-search"></i> <%= t('customer.check_billing.search_label', 'Cari pelanggan') %></label>
<div class="wrap"><i class="bi bi-search lead-ic"></i>
<input class="inp" id="q" name="q" value="<%= query || '' %>" placeholder="<%= t('customer.check_billing.search_ph', 'Nama / No. WA / username PPPoE / ID') %>" required></div>
</div>
<button class="lbtn lbtn-primary" type="submit" style="height:46px"><i class="bi bi-search"></i> <%= t('customer.check_billing.search_btn', 'Cek') %></button>
</div>
<div class="form-note"><%= t('customer.check_billing.search_note', 'Bisa pakai: Nama pelanggan, Nomor WhatsApp, PPPoE Username, GenieACS Tag, atau ID pelanggan.') %></div>
</form>
<% if (error) { %><div class="auth-alert err" style="margin-top:16px"><i class="bi bi-exclamation-triangle"></i> <%= error %></div><% } %>
<% if (info) { %><div class="auth-alert ok" style="margin-top:16px"><i class="bi bi-check-circle"></i> <%= info %></div><% } %>
<% if (query && !customer && (!matches || matches.length === 0)) { %>
<div class="auth-alert warn" style="margin-top:16px"><i class="bi bi-info-circle"></i> <%= t('customer.check_billing.not_found', 'Pelanggan tidak ditemukan. Pastikan input sudah benar.') %></div>
<% } %>
<% if (!customer && matches && matches.length > 0) { %>
<div class="sec-title"><i class="bi bi-people"></i> <%= t('customer.check_billing.results', 'Hasil Pencarian') %></div>
<div class="bill-sub" style="margin-bottom:10px"><%= t('customer.check_billing.results_hint', 'Pilih pelanggan untuk melihat detail tagihan') %></div>
<div class="srch-wrap">
<table class="btbl">
<thead><tr><th><%= t('customer.check_billing.col_customer', 'Pelanggan') %></th><th class="num"><%= t('customer.check_billing.unpaid', 'Belum Bayar') %></th><th class="num"><%= t('customer.check_billing.col_total', 'Total') %></th><th></th></tr></thead>
<tbody>
<% matches.forEach(function(m){ %>
<tr>
<td>
<div style="font-weight:700"><%= m.customer_name %></div>
<div class="bill-sub">ID: <%= m.customer_id %><% if (m.customer_phone) { %> • WA: <%= m.customer_phone %><% } %></div>
</td>
<td class="num" style="font-weight:700"><%= Number(m.unpaid_count || 0) %></td>
<td class="num" style="font-weight:700">Rp <%= Number(m.total_amount || 0).toLocaleString('id-ID') %></td>
<td style="text-align:right"><a class="lbtn lbtn-ghost lbtn-sm" href="/customer/check-billing?q=<%= encodeURIComponent(String(m.customer_id)) %>"><%= t('customer.check_billing.view', 'Lihat') %></a></td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } %>
<% if (customer) { %>
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-top:18px">
<div>
<div style="font-weight:700;font-size:15px"><%= customer.name %></div>
<div class="bill-sub">ID: <%= customer.id %><% if (customer.phone) { %> • WA: <%= customer.phone %><% } %><% if (customer.package_name) { %> • Paket: <%= customer.package_name %><% } %></div>
</div>
<div>
<% const unpaidCount = unpaidInvoices ? unpaidInvoices.length : 0; %>
<% if (unpaidCount > 0) { %>
<span class="badge bd"><i class="bi bi-exclamation-circle"></i> <%= unpaidCount %> <%= t('customer.check_billing.unpaid', 'Belum Bayar') %></span>
<% } else { %>
<span class="badge bs"><i class="bi bi-check-circle"></i> <%= t('customer.check_billing.paid', 'Lunas') %></span>
<% } %>
</div>
</div>
<% const monthNames = ['','Jan','Feb','Mar','Apr','Mei','Jun','Jul','Agu','Sep','Okt','Nov','Des']; %>
<% if (!unpaidInvoices || unpaidInvoices.length === 0) { %>
<div class="auth-alert ok" style="margin-top:16px"><i class="bi bi-check-circle"></i> <%= t('customer.check_billing.all_paid', 'Semua tagihan sudah lunas.') %></div>
<% } else { %>
<div class="sec-title"><i class="bi bi-hourglass-split"></i> <%= t('customer.check_billing.unpaid_bills', 'Tagihan Belum Dibayar') %></div>
<div class="srch-wrap">
<table class="btbl">
<thead><tr><th><%= t('customer.check_billing.col_period', 'Periode') %></th><th class="num"><%= t('customer.check_billing.col_amount', 'Nominal') %></th><th class="num" style="width:70px"><%= t('customer.check_billing.col_select', 'Pilih') %></th></tr></thead>
<tbody>
<% unpaidInvoices.forEach(function(inv, idx){ %>
<tr>
<td>
<div style="font-weight:700"><%= monthNames[Number(inv.period_month)] %> <%= inv.period_year %></div>
<div class="bill-sub">INV-<%= inv.id %></div>
</td>
<td class="num" style="font-weight:700">Rp <%= Number(inv.amount || 0).toLocaleString('id-ID') %></td>
<td class="num">
<input class="inv-radio" type="radio" name="inv_select" value="<%= inv.id %>"
data-inv-id="<%= inv.id %>" data-amount="<%= Number(inv.amount || 0) %>"
data-token="<%= (invoiceTokens && invoiceTokens[String(inv.id)]) ? invoiceTokens[String(inv.id)] : '' %>"
<%= idx === 0 ? 'checked' : '' %> <%= idx === 0 ? 'required' : '' %>>
</td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<div style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:12px">
<div class="sec-title" style="margin:0"><i class="bi bi-credit-card"></i> <%= t('customer.check_billing.payment', 'Pembayaran') %></div>
<div class="bill-sub"><span id="pay_inv_text"></span> <span id="pay_amount_text"></span></div>
</div>
<form id="payForm" method="POST" action="/customer/public/payment/create/0" class="stack">
<input type="hidden" name="token" id="pay_token" value="">
<input type="hidden" name="method" id="pay_method" value="">
<input type="hidden" name="method" value="QRIS_STATIC">
<div class="pay-grid">
<div class="pay-item" style="cursor:default">
<div class="pay-icon"><i class="bi bi-wallet2"></i></div>
<div class="pay-info">
<span class="pay-name"><%= t('customer.check_billing.pay_method', 'QRIS / Transfer Bank / E-Wallet') %></span>
<span class="pay-sub"><span class="pay-chip"><%= t('customer.check_billing.pay_note', 'Metode dipilih di halaman pembayaran') %></span></span>
</div>
</div>
</div>
<label class="chk">
<input type="checkbox" value="1" id="tos_pay" name="tos" required>
<span><%= t('customer.tos_agree', 'Saya setuju dengan') %> <a href="/customer/tos" target="_blank" rel="noopener"><%= t('customer.tos_terms', 'Syarat &amp; Ketentuan (TOS)') %></a></span>
</label>
<button id="payBtn" class="lbtn lbtn-primary" type="submit" disabled><i class="bi bi-credit-card"></i> <%= t('customer.check_billing.pay', 'Bayar') %></button>
</form>
</div>
<script>
(function () {
var invRadios = Array.prototype.slice.call(document.querySelectorAll('.inv-radio'));
var payMethodRadios = Array.prototype.slice.call(document.querySelectorAll('input[name="payment_method"]'));
var payForm = document.getElementById('payForm');
var payToken = document.getElementById('pay_token');
var payMethod = document.getElementById('pay_method');
var payBtn = document.getElementById('payBtn');
var invText = document.getElementById('pay_inv_text');
var amtText = document.getElementById('pay_amount_text');
function updatePaymentForm() {
if (!payForm || !payToken || !payMethod || !payBtn) return;
var selectedInv = invRadios.filter(function(r){ return r.checked; })[0];
if (!selectedInv) { payBtn.disabled = true; return; }
var invId = String(selectedInv.dataset.invId || selectedInv.value || '').trim();
var token = String(selectedInv.dataset.token || '').trim();
var amount = Number(selectedInv.dataset.amount || 0) || 0;
payForm.action = '/customer/public/payment/create/' + encodeURIComponent(invId);
payToken.value = token;
payMethod.value = 'QRIS_STATIC';
if (invText) invText.textContent = invId ? ('INV-' + invId + ' • ') : '';
if (amtText) amtText.textContent = amount ? ('Rp ' + amount.toLocaleString('id-ID')) : '';
payBtn.disabled = !invId || !token;
}
invRadios.forEach(function(r){ r.addEventListener('change', updatePaymentForm); });
payMethodRadios.forEach(function(r){ r.addEventListener('change', updatePaymentForm); });
updatePaymentForm();
})();
</script>
<% } %>
<% const paidInvoices = (invoices || []).filter(function(i){ return i.status === 'paid'; }); %>
<% if (paidInvoices.length > 0) { %>
<div class="sec-title" style="margin-top:22px"><i class="bi bi-clock-history"></i> <%= t('customer.check_billing.history', 'Riwayat (Lunas)') %></div>
<div class="bill-sub" style="margin-bottom:8px"><%= t('customer.check_billing.showing', 'Menampilkan') %> <%= Math.min(paidInvoices.length, 10) %> <%= t('customer.check_billing.data_latest', 'data terbaru') %></div>
<div class="srch-wrap">
<table class="btbl">
<thead><tr><th><%= t('customer.check_billing.col_period', 'Periode') %></th><th class="num"><%= t('customer.check_billing.col_amount', 'Nominal') %></th><th><%= t('customer.check_billing.col_date', 'Tanggal') %></th></tr></thead>
<tbody>
<% paidInvoices.slice(0, 10).forEach(function(inv){ %>
<tr>
<td><div style="font-weight:700"><%= monthNames[Number(inv.period_month)] %> <%= inv.period_year %></div><div class="bill-sub">INV-<%= inv.id %></div></td>
<td class="num">Rp <%= Number(inv.amount || 0).toLocaleString('id-ID') %></td>
<td class="bill-sub"><%= inv.paid_at ? new Date(inv.paid_at).toLocaleString('id-ID') : '-' %></td>
</tr>
<% }); %>
</tbody>
</table>
</div>
<% } %>
<% } %>
<div class="bill-foot">&copy; <%= new Date().getFullYear() %> <%= settings.company_header || '' %></div>
</div>
</div>
</main>
<script>
(function(){ var s = localStorage.getItem('landing-theme') || 'dark'; document.documentElement.setAttribute('data-theme', s); })();
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); localStorage.setItem('landing-theme',t); }
(function(){ var els=document.querySelectorAll('.reveal'); if(!('IntersectionObserver' in window)){els.forEach(function(e){e.classList.add('in')});return;} var io=new IntersectionObserver(function(en){en.forEach(function(x){if(x.isIntersecting){x.target.classList.add('in');io.unobserve(x.target);}});},{threshold:.1}); els.forEach(function(e){io.observe(e);}); })();
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
</body>
</html>
+203
View File
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.public_voucher.title', 'Voucher Hotspot') %> | <%= settings.company_header || 'OPEN-ISP' %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<script>document.documentElement.classList.add('js');</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="<%= t('customer.aria_home', 'Beranda') %>">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<div class="header-actions" style="margin-left:auto;display:flex;align-items:center;gap:6px">
<button class="icon-btn" onclick="toggleTheme()" aria-label="<%= t('customer.aria_theme', 'Ganti tema') %>"><i class="bi bi-moon-stars-fill"></i></button>
<% (typeof availableLangs !== 'undefined' && availableLangs ? availableLangs : ['id','en']).forEach(function(L){ %>
<a href="/lang/<%= L %>" class="lbtn lbtn-ghost lbtn-sm" style="padding:4px 8px;<%= (lang === L) ? 'font-weight:800;color:var(--primary);opacity:1' : 'opacity:.65' %>"><%= String(L).toUpperCase() %></a>
<% }); %>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/login"><i class="bi bi-box-arrow-in-right"></i> <%= t('customer.header_login', 'Login') %></a>
</div>
</div>
</header>
<main class="bill-wrap">
<div class="bill-card reveal">
<div class="bill-head">
<div>
<h1><i class="bi bi-ticket-perforated" style="color:var(--primary)"></i> <%= t('customer.public_voucher.title', 'Voucher Hotspot') %></h1>
<div class="sub"><%= settings.company_header || 'OPEN-ISP' %> — <%= t('customer.public_voucher.subtitle', 'beli &amp; kirim ke WhatsApp') %></div>
</div>
</div>
<div class="bill-body">
<% if (error) { %><div class="auth-alert err"><i class="bi bi-exclamation-triangle"></i> <%= error %></div><% } %>
<% if (info) { %><div class="auth-alert ok"><i class="bi bi-check-circle"></i> <%= info %></div><% } %>
<% if (order) { %>
<div style="margin-bottom:18px">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap">
<div>
<div style="font-weight:700"><%= t('customer.public_voucher.order_status', 'Status Pesanan') %></div>
<div class="bill-sub">Order #<%= order.id %> • WA: <%= order.buyer_phone %></div>
</div>
<div>
<% const st = String(order.status || 'pending'); %>
<% if (st === 'fulfilled') { %><span class="badge bs"><i class="bi bi-check-circle"></i> <%= t('customer.public_voucher.st_done', 'Selesai') %></span>
<% } else if (st === 'paid') { %><span class="badge bw"><i class="bi bi-hourglass-split"></i> <%= t('customer.public_voucher.st_process', 'Diproses') %></span>
<% } else { %><span class="badge bm"><i class="bi bi-clock"></i> <%= t('customer.public_voucher.st_wait', 'Menunggu') %></span><% } %>
</div>
</div>
<div class="bill-sub" style="margin-top:8px">
<%= t('customer.public_voucher.pkg', 'Paket') %>: <span style="color:var(--text)"><%= order.profile_name %></span> •
<%= t('customer.public_voucher.duration', 'Durasi') %>: <span style="color:var(--text)"><%= order.validity || '-' %></span> •
<%= t('customer.public_voucher.price', 'Harga') %>: <span style="color:var(--text)">Rp <%= Number(order.price || 0).toLocaleString('id-ID') %></span>
</div>
<% if (String(order.status) === 'fulfilled' && order.voucher_code) { %>
<div class="voucher-code">
<div style="font-weight:700;margin-bottom:6px"><i class="bi bi-ticket-perforated" style="color:var(--primary)"></i> <%= t('customer.public_voucher.ready', 'Voucher Siap') %></div>
<div class="row"><span class="muted">User</span><b><%= order.voucher_code %></b></div>
<div class="row"><span class="muted">Pass</span><b><%= order.voucher_password %></b></div>
<% if (Number(order.wa_sent || 0) === 1) { %>
<div class="bill-sub" style="margin-top:6px"><%= t('customer.public_voucher.sent_wa', 'Voucher berhasil dikirim ke WhatsApp pembeli.') %></div>
<% } else if (order.wa_error) { %>
<div class="bill-sub" style="margin-top:6px;color:var(--warning)"><%= t('customer.public_voucher.wa_fail', 'WhatsApp gagal terkirim') %>: <%= order.wa_error %></div>
<div class="bill-sub"><%= t('customer.public_voucher.backup', 'Cadangan: simpan kode voucher yang tampil di halaman ini.') %></div>
<% } else { %>
<div class="bill-sub" style="margin-top:6px"><%= t('customer.public_voucher.fallback', 'Jika WhatsApp gagal terkirim, kode voucher tetap tampil di halaman ini.') %></div>
<% } %>
</div>
<% } else if (String(order.status) === 'paid') { %>
<div class="auth-alert info" style="margin-top:12px"><i class="bi bi-hourglass-split"></i> <%= t('customer.public_voucher.paid_processing', 'Pembayaran berhasil — voucher sedang dibuat di MikroTik. Halaman ini diperbarui otomatis.') %></div>
<% } else if (String(order.status) === 'pending' && (String(order.payment_gateway || '') === 'qris_static' || order.qris_amount_unique)) { %>
<% if (orderToken) { %>
<a class="lbtn lbtn-primary lbtn-block" style="margin-top:12px" href="/customer/voucher/qris/<%= order.id %>?t=<%= encodeURIComponent(orderToken) %>"><i class="bi bi-qr-code-scan"></i> <%= t('customer.public_voucher.open_qris', 'Buka QRIS Statis') %></a>
<% } else { %>
<div class="auth-alert warn" style="margin-top:12px"><%= t('customer.public_voucher.qris_na', 'Link QRIS tidak tersedia.') %></div>
<% } %>
<% } else if (String(order.status) === 'pending' && order.payment_link) { %>
<a class="lbtn lbtn-primary lbtn-block" style="margin-top:12px" href="<%= order.payment_link %>"><i class="bi bi-credit-card"></i> <%= t('customer.public_voucher.continue_pay', 'Lanjutkan Pembayaran') %></a>
<% } %>
</div>
<hr style="border:none;border-top:1px solid var(--border);margin:0 0 18px">
<% } %>
<form method="POST" action="/customer/public/voucher/create-payment" class="stack">
<div class="vform">
<div>
<span class="label-cap"><%= t('customer.public_voucher.pkg_label', 'Paket Voucher') %></span>
<% if (!profiles || profiles.length === 0) { %>
<div class="bill-sub"><%= t('customer.public_voucher.no_profile', 'Belum ada profile voucher (metadata Mikhmon) di MikroTik.') %></div>
<% } else { %>
<div class="voucher-grid">
<% (profiles || []).forEach(function(p, idx){ %>
<input class="voucher-radio" type="radio" name="profile_name" id="vprofile_<%= idx %>" value="<%= p.name %>" <%= idx === 0 ? 'required' : '' %>>
<label class="voucher-card" for="vprofile_<%= idx %>">
<div class="voucher-title"><span><%= p.name %></span><span class="voucher-price">Rp <%= Number(p.price||0).toLocaleString('id-ID') %></span></div>
<p class="voucher-meta"><i class="bi bi-hourglass-split"></i> <%= p.validity %></p>
</label>
<% }); %>
</div>
<% } %>
</div>
<div>
<span class="label-cap"><%= t('customer.public_voucher.pay_method', 'Metode Pembayaran') %></span>
<% const hasStaticQris = (String(settings.qris_static_qr_url || '').trim() || String(settings.qris_static_payload || '').trim()) && (settings.qris_static_enabled !== false && settings.qris_static_enabled !== 'false' && settings.qris_static_enabled !== 0 && settings.qris_static_enabled !== '0'); %>
<% if (true) { %>
<input type="hidden" name="method" value="QRIS_STATIC">
<div class="pay-grid">
<div class="pay-item" style="cursor:default">
<div class="pay-icon"><i class="bi bi-qr-code-scan"></i></div>
<div class="pay-info">
<span class="pay-name"><%= t('customer.check_billing.pay_method', 'QRIS / Transfer Bank / E-Wallet') %></span>
<span class="pay-sub"><span class="pay-chip"><%= t('customer.check_billing.pay_note', 'Metode dipilih di halaman pembayaran') %></span></span>
</div>
</div>
</div>
<% } else { %>
<div class="pay-grid">
<% if (hasStaticQris) { %>
<label>
<input class="pay-radio" type="radio" name="method" value="QRIS_STATIC" checked>
<div class="pay-item">
<div class="pay-icon"><i class="bi bi-qr-code-scan"></i></div>
<div class="pay-info"><span class="pay-name"><%= t('customer.public_voucher.qris_static', 'QRIS Statis (Scan)') %></span><span class="pay-sub"><span class="pay-chip"><%= t('customer.public_voucher.unique_nominal', 'Nominal unik') %></span></span></div>
</div>
</label>
<% } %>
<% paymentChannels.forEach(function(ch, idx){
const code = String(ch.code || '').toUpperCase();
const name = ch.name || code;
const group = String(ch.group || '').toLowerCase();
let icon = 'bi-wallet2';
const c = code.toLowerCase();
if (c.includes('qris')) icon = 'bi-qr-code-scan';
else if (c.includes('va') || group.includes('virtual') || c.includes('bank')) icon = 'bi-bank';
else if (c.includes('snap') || c.includes('xendit') || c.includes('duitku')) icon = 'bi-credit-card';
const feeFlat = Number(ch.total_fee && ch.total_fee.flat || 0);
%>
<label>
<input class="pay-radio" type="radio" name="method" value="<%= code %>" <%= (!hasStaticQris && idx === 0) ? 'checked' : '' %>>
<div class="pay-item">
<div class="pay-icon">
<% if (ch.icon_url) { %><img src="<%= ch.icon_url %>" alt="<%= name %>" class="pay-logo"><% } else { %><i class="bi <%= icon %>"></i><% } %>
</div>
<div class="pay-info">
<span class="pay-name"><%= name %></span>
<span class="pay-sub">
<% if (feeFlat > 0) { %><span class="pay-chip">Fee: Rp <%= feeFlat.toLocaleString('id-ID') %></span><% } else { %><span class="pay-chip"><%= t('customer.public_voucher.free', 'Gratis') %></span><% } %>
</span>
</div>
</div>
</label>
<% }); %>
</div>
<% } %>
</div>
</div>
<div>
<span class="label-cap"><%= t('customer.public_voucher.buyer_wa', 'Nomor WhatsApp Pembeli') %></span>
<input class="inp inp-plain" name="buyer_phone" placeholder="<%= t('customer.public_voucher.buyer_ph', 'Contoh: 08xxxxxxxxxx') %>" inputmode="tel" required>
<div class="form-note"><%= t('customer.public_voucher.buyer_note', 'Kode voucher akan dikirim ke nomor ini.') %></div>
</div>
<label class="chk">
<input type="checkbox" value="1" id="tos_voucher" name="tos" required>
<span><%= t('customer.tos_agree', 'Saya setuju dengan') %> <a href="/customer/tos" target="_blank" rel="noopener"><%= t('customer.tos_terms', 'Syarat &amp; Ketentuan (TOS)') %></a></span>
</label>
<button class="lbtn lbtn-primary" type="submit" <%= (!profiles || profiles.length === 0) ? 'disabled' : '' %>>
<i class="bi bi-credit-card"></i> <%= t('customer.public_voucher.pay_create', 'Bayar &amp; Buat Voucher') %>
</button>
</form>
<div class="bill-foot">&copy; <%= new Date().getFullYear() %> <%= settings.company_header || '' %></div>
</div>
</div>
</main>
<script>
(function(){ var s = localStorage.getItem('landing-theme') || 'dark'; document.documentElement.setAttribute('data-theme', s); })();
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); localStorage.setItem('landing-theme',t); }
(function(){ var els=document.querySelectorAll('.reveal'); if(!('IntersectionObserver' in window)){els.forEach(function(e){e.classList.add('in')});return;} var io=new IntersectionObserver(function(en){en.forEach(function(x){if(x.isIntersecting){x.target.classList.add('in');io.unobserve(x.target);}});},{threshold:.1}); els.forEach(function(e){io.observe(e);}); })();
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
<% if (order && (String(order.status) === 'paid' || String(order.status) === 'pending') && !order.voucher_code) { %>
<script>setTimeout(function(){ try { window.location.reload(); } catch(e){} }, 5000);</script>
<% } %>
</body>
</html>
+264
View File
@@ -0,0 +1,264 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Pembayaran | <%= settings.company_header || 'OPEN-ISP' %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/isolated.css?v=3">
<link rel="icon" href="/img/icons/icon-192.png">
<style>
.pay-tabs{display:flex;gap:8px;margin:0 0 14px;flex-wrap:wrap}
.pay-tab{flex:1 1 0;min-width:92px;padding:10px 12px;border:1px solid var(--border);background:var(--bg);color:inherit;border-radius:12px;font-weight:700;font-size:13px;cursor:pointer;display:flex;align-items:center;justify-content:center;gap:6px}
.pay-tab.active{background:var(--primary);color:#0A0D14;border-color:var(--primary)}
.pay-panel{display:none}
.pay-panel.active{display:block}
</style>
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
</head>
<body>
<main class="iso-wrap">
<div class="iso-card">
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:16px">
<div>
<div style="font-family:var(--font-display);font-weight:700;font-size:18px"><i class="bi bi-credit-card-2-front" style="color:var(--primary)"></i> Pembayaran Tagihan</div>
<div class="muted" style="font-size:12.5px"><%= settings.company_header || 'OPEN-ISP' %></div>
</div>
<% if (backUrl) { %>
<a class="iso-btn ghost" style="width:auto;padding:8px 14px;font-size:13px" href="<%= backUrl %>"><i class="bi bi-arrow-left"></i> Kembali</a>
<% } %>
</div>
<% if (error) { %>
<div class="auth-alert err"><i class="bi bi-exclamation-triangle"></i> <%= error %></div>
<% } else { %>
<% if (info) { %><div class="auth-alert ok"><i class="bi bi-check-circle"></i> <%= info %></div><% } %>
<%
const kindSafe = String(kind || 'invoice').toLowerCase();
const isVoucher = kindSafe === 'voucher';
const refPrefix = isVoucher ? 'VOUCHER' : 'INV';
const parseAccounts = function (txt) {
return String(txt || '').split('\n').map(function(l){ return l.trim(); }).filter(Boolean).map(function(l){
const p = l.split('|').map(function(x){ return String(x).trim(); });
return { name: p[0] || '', number: p[1] || '', holder: p[2] || '' };
}).filter(function(a){ return a.number; });
};
const bankEnabled = (settings.payment_bank_enabled !== false && settings.payment_bank_enabled !== 'false');
const ewalletEnabled = (settings.payment_ewallet_enabled !== false && settings.payment_ewallet_enabled !== 'false');
const bankAccounts = bankEnabled ? parseAccounts(settings.payment_bank_accounts) : [];
const ewalletAccounts = ewalletEnabled ? parseAccounts(settings.payment_ewallet_accounts) : [];
// Metode aktif default: QRIS bila QR tersedia, jika tidak maka bank / e-wallet.
const defaultMethod = qrisQrUrl ? 'qris' : (bankAccounts.length ? 'bank' : (ewalletAccounts.length ? 'ewallet' : 'qris'));
%>
<% if (helpText) { %>
<div class="auth-alert warn" style="margin-bottom:14px"><i class="bi bi-exclamation-triangle"></i> <%= helpText %></div>
<% } %>
<div style="margin-bottom:14px">
<div style="font-weight:700"><%= customerName || (isVoucher ? 'Pembeli Voucher' : 'Pelanggan') %></div>
<div class="muted mono" style="font-size:12.5px;margin-top:2px"><%= refPrefix %>-<%= invoiceId %><% if (periodText) { %> • <%= periodText %><% } %></div>
</div>
<!-- Tab metode pembayaran -->
<div class="pay-tabs" id="payTabs">
<button type="button" class="pay-tab <%= defaultMethod === 'qris' ? 'active' : '' %>" data-method="qris"><i class="bi bi-qr-code-scan"></i> QRIS</button>
<% if (bankAccounts.length) { %><button type="button" class="pay-tab <%= defaultMethod === 'bank' ? 'active' : '' %>" data-method="bank"><i class="bi bi-bank"></i> Transfer Bank</button><% } %>
<% if (ewalletAccounts.length) { %><button type="button" class="pay-tab <%= defaultMethod === 'ewallet' ? 'active' : '' %>" data-method="ewallet"><i class="bi bi-phone"></i> E-Wallet</button><% } %>
</div>
<!-- Nominal (tampil untuk semua metode) -->
<div class="iso-amt-box" style="text-align:center;margin-bottom:14px">
<div class="l">Nominal yang harus dibayar (WAJIB tepat)</div>
<div class="v">Rp <%= Number(amountUnique || 0).toLocaleString('id-ID') %></div>
<% if (uniqueCode) { %><div class="iso-unique">Kode unik <span class="mono"><%= String(uniqueCode).padStart(3, '0') %></span></div><% } %>
<div class="iso-actions" style="margin-top:12px">
<button class="iso-btn ghost" type="button" id="copyAmountBtn"><i class="bi bi-clipboard"></i> Salin Nominal</button>
</div>
</div>
<!-- PANEL: QRIS -->
<div class="pay-panel <%= defaultMethod === 'qris' ? 'active' : '' %>" data-panel="qris">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:16px;text-align:center">
<% if (qrisQrUrl) { %>
<div class="iso-qr" style="margin-top:0"><img id="qrisImage" src="<%= qrisQrUrl %>" alt="QRIS"></div>
<% } else { %>
<div class="auth-alert warn" style="justify-content:center">QRIS belum diatur oleh admin.</div>
<% } %>
<div class="iso-actions">
<% if (qrisQrUrl) { %><button class="iso-btn ghost" type="button" id="downloadQrBtn"><i class="bi bi-download"></i> Download QRIS</button><% } %>
</div>
<div class="iso-help">
Scan QRIS di atas (DANA / OVO / ShopeePay / LinkAja / GoPay, dll), lalu masukkan nominal persis seperti tertera.
Status lunas terverifikasi otomatis setelah pembayaran terdeteksi.
</div>
</div>
</div>
<!-- PANEL: Transfer Bank -->
<% if (bankAccounts.length) { %>
<div class="pay-panel <%= defaultMethod === 'bank' ? 'active' : '' %>" data-panel="bank">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:16px">
<div style="font-weight:700"><i class="bi bi-bank" style="color:var(--primary)"></i> Transfer Bank</div>
<div class="muted" style="font-size:12.5px;margin-top:3px">Transfer <b>tepat</b> sebesar <b>Rp <%= Number(amountUnique || 0).toLocaleString('id-ID') %></b> (sudah termasuk kode unik) ke salah satu rekening:</div>
<% bankAccounts.forEach(function(a){ %>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-top:8px">
<div>
<div style="font-weight:700;font-size:13px"><%= a.name %></div>
<div class="mono" style="font-size:15px"><%= a.number %></div>
<% if (a.holder) { %><div class="muted" style="font-size:11.5px">a.n. <%= a.holder %></div><% } %>
</div>
<button class="iso-btn ghost" style="width:auto;padding:8px 12px;font-size:12px" type="button" onclick="copyText('<%= String(a.number).replace(/'/g,'') %>')"><i class="bi bi-clipboard"></i> Salin</button>
</div>
<% }); %>
</div>
</div>
<% } %>
<!-- PANEL: E-Wallet -->
<% if (ewalletAccounts.length) { %>
<div class="pay-panel <%= defaultMethod === 'ewallet' ? 'active' : '' %>" data-panel="ewallet">
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:16px">
<div style="font-weight:700"><i class="bi bi-phone" style="color:var(--primary)"></i> E-Wallet</div>
<div class="muted" style="font-size:12.5px;margin-top:3px">Kirim <b>tepat</b> sebesar <b>Rp <%= Number(amountUnique || 0).toLocaleString('id-ID') %></b> ke salah satu e-wallet:</div>
<% ewalletAccounts.forEach(function(a){ %>
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-top:8px">
<div>
<div style="font-weight:700;font-size:13px"><%= a.name %></div>
<div class="mono" style="font-size:15px"><%= a.number %></div>
<% if (a.holder) { %><div class="muted" style="font-size:11.5px">a.n. <%= a.holder %></div><% } %>
</div>
<button class="iso-btn ghost" style="width:auto;padding:8px 12px;font-size:12px" type="button" onclick="copyText('<%= String(a.number).replace(/'/g,'') %>')"><i class="bi bi-clipboard"></i> Salin</button>
</div>
<% }); %>
</div>
</div>
<% } %>
<div class="iso-poll"><div class="spin-pulse"></div><span>Menunggu pembayaran… (Auto-Check)</span></div>
<div style="background:var(--bg);border:1px solid var(--border);border-radius:16px;padding:16px;margin-top:14px">
<div style="font-weight:700">Konfirmasi Pembayaran</div>
<div class="muted" style="font-size:12.5px;margin-top:3px">Jika pembayaran belum otomatis terdeteksi, kirim bukti transfer ke admin.</div>
<% const proofAction = String(proofActionUrl || `/customer/payment/proof/${invoiceId}`); %>
<form method="POST" action="<%= proofAction %>" enctype="multipart/form-data" style="margin-top:12px">
<% if (publicToken) { %><input type="hidden" name="token" value="<%= publicToken %>"><% } %>
<div class="field" style="margin-top:0">
<label for="proof"><i class="bi bi-cloud-upload"></i> Bukti Transfer</label>
<input class="inp inp-plain" id="proof" type="file" name="proof" accept="image/png,image/jpeg,image/jpg,image/webp" required style="padding:11px">
</div>
<button class="iso-btn primary" type="submit"><i class="bi bi-cloud-upload"></i> Upload Bukti</button>
</form>
<% if (proofUrl) { %>
<div class="muted" style="font-size:12px;margin-top:8px">Bukti tersimpan: <a href="<%= proofUrl %>" target="_blank" rel="noopener"><%= proofUrl %></a></div>
<% } %>
<%
const adminWa = String(adminWaDigits || '').trim();
const msgLines = [
isVoucher ? 'Konfirmasi pembayaran Voucher' : 'Konfirmasi pembayaran',
`${refPrefix}-${invoiceId}${periodText ? ' • ' + periodText : ''}`,
`Nama: ${customerName || (isVoucher ? 'Pembeli Voucher' : 'Pelanggan')}`,
`Nominal: Rp ${Number(amountUnique || 0).toLocaleString('id-ID')} (kode ${String(uniqueCode || 0).padStart(3, '0')})`,
proofUrl ? `Bukti: ${proofUrl}` : 'Bukti: (sudah diupload / akan dikirim)',
];
const waLink = adminWa ? `https://wa.me/${adminWa}?text=${encodeURIComponent(msgLines.join('\n'))}` : '';
%>
<% if (waLink) { %>
<a class="iso-btn wa iso-space" href="<%= waLink %>" target="_blank" rel="noopener"><i class="bi bi-whatsapp"></i> Kirim WhatsApp ke Admin</a>
<% } else { %>
<div class="auth-alert warn" style="margin-top:12px">Nomor admin WhatsApp belum diatur.</div>
<% } %>
</div>
<% } %>
</div>
</main>
<script>
(function () {
var amount = <%= Number(amountUnique || 0) || 0 %>;
var qrSrc = <%- JSON.stringify(String(qrisQrUrl || '')).split('<').join('\\u003c') %>;
var kindSafe = <%- JSON.stringify(String(kind || 'invoice').toLowerCase()).split('<').join('\\u003c') %>;
var refPrefix = kindSafe === 'voucher' ? 'VOUCHER' : 'INV';
var refId = <%= Number(invoiceId || 0) || 0 %>;
var copy = async function (text) {
try { if (navigator && navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(String(text)); return true; } } catch (e) {}
try {
var ta = document.createElement('textarea'); ta.value = String(text); ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.focus(); ta.select();
var ok = document.execCommand('copy'); document.body.removeChild(ta); return ok;
} catch (e) { return false; }
};
window.copyText = copy;
// Tab metode pembayaran
var tabs = document.querySelectorAll('.pay-tab');
var panels = document.querySelectorAll('.pay-panel');
function showMethod(m) {
for (var i = 0; i < tabs.length; i++) { tabs[i].classList.toggle('active', tabs[i].getAttribute('data-method') === m); }
for (var j = 0; j < panels.length; j++) { panels[j].classList.toggle('active', panels[j].getAttribute('data-panel') === m); }
}
for (var k = 0; k < tabs.length; k++) {
tabs[k].addEventListener('click', function () { showMethod(this.getAttribute('data-method')); });
}
// Buka tab sesuai #hash (mis. #bank / #ewallet / #qris)
var _h = (window.location.hash || '').replace('#', '').toLowerCase();
if (_h && document.querySelector('.pay-tab[data-method="' + _h + '"]')) { showMethod(_h); }
var aBtn = document.getElementById('copyAmountBtn');
if (aBtn) aBtn.addEventListener('click', async function () { if (!amount) return; await copy(amount); });
var dBtn = document.getElementById('downloadQrBtn');
if (dBtn) dBtn.addEventListener('click', async function () {
if (!qrSrc) return;
var fileName = 'QRIS-' + refPrefix + '-' + (refId || 'ref') + '.png';
try {
if (qrSrc.startsWith('data:image/')) { var a0 = document.createElement('a'); a0.href = qrSrc; a0.download = fileName; document.body.appendChild(a0); a0.click(); a0.remove(); return; }
var res = await fetch(qrSrc, { credentials: 'same-origin' });
if (!res.ok) throw new Error('Fetch failed');
var blob = await res.blob(); var url = URL.createObjectURL(blob);
var a = document.createElement('a'); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); a.remove();
setTimeout(function(){ try { URL.revokeObjectURL(url); } catch (e) {} }, 1200);
} catch (e) { try { window.open(qrSrc, '_blank', 'noopener'); } catch (e2) {} }
});
var pollInterval = 5000, maxPolls = 120, pollCount = 0;
var checkPaymentStatus = async function () {
if (pollCount >= maxPolls) return;
pollCount++;
try {
var token = <%- JSON.stringify(String(publicToken || '')).split('<').join('\\u003c') %>;
var url = kindSafe === 'voucher'
? '/customer/voucher/status/' + refId + '?t=' + encodeURIComponent(token)
: '/customer/payment/status/' + refId + '?t=' + encodeURIComponent(token);
var res = await fetch(url, { method: 'GET', credentials: 'same-origin', headers: { 'Accept': 'application/json' } });
if (!res.ok) throw new Error('Status check failed');
var data = await res.json();
if (data.status === 'fulfilled' || data.status === 'paid') {
var bUrl = <%- JSON.stringify(String(backUrl || '')).split('<').join('\\u003c') %>;
if (bUrl) { window.location.href = bUrl; }
else {
var redirectUrl = kindSafe === 'voucher' ? '/customer/voucher?order=' + refId + '&t=' + encodeURIComponent(token) : '/customer/billing?invoice=' + refId;
window.location.href = redirectUrl;
}
return;
}
setTimeout(checkPaymentStatus, pollInterval);
} catch (err) { setTimeout(checkPaymentStatus, pollInterval); }
};
setTimeout(checkPaymentStatus, 3000);
})();
</script>
</body>
</html>
+313
View File
@@ -0,0 +1,313 @@
<!DOCTYPE html>
<html lang="id" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.register.title', 'Pendaftaran Pelanggan Baru') %> | <%= settings.company_header || 'OPEN-ISP' %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="<%= t('customer.register.aria_home', 'Beranda') %>">
<img class="bmark" src="<%= brandLogo %>" alt="<%= brandName %>"><span class="bname"><%= brandName %></span>
</a>
<div class="header-actions" style="margin-left:auto;display:flex;align-items:center;gap:6px">
<button class="icon-btn" onclick="toggleTheme()" aria-label="<%= t('customer.register.aria_theme', 'Ganti tema') %>"><i class="bi bi-moon-stars-fill"></i></button>
<% (typeof availableLangs !== 'undefined' && availableLangs ? availableLangs : ['id','en']).forEach(function(L){ %>
<a href="/lang/<%= L %>" class="lbtn lbtn-ghost lbtn-sm" style="padding:4px 8px;<%= (lang === L) ? 'font-weight:800;color:var(--primary);opacity:1' : 'opacity:.65' %>"><%= String(L).toUpperCase() %></a>
<% }); %>
<a class="lbtn lbtn-ghost lbtn-sm" href="/customer/login"><i class="bi bi-box-arrow-in-right"></i> <%= t('customer.register.header_login', 'Masuk') %></a>
</div>
</div>
</header>
<main class="reg-wrap">
<div class="form-card reveal">
<div class="reg-head">
<div class="auth-logo"><img src="<%= brandLogo %>" alt="Logo" onerror="this.style.display='none'"></div>
<h1><%= t('customer.register.title', 'Pendaftaran Pelanggan Baru') %></h1>
<p><%= settings.company_header || 'OPEN-ISP' %> — <%= t('customer.register.subtitle', 'Isi data di bawah, tim kami akan menghubungi Anda.') %></p>
</div>
<% if (error) { %>
<div class="auth-alert err"><i class="bi bi-exclamation-triangle"></i> <%= error %></div>
<% } %>
<% if (success) { %>
<div class="auth-alert ok" style="flex-direction:column;align-items:flex-start;text-align:left">
<div style="display:flex;gap:9px;align-items:center"><i class="bi bi-check-circle-fill" style="font-size:18px"></i> <strong><%= success %></strong></div>
<% if (typeof newCustomer !== 'undefined' && newCustomer) { var _np = (typeof packages !== 'undefined' && packages) ? packages.find(function(p){return String(p.id)===String(newCustomer.package_id);}) : null; %>
<div style="margin-top:12px;width:100%;font-size:13.5px;line-height:1.8">
<div><%= t('customer.register.succ_id', 'ID Pelanggan') %>: <b style="color:var(--primary);font-size:16px"><%= newCustomer.customer_code %></b></div>
<div><%= t('customer.register.succ_pkg', 'Paket') %>: <b><%= _np ? _np.name : (newCustomer.package_name || '-') %></b></div>
<% if (typeof bill !== 'undefined' && bill) { %>
<div><%= t('customer.register.succ_estimate', 'Estimasi Biaya Awal') %>: <b>Rp <%= Number(bill.total||0).toLocaleString('id-ID') %></b></div>
<div class="muted" style="font-size:12px">(<%= t('customer.register.succ_install', 'Biaya pasang') %> Rp <%= Number(bill.installFee||0).toLocaleString('id-ID') %> + <%= t('customer.register.succ_prorata', 'Prorata') %> Rp <%= Number(bill.prorata||0).toLocaleString('id-ID') %><% if (bill.discount) { %> &minus; <%= t('customer.register.succ_discount', 'Diskon') %> Rp <%= Number(bill.discount||0).toLocaleString('id-ID') %><% } %>)</div>
<% } %>
<div class="muted" style="font-size:12px;margin-top:4px"><i class="bi bi-info-circle"></i> <%= t('customer.register.succ_note', 'Simpan ID Pelanggan Anda. Nominal final ditetapkan admin saat pemasangan.') %></div>
</div>
<% } %>
<a class="lbtn lbtn-primary lbtn-sm" href="/customer/login" style="margin-top:12px"><i class="bi bi-box-arrow-in-right"></i> <%= t('customer.register.back_login', 'Kembali ke Login') %></a>
</div>
<% } else { %>
<form method="POST" action="/customer/register" id="regForm" novalidate>
<div id="regStep1">
<div class="field">
<label for="name"><i class="bi bi-person"></i> <%= t('customer.register.form_name', 'Nama Lengkap') %></label>
<div class="wrap"><i class="bi bi-person lead-ic"></i>
<input type="text" class="inp" id="name" name="name" placeholder="<%= t('customer.register.form_name_ph', 'Contoh: Budi Santoso') %>" required autofocus autocomplete="name"></div>
</div>
<div class="field">
<label for="phone"><i class="bi bi-whatsapp"></i> <%= t('customer.register.form_phone', 'Nomor WhatsApp Aktif') %></label>
<div class="wrap"><i class="bi bi-whatsapp lead-ic"></i>
<input type="text" class="inp" id="phone" name="phone" placeholder="<%= t('customer.register.form_phone_ph', 'Contoh: 081234567890') %>" required inputmode="tel" autocomplete="tel"></div>
</div>
<div class="field">
<label for="email"><i class="bi bi-envelope"></i> <%= t('customer.register.form_email', 'Email (Opsional)') %></label>
<div class="wrap"><i class="bi bi-envelope lead-ic"></i>
<input type="email" class="inp" id="email" name="email" placeholder="<%= t('customer.register.form_email_ph', 'Contoh: budi@gmail.com') %>" autocomplete="email"></div>
<div class="form-note"><%= t('customer.register.form_email_note', 'Email digunakan untuk pengiriman invoice otomatis.') %></div>
</div>
<div class="field">
<label for="nik"><i class="bi bi-person-vcard"></i> <%= t('customer.register.form_nik', 'No. Identitas — KTP/SIM/Paspor (Opsional)') %></label>
<div class="wrap"><i class="bi bi-person-vcard lead-ic"></i>
<input type="text" class="inp" id="nik" name="nik" placeholder="<%= t('customer.register.form_nik_ph', 'Contoh: 3201xxxxxxxxxxxx') %>" oninput="this.value=this.value.toUpperCase()" autocapitalize="characters" autocomplete="off"></div>
<div class="form-note"><%= t('customer.register.form_nik_note', 'Opsional, seperti email. Untuk kelengkapan data identitas.') %></div>
</div>
<div class="field">
<label for="address"><i class="bi bi-geo-alt"></i> <%= t('customer.register.form_address', 'Alamat Pemasangan') %></label>
<textarea class="inp inp-plain" id="address" name="address" rows="2" placeholder="<%= t('customer.register.form_address_ph', 'Sertakan RT/RW, Desa, Kecamatan') %>" required></textarea>
</div>
<div class="field">
<label><i class="bi bi-map"></i> <%= t('customer.register.form_location', 'Lokasi di Peta (Opsional)') %></label>
<div class="loc-btns">
<button type="button" class="lbtn lbtn-ghost lbtn-sm" onclick="openPicker()"><i class="bi bi-geo-alt-fill"></i> <%= t('customer.register.btn_pick_map', 'Pilih di Peta') %></button>
<button type="button" class="lbtn lbtn-ghost lbtn-sm" onclick="getLocation()"><i class="bi bi-crosshair"></i> <%= t('customer.register.btn_use_gps', 'Gunakan GPS') %></button>
</div>
<input type="hidden" name="lat" id="reg_lat">
<input type="hidden" name="lng" id="reg_lng">
<div class="chip-note" id="coordText"><%= t('customer.register.coord_none', 'Koordinat belum dipilih.') %></div>
</div>
<div class="field">
<label for="voucher"><i class="bi bi-ticket-perforated"></i> <%= t('customer.register.form_voucher', 'Kode Voucher (Opsional)') %></label>
<div class="wrap"><i class="bi bi-ticket-perforated lead-ic"></i>
<input type="text" class="inp" id="voucher" name="voucher" placeholder="<%= t('customer.register.form_voucher_ph', 'mis. HEMAT50') %>" style="text-transform:uppercase" oninput="this.value=this.value.toUpperCase()" autocapitalize="characters" autocomplete="off"></div>
<div class="form-note" id="voucherNote"><%= t('customer.register.voucher_note', 'Punya kode promo? Masukkan untuk potongan biaya awal.') %></div>
</div>
<button type="button" class="lbtn lbtn-primary btn-auth" onclick="regNext()"><i class="bi bi-arrow-right"></i> <%= t('customer.register.btn_next', 'Lanjut Pilih Paket') %></button>
</div><!-- /step1 -->
<div id="regStep2" style="display:none">
<button type="button" class="lbtn lbtn-ghost lbtn-sm" onclick="regBack()" style="margin-bottom:12px"><i class="bi bi-arrow-left"></i> <%= t('customer.register.btn_back', 'Kembali') %></button>
<div class="field">
<label for="package_id"><i class="bi bi-box-seam"></i> <%= t('customer.register.form_package', 'Pilih Paket Layanan') %></label>
<select class="inp inp-plain" id="package_id" name="package_id" required>
<option value="" disabled selected><%= t('customer.register.package_ph', 'Pilih salah satu paket…') %></option>
<% (packages || []).forEach(function(pkg) { %>
<option value="<%= pkg.id %>" data-price="<%= Number(pkg.price||0) %>"><%= pkg.name %> — Rp <%= Number(pkg.price||0).toLocaleString('id-ID') %>/<%= t('customer.register.per_month', 'bulan') %></option>
<% }); %>
</select>
</div>
<div id="regEstimate" style="display:none;border:1px solid var(--border);border-radius:14px;padding:14px;margin:6px 0 14px;background:var(--glass-2,rgba(17,22,34,.72))">
<div style="font-weight:800;margin-bottom:8px;display:flex;align-items:center;gap:8px"><i class="bi bi-calculator" style="color:var(--primary)"></i> <%= t('customer.register.est_title', 'Estimasi Biaya Awal') %></div>
<div style="display:flex;justify-content:space-between;font-size:13.5px;padding:4px 0"><span><%= t('customer.register.est_install', 'Biaya Pasang') %></span><span id="reInstall">Rp 0</span></div>
<div style="display:flex;justify-content:space-between;font-size:13.5px;padding:4px 0"><span><%= t('customer.register.est_prorata', 'Paket Prorata') %> (<span id="reDays">0</span> <%= t('customer.register.est_days', 'hari') %>)</span><span id="reProrata">Rp 0</span></div>
<div id="reDiscRow" style="display:none;justify-content:space-between;font-size:13.5px;padding:4px 0;color:var(--danger,#fb7185)"><span><%= t('customer.register.est_discount', 'Diskon Voucher') %> <span id="reVoucherCode"></span></span><span id="reDiscount">- Rp 0</span></div>
<div style="display:flex;justify-content:space-between;font-size:15px;font-weight:800;padding:8px 0 2px;border-top:1px solid var(--border);margin-top:6px"><span><%= t('customer.register.est_total', 'Total Bayar Awal') %></span><span id="reTotal" style="color:var(--primary)">Rp 0</span></div>
<div style="font-size:11.5px;color:var(--muted);margin-top:6px"><%= t('customer.register.est_note_pre', 'Paket dihitung prorata s/d') %> <b id="reDue">-</b> <%= t('customer.register.est_note_post', '(acuan tanggal 5). Ini estimasi; nominal final ditetapkan admin.') %></div>
</div>
<button type="submit" class="lbtn lbtn-primary btn-auth"><i class="bi bi-send-fill"></i> <%= t('customer.register.btn_submit', 'Kirim Pendaftaran') %></button>
</div><!-- /step2 -->
</form>
<% } %>
<div class="auth-foot">
<%= t('customer.register.have_account', 'Sudah punya akun?') %> <a href="/customer/login"><%= t('customer.register.login_here', 'Login di sini') %></a>
<div style="margin-top:10px;display:flex;flex-wrap:wrap;gap:12px;justify-content:center;font-size:12px">
<a href="/customer/about" class="muted"><%= t('customer.register.link_about', 'Tentang Kami') %></a>
<a href="/customer/tos" class="muted"><%= t('customer.register.link_tos', 'Ketentuan') %></a>
<a href="/customer/privacy" class="muted"><%= t('customer.register.link_privacy', 'Privasi') %></a>
<a href="/customer/contact" class="muted"><%= t('customer.register.link_contact', 'Kontak') %></a>
</div>
<div style="margin-top:12px;font-size:11.5px" class="muted">&copy; <%= new Date().getFullYear() %> <%= settings.company_header || 'OPEN-ISP' %> · v<%= appVersion %> (<%= appVersionName %>)</div>
</div>
</div>
</main>
<!-- Modal peta (lazy) -->
<div class="mo" id="mapPickerModal">
<div class="mb">
<div class="mh"><h3><i class="bi bi-geo-alt-fill" style="color:var(--primary)"></i> <%= t('customer.register.modal_title', 'Pilih Lokasi Pemasangan') %></h3>
<button class="mc" onclick="closePicker()" aria-label="<%= t('customer.register.modal_close', 'Tutup') %>"><i class="bi bi-x-lg"></i></button></div>
<div class="mbody">
<div id="picker-map"></div>
<div class="form-note"><%= t('customer.register.modal_tip', 'Geser pin atau klik peta untuk menentukan titik lokasi.') %></div>
</div>
<div class="mf"><button class="lbtn lbtn-primary lbtn-sm" onclick="closePicker()"><%= t('customer.register.btn_done', 'Selesai') %></button></div>
</div>
</div>
<script>
var I18N = {
fillRequired: "<%= t('customer.register.js_fill_required', 'Lengkapi Nama, No. WhatsApp, dan Alamat dulu.') %>",
fillRequired2: "<%= t('customer.register.js_fill_required2', 'Lengkapi Nama, No. WhatsApp, dan Alamat terlebih dahulu.') %>",
pickPackage: "<%= t('customer.register.js_pick_package', 'Pilih paket layanan terlebih dahulu.') %>",
gpsUnsupported: "<%= t('customer.register.js_gps_unsupported', 'GPS tidak didukung di perangkat ini.') %>",
gpsFail: "<%= t('customer.register.js_gps_fail', 'Gagal mengambil lokasi: ') %>",
voucherEnter: "<%= t('customer.register.voucher_note', 'Punya kode promo? Masukkan untuk potongan biaya awal.') %>",
voucherPickPkg: "<%= t('customer.register.voucher_pick_pkg', 'Pilih paket dulu untuk cek voucher.') %>"
};
(function(){ var s = localStorage.getItem('landing-theme') || 'dark'; document.documentElement.setAttribute('data-theme', s); })();
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); localStorage.setItem('landing-theme',t); }
(function(){ var els=document.querySelectorAll('.reveal'); if(!('IntersectionObserver' in window)){els.forEach(function(e){e.classList.add('in')});return;} var io=new IntersectionObserver(function(en){en.forEach(function(x){if(x.isIntersecting){x.target.classList.add('in');io.unobserve(x.target);}});},{threshold:.12}); els.forEach(function(e){io.observe(e);}); })();
// ── Peta (Leaflet dimuat saat dibutuhkan) ──
var officeLat = Number('<%= settings.office_lat || "" %>') || -6.200000;
var officeLng = Number('<%= settings.office_lng || "" %>') || 106.816666;
var pickerMap, pickerMarker, leafletReady = false, leafletLoading = false;
function loadLeaflet(cb){
if(window.L){ cb(); return; }
if(leafletLoading){ var t=setInterval(function(){ if(window.L){ clearInterval(t); cb(); } },60); return; }
leafletLoading = true;
var css=document.createElement('link'); css.rel='stylesheet'; css.href='https://unpkg.com/leaflet@1.9.4/dist/leaflet.css'; document.head.appendChild(css);
var js=document.createElement('script'); js.src='https://unpkg.com/leaflet@1.9.4/dist/leaflet.js';
js.onload=function(){ leafletReady=true; cb(); };
document.body.appendChild(js);
}
function setCoords(lat, lng){
var latStr=Number(lat).toFixed(8), lngStr=Number(lng).toFixed(8);
document.getElementById('reg_lat').value=latStr;
document.getElementById('reg_lng').value=lngStr;
document.getElementById('coordText').innerText='Lat: '+latStr+' • Lng: '+lngStr;
}
function openPicker(){
document.getElementById('mapPickerModal').classList.add('show');
var latVal=parseFloat(document.getElementById('reg_lat').value)||officeLat;
var lngVal=parseFloat(document.getElementById('reg_lng').value)||officeLng;
loadLeaflet(function(){
setTimeout(function(){
if(!pickerMap){
pickerMap=L.map('picker-map').setView([latVal,lngVal],16);
L.tileLayer('https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}',{maxZoom:20,subdomains:['mt0','mt1','mt2','mt3']}).addTo(pickerMap);
pickerMarker=L.marker([latVal,lngVal],{draggable:true}).addTo(pickerMap);
pickerMarker.on('dragend',function(){var p=pickerMarker.getLatLng();setCoords(p.lat,p.lng);});
pickerMap.on('click',function(e){pickerMarker.setLatLng(e.latlng);setCoords(e.latlng.lat,e.latlng.lng);});
} else {
pickerMap.setView([latVal,lngVal],16); pickerMarker.setLatLng([latVal,lngVal]); pickerMap.invalidateSize();
}
},200);
});
}
function closePicker(){ document.getElementById('mapPickerModal').classList.remove('show'); }
function getLocation(){
if(!navigator.geolocation) return alert(I18N.gpsUnsupported);
navigator.geolocation.getCurrentPosition(function(pos){ setCoords(pos.coords.latitude,pos.coords.longitude); },
function(err){ alert(I18N.gpsFail+(err.message||err)); }, {enableHighAccuracy:true,timeout:8000});
}
// ── Alur daftar: 2 langkah + estimasi prorata (acuan tgl 5) ──
var REG = {
startDate: '<%= (typeof regCalc !== "undefined" && regCalc) ? regCalc.startDate : "" %>',
dueDate: '<%= (typeof regCalc !== "undefined" && regCalc) ? regCalc.dueDate : "" %>',
diffDays: <%= (typeof regCalc !== "undefined" && regCalc) ? regCalc.diffDays : 0 %>,
daysInMonth: <%= (typeof regCalc !== "undefined" && regCalc) ? regCalc.daysInMonth : 30 %>,
installFee: <%= (typeof installFee !== "undefined") ? installFee : 0 %>
};
var _voucherDiscount = 0;
function fmtRp(n){ return 'Rp ' + Math.round(Number(n)||0).toLocaleString('id-ID'); }
function fmtTgl(s){ try { var d=new Date(s); return isNaN(d.getTime())?s:d.toLocaleDateString('id-ID',{day:'numeric',month:'long',year:'numeric'}); } catch(e){ return s; } }
function regNext(){
var name=(document.getElementById('name')||{}).value||'';
var phone=(document.getElementById('phone')||{}).value||'';
var address=(document.getElementById('address')||{}).value||'';
if(!name.trim()||!phone.trim()||!address.trim()){ alert(I18N.fillRequired); return; }
document.getElementById('regStep1').style.display='none';
document.getElementById('regStep2').style.display='block';
checkVoucher();
try { window.scrollTo({top:0,behavior:'smooth'}); } catch(e){ window.scrollTo(0,0); }
}
function regBack(){ document.getElementById('regStep2').style.display='none'; document.getElementById('regStep1').style.display='block'; }
function currentPrice(){ var s=document.getElementById('package_id'); if(!s) return 0; var o=s.options[s.selectedIndex]; return (o && o.dataset)?Number(o.dataset.price||0):0; }
function renderEstimate(){
var box=document.getElementById('regEstimate'); if(!box) return;
var price=currentPrice(); if(!price){ box.style.display='none'; return; }
var perDay = price / (REG.daysInMonth||30);
var prorata = Math.round(perDay * (REG.diffDays||0));
var gross = prorata + (REG.installFee||0);
var disc = Math.min(_voucherDiscount||0, gross);
var total = Math.max(0, gross - disc);
box.style.display='block';
document.getElementById('reInstall').textContent = fmtRp(REG.installFee);
document.getElementById('reDays').textContent = REG.diffDays;
document.getElementById('reProrata').textContent = fmtRp(prorata);
document.getElementById('reDue').textContent = fmtTgl(REG.dueDate);
if(disc>0){ document.getElementById('reDiscRow').style.display='flex'; document.getElementById('reDiscount').textContent='- '+fmtRp(disc); document.getElementById('reVoucherCode').textContent='( '+((document.getElementById('voucher')||{}).value||'').toUpperCase()+' )'; }
else { document.getElementById('reDiscRow').style.display='none'; }
document.getElementById('reTotal').textContent = fmtRp(total);
}
function checkVoucher(){
var vEl=document.getElementById('voucher'); var note=document.getElementById('voucherNote');
var code=(vEl?String(vEl.value||'').trim():'');
var pEl=document.getElementById('package_id'); var pid=pEl?pEl.value:'';
if(!code){ _voucherDiscount=0; if(note){note.textContent=I18N.voucherEnter;note.style.color='';} renderEstimate(); return; }
if(!pid){ _voucherDiscount=0; if(note){note.textContent=I18N.voucherPickPkg;note.style.color='var(--muted)';} renderEstimate(); return; }
fetch('/customer/register/validate-voucher',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({code:code,package_id:pid})})
.then(function(r){return r.json();}).then(function(d){ _voucherDiscount=(d&&d.ok)?(d.discount||0):0; if(note){note.textContent=(d&&d.message)||''; note.style.color=(d&&d.ok)?'var(--success)':'var(--danger,#fb7185)';} renderEstimate(); })
.catch(function(){ _voucherDiscount=0; renderEstimate(); });
}
document.addEventListener('DOMContentLoaded', function(){
var s=document.getElementById('package_id'); if(s) s.addEventListener('change', function(){ checkVoucher(); renderEstimate(); });
var v=document.getElementById('voucher'); if(v) v.addEventListener('change', checkVoucher);
var f=document.getElementById('regForm');
if(f) f.addEventListener('submit', function(e){
var name=(document.getElementById('name')||{}).value||'';
var phone=(document.getElementById('phone')||{}).value||'';
var addr=(document.getElementById('address')||{}).value||'';
var pid=(document.getElementById('package_id')||{}).value||'';
if(!name.trim()||!phone.trim()||!addr.trim()){
e.preventDefault();
alert(I18N.fillRequired2);
var s2=document.getElementById('regStep2'); var s1=document.getElementById('regStep1');
if(s2) s2.style.display='none'; if(s1) s1.style.display='block';
return;
}
if(!pid){ e.preventDefault(); alert(I18N.pickPackage); return; }
});
['name','phone','email','nik','voucher'].forEach(function(id){
var el=document.getElementById(id);
if(el) el.addEventListener('keydown', function(ev){ if(ev.key==='Enter'){ ev.preventDefault(); regNext(); } });
});
});
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){});
}
</script>
</body>
</html>
+657
View File
@@ -0,0 +1,657 @@
<%- include('partials/header') %>
<div class="mb-4">
<div class="text-secondary small"><%= t('collector.attendance_title','Absensi Karyawan') %></div>
<div class="fs-5 fw-bold"><i class="bi bi-calendar-check text-primary me-2"></i><%= techName %></div>
</div>
<% if (msg) { %>
<div class="alert alert-<%= msg.type === 'error' ? 'danger' : 'success' %> alert-dismissible fade show rounded-4" role="alert" style="font-size: 13px;">
<i class="bi bi-<%= msg.type === 'error' ? 'exclamation-circle' : 'check-circle' %>-fill me-2"></i>
<%= msg.text %>
<button type="button" class="btn-close btn-close-white" style="font-size: 10px;" data-bs-dismiss="alert"></button>
</div>
<% } %>
<!-- Status Hari Ini -->
<div class="t-card" style="background: linear-gradient(135deg, var(--pdim), transparent 60%), var(--bg2); border: 1px solid var(--primary-glow);">
<h6 class="fw-bold mb-3" style="color: var(--text);"><i class="bi bi-clock-history me-2"></i><%= t('collector.today_status','Status Hari Ini') %></h6>
<% if (todayAttendance) { %>
<% if (todayAttendance.status === 'checked_in') { %>
<span class="badge-status" style="background: rgba(16, 185, 129, 0.3); color: #10b981; border: 1px solid #10b981;">
<i class="bi bi-check-circle-fill"></i> <%= t('collector.checked_in','Sudah Check-in') %>
</span>
<% } else { %>
<span class="badge-status" style="background: rgba(100, 116, 139, 0.3); color: #cbd5e1; border: 1px solid #94a3b8;">
<i class="bi bi-check-all"></i> <%= t('collector.checked_out','Sudah Check-out') %>
</span>
<% } %>
<div style="background: rgba(255, 255, 255, 0.1); border-radius: 12px; padding: 15px; margin-top: 15px;">
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
<span style="color: var(--muted); font-size: 13px;"><%= t('collector.checkin','Check-in') %></span>
<span style="color: white; font-weight: 600; font-size: 13px;">
<%= formatTimeLocal(todayAttendance.check_in_time) %>
</span>
</div>
<% if (todayAttendance.check_out_time) { %>
<div style="display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.1);">
<span style="color: var(--muted); font-size: 13px;"><%= t('collector.checkout','Check-out') %></span>
<span style="color: white; font-weight: 600; font-size: 13px;">
<%= formatTimeLocal(todayAttendance.check_out_time) %>
</span>
</div>
<div style="display: flex; justify-content: space-between; padding: 8px 0;">
<span style="color: var(--muted); font-size: 13px;"><%= t('collector.work_duration','Durasi Kerja') %></span>
<span style="color: white; font-weight: 600; font-size: 13px;">
<%= Math.floor(todayAttendance.work_duration_minutes / 60) %> jam <%= todayAttendance.work_duration_minutes % 60 %> menit
</span>
</div>
<% } %>
<% if (todayAttendance.check_in_note) { %>
<div style="padding: 8px 0; border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 8px;">
<span style="color: var(--muted); font-size: 12px;"><%= t('collector.note_checkin','Catatan Check-in:') %></span>
<div style="color: white; font-size: 13px; margin-top: 4px;"><%= todayAttendance.check_in_note %></div>
</div>
<% } %>
<% if (todayAttendance.check_out_note) { %>
<div style="padding: 8px 0; border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 8px;">
<span style="color: var(--muted); font-size: 12px;"><%= t('collector.note_checkout','Catatan Check-out:') %></span>
<div style="color: white; font-size: 13px; margin-top: 4px;"><%= todayAttendance.check_out_note %></div>
</div>
<% } %>
<% if (todayAttendance.check_in_photo || todayAttendance.check_out_photo) { %>
<div style="padding: 8px 0; border-top: 1px solid rgba(255, 255, 255, 0.1); margin-top: 8px;">
<span style="color: var(--muted); font-size: 12px;"><%= t('collector.attendance_photo','Foto Absensi:') %></span>
<div style="display: flex; gap: 10px; flex-wrap: wrap; margin-top: 8px;">
<% if (todayAttendance.check_in_photo) { %>
<a href="<%= todayAttendance.check_in_photo %>" target="_blank" style="color: white; text-decoration: none; font-size: 12px;">
<img src="<%= todayAttendance.check_in_photo %>" alt="Foto check-in" style="width: 72px; height: 72px; object-fit: cover; border-radius: 10px; display: block; margin-bottom: 4px;">
<%= t('collector.checkin','Check-in') %>
</a>
<% } %>
<% if (todayAttendance.check_out_photo) { %>
<a href="<%= todayAttendance.check_out_photo %>" target="_blank" style="color: white; text-decoration: none; font-size: 12px;">
<img src="<%= todayAttendance.check_out_photo %>" alt="Foto check-out" style="width: 72px; height: 72px; object-fit: cover; border-radius: 10px; display: block; margin-bottom: 4px;">
<%= t('collector.checkout','Check-out') %>
</a>
<% } %>
</div>
</div>
<% } %>
</div>
<% if (todayAttendance.status === 'checked_in') { %>
<textarea id="checkoutNote" class="form-control mt-3" placeholder="<%= t('collector.note_out_ph','Catatan check-out (opsional)') %>" rows="2" style="background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); color: white; font-size: 13px;"></textarea>
<div class="mt-3 text-center">
<label style="display: block; color: var(--text); font-size: 12px; margin-bottom: 8px;"><%= t('collector.selfie_checkout','Foto selfie check-out (wajib)') %></label>
<input id="checkoutPhoto" type="file" accept="image/*" capture="user" onchange="updatePhotoInfo('checkoutPhoto', 'checkoutPhotoInfo', 'checkoutPhotoPreviewWrap', 'checkoutPhotoPreview')" style="display: none;">
<div style="margin: 12px 0;">
<button type="button" onclick="document.getElementById('checkoutPhoto').click()" class="btn d-inline-flex align-items-center justify-content-center" style="width: 64px; height: 64px; border-radius: 50%; background: #7c3aed; color: white; font-size: 24px; box-shadow: 0 4px 12px rgba(124, 58, 237, 0.4); border: none; transition: transform 0.2s;" onmousedown="this.style.transform='scale(0.92)'" onmouseup="this.style.transform='scale(1)'">
<i class="bi bi-camera-fill"></i>
</button>
<div style="font-size: 11px; color: rgba(255, 255, 255, 0.6); margin-top: 6px;"><%= t('collector.tap_camera','Ketuk tombol kamera untuk mengambil foto selfie') %></div>
</div>
<div id="checkoutPhotoInfo" style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.no_selfie_out','Belum ada foto selfie dipilih. Foto wajib untuk check-out.') %></div>
<div id="checkoutPhotoStatus" style="display: inline-block; margin-top: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11px; background: var(--ddim); color: var(--danger); border: 1px solid var(--danger-glow);"><%= t('collector.selfie_required','Foto wajib') %></div>
<div style="margin-top: 8px;">
<button type="button" onclick="clearSelectedPhoto('checkoutPhoto')" class="btn btn-sm btn-outline-danger" id="checkoutPhotoClearBtn" style="display: none; border-radius: 8px; font-size: 11px;"><%= t('collector.delete_photo','Hapus Foto') %></button>
</div>
<div id="checkoutPhotoPreviewWrap" style="display: none; margin-top: 8px;">
<div style="font-size: 11px; color: var(--muted); margin-bottom: 6px;"><%= t('collector.photo_preview','Preview foto final yang akan dikirim:') %></div>
<img id="checkoutPhotoPreview" alt="Preview foto check-out" style="width: 100%; max-width: 240px; border-radius: 12px; border: 1px solid var(--border); object-fit: cover;">
</div>
</div>
<button id="checkoutSubmitBtn" class="btn btn-danger btn-action mt-2" onclick="checkout(event)" disabled style="opacity: 0.55; cursor: not-allowed;">
<i class="bi bi-box-arrow-right me-2"></i><%= t('collector.checkout_now','Check-out Sekarang') %>
</button>
<% } %>
<% } else { %>
<span class="badge-status" style="background: rgba(239, 68, 68, 0.3); color: #ef4444; border: 1px solid #ef4444;">
<i class="bi bi-x-circle-fill"></i> <%= t('collector.not_checked_in','Belum Check-in') %>
</span>
<p style="color: var(--text); margin: 15px 0 0 0; font-size: 13px;">
<%= t('collector.start_day','Silakan lakukan check-in untuk memulai hari kerja Anda.') %>
</p>
<textarea id="checkinNote" class="form-control mt-3" placeholder="<%= t('collector.note_in_ph','Catatan check-in (opsional)') %>" rows="2" style="background: rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.2); color: white; font-size: 13px;"></textarea>
<div class="mt-3 text-center">
<label style="display: block; color: var(--text); font-size: 12px; margin-bottom: 8px;"><%= t('collector.selfie_checkin','Foto selfie check-in (wajib)') %></label>
<input id="checkinPhoto" type="file" accept="image/*" capture="user" onchange="updatePhotoInfo('checkinPhoto', 'checkinPhotoInfo', 'checkinPhotoPreviewWrap', 'checkinPhotoPreview')" style="display: none;">
<div style="margin: 12px 0;">
<button type="button" onclick="document.getElementById('checkinPhoto').click()" class="btn d-inline-flex align-items-center justify-content-center" style="width: 64px; height: 64px; border-radius: 50%; background: #10b981; color: white; font-size: 24px; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.4); border: none; transition: transform 0.2s;" onmousedown="this.style.transform='scale(0.92)'" onmouseup="this.style.transform='scale(1)'">
<i class="bi bi-camera-fill"></i>
</button>
<div style="font-size: 11px; color: rgba(255, 255, 255, 0.6); margin-top: 6px;"><%= t('collector.tap_camera','Ketuk tombol kamera untuk mengambil foto selfie') %></div>
</div>
<div id="checkinPhotoInfo" style="font-size: 11px; color: var(--muted); margin-top: 6px;"><%= t('collector.no_selfie_in','Belum ada foto selfie dipilih. Foto wajib untuk check-in.') %></div>
<div id="checkinPhotoStatus" style="display: inline-block; margin-top: 6px; padding: 4px 10px; border-radius: 999px; font-size: 11px; background: var(--ddim); color: var(--danger); border: 1px solid var(--danger-glow);"><%= t('collector.selfie_required','Foto wajib') %></div>
<div style="margin-top: 8px;">
<button type="button" onclick="clearSelectedPhoto('checkinPhoto')" class="btn btn-sm btn-outline-danger" id="checkinPhotoClearBtn" style="display: none; border-radius: 8px; font-size: 11px;"><%= t('collector.delete_photo','Hapus Foto') %></button>
</div>
<div id="checkinPhotoPreviewWrap" style="display: none; margin-top: 8px;">
<div style="font-size: 11px; color: var(--muted); margin-bottom: 6px;"><%= t('collector.photo_preview','Preview foto final yang akan dikirim:') %></div>
<img id="checkinPhotoPreview" alt="Preview foto check-in" style="width: 100%; max-width: 240px; border-radius: 12px; border: 1px solid var(--border); object-fit: cover;">
</div>
</div>
<button id="checkinSubmitBtn" class="btn btn-success btn-action mt-2" onclick="checkin(event)" disabled style="opacity: 0.55; cursor: not-allowed;">
<i class="bi bi-box-arrow-in-right me-2"></i><%= t('collector.checkin_now','Check-in Sekarang') %>
</button>
<% } %>
<div id="locationInfo" style="display: none; font-size: 11px; color: var(--muted); margin-top: 10px; padding: 8px; background: rgba(0, 0, 0, 0.2); border-radius: 8px;">
<i class="bi bi-geo-alt-fill"></i> <%= t('collector.getting_gps','Mengambil lokasi GPS...') %>
</div>
</div>
<!-- Ringkasan Bulan Ini -->
<h6 class="mb-3 text-secondary fw-bold text-uppercase" style="font-size: 13px; letter-spacing: 1px;">
<i class="bi bi-calendar3 me-1"></i><%= t('collector.month_summary','Ringkasan Bulan Ini') %>
</h6>
<div class="stat-grid">
<div class="stat-box" style="border-left: 3px solid #3b82f6;">
<div class="val text-primary"><%= summary.total_days || 0 %></div>
<div class="lbl"><%= t('collector.total_days','Total Hari') %></div>
</div>
<div class="stat-box" style="border-left: 3px solid #10b981;">
<div class="val text-success"><%= summary.completed_days || 0 %></div>
<div class="lbl"><%= t('collector.full_days','Hari Lengkap') %></div>
</div>
<div class="stat-box" style="border-left: 3px solid #f59e0b;">
<div class="val text-warning"><%= summary.total_minutes ? Math.floor(summary.total_minutes / 60) : 0 %></div>
<div class="lbl"><%= t('collector.total_hours','Total Jam') %></div>
</div>
<div class="stat-box" style="border-left: 3px solid #8b5cf6;">
<div class="val" style="color: #a78bfa;"><%= summary.avg_minutes ? Math.floor(summary.avg_minutes / 60) : 0 %></div>
<div class="lbl"><%= t('collector.avg_hours','Rata-rata Jam') %></div>
</div>
</div>
<!-- Riwayat Terakhir -->
<h6 class="mb-3 text-secondary fw-bold text-uppercase" style="font-size: 13px; letter-spacing: 1px;">
<i class="bi bi-clock-history me-1"></i><%= t('collector.recent_history','Riwayat Terakhir') %>
</h6>
<% if (history && history.length > 0) { %>
<% history.forEach(item => { %>
<div class="t-card">
<div class="fw-bold mb-2" style="font-size: 14px; color: #f8fafc;">
<%= parseDateInTimezone(item.check_in_time).toLocaleDateString('id-ID', {
weekday: 'long',
day: 'numeric',
month: 'long'
}) %>
</div>
<div class="text-secondary mb-1" style="font-size: 12px;">
<i class="bi bi-box-arrow-in-right text-success me-1"></i>
<%= t('collector.checkin_label','Check-in:') %> <%= formatTimeLocal(item.check_in_time) %>
</div>
<% if (item.check_out_time) { %>
<div class="text-secondary mb-1" style="font-size: 12px;">
<i class="bi bi-box-arrow-right text-danger me-1"></i>
<%= t('collector.checkout_label','Check-out:') %> <%= formatTimeLocal(item.check_out_time) %>
</div>
<div class="text-secondary" style="font-size: 12px;">
<i class="bi bi-clock text-primary me-1"></i>
<%= t('collector.duration_label','Durasi:') %> <%= Math.floor(item.work_duration_minutes / 60) %> <%= t('common.hours','jam') %> <%= item.work_duration_minutes % 60 %> <%= t('collector.minutes','menit') %>
</div>
<% } else { %>
<div class="text-danger" style="font-size: 12px;">
<i class="bi bi-exclamation-triangle-fill me-1"></i>
<%= t('collector.not_checked_out','Belum check-out') %>
</div>
<% } %>
</div>
<% }) %>
<% } else { %>
<div class="text-center py-5">
<i class="bi bi-inbox text-secondary" style="font-size: 40px;"></i>
<p class="text-secondary mt-2 small"><%= t('collector.no_history','Belum ada riwayat absensi') %></p>
</div>
<% } %>
<%- include('partials/bottom_nav') %>
<script>
let currentLocation = null;
const processedAttendancePhotos = {};
const photoFieldConfig = {
checkinPhoto: { infoId: 'checkinPhotoInfo', previewWrapId: 'checkinPhotoPreviewWrap', previewImgId: 'checkinPhotoPreview', statusId: 'checkinPhotoStatus', submitBtnId: 'checkinSubmitBtn' },
checkoutPhoto: { infoId: 'checkoutPhotoInfo', previewWrapId: 'checkoutPhotoPreviewWrap', previewImgId: 'checkoutPhotoPreview', statusId: 'checkoutPhotoStatus', submitBtnId: 'checkoutSubmitBtn' }
};
function setPhotoStatus(statusId, text, mode = 'idle') {
const el = document.getElementById(statusId);
if (!el) return;
const palette = {
idle: ['var(--ddim)', 'var(--danger)'],
waiting: ['var(--wdim)', 'var(--warning)'],
processing: ['var(--idim)', 'var(--info)'],
ready: ['var(--sdim)', 'var(--success)'],
error: ['var(--ddim)', 'var(--danger)']
};
const [bg, color] = palette[mode] || palette.idle;
el.style.background = bg;
el.style.color = color;
el.textContent = text;
}
function setSubmitButtonState(buttonId, enabled) {
const button = document.getElementById(buttonId);
if (!button) return;
button.disabled = !enabled;
button.style.opacity = enabled ? '1' : '0.55';
button.style.cursor = enabled ? 'pointer' : 'not-allowed';
}
function resetPhotoPreview(previewWrapId, previewImgId) {
const previewWrap = document.getElementById(previewWrapId);
const previewImg = document.getElementById(previewImgId);
if (previewImg && previewImg.dataset.objectUrl) {
URL.revokeObjectURL(previewImg.dataset.objectUrl);
delete previewImg.dataset.objectUrl;
}
if (previewImg) previewImg.removeAttribute('src');
if (previewWrap) previewWrap.style.display = 'none';
}
function setPhotoPreview(previewWrapId, previewImgId, file) {
const previewWrap = document.getElementById(previewWrapId);
const previewImg = document.getElementById(previewImgId);
if (!previewWrap || !previewImg) return;
resetPhotoPreview(previewWrapId, previewImgId);
const previewSource = file && file.blob ? file.blob : file;
const objectUrl = URL.createObjectURL(previewSource);
previewImg.src = objectUrl;
previewImg.dataset.objectUrl = objectUrl;
previewWrap.style.display = 'block';
}
function getOriginalPhotoFile(inputId) {
const input = document.getElementById(inputId);
return input && input.files && input.files[0] ? input.files[0] : null;
}
function updatePhotoInfo(inputId, infoId, previewWrapId, previewImgId) {
const input = document.getElementById(inputId);
const info = document.getElementById(infoId);
const config = getPhotoState(inputId);
if (!input || !info) return;
const file = input.files && input.files[0];
delete processedAttendancePhotos[inputId];
if (!file) {
info.textContent = 'Belum ada foto selfie dipilih.';
resetPhotoPreview(previewWrapId, previewImgId);
if (config) setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
if (config) setSubmitButtonState(config.submitBtnId, false);
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'none';
return;
}
info.textContent = `Foto dipilih: ${file.name} (${Math.round(file.size / 1024)} KB)`;
if (config) setPhotoStatus(config.statusId, 'Menyiapkan preview', 'processing');
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'inline-block';
refreshPhotoPreview(inputId).catch((error) => {
info.textContent = error.message;
resetPhotoPreview(previewWrapId, previewImgId);
if (config) setPhotoStatus(config.statusId, 'Preview gagal', 'error');
});
}
function getPhotoState(inputId) {
return photoFieldConfig[inputId];
}
function clearSelectedPhoto(inputId) {
const config = getPhotoState(inputId);
const input = document.getElementById(inputId);
if (!config || !input) return;
input.value = '';
delete processedAttendancePhotos[inputId];
const info = document.getElementById(config.infoId);
if (info) info.textContent = 'Belum ada foto selfie dipilih. Foto wajib untuk absensi.';
resetPhotoPreview(config.previewWrapId, config.previewImgId);
setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
setSubmitButtonState(config.submitBtnId, false);
const clearBtn = document.getElementById(inputId + 'ClearBtn');
if (clearBtn) clearBtn.style.display = 'none';
}
async function refreshPhotoPreview(inputId, force = false) {
const config = getPhotoState(inputId);
const input = document.getElementById(inputId);
const info = config ? document.getElementById(config.infoId) : null;
const file = input && input.files && input.files[0];
if (!config || !info) return null;
if (!file) {
info.textContent = 'Belum ada foto selfie dipilih. Foto wajib untuk absensi.';
resetPhotoPreview(config.previewWrapId, config.previewImgId);
delete processedAttendancePhotos[inputId];
setPhotoStatus(config.statusId, 'Foto wajib', 'idle');
setSubmitButtonState(config.submitBtnId, false);
return null;
}
if (!currentLocation) {
info.textContent = `Foto dipilih: ${file.name} (${Math.round(file.size / 1024)} KB). Preview menunggu lokasi GPS.`;
resetPhotoPreview(config.previewWrapId, config.previewImgId);
setPhotoStatus(config.statusId, 'Menunggu GPS', 'waiting');
setSubmitButtonState(config.submitBtnId, false);
return null;
}
const cacheKey = [file.name, file.size, file.lastModified, currentLocation.lat, currentLocation.lng].join('|');
const cached = processedAttendancePhotos[inputId];
if (!force && cached && cached.cacheKey === cacheKey && cached.file) {
setPhotoPreview(config.previewWrapId, config.previewImgId, cached.file);
info.textContent = `Preview siap: ${(cached.file.filename || cached.file.name)} (${Math.round(cached.file.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Preview siap dikirim', 'ready');
setSubmitButtonState(config.submitBtnId, true);
return cached.file;
}
info.textContent = 'Memproses foto untuk preview...';
setPhotoStatus(config.statusId, 'Memproses foto', 'processing');
setSubmitButtonState(config.submitBtnId, false);
let processedFile;
try {
processedFile = await processAttendancePhotoFile(file, currentLocation);
} catch (error) {
processedFile = file;
info.textContent = `Foto asli siap dikirim: ${file.name} (${Math.round(file.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Foto asli siap dikirim', 'ready');
}
processedAttendancePhotos[inputId] = { cacheKey, file: processedFile };
setPhotoPreview(config.previewWrapId, config.previewImgId, processedFile);
if (processedFile === file) {
info.textContent = `Foto asli siap dikirim: ${file.name} (${Math.round(file.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Foto asli siap dikirim', 'ready');
} else {
info.textContent = `Preview siap: ${processedFile.filename} (${Math.round(processedFile.size / 1024)} KB).`;
setPhotoStatus(config.statusId, 'Preview siap dikirim', 'ready');
}
setSubmitButtonState(config.submitBtnId, true);
return processedFile;
}
function refreshAllPhotoPreviews() {
Object.keys(photoFieldConfig).forEach((inputId) => {
refreshPhotoPreview(inputId, true).catch(() => {});
});
}
function buildAttendanceFormData(location, note, photoInputId) {
const formData = new FormData();
formData.append('lat', location.lat);
formData.append('lng', location.lng);
formData.append('note', note);
return getProcessedAttendancePhoto(photoInputId, location).then((processedPhoto) => {
if (processedPhoto && processedPhoto.blob) {
formData.append('photo', processedPhoto.blob, processedPhoto.filename);
} else if (processedPhoto) {
formData.append('photo', processedPhoto, processedPhoto.name || 'attendance-selfie.jpg');
} else {
const originalFile = getOriginalPhotoFile(photoInputId);
if (originalFile) {
formData.append('photo', originalFile, originalFile.name);
}
}
return formData;
});
}
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const img = new Image();
const objectUrl = URL.createObjectURL(file);
img.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Gagal membaca foto yang dipilih'));
};
img.src = objectUrl;
});
}
function canvasToBlob(canvas, type, quality) {
return new Promise((resolve, reject) => {
const fallback = () => {
try {
const dataUrl = canvas.toDataURL(type, quality);
const parts = dataUrl.split(',');
if (parts.length < 2) throw new Error('Gagal memproses foto absensi');
const mime = (parts[0].match(/data:(.*?);base64/) || [])[1] || type || 'image/jpeg';
const binary = atob(parts[1]);
const len = binary.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) bytes[i] = binary.charCodeAt(i);
resolve(new Blob([bytes], { type: mime }));
} catch (e) {
reject(new Error('Gagal memproses foto absensi'));
}
};
if (typeof canvas.toBlob !== 'function') return fallback();
canvas.toBlob((blob) => {
if (blob) return resolve(blob);
fallback();
}, type, quality);
});
}
function buildWatermarkLines(location) {
const timestamp = new Date().toLocaleString('id-ID', {
timeZone: '<%= settings.timezone %>',
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
return [
'<%= settings.companyName %>'.toUpperCase(),
'TEKNISI PORTAL - ABSENSI',
`Waktu: ${timestamp}`,
`GPS: ${location.lat}, ${location.lng}`
];
}
async function processAttendancePhotoFile(originalFile, location) {
const image = await loadImageFromFile(originalFile);
const maxWidth = 1280;
const scale = Math.min(1, maxWidth / image.width);
const width = Math.max(1, Math.round(image.width * scale));
const height = Math.max(1, Math.round(image.height * scale));
const watermarkLines = buildWatermarkLines(location);
const fontSize = Math.max(20, Math.round(width * 0.028));
const lineGap = Math.round(fontSize * 0.35);
const padding = Math.round(fontSize * 0.8);
const overlayHeight = Math.round((fontSize + lineGap) * watermarkLines.length + padding * 1.6);
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx.drawImage(image, 0, 0, width, height);
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)';
ctx.fillRect(0, height - overlayHeight, width, overlayHeight);
ctx.fillStyle = '#ffffff';
ctx.font = `600 ${fontSize}px Arial, sans-serif`;
ctx.textBaseline = 'top';
let textY = height - overlayHeight + padding;
watermarkLines.forEach((line, index) => {
if (index > 0) {
ctx.font = `500 ${Math.max(16, Math.round(fontSize * 0.88))}px Arial, sans-serif`;
} else {
ctx.font = `600 ${fontSize}px Arial, sans-serif`;
}
ctx.fillText(line, padding, textY);
textY += (index > 0 ? Math.max(16, Math.round(fontSize * 0.88)) : fontSize) + lineGap;
});
const blob = await canvasToBlob(canvas, 'image/jpeg', 0.82);
const baseName = (originalFile.name || 'attendance-photo').replace(/\.[^.]+$/, '');
return {
blob,
filename: `${baseName}-wm.jpg`,
size: blob.size
};
}
async function getProcessedAttendancePhoto(photoInputId, location) {
currentLocation = location;
try {
const processed = await refreshPhotoPreview(photoInputId);
if (processed) return processed;
const originalFile = getOriginalPhotoFile(photoInputId);
if (!originalFile) return null;
return await processAttendancePhotoFile(originalFile, location);
} catch (error) {
return getOriginalPhotoFile(photoInputId);
}
}
// Get current location
function getLocation() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation tidak didukung browser Anda'));
return;
}
const locationInfo = document.getElementById('locationInfo');
locationInfo.style.display = 'block';
locationInfo.innerHTML = '<i class="bi bi-geo-alt-fill"></i> Mengambil lokasi GPS...';
navigator.geolocation.getCurrentPosition(
(position) => {
currentLocation = {
lat: position.coords.latitude.toString(),
lng: position.coords.longitude.toString()
};
locationInfo.innerHTML = `<i class="bi bi-geo-alt-fill text-success"></i> Lokasi berhasil didapat`;
refreshAllPhotoPreviews();
resolve(currentLocation);
},
(error) => {
locationInfo.innerHTML = '<i class="bi bi-exclamation-triangle-fill text-danger"></i> <%= t('collector.gps_fail','Gagal mendapatkan lokasi GPS') %>';
reject(error);
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
});
}
async function checkin(evt) {
const btn = evt.currentTarget;
if (!document.getElementById('checkinPhoto')?.files?.[0]) {
alert('Foto check-in wajib diunggah sebelum absensi.');
return;
}
try {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Memproses...';
const location = await getLocation();
const note = document.getElementById('checkinNote').value.trim();
const response = await fetch('/tech/attendance/checkin', {
method: 'POST',
body: await buildAttendanceFormData(location, note, 'checkinPhoto')
});
const result = await response.json();
if (result.success) {
alert(' ' + result.message);
window.location.reload();
} else {
alert(' ' + result.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Check-in Sekarang';
}
} catch (error) {
alert('Gagal check-in: ' + error.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-in-right me-2"></i>Check-in Sekarang';
}
}
async function checkout(evt) {
const btn = evt.currentTarget;
if (!document.getElementById('checkoutPhoto')?.files?.[0]) {
alert('Foto check-out wajib diunggah sebelum absensi.');
return;
}
try {
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Memproses...';
const location = await getLocation();
const note = document.getElementById('checkoutNote').value.trim();
const response = await fetch('/tech/attendance/checkout', {
method: 'POST',
body: await buildAttendanceFormData(location, note, 'checkoutPhoto')
});
const result = await response.json();
if (result.success) {
alert(' ' + result.message);
window.location.reload();
} else {
alert(' ' + result.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-right me-2"></i>Check-out Sekarang';
}
} catch (error) {
alert('Gagal check-out: ' + error.message);
btn.disabled = false;
btn.innerHTML = '<i class="bi bi-box-arrow-right me-2"></i>Check-out Sekarang';
}
}
// Auto-get location on page load
window.addEventListener('load', () => {
getLocation().catch(() => {});
});
</script>
+155
View File
@@ -0,0 +1,155 @@
<%- include('partials/header') %>
<div class="p-card">
<div style="font-weight:700;font-size:15px;display:flex;align-items:center;gap:8px"><i class="bi bi-person-plus-fill" style="color:var(--primary)"></i> <%= t('tech.cc.title','Tambah Pelanggan') %></div>
<div class="muted" style="font-size:12.5px;margin-top:3px"><%= t('tech.cc.subtitle','Isi data pelanggan dan lokasi pemasangan.') %></div>
<form method="POST" action="/tech/customers" style="margin-top:14px">
<div class="p-field">
<label><%= t('iso.cust_name','Nama Pelanggan') %></label>
<input name="name" class="p-input" required>
</div>
<div class="p-grid2">
<div class="p-field"><label><%= t('tech.cc.phone','No. HP') %></label><input name="phone" class="p-input" placeholder="08xxxxxxxxxx"></div>
<div class="p-field"><label><%= t('pwa.field.package','Paket') %></label>
<select name="package_id" class="p-input">
<option value=""><%= t('tech.cc.ph_pkg','-- Pilih Paket --') %></option>
<% packages.forEach(function(p){ %><option value="<%= p.id %>"><%= p.name %> (Rp <%= Number(p.price||0).toLocaleString('id-ID') %>)</option><% }) %>
</select>
</div>
</div>
<div class="p-grid2">
<div class="p-field"><label>Email</label><input name="email" class="p-input" type="email" placeholder="<%= t('tech.cc.ph_optional','opsional') %>"></div>
<div class="p-field"><label><%= t('tech.cc.identity','No. Identitas (KTP/SIM/Paspor)') %></label><input name="nik" class="p-input" placeholder="<%= t('tech.cc.ph_optional','opsional') %>"></div>
</div>
<div class="p-field"><label><%= t('pwa.field.address','Alamat') %></label><textarea name="address" class="p-input" rows="2"></textarea></div>
<div class="p-field"><label><%= t('collector.col_area','Area / Wilayah') %></label><input name="area" class="p-input" placeholder="<%= t('tech.cc.ph_address','Contoh: Kelurahan A, Blok B') %>"></div>
<div class="p-grid2">
<div class="p-field"><label><%= t('tech.cc.router','Router (MikroTik) *') %></label>
<select id="router_id" name="router_id" class="p-input" required onchange="loadPppoeUsers(); loadPppoeProfiles()">
<option value=""><%= t('tech.cc.ph_router','-- Pilih Router --') %></option>
<% (routers || []).forEach(function(r){ %><option value="<%= r.id %>"><%= r.name %></option><% }) %>
</select>
<div class="muted" style="font-size:11px;margin-top:5px"><%= t('tech.cc.required_pppoe','Wajib untuk pelanggan PPPoE/Hotspot') %></div>
</div>
<div class="p-field"><label><%= t('tech.cc.pppoe_user','PPPoE Username') %></label>
<input id="pppoe_username" name="pppoe_username" class="p-input" list="pppoeList" placeholder="Pilih / cari dari MikroTik">
<datalist id="pppoeList"></datalist>
<div id="pppoeHint" class="muted" style="font-size:11px;margin-top:5px"></div>
</div>
</div>
<div class="p-grid2">
<div class="p-field"><label><%= t('tech.cc.isolir_profile','Profile Isolir') %></label><select id="isolir_profile" name="isolir_profile" class="p-input"></select></div>
<div class="p-field"><label>Status</label>
<select name="status" class="p-input"><option value="active">Aktif</option><option value="suspended">Suspend</option><option value="inactive">Nonaktif</option></select>
</div>
</div>
<div class="p-grid2">
<div class="p-field"><label><%= t('tech.cc.auto_isolate','Isolir Otomatis') %></label><select name="auto_isolate" class="p-input"><option value="1">Aktif</option><option value="0"><%= t('tech.cc.inactive2','Tidak Aktif') %></option></select></div>
<div class="p-field"><label><%= t('tech.cc.isolir_date','Tanggal Isolir (1-31)') %></label><input name="isolate_day" type="number" min="1" max="31" value="10" class="p-input"></div>
</div>
<div class="p-grid2">
<div class="p-field"><label><%= t('tech.cc.install_date','Tanggal Pasang') %></label><input name="install_date" type="date" class="p-input"></div>
<div class="p-field"><label>OLT</label>
<select name="olt_id" class="p-input"><option value=""><%= t('tech.cc.ph_olt','-- Tanpa OLT --') %></option><% (olts || []).forEach(function(o){ %><option value="<%= o.id %>"><%= o.name %></option><% }) %></select>
</div>
</div>
<div class="p-sub"><i class="bi bi-geo-alt-fill" style="color:var(--warning)"></i> <%= t('tech.cc.cust_location','Lokasi Pelanggan') %></div>
<div class="p-grid2">
<div class="p-field"><label>Latitude</label><input id="lat" name="lat" class="p-input" placeholder="-6.2"></div>
<div class="p-field"><label>Longitude</label><input id="lng" name="lng" class="p-input" placeholder="106.8"></div>
</div>
<button type="button" class="p-btn ghost" onclick="getLocation()"><i class="bi bi-crosshair"></i> <%= t('tech.cc.gps_btn','Ambil Lokasi HP') %></button>
<div class="p-sub"><i class="bi bi-diagram-3-fill" style="color:var(--success)"></i> <%= t('tech.cc.odp_port','ODP & Port') %></div>
<div class="p-field"><label>ODP</label>
<select id="odp_id" name="odp_id" class="p-input" onchange="onOdpChanged()">
<option value=""><%= t('tech.cc.ph_odp','-- Pilih ODP --') %></option>
<% odps.forEach(function(o){ %><option value="<%= o.id %>" data-lat="<%= o.lat || '' %>" data-lng="<%= o.lng || '' %>" data-pon="<%= o.pon_port || '' %>" data-cap="<%= o.port_capacity || 16 %>"><%= o.name %></option><% }) %>
</select>
<div id="odpInfo" class="muted" style="font-size:11.5px;margin-top:6px"></div>
</div>
<div class="p-grid2">
<div class="p-field"><label><%= t('tech.cc.cust_port','Port Pelanggan (ODP)') %></label><input id="pon_port" name="pon_port" class="p-input" placeholder="contoh: 1"></div>
<div class="p-field"><label><%= t('tech.cc.update_odp','Update ODP') %></label><select name="update_odp" id="update_odp" class="p-input" onchange="toggleOdpEdit()"><option value="0"><%= t('tech.cc.no','Tidak') %></option><option value="1"><%= t('tech.cc.yes','Ya') %></option></select></div>
</div>
<div id="odpEdit" style="display:none;margin-top:4px">
<div class="p-grid2">
<div class="p-field"><label>ODP Latitude</label><input id="odp_lat" name="odp_lat" class="p-input"></div>
<div class="p-field"><label>ODP Longitude</label><input id="odp_lng" name="odp_lng" class="p-input"></div>
</div>
<div class="p-grid2">
<div class="p-field"><label><%= t('tech.cc.odp_pon','ODP PON Port (Trunk)') %></label><input id="odp_pon_port" name="odp_pon_port" class="p-input" placeholder="<%= t('tech.cc.ph_optional','opsional') %>"></div>
<div class="p-field"><label><%= t('tech.cc.odp_capacity','Kapasitas Port ODP') %></label><input id="odp_port_capacity" name="odp_port_capacity" type="number" min="1" class="p-input" value="16"></div>
</div>
<button type="button" class="p-btn ghost" onclick="copyCustomerToOdp()"><i class="bi bi-arrow-down-circle"></i> <%= t('tech.cc.same_location','Samakan Lokasi ODP = Lokasi Pelanggan') %></button>
</div>
<div class="p-field" style="margin-top:14px"><label><%= t('tech.cc.note','Catatan') %></label><textarea name="notes" class="p-input" rows="2" placeholder="<%= t('tech.cc.ph_optional','opsional') %>"></textarea></div>
<button type="submit" class="p-btn primary" style="margin-top:8px"><i class="bi bi-save2"></i> <%= t('tech.cc.save','Simpan Pelanggan') %></button>
</form>
</div>
<script>
function getLocation() {
if (!navigator.geolocation) return alert('Geolocation tidak didukung di perangkat ini.');
navigator.geolocation.getCurrentPosition(function(pos){ document.getElementById('lat').value = pos.coords.latitude; document.getElementById('lng').value = pos.coords.longitude; },
function(err){ alert('Gagal mengambil lokasi: ' + (err.message || err)); }, { enableHighAccuracy: true, timeout: 8000 });
}
function toggleOdpEdit() { document.getElementById('odpEdit').style.display = document.getElementById('update_odp').value === '1' ? 'block' : 'none'; }
function copyCustomerToOdp() { document.getElementById('odp_lat').value = document.getElementById('lat').value; document.getElementById('odp_lng').value = document.getElementById('lng').value; }
async function onOdpChanged() {
var sel = document.getElementById('odp_id'), opt = sel.options[sel.selectedIndex], odpId = sel.value;
document.getElementById('odp_lat').value = (opt && opt.dataset && opt.dataset.lat) || '';
document.getElementById('odp_lng').value = (opt && opt.dataset && opt.dataset.lng) || '';
document.getElementById('odp_pon_port').value = (opt && opt.dataset && opt.dataset.pon) || '';
document.getElementById('odp_port_capacity').value = (opt && opt.dataset && opt.dataset.cap) || 16;
var infoEl = document.getElementById('odpInfo'); infoEl.innerText = '';
if (!odpId) return;
try {
var res = await fetch('/tech/api/odps/' + encodeURIComponent(odpId) + '/ports');
var data = await res.json();
if (!res.ok) throw new Error(data.error || 'Gagal ambil info ODP');
var used = Array.isArray(data.usedPorts) ? data.usedPorts.join(', ') : '';
infoEl.innerText = 'Port terpakai: ' + data.usedCount + '/' + data.capacity + ' • Sisa: ' + data.remaining + (used ? ' • (' + used + ')' : '');
} catch (e) { infoEl.innerText = e.message; }
}
async function loadPppoeUsers() {
var routerId = (document.getElementById('router_id') || {}).value || '';
var hint = document.getElementById('pppoeHint'), list = document.getElementById('pppoeList');
if (hint) hint.innerText = 'Memuat user PPPoE dari MikroTik…';
if (list) list.innerHTML = '';
try {
var url = routerId ? '/tech/api/mikrotik/pppoe-users?routerId=' + encodeURIComponent(routerId) : '/tech/api/mikrotik/pppoe-users';
var res = await fetch(url); var data = await res.json();
if (!res.ok) throw new Error(data.error || 'Gagal memuat user PPPoE');
var users = Array.isArray(data) ? data : [];
if (list) list.innerHTML = users.filter(function(u){return u && u.name;}).map(function(u){ return '<option value="' + String(u.name).replace(/"/g,'&quot;') + '"></option>'; }).join('');
if (hint) hint.innerText = users.length ? ('Tersedia ' + users.length + ' user PPPoE.') : 'Tidak ada user PPPoE.';
} catch (e) { if (hint) hint.innerText = e.message; }
}
async function loadPppoeProfiles() {
var routerId = (document.getElementById('router_id') || {}).value || '';
var sel = document.getElementById('isolir_profile'); if (sel) sel.innerHTML = '';
try {
var url = routerId ? '/tech/api/mikrotik/pppoe-profiles?routerId=' + encodeURIComponent(routerId) : '/tech/api/mikrotik/pppoe-profiles';
var res = await fetch(url); var data = await res.json();
if (!res.ok) throw new Error(data.error || 'Gagal memuat profile PPPoE');
var profiles = Array.isArray(data) ? data : [];
var opts = ['<option value="isolir">isolir</option>'];
for (var i = 0; i < profiles.length; i++) { var p = profiles[i]; if (!p || !p.name || p.name === 'isolir') continue; opts.push('<option value="' + String(p.name).replace(/"/g,'&quot;') + '">' + p.name + '</option>'); }
if (sel) sel.innerHTML = opts.join('');
} catch (e) { if (sel) sel.innerHTML = '<option value="isolir">isolir</option>'; }
}
loadPppoeUsers();
loadPppoeProfiles();
</script>
<%- include('partials/bottom_nav') %>
+135
View File
@@ -0,0 +1,135 @@
<%- include('partials/header') %>
<div style="margin-bottom:18px">
<div class="muted" style="font-size:12.5px"><%= t('tech.dashboard.greeting', 'Halo, Selamat Bekerja!') %></div>
<div style="font-size:17px;font-weight:700;display:flex;align-items:center;gap:8px"><i class="bi bi-person-badge" style="color:var(--primary)"></i><%= techName %></div>
</div>
<div class="p-stat-grid">
<div class="p-stat" style="border-left:3px solid var(--warning)">
<div class="lbl"><%= t('tech.dashboard.in_progress', 'Sedang Dikerjakan') %></div>
<div class="val" style="color:var(--warning)"><%= stats.inProgress %></div>
</div>
<div class="p-stat" style="border-left:3px solid var(--success)">
<div class="lbl"><%= t('tech.dashboard.resolved_today', 'Selesai Hari Ini') %></div>
<div class="val" style="color:var(--success)"><%= stats.resolved %></div>
</div>
</div>
<div class="p-sec"><i class="bi bi-list-task"></i> <%= t('tech.dashboard.active_tasks', 'Tugas Aktif Anda') %> (<%= tickets.length %>)</div>
<% if (tickets.length === 0) { %>
<div class="p-empty">
<i class="bi bi-check2-circle" style="color:var(--success);opacity:1"></i>
<p style="font-size:13px"><%= t('tech.dashboard.empty_active', 'Bagus! Tidak ada tugas aktif saat ini.') %><br><%= t('tech.dashboard.empty_active_hint', 'Silakan cek tab "Tiket Baru" untuk mengambil tugas.') %></p>
</div>
<% } else { %>
<% tickets.forEach(function(ticket){ %>
<div class="p-card">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px">
<span class="badge-status badge-<%= ticket.status %>">
<% var statusLabel = ticket.status; if (ticket.status === 'open') statusLabel = t('tech.ticket.open', 'Belum Ditangani'); if (ticket.status === 'in_progress') statusLabel = t('tech.ticket.in_progress', 'Sedang Dikerjakan'); if (ticket.status === 'resolved') statusLabel = t('tech.ticket.resolved', 'Selesai'); %>
<% if (ticket.status === 'in_progress') { %><i class="bi bi-tools"></i><% } %> <%= statusLabel %>
</span>
<span class="muted" style="font-size:11px"><%= typeof formatTimeLocal === 'function' ? formatTimeLocal(ticket.created_at) : (ticket.created_at ? new Date(ticket.created_at).toLocaleTimeString('id-ID', {hour:'2-digit', minute:'2-digit'}) : '-') %></span>
</div>
<div style="font-weight:700;font-size:15px"><%= ticket.customer_name %></div>
<div class="muted" style="font-size:12px;margin:6px 0 0">
<i class="bi bi-geo-alt-fill"></i> <%= ticket.customer_address || t('tech.ticket.no_address', 'Tidak ada alamat') %><br>
<i class="bi bi-telephone-fill"></i> <a href="https://wa.me/<%= ticket.customer_phone %>" style="color:var(--info)"><%= ticket.customer_phone || '-' %></a>
</div>
<div class="ticket-msg">
<div class="subj"><%= ticket.subject %></div>
<div><%= ticket.message %></div>
<% if (ticket.customer_photos && ticket.customer_photos !== '' && ticket.customer_photos !== '[]') { %>
<% var customerPhotos = JSON.parse(ticket.customer_photos || '[]'); %>
<% if (customerPhotos.length > 0) { %>
<div style="margin-top:10px;padding-top:10px;border-top:1px solid var(--border)">
<div style="color:var(--info);font-size:12px;margin-bottom:8px"><i class="bi bi-images"></i> <%= t('tech.cust_photos','Foto dari pelanggan') %><%= customerPhotos.length %>)</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
<% customerPhotos.slice(0,3).forEach(function(photo){ %>
<a href="<%= photo %>" target="_blank" rel="noopener"><img src="<%= photo %>" class="p-img" alt="Foto"></a>
<% }); %>
</div>
<% if (customerPhotos.length > 3) { %><div class="muted" style="font-size:11px;margin-top:6px">+<%= customerPhotos.length - 3 %> <%= t('tech.more_photos_suffix','foto lainnya') %></div><% } %>
</div>
<% } %>
<% } %>
</div>
<button type="button" class="p-btn success" onclick="openResolve(<%= ticket.id %>)"><i class="bi bi-check-circle-fill"></i> <%= t('tech.ticket.mark_resolved', 'Tandai Selesai') %></button>
</div>
<!-- Modal Selesai -->
<div class="mo" id="resolveModal<%= ticket.id %>" onclick="if(event.target===this)hideModal('resolveModal<%= ticket.id %>')">
<div class="mb">
<div class="mh"><h3><i class="bi bi-check-circle" style="color:var(--success)"></i> <%= t('tech.ticket.resolve_title', 'Selesaikan Tugas') %></h3>
<button class="mc" onclick="hideModal('resolveModal<%= ticket.id %>')"><i class="bi bi-x-lg"></i></button></div>
<form method="POST" action="/tech/tickets/<%= ticket.id %>/update" enctype="multipart/form-data" id="resolveForm<%= ticket.id %>">
<div class="mbody">
<div class="auth-alert info"><i class="bi bi-info-circle"></i> <%= t('tech.task_for','Tugas untuk') %> <b><%= ticket.customer_name %></b> — <b><%= ticket.subject %></b></div>
<input type="hidden" name="status" value="resolved">
<div class="p-field">
<label><%= t('tech.job_note','Catatan Pekerjaan *') %></label>
<textarea name="notes" class="p-input" rows="3" placeholder="<%= t('tech.note_ph','Jelaskan pekerjaan, kendala, material yang digunakan…') %>" required></textarea>
<div class="muted" style="font-size:11.5px;margin-top:5px"><%= t('tech.job_note_hint','Catatan ini akan dilihat admin dan pelanggan.') %></div>
</div>
<div class="p-field">
<label><%= t('tech.job_photos','Foto Pekerjaan (Opsional, max 10)') %></label>
<input type="file" name="photos" class="p-input" accept="image/*" multiple capture="environment" onchange="previewPhotos(this, <%= ticket.id %>)" style="padding:10px">
<div class="muted" style="font-size:11.5px;margin-top:5px"><%= t('tech.job_photos_hint','Foto sebelum/sesudah, perangkat, atau signal level. Max 5MB/foto.') %></div>
</div>
<div id="photoPreview<%= ticket.id %>" style="display:none;grid-template-columns:repeat(3,1fr);gap:8px;margin-bottom:12px"></div>
<input type="hidden" id="gpsLat<%= ticket.id %>" name="gps_lat" value="">
<input type="hidden" id="gpsLng<%= ticket.id %>" name="gps_lng" value="">
<div class="auth-alert warn"><i class="bi bi-geo-alt-fill"></i> <%= t('tech.gps_note','Lokasi GPS otomatis tersimpan saat menyelesaikan tugas.') %></div>
</div>
<div class="mf">
<button type="button" class="lbtn lbtn-ghost" onclick="hideModal('resolveModal<%= ticket.id %>')"><i class="bi bi-x-circle"></i> <%= t('common.cancel','Batal') %></button>
<button type="submit" class="lbtn lbtn-primary"><i class="bi bi-check-circle-fill"></i> <%= t('tech.done_save','Selesai & Simpan') %></button>
</div>
</form>
</div>
</div>
<% }); %>
<% } %>
<script>
function showModal(id){ var el=document.getElementById(id); if(el) el.classList.add('show'); }
function hideModal(id){ var el=document.getElementById(id); if(el) el.classList.remove('show'); }
function getGps(id){
if(!navigator.geolocation) return;
navigator.geolocation.getCurrentPosition(function(p){
var la=document.getElementById('gpsLat'+id), ln=document.getElementById('gpsLng'+id);
if(la) la.value=p.coords.latitude; if(ln) ln.value=p.coords.longitude;
}, function(){}, {enableHighAccuracy:true, timeout:8000});
}
function openResolve(id){ showModal('resolveModal'+id); getGps(id); }
function previewPhotos(input, ticketId) {
var previewContainer = document.getElementById('photoPreview' + ticketId);
previewContainer.innerHTML = '';
if (input.files && input.files.length > 0) {
previewContainer.style.display = 'grid';
var files = Array.prototype.slice.call(input.files, 0, 10);
files.forEach(function(file, index){
var reader = new FileReader();
reader.onload = function(e){
var col = document.createElement('div');
col.style.position = 'relative';
col.innerHTML = '<img src="'+e.target.result+'" style="width:100%;height:78px;object-fit:cover;border:2px solid var(--border);border-radius:8px"><span style="position:absolute;top:4px;left:4px;background:var(--primary);color:var(--on-primary);border-radius:6px;font-size:10px;font-weight:800;padding:1px 6px">'+(index+1)+'</span>';
previewContainer.appendChild(col);
};
reader.readAsDataURL(file);
});
if (input.files.length > 10) alert('Maksimal 10 foto. Sisanya diabaikan.');
} else { previewContainer.style.display = 'none'; }
}
</script>
<%- include('partials/bottom_nav') %>
+23
View File
@@ -0,0 +1,23 @@
<%- include('partials/header') %>
<div style="margin-bottom:18px">
<h5 style="margin:0;font-weight:700"><%= t('tech.history_page.title', 'Riwayat Tugas') %></h5>
<div class="muted" style="font-size:12px"><%= t('tech.history_page.subtitle', '50 tugas terakhir yang sudah diselesaikan') %></div>
</div>
<% if (tickets.length === 0) { %>
<div class="p-empty"><i class="bi bi-clock-history"></i><p style="font-size:13px"><%= t('tech.history_page.empty', 'Belum ada riwayat tugas yang diselesaikan.') %></p></div>
<% } else { %>
<% tickets.forEach(function(ticket){ %>
<div class="p-card" style="border-color:var(--success-glow)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px">
<span class="badge-status badge-resolved"><i class="bi bi-check-circle-fill"></i> <%= t('tech.ticket.resolved', 'Selesai') %></span>
<span class="muted" style="font-size:11px"><%= new Date(ticket.updated_at).toLocaleString('id-ID', {day:'2-digit', month:'short', year:'numeric'}) %></span>
</div>
<div style="font-weight:700;font-size:14px"><%= ticket.customer_name %></div>
<div class="muted" style="font-size:12px;margin-top:4px"><i class="bi bi-tools"></i> <%= ticket.subject %></div>
</div>
<% }); %>
<% } %>
<%- include('partials/bottom_nav') %>
+43
View File
@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('tech.login_title', 'Login Teknisi') %> - <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/theme-toggle.css">
<link rel="icon" href="/img/icons/icon-192.png">
<script>document.documentElement.classList.add('js');</script>
</head>
<body>
<main class="otp-wrap">
<div class="auth-card">
<div class="auth-logo"><i class="bi bi-tools" style="font-size:26px;color:var(--primary)"></i></div>
<h2><%= t('tech.portal_title', 'Portal Teknisi') %></h2>
<p class="sub"><%= company %></p>
<% if (error) { %><div class="auth-alert err" style="justify-content:center"><i class="bi bi-exclamation-triangle"></i> <%= error %></div><% } %>
<form method="POST" action="/tech/login">
<div class="field">
<label for="username"><i class="bi bi-person-badge"></i> <%= t('auth.tech_username','Username Teknisi') %></label>
<div class="wrap"><i class="bi bi-person-badge lead-ic"></i>
<input type="text" id="username" name="username" class="inp" required placeholder="<%= t('auth.username_ph','Masukkan username') %>" autocomplete="username" autofocus></div>
</div>
<div class="field">
<label for="password"><i class="bi bi-key"></i> <%= t('auth.password','Password') %></label>
<div class="wrap"><i class="bi bi-key lead-ic"></i>
<input type="password" id="password" name="password" class="inp" required placeholder="<%= t('auth.password_ph','Masukkan password') %>" autocomplete="current-password"></div>
</div>
<button type="submit" class="lbtn lbtn-primary btn-auth"><i class="bi bi-box-arrow-in-right"></i> <%= t('common.login','Masuk') %></button>
</form>
</div>
</main>
<script src="/js/theme-toggle.js" defer></script>
</body>
</html>
+267
View File
@@ -0,0 +1,267 @@
<!-- views/tech/map.ejs -->
<%- include('partials/header') %>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<style>
#map { height: calc(100vh - 145px); border-radius: 12px; margin-top: 10px; z-index: 1; border: 1px solid #334155; background: #0f172a; }
.map-info { background: #1e293b; padding: 12px; border-radius: 12px; margin-bottom: 10px; font-size: 13px; border: 1px solid #334155; box-shadow: 0 4px 15px rgba(0,0,0,0.2); }
/* Tech Stats */
.tech-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
.tsc { background: #1e293b; border: 1px solid #334155; padding: 10px; border-radius: 10px; text-align: center; }
.tsc .val { display: block; font-size: 16px; font-weight: 700; color: #f8fafc; }
.tsc .lbl { display: block; font-size: 9px; color: #94a3b8; text-transform: uppercase; font-weight: 600; margin-top: 2px; }
.tsc-w { border-bottom: 3px solid #f59e0b; }
.tsc-s { border-bottom: 3px solid #10b981; }
.tsc-d { border-bottom: 3px solid #ef4444; }
/* Tech Legend Grid */
.legend-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 10px; }
.lc { background: #1e293b; border: 1px solid #334155; padding: 8px; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 8px; }
.lc i { width: 10px; height: 10px; border-radius: 50%; border: 2px solid white; box-shadow: 0 0 5px rgba(0,0,0,0.5); }
.lc span { font-size: 11px; font-weight: 700; color: #f1f5f9; }
.marker-odp { color: #f59e0b; font-size: 16px; text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
.marker-cust-active { color: #3b82f6; font-size: 18px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
.marker-cust-suspended { color: #ef4444; font-size: 18px; filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3)); text-shadow: -1px -1px 0 #fff, 1px -1px 0 #fff, -1px 1px 0 #fff, 1px 1px 0 #fff; }
.popup-dark { color: #1e293b; }
.popup-dark h6 { margin: 0 0 5px 0; border-bottom: 1px solid #ddd; padding-bottom: 5px; font-weight: bold; }
.btn-wa { background: #25d366; color: white; border: none; padding: 5px 10px; border-radius: 5px; text-decoration: none; display: inline-block; margin-top: 8px; font-size: 12px; }
/* Flowing line animation */
@keyframes flow {
from { stroke-dashoffset: 20; }
to { stroke-dashoffset: 0; }
}
.flowing-line {
animation: flow 1s linear infinite;
}
</style>
<style>
/* Re-tema ke design system */
#map { border: 1px solid var(--border); background: var(--bg); }
.map-info { background: var(--glass-2); border-color: var(--border); color: var(--text); }
.tech-stats .tsc { background: var(--glass-2); border-color: var(--border); }
.tsc .val { color: var(--text); }
.tsc .lbl { color: var(--muted); }
.legend-grid .lc { background: var(--glass-2); border-color: var(--border); }
.lc span { color: var(--text); }
.leaflet-popup-content .btn { color: #fff !important; font-weight: 700; border: none !important; }
.leaflet-popup-content .btn-wa { background: #25D366 !important; }
.leaflet-popup-content .btn-wa[style*="3b82f6"] { background: #3b82f6 !important; }
</style>
<div class="tech-stats">
<div class="tsc tsc-w">
<span class="val"><%= odps.length %></span>
<span class="lbl"><%= t('tech.map.total_odp', 'Total ODP') %></span>
</div>
<div class="tsc tsc-s">
<span class="val"><%= customers.filter(c => c.status === 'active').length %></span>
<span class="lbl"><%= t('tech.map.active', 'Aktif') %></span>
</div>
<div class="tsc tsc-d">
<span class="val"><%= customers.filter(c => c.status !== 'active').length %></span>
<span class="lbl"><%= t('tech.map.isolated', 'Isolir') %></span>
</div>
</div>
<div class="legend-grid">
<div class="lc">
<i style="background: #f59e0b;"></i>
<span>ODP</span>
</div>
<div class="lc">
<i style="background: #3b82f6;"></i>
<span><%= t('tech.map.active', 'Aktif') %></span>
</div>
<div class="lc">
<i style="background: #ef4444;"></i>
<span><%= t('tech.map.isolated', 'Isolir') %></span>
</div>
</div>
<div class="map-info">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span><i class="bi bi-info-circle me-1"></i> <%= t('tech.map.mode', 'Mode') %>: <b><%= t('tech.map.mode_sat', 'Satelit (Navigasi)') %></b></span>
<span class="badge bp"><%= t('tech.map.gps_ready', 'GPS Ready') %></span>
</div>
</div>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script id="settings-data" type="application/json"><%- JSON.stringify(settings).split('<').join('\\u003c') %></script>
<script>
const appSettings = JSON.parse(document.getElementById('settings-data').textContent);
const officeCoords = {
lat: parseFloat(appSettings.office_lat) || -6.200000,
lng: parseFloat(appSettings.office_lng) || 106.816666
};
// Initialize Map
const map = L.map('map').setView([officeCoords.lat, officeCoords.lng], 13);
// Layers
const osm = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '© OSM'
});
const googleHybrid = L.tileLayer('https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', {
maxZoom: 20,
subdomains: ['mt0', 'mt1', 'mt2', 'mt3'],
attribution: '© Google Sat'
});
osm.addTo(map); // Default set to OSM first
googleHybrid.addTo(map); // Then overlay satellite
const baseMaps = {
"<%= t('tech.map.satellite', 'Satelit') %>": googleHybrid,
"<%= t('tech.map.standard', 'Standard') %>": osm
};
L.control.layers(baseMaps).addTo(map);
// Data Injection with safety
let customers = [];
let odps = [];
try {
customers = <%- JSON.stringify(customers).replace(/<\/script/g, '<\\/script').replace(/<!--/g, '<\\!--') %>;
odps = <%- JSON.stringify(odps).replace(/<\/script/g, '<\\/script').replace(/<!--/g, '<\\!--') %>;
} catch (e) {
console.error("Data mapping error:", e);
}
// Add ODP Markers
const odpMarkers = {};
odps.forEach(odp => {
if (odp.lat && odp.lng) {
const odpLat = parseFloat(odp.lat);
const odpLng = parseFloat(odp.lng);
odpMarkers[odp.id] = [odpLat, odpLng];
const marker = L.marker([odpLat, odpLng], {
icon: L.divIcon({
html: '<i class="bi bi-box-seam-fill marker-odp"></i>',
className: 'custom-div-icon',
iconSize: [16, 16],
iconAnchor: [8, 8]
})
}).addTo(map);
marker.bindPopup(`
<div class="popup-dark">
<h6>${odp.name}</h6>
<div style="font-size:11px">
<b><%= t('tech.map.olt', 'OLT') %>:</b> ${odp.olt_name || '-'}<br>
<b><%= t('tech.map.port', 'Port') %>:</b> ${odp.pon_port || '-'}<br>
<b><%= t('tech.map.ket', 'Ket') %>:</b> ${odp.description || '-'}
</div>
<a href="https://www.google.com/maps/dir/?api=1&destination=${odp.lat},${odp.lng}" target="_blank" class="btn btn-wa w-100 text-center mt-2" style="background:#3b82f6">
<i class="bi bi-signpost-2"></i> <%= t('tech.map.directions', 'Petunjuk Arah') %>
</a>
</div>
`);
}
});
// Add Customer Markers
customers.forEach(cust => {
if (cust.lat && cust.lng) {
const is_active = cust.status === 'active';
const iconClass = is_active ? 'marker-cust-active' : 'marker-cust-suspended';
const custLat = parseFloat(cust.lat);
const custLng = parseFloat(cust.lng);
// Draw line to ODP if connected
if (cust.odp_id && odpMarkers[cust.odp_id]) {
let pathCoords = [
[custLat, custLng],
odpMarkers[cust.odp_id]
];
if (cust.cable_path) {
try {
const parsed = JSON.parse(cust.cable_path);
if (Array.isArray(parsed) && parsed.length >= 2) {
const cleaned = parsed
.filter(p => Array.isArray(p) && p.length >= 2)
.map(p => [parseFloat(p[0]), parseFloat(p[1])])
.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]));
if (cleaned.length >= 2) pathCoords = cleaned;
}
} catch (e) {}
}
L.polyline(pathCoords, {
color: is_active ? '#3b82f6' : '#ef4444',
weight: 3,
opacity: 0.8,
dashArray: '10, 10',
className: 'flowing-line',
interactive: false
}).addTo(map);
}
const marker = L.marker([custLat, custLng], {
icon: L.divIcon({
html: `<i class="bi bi-person-fill ${iconClass}"></i>`,
className: 'custom-div-icon',
iconSize: [18, 18],
iconAnchor: [9, 9]
})
}).addTo(map);
marker.bindPopup(`
<div class="popup-dark">
<h6>${cust.name}</h6>
<div style="font-size:11px">
<b><%= t('tech.map.address', 'Alamat') %>:</b> ${cust.address}<br>
<b><%= t('tech.map.package', 'Paket') %>:</b> ${cust.package_name || '-'}<br>
<b><%= t('tech.map.status', 'Status') %>:</b> ${cust.status}
</div>
<div class="d-flex gap-1">
<a href="https://wa.me/${cust.phone}" class="btn btn-wa flex-grow-1 text-center">
<i class="bi bi-whatsapp"></i> <%= t('tech.map.chat', 'Chat') %>
</a>
<a href="https://www.google.com/maps/dir/?api=1&destination=${cust.lat},${cust.lng}" target="_blank" class="btn btn-wa flex-grow-1 text-center" style="background:#3b82f6">
<i class="bi bi-geo-alt"></i> <%= t('tech.map.route', 'Rute') %>
</a>
</div>
</div>
`);
}
});
// Geolocation for Tech
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(position => {
const myLat = position.coords.latitude;
const myLng = position.coords.longitude;
L.marker([myLat, myLng], {
icon: L.divIcon({
html: '<i class="bi bi-person-circle text-white" style="font-size:24px; text-shadow: 0 0 10px #3b82f6"></i>',
className: 'custom-div-icon',
iconSize: [24, 24],
iconAnchor: [12, 12]
})
}).addTo(map).bindPopup("<div class='popup-dark text-center'><b><%= t('tech.map.your_pos', 'Posisi Anda') %></b></div>");
});
}
// Auto zoom
const _mkLayers = [];
map.eachLayer(function (l) { if (l instanceof L.Marker || l instanceof L.CircleMarker) _mkLayers.push(l); });
const group = L.featureGroup(_mkLayers);
if (group.getLayers().length > 0) {
map.fitBounds(group.getBounds().pad(0.1));
}
// Fix for blank map on load
setTimeout(() => {
map.invalidateSize();
}, 500);
</script>
<%- include('partials/bottom_nav') %>
+655
View File
@@ -0,0 +1,655 @@
<%- include('partials/header') %>
<style>
.bind-tag { display: flex; align-items: center; justify-content: center; gap: 4px; padding: 8px 4px; border: 1px solid rgba(255,255,255,0.15); border-radius: 8px; background: rgba(255,255,255,0.02); font-size: 11px; font-weight: 700; cursor: pointer; text-align: center; transition: all 0.2s; color: #ced4da; }
.bind-tag:hover { border-color: #10b981; background: rgba(16,185,129,0.05); }
.bind-tag.active { background: #10b981 !important; color: white !important; border-color: #10b981 !important; box-shadow: 0 4px 12px rgba(16,185,129,0.2); }
</style>
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="mb-0 fw-bold">Monitoring ONU</h5>
<div class="text-secondary" style="font-size: 12px;"><%= t('tech.mon.subtitle','Kelola perangkat pelanggan di lapangan') %></div>
</div>
<button class="btn btn-sm btn-outline-primary rounded-circle" onclick="loadDevices()">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
<!-- Search & Filter Bar -->
<div class="mb-3">
<div class="row g-2">
<div class="col-12 col-md-7">
<div class="input-group">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-search"></i></span>
<input type="text" id="search-input" class="form-control bg-dark border-secondary text-white shadow-none" placeholder="Cari: Nama / No HP / PPPoE / SN / Tag..." oninput="filterDevices()">
</div>
</div>
<div class="col-12 col-md-5">
<div class="input-group">
<span class="input-group-text bg-dark border-secondary text-secondary"><i class="bi bi-server"></i></span>
<select id="server-filter" class="form-select bg-dark border-secondary text-white shadow-none" onchange="loadDevices()">
<option value="all"><%= t('tech.mon.all_acs','Semua Server ACS') %></option>
<% if (typeof acsServers !== 'undefined') { %>
<% acsServers.forEach(s => { %>
<option value="<%= s.id %>"><%= s.name %></option>
<% }); %>
<% } %>
</select>
</div>
</div>
</div>
</div>
<div id="device-list">
<div class="text-center py-5">
<div class="spinner-border text-primary" role="status"></div>
<p class="text-secondary mt-2 small"><%= t('tech.mon.loading_devices','Memuat data perangkat...') %></p>
</div>
</div>
<!-- Modal Edit SSID/Password -->
<div class="modal fade" id="actionModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered mx-3">
<div class="modal-content bg-dark border-secondary text-white">
<div class="modal-header border-secondary">
<h6 class="modal-title" id="modalTitle"><%= t('tech.mon.edit_settings','Ubah Pengaturan') %></h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input type="hidden" id="modal-device-tag">
<input type="hidden" id="modal-action-type">
<div id="ssid-input-group" class="d-none">
<label class="form-label small text-secondary"><%= t('tech.mon.new_ssid','Nama WiFi (SSID) Baru') %></label>
<input type="text" id="new-ssid" class="form-control bg-dark border-secondary text-white" placeholder="Masukkan SSID baru">
</div>
<div id="pass-input-group" class="d-none">
<label class="form-label small text-secondary"><%= t('tech.mon.new_pass','Password WiFi Baru') %></label>
<input type="text" id="new-password" class="form-control bg-dark border-secondary text-white" placeholder="Minimal 8 karakter">
</div>
<div id="reboot-confirm" class="d-none text-center py-2">
<i class="bi bi-exclamation-triangle-fill text-warning fs-1"></i>
<p class="mt-2 mb-0"><%= t('tech.mon.reboot_confirm','Apakah Anda yakin ingin melakukan reboot pada perangkat ini?') %></p>
</div>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Batal</button>
<button type="button" id="btn-submit-action" class="btn btn-primary btn-sm"><%= t('tech.mon.save','Simpan Perubahan') %></button>
</div>
</div>
</div>
</div>
<!-- Unified Add WAN All-in-One Modal (Technician Dark Style) -->
<div class="modal fade" id="addWanModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg mx-3">
<div class="modal-content bg-dark border-secondary text-white">
<div class="modal-header border-secondary">
<h6 class="modal-title"><i class="bi bi-gear-fill me-1 text-success"></i> <%= t('tech.mon.prov_title','Provisioning All-in-One WAN') %></h6>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
<form id="addWanForm" onsubmit="submitAddWanForm(event)">
<input type="hidden" id="wanDeviceId" value="">
<input type="hidden" id="wanAcsId" value="">
<div class="p-3 bg-secondary bg-opacity-10 border border-secondary rounded mb-3" style="font-size: 13px;">
<div class="row g-2">
<div class="col-6"><strong><%= t('tech.mon.sn','SN Perangkat:') %></strong> <span id="lblWanSn" class="text-primary fw-bold">-</span></div>
<div class="col-6"><strong><%= t('tech.mon.vendor','Vendor:') %></strong> <span id="lblWanVendor" class="text-info fw-bold" style="text-transform: capitalize;">-</span></div>
<div class="col-6"><strong><%= t('tech.mon.model','Tipe Model:') %></strong> <span id="lblWanModel" class="fw-bold">-</span></div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-6">
<label class="form-label small text-secondary"><%= t('tech.mon.wan_mode','WAN Connection Mode') %></label>
<select id="wanMode" class="form-select bg-dark border-secondary text-white shadow-none" onchange="toggleWanModeFields()">
<option value="pppoe"><%= t('tech.mon.pppoe_router','PPPoE (Router Mode)') %></option>
<option value="bridge">Bridge Mode</option>
</select>
</div>
<div class="col-6">
<label class="form-label small text-secondary">VLAN ID</label>
<input type="number" id="wanVlanId" class="form-control bg-dark border-secondary text-white shadow-none" required min="1" max="4094" placeholder="e.g. 100">
</div>
</div>
<div class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="wanDhcp" checked>
<label class="form-check-label small text-white fw-bold" style="cursor: pointer;" for="wanDhcp">
<i class="bi bi-server text-success me-1"></i> <%= t('tech.mon.dhcp','Aktifkan DHCP Server LAN (Nonaktifkan jika Bridge)') %>
</label>
</div>
</div>
<!-- PPPoE Credentials Block -->
<div id="pppoeFields" class="p-3 bg-primary bg-opacity-10 border border-primary border-opacity-25 rounded mb-3">
<h6 class="mb-2 text-primary"><i class="bi bi-key-fill me-1"></i> <%= t('tech.mon.pppoe_cred','Kredensial PPPoE') %></h6>
<div class="row g-3 mb-2">
<div class="col-6">
<label class="form-label small text-secondary"><%= t('tech.mon.pppoe_username','Username PPPoE') %></label>
<input type="text" id="wanPppoeUser" class="form-control bg-dark border-secondary text-white shadow-none" placeholder="e.g. customer123">
</div>
<div class="col-6">
<label class="form-label small text-secondary"><%= t('tech.mon.pppoe_pass','Password PPPoE') %></label>
<input type="text" id="wanPppoePass" class="form-control bg-dark border-secondary text-white shadow-none" placeholder="e.g. secretpass">
</div>
</div>
<div class="row g-2 align-items-center mt-3 pt-2 border-top border-secondary border-opacity-50">
<div class="col-12 col-sm-6">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" id="wanAutoMikrotik" checked onchange="toggleMikrotikProfileField()">
<label class="form-check-label small text-white" style="cursor: pointer;" for="wanAutoMikrotik"><%= t('tech.mon.auto_create','Auto-create di MikroTik') %></label>
</div>
</div>
<div class="col-12 col-sm-6" id="pppoeProfileContainer">
<label class="form-label small text-secondary"><%= t('tech.mon.mikrotik_profile','Profile MikroTik') %></label>
<select id="wanPppoeProfile" class="form-select bg-dark border-secondary text-white shadow-none">
<% if (typeof pppoeProfiles !== 'undefined') { %>
<% pppoeProfiles.forEach(p => { %>
<option value="<%= p.name %>" <%= p.name === 'default' ? 'selected' : '' %>><%= p.name %></option>
<% }); %>
<% } %>
</select>
</div>
</div>
</div>
<!-- Unified Wi-Fi Block -->
<div class="border border-secondary rounded mb-3 overflow-hidden">
<div class="p-3 bg-secondary bg-opacity-10 border-bottom border-secondary d-flex justify-content-between align-items-center">
<div class="form-check form-switch w-100 mb-0">
<input class="form-check-input" type="checkbox" id="wanConfigureWifi" onchange="toggleWifiFields()">
<label class="form-check-label small text-white fw-bold" style="cursor: pointer;" for="wanConfigureWifi">
<i class="bi bi-wifi text-success me-1"></i> <%= t('tech.mon.wifi_config','Konfigurasikan Wi-Fi Sekaligus') %>
</label>
</div>
</div>
<div id="wifiFields" style="display: none;" class="p-3 bg-dark">
<div id="wifiLoading" class="text-center py-2 text-secondary small">
<div class="spinner-border spinner-border-sm text-primary me-1" role="status"></div> <%= t('tech.mon.loading_ssid','Mengunduh SSID lama...') %>
</div>
<div id="wifiBandsContainer" class="d-grid gap-3">
<!-- 2.4GHz Band -->
<div id="band24Fields">
<h6 class="mb-2 text-white" style="font-size: 13px;"><i class="bi bi-broadcast me-1"></i> Wi-Fi 2.4GHz</h6>
<div class="row g-3">
<div class="col-6">
<label class="form-label small text-secondary"><%= t('tech.mon.ssid_label','SSID (Nama Wi-Fi)') %></label>
<input type="text" id="wifiSsid24" class="form-control bg-dark border-secondary text-white shadow-none" placeholder="Mengunduh...">
</div>
<div class="col-6">
<label class="form-label small text-secondary"><%= t('tech.mon.wifi_pass','Sandi Wi-Fi (WPA2)') %></label>
<input type="password" id="wifiPass24" class="form-control bg-dark border-secondary text-white shadow-none" placeholder="Isi jika diganti">
</div>
</div>
</div>
<!-- 5GHz Band -->
<div id="band5Fields" class="border-top border-secondary border-opacity-50 pt-3" style="display: none;">
<h6 class="mb-2 text-white" style="font-size: 13px;"><i class="bi bi-lightning-charge me-1"></i> Wi-Fi 5GHz</h6>
<div class="row g-3">
<div class="col-6">
<label class="form-label small text-secondary"><%= t('tech.mon.ssid_label','SSID (Nama Wi-Fi)') %></label>
<input type="text" id="wifiSsid5" class="form-control bg-dark border-secondary text-white shadow-none" placeholder="Mengunduh...">
</div>
<div class="col-6">
<label class="form-label small text-secondary"><%= t('tech.mon.wifi_pass','Sandi Wi-Fi (WPA2)') %></label>
<input type="password" id="wifiPass5" class="form-control bg-dark border-secondary text-white shadow-none" placeholder="Isi jika diganti">
</div>
</div>
</div>
</div>
</div>
</div>
<!-- LAN / WLAN Port Binding -->
<div class="p-3 bg-secondary bg-opacity-10 border border-secondary rounded mb-2">
<h6 class="mb-2 text-white fw-bold"><i class="bi bi-bezier2 me-1 text-success"></i> <%= t('tech.mon.port_binding','LAN / WLAN Port Binding') %></h6>
<label class="form-label small text-secondary mb-1"><%= t('tech.mon.port_binding_label','LAN Port Binding:') %></label>
<div class="row g-2 mb-3">
<div class="col-3">
<label class="bind-tag active" id="lblLan1">
<input type="checkbox" name="lanPorts" value="LAN1" checked style="display: none;" onchange="syncTagClass(this, 'lblLan1')"> LAN 1
</label>
</div>
<div class="col-3">
<label class="bind-tag active" id="lblLan2">
<input type="checkbox" name="lanPorts" value="LAN2" checked style="display: none;" onchange="syncTagClass(this, 'lblLan2')"> LAN 2
</label>
</div>
<div class="col-3">
<label class="bind-tag active" id="lblLan3">
<input type="checkbox" name="lanPorts" value="LAN3" checked style="display: none;" onchange="syncTagClass(this, 'lblLan3')"> LAN 3
</label>
</div>
<div class="col-3">
<label class="bind-tag active" id="lblLan4">
<input type="checkbox" name="lanPorts" value="LAN4" checked style="display: none;" onchange="syncTagClass(this, 'lblLan4')"> LAN 4
</label>
</div>
</div>
<label class="form-label small text-secondary mb-1"><%= t('tech.mon.wlan_binding','WLAN (SSID) Binding:') %></label>
<div class="row g-2">
<div class="col-3">
<label class="bind-tag active" id="lblWlan1">
<input type="checkbox" name="wlanSsids" value="SSID1" checked style="display: none;" onchange="syncTagClass(this, 'lblWlan1')"> SSID 1
</label>
</div>
<div class="col-3">
<label class="bind-tag" id="lblWlan2">
<input type="checkbox" name="wlanSsids" value="SSID2" style="display: none;" onchange="syncTagClass(this, 'lblWlan2')"> SSID 2
</label>
</div>
<div class="col-3">
<label class="bind-tag" id="lblWlan3">
<input type="checkbox" name="wlanSsids" value="SSID3" style="display: none;" onchange="syncTagClass(this, 'lblWlan3')"> SSID 3
</label>
</div>
<div class="col-3">
<label class="bind-tag" id="lblWlan4">
<input type="checkbox" name="wlanSsids" value="SSID4" style="display: none;" onchange="syncTagClass(this, 'lblWlan4')"> SSID 4
</label>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer border-secondary">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Batal</button>
<button type="submit" form="addWanForm" class="btn btn-success btn-sm" id="btn-save-add-wan"><%= t('tech.mon.run_prov','Jalankan Provisioning') %></button>
</div>
</div>
</div>
</div>
<script>
let allDevices = [];
const actionModal = new bootstrap.Modal(document.getElementById('actionModal'));
async function loadDevices() {
const container = document.getElementById('device-list');
container.innerHTML = `<div class="text-center py-5"><div class="spinner-border text-primary" role="status"></div><p class="text-secondary mt-2 small"><%= t('tech.mon.loading_devices','Memuat data perangkat...') %></p></div>`;
const acsFilter = document.getElementById('server-filter').value;
try {
const res = await fetch(`/tech/api/devices?acs=${encodeURIComponent(acsFilter)}`);
const data = await res.json();
allDevices = data.devices || [];
filterDevices(); // Apply client search if any
} catch (e) {
container.innerHTML = `<div class="alert alert-danger small">Gagal memuat data: ${e.message}</div>`;
}
}
function renderDevices(devices) {
const container = document.getElementById('device-list');
if (devices.length === 0) {
container.innerHTML = `<div class="text-center py-5 text-secondary small"><%= t('tech.mon.no_devices','Tidak ada perangkat ditemukan') %></div>`;
return;
}
container.innerHTML = devices.map(d => {
const isOn = d.status === 'online';
const title = d.customerName ? d.customerName : (d.tags.join(', ') || 'Tanpa Tag');
const sub = d.customerName ? `${d.tags.join(', ') || 'Tanpa Tag'} | SN: ${d.serialNumber}` : `${d.model || 'ONU'} | SN: ${d.serialNumber}`;
return `
<div class="t-card mb-3 p-3" style="border-left: 4px solid ${isOn ? '#10b981' : '#ef4444'}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<div class="fw-bold" style="font-size: 15px;">${title}</div>
<div class="text-secondary small">${sub}</div>
${d.customerPhone ? `<div class="text-secondary small"><i class="bi bi-telephone-fill"></i> ${d.customerPhone}</div>` : ``}
</div>
<span class="badge-status badge-${isOn ? 'resolved' : 'open'}">
${isOn ? 'Online' : 'Offline'}
</span>
</div>
<div class="mb-2">
<span class="badge bg-primary bg-opacity-10 text-primary border border-primary border-opacity-25" style="font-size: 10px; padding: 3px 8px;">
<i class="bi bi-server me-1"></i>${d.acsServerName || 'Default ACS'}
</span>
</div>
<div class="row g-2 mb-3 mt-1" style="font-size: 12px;">
<div class="col-6">
<div class="text-secondary"><i class="bi bi-person-badge me-1"></i> <%= t('tech.mon.pppoe_user','PPPoE User') %></div>
<div class="fw-semibold text-truncate">${d.pppoeUsername && d.pppoeUsername !== 'N/A' ? d.pppoeUsername : '-'}</div>
</div>
<div class="col-6">
<div class="text-secondary"><i class="bi bi-activity me-1"></i> <%= t('tech.mon.rx','Redaman (RX)') %></div>
<div class="fw-semibold ${d.rxPower && String(d.rxPower).includes('-') ? (parseFloat(d.rxPower) < -27 ? 'text-danger' : 'text-success') : ''}">${d.rxPower || '-'}</div>
</div>
<div class="col-6">
<div class="text-secondary"><i class="bi bi-wifi me-1"></i> SSID</div>
<div class="fw-semibold text-truncate">${d.ssid || '-'}</div>
</div>
<div class="col-6">
<div class="text-secondary"><i class="bi bi-globe me-1"></i> IP Address</div>
<div class="fw-semibold small">${d.pppoeIP || '-'}</div>
</div>
</div>
<div class="d-flex gap-2">
<button class="btn btn-sm btn-outline-primary flex-fill" style="font-size: 11px;" onclick="openActionModal('${d.tags[0]}', 'ssid', '${d.ssid}')">
<i class="bi bi-wifi"></i> SSID
</button>
<button class="btn btn-sm btn-outline-primary flex-fill" style="font-size: 11px;" onclick="openActionModal('${d.tags[0]}', 'pass')">
<i class="bi bi-key"></i> Pass
</button>
<button class="btn btn-sm btn-outline-success flex-fill" style="font-size: 11px;" onclick="openAddWanModal('${d.id}', '${d.acsServerId}', '${d.serialNumber}', '${d.manufacturer || ""}', '${d.model || ""}')">
<i class="bi bi-gear-fill"></i> WAN
</button>
<button class="btn btn-sm btn-outline-danger" style="font-size: 11px;" onclick="openActionModal('${d.tags[0]}', 'reboot')">
<i class="bi bi-power"></i>
</button>
</div>
</div>
`;
}).join('');
}
function filterDevices() {
const q = document.getElementById('search-input').value.toLowerCase();
const filtered = allDevices.filter(d => {
return d.tags.some(t => t.toLowerCase().includes(q)) ||
d.serialNumber.toLowerCase().includes(q) ||
(d.pppoeUsername && d.pppoeUsername !== 'N/A' && String(d.pppoeUsername).toLowerCase().includes(q)) ||
(d.customerName && String(d.customerName).toLowerCase().includes(q)) ||
(d.customerPhone && String(d.customerPhone).toLowerCase().includes(q)) ||
(d.id && String(d.id).toLowerCase().includes(q));
});
renderDevices(filtered);
}
function openActionModal(tag, type, currentSsid = '') {
document.getElementById('modal-device-tag').value = tag;
document.getElementById('modal-action-type').value = type;
document.getElementById('ssid-input-group').classList.add('d-none');
document.getElementById('pass-input-group').classList.add('d-none');
document.getElementById('reboot-confirm').classList.add('d-none');
const submitBtn = document.getElementById('btn-submit-action');
submitBtn.className = 'btn btn-primary btn-sm';
if (type === 'ssid') {
document.getElementById('modalTitle').innerText = 'Ubah SSID';
document.getElementById('ssid-input-group').classList.remove('d-none');
document.getElementById('new-ssid').value = currentSsid;
submitBtn.innerText = 'Simpan SSID';
} else if (type === 'pass') {
document.getElementById('modalTitle').innerText = 'Ubah Password WiFi';
document.getElementById('pass-input-group').classList.remove('d-none');
document.getElementById('new-password').value = '';
submitBtn.innerText = 'Simpan Password';
} else if (type === 'reboot') {
document.getElementById('modalTitle').innerText = 'Reboot Perangkat';
document.getElementById('reboot-confirm').classList.remove('d-none');
submitBtn.innerText = 'Ya, Reboot Now';
submitBtn.className = 'btn btn-danger btn-sm';
}
actionModal.show();
}
document.getElementById('btn-submit-action').addEventListener('click', async function() {
const tag = document.getElementById('modal-device-tag').value;
const type = document.getElementById('modal-action-type').value;
const btn = this;
let url = '';
let body = {};
if (type === 'ssid') {
const ssid = document.getElementById('new-ssid').value.trim();
if (!ssid) return alert('SSID tidak boleh kosong');
url = `/tech/api/device/${tag}/ssid`;
body = { ssid };
} else if (type === 'pass') {
const password = document.getElementById('new-password').value.trim();
if (password.length < 8) return alert('Password minimal 8 karakter');
url = `/tech/api/device/${tag}/password`;
body = { password };
} else if (type === 'reboot') {
url = `/tech/api/device/${tag}/reboot`;
body = {};
}
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Proses...';
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const result = await res.json();
if (result.success || result.ok) {
alert('Perintah berhasil dikirim!');
actionModal.hide();
setTimeout(loadDevices, 2000);
} else {
alert('Gagal: ' + (result.error || result.message || 'Terjadi kesalahan'));
}
} catch (e) {
alert('Error: ' + e.message);
} finally {
btn.disabled = false;
btn.innerText = 'Simpan Perubahan';
}
});
const addWanModal = new bootstrap.Modal(document.getElementById('addWanModal'));
// Sync class active on binding tag label based on checkbox state
function syncTagClass(checkbox, labelId) {
const lbl = document.getElementById(labelId);
if (checkbox.checked) {
lbl.classList.add('active');
} else {
lbl.classList.remove('active');
}
}
// Toggle visibility of PPPoE-specific fields
function toggleWanModeFields() {
const mode = document.getElementById('wanMode').value;
const pppoeFields = document.getElementById('pppoeFields');
const wanDhcp = document.getElementById('wanDhcp');
if (mode === 'pppoe') {
pppoeFields.style.display = 'block';
if (wanDhcp) wanDhcp.checked = true;
} else {
pppoeFields.style.display = 'none';
if (wanDhcp) wanDhcp.checked = false;
}
}
// Toggle visibility of Profile MikroTik field based on auto-create check
function toggleMikrotikProfileField() {
const autoChk = document.getElementById('wanAutoMikrotik');
const container = document.getElementById('pppoeProfileContainer');
if (autoChk.checked) {
container.style.visibility = 'visible';
} else {
container.style.visibility = 'hidden';
}
}
// Toggle Wi-Fi configuration slide-down and AJAX fetch
async function toggleWifiFields() {
const configureWifi = document.getElementById('wanConfigureWifi').checked;
const wifiFields = document.getElementById('wifiFields');
if (configureWifi) {
wifiFields.style.display = 'block';
const deviceId = document.getElementById('wanDeviceId').value;
const acsId = document.getElementById('wanAcsId').value;
const wifiLoading = document.getElementById('wifiLoading');
const band24Fields = document.getElementById('band24Fields');
const band5Fields = document.getElementById('band5Fields');
wifiLoading.style.display = 'block';
band24Fields.style.opacity = '0.5';
band5Fields.style.opacity = '0.5';
try {
const res = await fetch(`/tech/api/wifi-settings/${encodeURIComponent(deviceId)}?acsId=${encodeURIComponent(acsId)}`);
const d = await res.json();
if (d && d.success && Array.isArray(d.bands)) {
// Reset fields
document.getElementById('wifiSsid24').value = '';
document.getElementById('wifiSsid5').value = '';
band5Fields.style.display = 'none';
d.bands.forEach(b => {
if (b.index === '1') {
document.getElementById('wifiSsid24').value = b.ssid;
document.getElementById('wifiSsid24').placeholder = 'Nama Wi-Fi 2.4G';
} else if (b.index === '5' || b.index === '2') {
band5Fields.style.display = 'block';
document.getElementById('wifiSsid5').value = b.ssid;
document.getElementById('wifiSsid5').placeholder = 'Nama Wi-Fi 5G';
}
});
}
} catch (e) {
console.error('Gagal mengambil SSID perangkat:', e);
} finally {
wifiLoading.style.display = 'none';
band24Fields.style.opacity = '1';
band5Fields.style.opacity = '1';
}
} else {
wifiFields.style.display = 'none';
}
}
// Open modal Add WAN All-in-One and populate basic details
async function openAddWanModal(deviceId, acsId, sn, manufacturer, model) {
document.getElementById('wanDeviceId').value = deviceId;
document.getElementById('wanAcsId').value = acsId;
document.getElementById('lblWanSn').innerText = sn || deviceId;
document.getElementById('lblWanVendor').innerText = manufacturer || '-';
document.getElementById('lblWanModel').innerText = model || '-';
// Reset form fields
document.getElementById('wanVlanId').value = '';
document.getElementById('wanPppoeUser').value = '';
document.getElementById('wanPppoePass').value = '';
document.getElementById('wanConfigureWifi').checked = false;
document.getElementById('wifiFields').style.display = 'none';
// Default active bindings
document.querySelectorAll('input[name="lanPorts"]').forEach(c => {
c.checked = true;
syncTagClass(c, 'lbl' + c.value.charAt(0) + c.value.slice(1).toLowerCase());
});
// Reset SSID labels to original text first
for (let i = 1; i <= 4; i++) {
const lbl = document.getElementById(`lblWlan${i}`);
if (lbl) {
lbl.innerHTML = `<input type="checkbox" name="wlanSsids" value="SSID${i}" ${i === 1 ? 'checked' : ''} style="display: none;" onchange="syncTagClass(this, 'lblWlan${i}')"> SSID ${i}`;
if (i === 1) lbl.classList.add('active');
else lbl.classList.remove('active');
}
}
toggleWanModeFields();
toggleMikrotikProfileField();
addWanModal.show();
// Async fetch WLAN actual SSID names
try {
const res = await fetch(`/tech/api/wifi-settings/${encodeURIComponent(deviceId)}?acsId=${encodeURIComponent(acsId)}`);
const d = await res.json();
if (d && d.success && Array.isArray(d.bands)) {
d.bands.forEach(b => {
const lbl = document.getElementById(`lblWlan${b.index}`);
if (lbl) {
const isChecked = b.index === '1'; // Default SSID 1 is checked
lbl.innerHTML = `<input type="checkbox" name="wlanSsids" value="SSID${b.index}" ${isChecked ? 'checked' : ''} style="display: none;" onchange="syncTagClass(this, 'lblWlan${b.index}')"> SSID ${b.index}: ${b.ssid}`;
}
});
}
} catch (e) {
console.error('Gagal mengambil nama SSID:', e);
}
}
// AJAX submission of the Provisioning form
async function submitAddWanForm(e) {
e.preventDefault();
const btn = document.getElementById('btn-save-add-wan');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Memproses...';
const deviceId = document.getElementById('wanDeviceId').value;
const acsId = document.getElementById('wanAcsId').value;
const lanPorts = [];
document.querySelectorAll('input[name="lanPorts"]:checked').forEach(c => lanPorts.push(c.value));
const wlanSsids = [];
document.querySelectorAll('input[name="wlanSsids"]:checked').forEach(c => wlanSsids.push(c.value));
const body = {
acsId,
mode: document.getElementById('wanMode').value,
vlanId: document.getElementById('wanVlanId').value,
pppoeUser: document.getElementById('wanPppoeUser').value,
pppoePass: document.getElementById('wanPppoePass').value,
pppoeProfile: document.getElementById('wanPppoeProfile').value,
autoCreateMikrotik: document.getElementById('wanAutoMikrotik').checked,
lanPorts,
wlanSsids,
configureWifi: document.getElementById('wanConfigureWifi').checked,
wifiSsid24: document.getElementById('wifiSsid24').value,
wifiPass24: document.getElementById('wifiPass24').value,
wifiSsid5: document.getElementById('wifiSsid5').value,
wifiPass5: document.getElementById('wifiPass5').value,
dhcp: document.getElementById('wanDhcp').checked
};
try {
const res = await fetch(`/tech/api/add-wan/${encodeURIComponent(deviceId)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json();
if (data && data.success) {
alert(data.message);
addWanModal.hide();
setTimeout(loadDevices, 2000);
} else {
alert(data.message || 'Gagal menjalankan provisioning');
}
} catch (err) {
alert('Terjadi kesalahan: ' + err.message);
} finally {
btn.disabled = false;
btn.innerText = 'Jalankan Provisioning';
}
}
loadDevices();
</script>
<%- include('partials/bottom_nav') %>
+12
View File
@@ -0,0 +1,12 @@
</div> <!-- End container -->
<nav class="portal-bottomnav">
<a href="/tech" class="portal-nav-item <%= activePage === 'dashboard' ? 'active' : '' %>"><i class="bi bi-briefcase-fill"></i><span><%= t('tech.nav.my_tasks', 'Tugas Saya') %></span></a>
<a href="/tech/pool" class="portal-nav-item <%= activePage === 'pool' ? 'active' : '' %>"><i class="bi bi-inbox-fill"></i><span><%= t('tech.nav.new_tickets', 'Tiket Baru') %></span></a>
<a href="/tech/attendance" class="portal-nav-item <%= activePage === 'attendance' ? 'active' : '' %>"><i class="bi bi-calendar-check-fill"></i><span><%= t('collector.absensi','Absensi') %></span></a>
<a href="/tech/map" class="portal-nav-item <%= activePage === 'map' ? 'active' : '' %>"><i class="bi bi-map-fill"></i><span><%= t('tech.nav.map', 'Peta') %></span></a>
<a href="/tech/monitoring" class="portal-nav-item <%= activePage === 'monitoring' ? 'active' : '' %>"><i class="bi bi-display-fill"></i><span><%= t('tech.nav.monitor', 'Monitor') %></span></a>
</nav>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!-- views/tech/partials/header.ejs -->
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= title %> - <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="/vendor/bootstrap/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<script src="/vendor/bootstrap/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/portal.css?v=4">
<link rel="stylesheet" href="/css/portal-theme.css?v=3">
<link rel="icon" href="/img/icons/icon-192.png">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();function toggleTheme(){var el=document.documentElement;var t=el.getAttribute('data-theme')==='dark'?'light':'dark';el.setAttribute('data-theme',t);try{localStorage.setItem('landing-theme',t);}catch(e){}}</script>
</head>
<body class="portal-body">
<div class="portal-topnav">
<div class="t"><i class="bi bi-tools" style="color:var(--primary)"></i> <%= title %> <span style="font-size:10px;opacity:.55;font-weight:400">v<%= appVersion %></span></div>
<div class="actions">
<a href="/lang/id" class="nav-btn lang-btn" title="Bahasa Indonesia">ID</a>
<a href="/lang/en" class="nav-btn lang-btn" title="English">EN</a>
<button type="button" class="nav-btn" onclick="toggleTheme()" title="Ganti tema"><i class="bi bi-moon-stars-fill"></i></button>
<a href="/tech/customers/new" class="nav-btn" title="<%= t('tech.new_customer', 'Tambah Pelanggan') %>"><i class="bi bi-person-plus-fill"></i></a>
<a href="/tech/logout" class="nav-btn" onclick="return confirm('<%= t('tech.logout_confirm', 'Apakah Anda yakin ingin logout?') %>')" title="Logout"><i class="bi bi-box-arrow-right"></i></a>
</div>
</div>
<div class="container" style="max-width:720px;padding-top:16px">
<%- include('../../partials/service-notice') %>
<% if (typeof msg !== 'undefined' && msg) { %>
<div class="auth-alert <%= msg.type==='error'?'err':'ok' %>" id="techAlert">
<i class="bi bi-<%= msg.type==='error'?'exclamation-circle':'check-circle' %>"></i> <%= msg.text %>
</div>
<script>setTimeout(function(){var a=document.getElementById('techAlert');if(a)a.style.display='none';},4500);</script>
<% } %>
+39
View File
@@ -0,0 +1,39 @@
<%- include('partials/header') %>
<div style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:16px">
<div>
<h5 style="margin:0;font-weight:700"><%= t('tech.pool.title', 'Tiket Baru') %></h5>
<div class="muted" style="font-size:12px"><%= t('tech.pool.subtitle', 'Daftar keluhan yang belum ditangani') %></div>
</div>
<div style="width:42px;height:42px;border-radius:50%;background:var(--ddim);color:var(--danger);display:grid;place-items:center;font-weight:800;flex:0 0 auto"><%= tickets.length %></div>
</div>
<% if (tickets.length === 0) { %>
<div class="p-empty">
<i class="bi bi-clipboard-check"></i>
<p style="font-size:13px"><%= t('tech.pool.empty', 'Tidak ada keluhan baru saat ini.') %><br><%= t('tech.pool.empty_hint', 'Semua pelanggan berjalan normal.') %></p>
</div>
<% } else { %>
<% tickets.forEach(function(ticket){ %>
<div class="p-card" style="border-color:var(--danger-glow)">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:8px">
<span class="badge-status badge-open"><i class="bi bi-exclamation-circle-fill"></i> <%= t('tech.ticket.open', 'Belum Ditangani') %></span>
<span class="muted" style="font-size:11px"><%= typeof formatDateLocal === 'function' ? formatDateLocal(ticket.created_at) : (ticket.created_at ? new Date(ticket.created_at).toLocaleString('id-ID', {day:'2-digit', month:'short', hour:'2-digit', minute:'2-digit'}) : '-') %></span>
</div>
<div style="font-weight:700;font-size:15px"><%= ticket.customer_name %></div>
<div class="muted" style="font-size:12px;margin:6px 0 0"><i class="bi bi-geo-alt-fill"></i> <%= ticket.customer_address || t('tech.ticket.no_address', 'Tidak ada alamat') %></div>
<div class="ticket-msg">
<div class="subj" style="color:var(--danger)"><%= ticket.subject %></div>
<div><%= ticket.message %></div>
</div>
<form method="POST" action="/tech/tickets/<%= ticket.id %>/take">
<button type="submit" class="p-btn primary" onclick="return confirm(<%- JSON.stringify(t('tech.pool.take_confirm', 'Ambil tiket ini untuk Anda kerjakan sekarang?')).split('<').join('\\u003c') %>)"><i class="bi bi-hand-index-thumb-fill"></i> <%= t('tech.pool.take_action', 'Ambil Tugas Ini') %></button>
</form>
</div>
<% }); %>
<% } %>
<%- include('partials/bottom_nav') %>
+76
View File
@@ -0,0 +1,76 @@
<!DOCTYPE html>
<html lang="<%= lang || 'id' %>" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title><%= t('customer.pages.tos.meta_title', 'Syarat & Ketentuan') %> | <%= company %></title>
<meta name="theme-color" content="#0A0D14">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
<link rel="stylesheet" href="/css/landing.css?v=4">
<link rel="stylesheet" href="/css/auth.css?v=4">
<link rel="stylesheet" href="/css/pages.css?v=3">
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/img/icons/icon-192.png">
<link rel="apple-touch-icon" href="/img/icons/apple-touch-icon.png">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<script>document.documentElement.classList.add('js');(function(){try{var s=localStorage.getItem('landing-theme')||'dark';document.documentElement.setAttribute('data-theme',s);}catch(e){}})();</script>
</head>
<body>
<header class="site-header">
<div class="container inner">
<a class="brand" href="/" aria-label="Beranda"><img class="bmark" src="<%= brandLogo %>" alt="<%= company %>"><span class="bname"><%= brandName %></span></a>
<div class="header-actions" style="margin-left:auto">
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill"></i></button>
<a class="lbtn lbtn-ghost lbtn-sm" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-box-arrow-in-right"></i> <%= isLoggedIn ? 'Dashboard' : 'Masuk' %></a>
</div>
</div>
</header>
<main class="container">
<section class="page-hero">
<h1><%= t('customer.pages.tos.title', 'Syarat & Ketentuan') %></h1>
<p><%= t('customer.pages.tos.subtitle', 'Harap baca dengan teliti sebelum menggunakan layanan kami.') %></p>
</section>
<div class="prose">
<p><%- String(t('customer.pages.tos.p1', 'Selamat datang di layanan internet <strong>{company}</strong>. Dengan menggunakan layanan kami dan melakukan pembayaran melalui sistem ini, Anda setuju untuk terikat oleh syarat dan ketentuan berikut:')).replace('{company}', company) %></p>
<h4><i class="bi bi-broadcast"></i> 1. <%= t('customer.pages.tos.s1_title', 'Layanan') %></h4>
<p><%= t('customer.pages.tos.s1_body', 'Kami menyediakan layanan akses internet (ISP) kepada pelanggan sesuai dengan paket yang dipilih. Kualitas layanan dapat dipengaruhi oleh faktor teknis, lokasi geografis, dan kondisi cuaca yang ekstrem.') %></p>
<h4><i class="bi bi-wallet2"></i> 2. <%= t('customer.pages.tos.s2_title', 'Pembayaran & Tagihan') %></h4>
<ul>
<li><%= t('customer.pages.tos.s2_li1', 'Tagihan internet akan diterbitkan secara otomatis oleh sistem setiap bulan sesuai dengan siklus langganan Anda.') %></li>
<li><%= t('customer.pages.tos.s2_li2', 'Pembayaran dapat dilakukan secara mandiri melalui Portal Pelanggan menggunakan metode QRIS, Transfer Bank, atau E-Wallet yang tersedia.') %></li>
<li><%= t('customer.pages.tos.s2_li3', 'Setiap pembayaran yang sudah berhasil diverifikasi oleh sistem bersifat final dan tidak dapat dibatalkan atau dikembalikan (Non-Refundable).') %></li>
</ul>
<h4><i class="bi bi-shield-lock"></i> 3. <%= t('customer.pages.tos.s3_title', 'Kebijakan Isolir (Suspend)') %></h4>
<p><%= t('customer.pages.tos.s3_body', 'Sistem kami akan melakukan pemutusan layanan sementara (Isolir) secara otomatis apabila tagihan belum dilunasi hingga melewati batas waktu (Jatuh Tempo) yang telah ditentukan. Layanan akan aktif kembali secara otomatis segera setelah pembayaran terverifikasi.') %></p>
<h4 id="refund"><i class="bi bi-arrow-repeat"></i> 4. <%= t('customer.pages.tos.s4_title', 'Pengembalian Dana (Refund)') %></h4>
<p><%= t('customer.pages.tos.s4_body', 'Pengembalian dana hanya dapat dipertimbangkan jika terjadi kesalahan sistem pada payment gateway kami yang menyebabkan transaksi ganda untuk satu tagihan yang sama.') %></p>
<h4><i class="bi bi-exclamation-octagon"></i> 5. <%= t('customer.pages.tos.s5_title', 'Batasan Tanggung Jawab') %></h4>
<p><%= String(t('customer.pages.tos.s5_body', '{company} tidak bertanggung jawab atas kerugian yang disebabkan oleh gangguan teknis dari penyedia hulu (Upstream), kerusakan perangkat akibat kelalaian pelanggan, atau kejadian luar biasa (Force Majeure).')).replace('{company}', company) %></p>
<div style="text-align:center;margin-top:30px">
<a class="lbtn lbtn-primary" href="<%= isLoggedIn ? '/customer/dashboard' : '/customer/login' %>"><i class="bi bi-arrow-left"></i> <%= t('common.back_to', 'Kembali ke') %> <%= isLoggedIn ? t('customer.dashboard_title', 'Dashboard Pelanggan') : t('common.portal', 'Portal') %></a>
</div>
</div>
<div class="page-foot">&copy; <%= new Date().getFullYear() %> <%= company %> • <%= t('customer.pages.tos.footer_tagline', 'Semua Hak Dilindungi') %></div>
</main>
<script>
function toggleTheme(){ var el=document.documentElement; var t=el.getAttribute('data-theme')==='dark'?'light':'dark'; el.setAttribute('data-theme',t); try{localStorage.setItem('landing-theme',t);}catch(e){} }
if('serviceWorker' in navigator){ navigator.serviceWorker.register('/sw.js',{scope:'/customer/'}).then(function(r){try{r.update();}catch(e){}}).catch(function(){}); }
</script>
<%- include('partials/customer_bottom_nav', { activeNav: 'none' }) %>
</body>
</html>