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

+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') %>