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

+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>