Track public JS yg dipakai view (tooltips, dhcp, stasiunkerja, tarifstiker, member-details/extend) biar ikut ke-deploy

This commit is contained in:
2026-10-14 12:59:35 +07:00
parent fae9184c1b
commit 7dec985309
6 changed files with 1773 additions and 0 deletions
+382
View File
@@ -0,0 +1,382 @@
let editing = false;
/** Create */
function openCreateModal() {
editing = false;
document.getElementById("reservationModalTitle").innerText = "Add DHCP Reservation";
document.getElementById("reservationForm").reset();
document.getElementById("reservationId").value = "";
document.getElementById("enabled").checked = true;
clearValidation();
}
/*** Edit*/
function editReservation(hostname, mac, ip, enabled) {
editing = true;
document.getElementById("reservationModalTitle").innerText = "Edit DHCP Reservation";
document.getElementById("hostname").value = hostname;
document.getElementById("macAddress").value = mac;
document.getElementById("ipAddress").value = ip;
document.getElementById("enabled").checked = enabled;
const modal = new bootstrap.Modal(document.getElementById("reservationModal"));
modal.show();
}
/*** MAC formatting */
document.getElementById("macAddress").addEventListener("input", function () {
let value = this.value.replace(/[^a-fA-F0-9]/g, "").toUpperCase();
let pairs = value.match(/.{1,2}/g);
this.value = pairs ? pairs.join(":").substring(0, 17) : "";
});
function clearValidation() {
document.getElementById("macAddress").classList.remove("is-invalid");
document.getElementById("ipAddress").classList.remove("is-invalid");
document.getElementById("macError").innerText = "Invalid MAC address.";
document.getElementById("ipError").innerText = "Invalid IP address.";
}
document.getElementById("reservationForm").addEventListener("submit", function (event) {
event.preventDefault();
clearValidation();
const id = document.getElementById("reservationId").value;
const payload = {
hostname: document.getElementById("hostname").value.trim(),
mac_address: document.getElementById("macAddress").value.trim(),
ip_address: document.getElementById("ipAddress").value.trim(),
enabled: document.getElementById("enabled").checked,
};
const macValid = /^([0-9A-F]{2}:){5}[0-9A-F]{2}$/i.test(payload.mac_address);
const ipValid = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/.test(
payload.ip_address,
);
if (!macValid) {
document.getElementById("macAddress").classList.add("is-invalid");
return;
}
if (!ipValid) {
document.getElementById("ipAddress").classList.add("is-invalid");
return;
}
const url = id ? dhcpConfig.reservationBaseUrl + "/" + id : dhcpConfig.reservationStoreUrl;
const method = id ? "PUT" : "POST";
const submitButton = this.querySelector('button[type="submit"]');
if (submitButton) {
submitButton.disabled = true;
submitButton.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>' + "Saving...";
}
fetch(url, {
method: method,
headers: {
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').getAttribute("content"),
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
.then(async (response) => {
const data = await response.json();
if (!response.ok) {
throw data;
}
return data;
})
.then((data) => {
if (!data.success) {
throw data;
}
const modal = bootstrap.Modal.getInstance(document.getElementById("reservationModal"));
if (modal) {
modal.hide();
}
window.location.reload();
})
.catch((error) => {
console.error("Reservation save error:", error);
if (error.errors && error.errors.mac_address) {
document.getElementById("macAddress").classList.add("is-invalid");
document.getElementById("macError").innerText = error.errors.mac_address[0];
}
if (error.errors && error.errors.ip_address) {
document.getElementById("ipAddress").classList.add("is-invalid");
document.getElementById("ipError").innerText = error.errors.ip_address[0];
}
Swal.fire({icon: 'error', title: 'Gagal menyimpan', text: error.message || "Failed to save reservation."});
})
.finally(() => {
if (submitButton) {
submitButton.disabled = false;
submitButton.innerHTML = '<i class="bi bi-check-lg me-1"></i>' + "Save Reservation";
}
});
});
/*** Search*/
document.getElementById("searchReservation").addEventListener("input", function () {
const keyword = this.value.toLowerCase();
document.querySelectorAll("#reservationTable tbody tr").forEach(function (row) {
row.style.display = row.innerText.toLowerCase().includes(keyword) ? "" : "none";
});
});
/*** Delete*/
function deleteReservation(hostname) {
Swal.fire({title: 'Hapus reservasi "' + hostname + '"?', icon: 'warning', showCancelButton: true, confirmButtonText: 'Ya, hapus', cancelButtonText: 'Batal', confirmButtonColor: '#dc3545'})
.then((res) => {
if (!res.isConfirmed) return;
Swal.fire({icon: 'info', title: 'Mode demo', text: 'Reservation "' + hostname + '" deleted.'});
});
}
/**
* Test DHCP
*/
function testDhcp() {
Swal.fire({icon: 'info', title: 'Mode demo', text: "DHCP configuration is valid."});
}
/**
* Apply
*/
function applyDhcp() {
const modal = new bootstrap.Modal(document.getElementById("applyModal"));
modal.show();
}
/*** Confirm Apply */
function confirmApply() {
const modalElement = document.getElementById("applyModal");
const modal = bootstrap.Modal.getInstance(modalElement);
if (modal) {
modal.hide();
}
const button = document.querySelector('#applyModal [onclick="confirmApply()"]');
if (button) {
button.disabled = true;
button.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>' + "Applying...";
}
fetch(dhcpConfig.applyUrl, {
method: "POST",
headers: {
"X-CSRF-TOKEN": document.querySelector('meta[name="csrf-token"]').getAttribute("content"),
Accept: "application/json",
},
})
.then(async (response) => {
const data = await response.json();
if (!response.ok) {
throw data;
}
return data;
})
.then((data) => {
if (!data.success) {
throw data;
}
Swal.fire({icon: 'success', title: 'Berhasil', text: data.message || "DHCP configuration applied successfully."});
refreshDhcpStatus();
window.location.reload();
})
.catch((error) => {
console.error("DHCP apply error:", error);
Swal.fire({icon: 'error', title: 'Gagal apply', text: error.message || "Failed to apply DHCP configuration."});
})
.finally(() => {
if (button) {
button.disabled = false;
button.innerHTML = "Apply Configuration";
}
});
}
function refreshDhcpStatus() {
fetch(dhcpConfig.statusUrl)
.then(async (response) => {
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
return response.json();
})
.then((data) => {
document.getElementById("dhcpServerHostname").innerText = data.hostname ?? "DHCP Server";
const badge = document.getElementById("dhcpStatusBadge");
const clients = document.getElementById("activeClients");
const lastApplied = document.getElementById("lastApplied");
const serverIp = document.getElementById("dhcpServerIp");
const serverInterface = document.getElementById("dhcpServerInterface");
serverIp.innerText = data.ip ?? "-";
serverInterface.innerText = data.interface ?? "-";
// STATUS
if (data.running) {
badge.innerHTML = `
<span class="badge rounded-pill bg-success bg-opacity-10 text-success">
<span
class="d-inline-block rounded-circle bg-success me-1"
style="width:7px;height:7px;"
></span>
Running
</span>
`;
} else {
badge.innerHTML = `
<span class="badge rounded-pill bg-danger bg-opacity-10 text-danger">
<span
class="d-inline-block rounded-circle bg-danger me-1"
style="width:7px;height:7px;"
></span>
Stopped
</span>
`;
}
// ACTIVE CLIENTS
clients.innerHTML =
(data.reservations_count ?? data.active_clients ?? 0) +
' <span class="text-muted fw-normal small">registered</span>';
// LAST APPLIED
if (data.last_applied) {
const date = new Date(data.last_applied.replace(" ", "T"));
lastApplied.innerText = date.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
});
} else {
lastApplied.innerText = "Never";
}
})
.catch((error) => {
console.error("DHCP status error:", error);
});
}
function loadDhcpLogs() {
const container = document.getElementById("dhcpLogs");
container.innerHTML = `
<div class="text-muted p-4">
Loading...
</div>
`;
fetch(dhcpConfig.logsUrl)
.then(async (response) => {
if (!response.ok) {
throw new Error("HTTP " + response.status);
}
return response.json();
})
.then((data) => {
if (!data.logs || !data.logs.length) {
container.innerHTML = `
<div class="text-muted p-4">
No DHCP events found.
</div>
`;
return;
}
container.innerHTML = data.logs
.map((log) => {
const escaped = escapeHtml(log);
let className = "text-muted";
if (log.includes("DHCPACK")) {
className = "text-success";
} else if (log.includes("DHCPNAK")) {
className = "text-danger";
} else if (log.includes("DHCPREQUEST")) {
className = "text-primary";
} else if (log.includes("DHCPDISCOVER")) {
className = "text-warning";
}
return `
<div class="px-4 py-2 border-bottom
${className}">
${escaped}
</div>
`;
})
.join("");
})
.catch((error) => {
console.error("DHCP logs error:", error);
container.innerHTML = `
<div class="text-danger p-4">
Failed to load DHCP logs.
</div>
`;
});
}
function escapeHtml(value) {
const div = document.createElement("div");
div.textContent = value;
return div.innerHTML;
}
refreshDhcpStatus();
loadDhcpLogs();
setInterval(refreshDhcpStatus, 5000, loadDhcpLogs, 5000);
+354
View File
@@ -0,0 +1,354 @@
/**
* CRUD Stasiun Kerja - Vanilla JS (fetch API)
*
* Catatan penting soal CSRF & session (biar tidak kejadian lagi
* seperti "CSRF token mismatch" / auto-logout):
* 1. Selalu kirim header X-CSRF-TOKEN dari meta tag <meta name="csrf-token">.
* Pastikan tag ini ADA di <head> layout utama:
* <meta name="csrf-token" content="{{ csrf_token() }}">
* 2. Selalu set credentials: 'same-origin' supaya cookie session ikut terkirim.
* 3. Endpoint diambil dari window.STASIUNKERJA_ENDPOINT (di-inject dari Blade
* pakai url()), BUKAN di-hardcode, supaya otomatis ikut host/port yang aktif.
*/
(function () {
"use strict";
const ENDPOINT = window.STASIUNKERJA_ENDPOINT; // contoh: http://localhost:8888/stasiunkerja
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content;
if (!CSRF_TOKEN) {
console.error(
'Meta tag csrf-token tidak ditemukan. Tambahkan <meta name="csrf-token" content="{{ csrf_token() }}"> di head layout.',
);
}
const tbody = document.getElementById("tbodyStasiunKerja");
const modalEl = document.getElementById("modalStasiunKerja");
const modal = new bootstrap.Modal(modalEl);
const form = document.getElementById("formStasiunKerja");
const btnTambah = document.getElementById("btnTambah");
const modalTitle = document.getElementById("modalStasiunKerjaTitle");
const inputMode = document.getElementById("inputMode");
const inputKodeOriginal = document.getElementById("inputKodeOriginal");
const inputKode = document.getElementById("inputKode");
const inputNama = document.getElementById("inputNama");
const inputMargin = document.getElementById("inputMargin");
const inputTarget = document.getElementById("inputTarget");
const inputStatus = document.getElementById("inputStatus");
const inputKodeAkses = document.getElementById("inputKodeAkses");
const inputSpv = document.getElementById("inputSpv");
const inputIdLokasi = document.getElementById("inputIdLokasi");
const inputLantai = document.getElementById("inputLantai");
const inputBank = document.getElementById("inputBank");
const inputTelp = document.getElementById("inputTelp");
const inputAlamat = document.getElementById("inputAlamat");
/**
* Notifikasi interaktif pengganti alert()/confirm() bawaan browser.
* SweetAlert2 wajib di-load di view (vendor/sweetalert2).
*/
function info(icon, title, text) {
return Swal.fire({ icon, title, text });
}
function loadingRow(colspan, text) {
return `<tr><td colspan="${colspan}" class="text-center text-muted py-4">
<span class="spinner-border spinner-border-sm me-2"></span>${text}</td></tr>`;
}
/**
* Helper fetch generik dengan header CSRF + credentials.
*/
async function apiFetch(url, options = {}) {
const headers = Object.assign(
{
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": CSRF_TOKEN,
},
options.headers || {},
);
// Kalau body berupa object biasa (bukan FormData), kirim sebagai JSON.
if (options.body && !(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
options.body = JSON.stringify(options.body);
}
const response = await fetch(url, {
credentials: "same-origin", // WAJIB supaya cookie session ikut terkirim
...options,
headers,
});
// Kalau sesi habis / tidak ter-autentikasi, kasih info lalu redirect ke login
// daripada diam-diam gagal.
if (response.status === 401 || response.status === 419) {
info("warning", "Sesi habis", "Sesi kamu sudah habis, silakan login ulang.").then(() => {
window.location.href = "/login";
});
throw new Error("Unauthenticated / session expired");
}
const json = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(json?.message || "Terjadi kesalahan pada server.");
error.status = response.status;
error.payload = json;
throw error;
}
return json;
}
/**
* Escape teks sederhana biar aman ditaruh ke innerHTML (mencegah XSS dasar).
*/
function escapeHtml(text) {
if (text === null || text === undefined) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Render baris tabel dari array data.
*/
function renderTable(rows) {
tbody.innerHTML = "";
if (!rows.length) {
tbody.innerHTML = `
<tr>
<td colspan="13" class="text-center text-muted py-4">
Belum ada data stasiun kerja.
</td>
</tr>`;
return;
}
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `
<td>${escapeHtml(row.kode)}</td>
<td>${escapeHtml(row.nama)}</td>
<td>${escapeHtml(row.margin)}</td>
<td>${escapeHtml(row.spv)}</td>
<td>${escapeHtml(row.target)}</td>
<td>${escapeHtml(row.id_lokasi)}</td>
<td>${escapeHtml(row.lantai)}</td>
<td>${
row.status == 1
? '<span class="badge bg-success">Aktif</span>'
: '<span class="badge bg-secondary">Nonaktif</span>'
}
</td>
<td>${escapeHtml(row.alamat)}</td>
<td>${escapeHtml(row.bank)}</td>
<td>${escapeHtml(row.telp)}</td>
<td>${escapeHtml(row.kode_akses)}</td>
<td class="text-nowrap">
<button class="btn btn-sm btn-warning btn-edit" data-kode="${escapeHtml(row.kode)}">
Edit
</button>
<button class="btn btn-sm btn-danger btn-hapus" data-kode="${escapeHtml(row.kode)}">
Hapus
</button>
</td>
`;
tbody.appendChild(tr);
});
}
/**
* Ambil semua data dari server dan render ke tabel.
*/
async function loadData() {
tbody.innerHTML = loadingRow(13, "Memuat data stasiun kerja...");
try {
const json = await apiFetch(`${ENDPOINT}/data`, { method: "GET" });
renderTable(json.data || []);
} catch (err) {
console.error(err);
tbody.innerHTML = `
<tr>
<td colspan="13" class="text-center text-danger py-4">
Gagal memuat data: ${escapeHtml(err.message)}
</td>
</tr>`;
}
}
/**
* Bersihkan semua pesan error validasi & class is-invalid di form.
*/
function clearFormErrors() {
form.querySelectorAll(".is-invalid").forEach((el) => el.classList.remove("is-invalid"));
form.querySelectorAll("[data-error-for]").forEach((el) => (el.textContent = ""));
}
/**
* Tampilkan error validasi (422) dari Laravel ke masing-masing field.
*/
function showFormErrors(errors) {
if (!errors) return;
Object.keys(errors).forEach((field) => {
const errorBox = form.querySelector(`[data-error-for="${field}"]`);
const inputEl = form.querySelector(
`#input${field.charAt(0).toUpperCase()}${field.slice(1).replace(/_([a-z])/g, (_, c) => c.toUpperCase())}`,
);
if (errorBox) errorBox.textContent = errors[field][0];
if (inputEl) inputEl.classList.add("is-invalid");
});
}
/**
* Reset form ke kondisi kosong (mode create).
*/
function resetForm() {
form.reset();
clearFormErrors();
inputMode.value = "create";
inputKodeOriginal.value = "";
inputKode.disabled = false;
modalTitle.textContent = "Tambah Stasiun Kerja";
}
/**
* Isi form dengan data existing untuk mode edit.
*/
function fillForm(data) {
inputMode.value = "edit";
inputKodeOriginal.value = data.kode;
inputKode.value = data.kode;
inputKode.disabled = true; // kode adalah primary key, tidak boleh diubah saat edit
inputNama.value = data.nama ?? "";
inputMargin.value = data.margin ?? "";
inputTarget.value = data.target ?? "";
inputStatus.value = data.status ?? 1;
inputKodeAkses.value = data.kode_akses ?? "";
inputSpv.value = data.spv ?? "";
inputIdLokasi.value = data.id_lokasi ?? "";
inputLantai.value = data.lantai ?? "";
inputBank.value = data.bank ?? "";
inputTelp.value = data.telp ?? "";
inputAlamat.value = data.alamat ?? "";
modalTitle.textContent = `Edit Stasiun Kerja - ${data.kode}`;
}
/**
* Kumpulkan payload dari isi form.
*/
function collectPayload() {
return {
kode: inputKode.value.trim(),
nama: inputNama.value.trim(),
margin: inputMargin.value === "" ? null : Number(inputMargin.value),
target: inputTarget.value === "" ? null : Number(inputTarget.value),
status: Number(inputStatus.value),
kode_akses: inputKodeAkses.value.trim() || null,
spv: inputSpv.value.trim() || null,
id_lokasi: inputIdLokasi.value.trim() || null,
lantai: inputLantai.value.trim() || null,
bank: inputBank.value.trim() || null,
telp: inputTelp.value.trim() || null,
alamat: inputAlamat.value.trim() || null,
};
}
// ============== EVENT: Buka modal tambah ==============
btnTambah.addEventListener("click", () => {
resetForm();
modal.show();
});
// ============== EVENT: Klik tombol Edit / Hapus di tabel (delegasi) ==============
tbody.addEventListener("click", async (e) => {
const editBtn = e.target.closest(".btn-edit");
const hapusBtn = e.target.closest(".btn-hapus");
if (editBtn) {
const kode = editBtn.dataset.kode;
editBtn.disabled = true;
const origText = editBtn.textContent;
editBtn.textContent = "Memuat...";
try {
const json = await apiFetch(`${ENDPOINT}/${encodeURIComponent(kode)}`, { method: "GET" });
clearFormErrors();
fillForm(json.data);
modal.show();
} catch (err) {
info("error", "Gagal mengambil data", err.message);
} finally {
editBtn.disabled = false;
editBtn.textContent = origText;
}
}
if (hapusBtn) {
const kode = hapusBtn.dataset.kode;
const tanya = await Swal.fire({
title: `Hapus stasiun kerja "${kode}"?`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Ya, hapus",
cancelButtonText: "Batal",
confirmButtonColor: "#dc3545",
});
if (!tanya.isConfirmed) return;
try {
await apiFetch(`${ENDPOINT}/${encodeURIComponent(kode)}`, { method: "DELETE" });
await loadData();
info("success", "Berhasil", `Stasiun kerja "${kode}" dihapus.`);
} catch (err) {
info("error", "Gagal menghapus data", err.message);
}
}
});
// ============== EVENT: Submit form (create / update) ==============
form.addEventListener("submit", async (e) => {
e.preventDefault();
clearFormErrors();
const payload = collectPayload();
const isEdit = inputMode.value === "edit";
const url = isEdit ? `${ENDPOINT}/${encodeURIComponent(inputKodeOriginal.value)}` : ENDPOINT;
const method = isEdit ? "PUT" : "POST";
const btnSimpan = document.getElementById("btnSimpan");
btnSimpan.disabled = true;
btnSimpan.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Menyimpan...';
try {
await apiFetch(url, { method, body: payload });
modal.hide();
await loadData();
info("success", "Berhasil", isEdit ? "Perubahan stasiun kerja disimpan." : "Stasiun kerja baru ditambahkan.");
} catch (err) {
if (err.status === 422 && err.payload?.errors) {
showFormErrors(err.payload.errors);
} else {
info("error", "Gagal menyimpan data", err.message);
}
} finally {
btnSimpan.disabled = false;
btnSimpan.textContent = "Simpan";
}
});
// ============== INIT ==============
document.addEventListener("DOMContentLoaded", loadData);
// Kalau script ini di-load setelah DOMContentLoaded (misal via @push di akhir body),
// panggil juga langsung supaya tidak tertinggal.
if (document.readyState !== "loading") {
loadData();
}
})();
+514
View File
@@ -0,0 +1,514 @@
/**
* CRUD Tarif Stiker - Vanilla JS dengan pola OPTIMISTIC UI.
*
* Konsep optimistic UI:
* - Begitu user submit/edit/hapus, DOM langsung diupdate SEKETIKA
* (tanpa nunggu response server), supaya terasa instan.
* - Request ke server tetap dikirim di belakang layar.
* - Kalau server balas SUKSES -> data "disegel" jadi permanen
* (nomor/ID asli dari server dipakai buat gantiin ID sementara).
* - Kalau server balas GAGAL -> perubahan di DOM di-ROLLBACK
* (baris balik ke kondisi semula / dihapus lagi / dikembalikan).
*
* Trade-off yang harus disadari:
* - UX terasa lebih cepat & responsif.
* - Tapi kalau internet lambat / gagal, user sempat lihat data yang
* "belum tentu benar" sebelum akhirnya di-rollback. Makanya kita
* kasih indikator visual (opacity pudar) untuk baris yang masih
* "pending" konfirmasi dari server.
*/
(function () {
"use strict";
const ENDPOINT = window.TARIFSTIKER_ENDPOINT;
const CSRF_TOKEN = document.querySelector('meta[name="csrf-token"]')?.content;
if (!CSRF_TOKEN) {
console.error("Meta tag csrf-token tidak ditemukan di head layout.");
}
const tbody = document.getElementById("tbodyTarifStiker");
const modalEl = document.getElementById("modalTarifStiker");
const modal = new bootstrap.Modal(modalEl);
const form = document.getElementById("formTarifStiker");
const btnTambah = document.getElementById("btnTambah");
const modalTitle = document.getElementById("modalTarifStikerTitle");
const btnSimpan = document.getElementById("btnSimpan");
const inputMode = document.getElementById("inputMode");
const inputTarifIdOriginal = document.getElementById("inputTarifIdOriginal");
const inputTarifId = document.getElementById("inputTarifId");
const inputIdMobil = document.getElementById("inputIdMobil");
const inputJenisLangganan = document.getElementById("inputJenisLangganan");
const inputTarif = document.getElementById("inputTarif");
const inputLastMember = document.getElementById("inputLastMember");
const inputTglAkhir = document.getElementById("inputTglAkhir");
const inputJenis = document.getElementById("inputJenis");
const inputJenisMember = document.getElementById("inputJenisMember");
const inputHitungAwalBulan = document.getElementById("inputHitungAwalBulan");
const inputAvailableOnline = document.getElementById("inputAvailableOnline");
const inputLangganan = document.getElementById("inputLangganan");
const inputKodeAkses = document.getElementById("inputKodeAkses");
// Menyimpan salinan data asli dari server. Dipakai sebagai
// "source of truth" untuk rollback kalau ada request yang gagal.
let cacheData = [];
// Counter untuk ID sementara pada baris baru yang belum dikonfirmasi server.
let tempIdCounter = 0;
// State sorting berdasarkan tarif_id. 'desc' = data terbaru di atas (default).
let sortDirection = "desc";
const thTarifId = document.getElementById("thTarifId");
const sortIconTarifId = document.getElementById("sortIconTarifId");
/**
* Urutkan cacheData berdasarkan tarif_id sesuai sortDirection saat ini.
* Dipanggil sebelum setiap renderAll() supaya urutan selalu konsisten,
* termasuk setelah create/update/delete optimistic.
*/
function applySorting() {
cacheData.sort((a, b) => {
const idA = Number(a.tarif_id);
const idB = Number(b.tarif_id);
return sortDirection === "asc" ? idA - idB : idB - idA;
});
}
/**
* Update ikon panah di header sesuai arah sort aktif.
*/
function updateSortIcon() {
if (sortIconTarifId) {
sortIconTarifId.textContent = sortDirection === "asc" ? "▲" : "▼";
}
}
/**
* Notifikasi interaktif pengganti alert()/confirm() bawaan browser.
* SweetAlert2 wajib di-load di view (vendor/sweetalert2).
*/
function info(icon, title, text) {
return Swal.fire({ icon, title, text });
}
function loadingRow(colspan, text) {
return `<tr><td colspan="${colspan}" class="text-center text-muted py-4">
<span class="spinner-border spinner-border-sm me-2"></span>${text}</td></tr>`;
}
/**
* Helper fetch generik + header CSRF & credentials.
*/
async function apiFetch(url, options = {}) {
const headers = Object.assign(
{
Accept: "application/json",
"X-Requested-With": "XMLHttpRequest",
"X-CSRF-TOKEN": CSRF_TOKEN,
},
options.headers || {},
);
if (options.body && !(options.body instanceof FormData)) {
headers["Content-Type"] = "application/json";
options.body = JSON.stringify(options.body);
}
const response = await fetch(url, {
credentials: "same-origin",
...options,
headers,
});
if (response.status === 401 || response.status === 419) {
info("warning", "Sesi habis", "Sesi kamu sudah habis, silakan login ulang.").then(() => {
window.location.href = "/login";
});
throw new Error("Unauthenticated / session expired");
}
const json = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(json?.message || "Terjadi kesalahan pada server.");
error.status = response.status;
error.payload = json;
throw error;
}
return json;
}
function escapeHtml(text) {
if (text === null || text === undefined) return "";
return String(text)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Render satu baris <tr> dari objek data.
* `pending = true` -> baris ditampilkan pudar (opacity) menandakan
* masih menunggu konfirmasi dari server (optimistic state).
*/
function buildRow(row, pending = false) {
const tr = document.createElement("tr");
tr.dataset.tarifId = row.tarif_id;
if (pending) tr.classList.add("opacity-50");
tr.innerHTML = `
<td>${escapeHtml(row.tarif_id)}</td>
<td>${escapeHtml(row.id_mobil)}</td>
<td>${escapeHtml(row.jenis_langganan)}</td>
<td>${escapeHtml(row.tarif)}</td>
<td>${escapeHtml(row.jenis)}</td>
<td>${escapeHtml(row.jenis_member)}</td>
<td>${escapeHtml(row.stasiun_kerja?.nama ?? row.langganan ?? "-")}</td>
<td>${
row.available_online
? '<span class="badge bg-success">Ya</span>'
: '<span class="badge bg-secondary">Tidak</span>'
}
</td>
<td>${escapeHtml(row.kode_akses)}</td>
<td class="text-nowrap">
<button class="btn btn-sm btn-warning btn-edit" data-tarif-id="${escapeHtml(row.tarif_id)}" ${pending ? "disabled" : ""}>
Edit
</button>
<button class="btn btn-sm btn-danger btn-hapus" data-tarif-id="${escapeHtml(row.tarif_id)}" ${pending ? "disabled" : ""}>
Hapus
</button>
</td>
`;
return tr;
}
function renderEmptyState() {
tbody.innerHTML = `
<tr>
<td colspan="10" class="text-center text-muted py-4">
Belum ada data tarif stiker.
</td>
</tr>`;
}
function renderAll() {
applySorting();
updateSortIcon();
tbody.innerHTML = "";
if (!cacheData.length) {
renderEmptyState();
return;
}
cacheData.forEach((row) => tbody.appendChild(buildRow(row)));
}
/**
* Load data awal dari server. Ini SATU-SATUNYA tempat yang
* "menunggu" server secara normal (bukan optimistic), karena
* belum ada apa-apa untuk ditampilkan sebelum data pertama datang.
*/
async function loadData() {
tbody.innerHTML = loadingRow(10, "Memuat data tarif stiker...");
try {
const json = await apiFetch(`${ENDPOINT}/data`, { method: "GET" });
cacheData = json.data || [];
renderAll();
} catch (err) {
console.error(err);
tbody.innerHTML = `
<tr>
<td colspan="10" class="text-center text-danger py-4">
Gagal memuat data: ${escapeHtml(err.message)}
</td>
</tr>`;
}
}
function clearFormErrors() {
form.querySelectorAll(".is-invalid").forEach((el) => el.classList.remove("is-invalid"));
form.querySelectorAll("[data-error-for]").forEach((el) => (el.textContent = ""));
}
function showFormErrors(errors) {
if (!errors) return;
const map = {
tarif_id: inputTarifId,
id_mobil: inputIdMobil,
jenis_langganan: inputJenisLangganan,
tarif: inputTarif,
last_member: inputLastMember,
tgl_akhir: inputTglAkhir,
jenis: inputJenis,
jenis_member: inputJenisMember,
hitung_awal_bulan: inputHitungAwalBulan,
langganan: inputLangganan,
kode_akses: inputKodeAkses,
};
Object.keys(errors).forEach((field) => {
const errorBox = form.querySelector(`[data-error-for="${field}"]`);
if (errorBox) errorBox.textContent = errors[field][0];
if (map[field]) map[field].classList.add("is-invalid");
});
}
function resetForm() {
form.reset();
clearFormErrors();
inputMode.value = "create";
inputTarifIdOriginal.value = "";
inputTarifId.disabled = false;
modalTitle.textContent = "Tambah Tarif Stiker";
}
function fillForm(data) {
inputMode.value = "edit";
inputTarifIdOriginal.value = data.tarif_id;
inputTarifId.value = data.tarif_id;
inputTarifId.disabled = true; // tarif_id adalah PK, tidak boleh diubah saat edit
inputIdMobil.value = data.id_mobil ?? "";
inputJenisLangganan.value = data.jenis_langganan ?? "";
inputTarif.value = data.tarif ?? "";
inputLastMember.value = data.last_member ?? "";
inputTglAkhir.value = data.tgl_akhir ?? "";
inputJenis.value = data.jenis ?? "";
inputJenisMember.value = data.jenis_member ?? "";
inputHitungAwalBulan.value = data.hitung_awal_bulan ?? "";
inputAvailableOnline.checked = !!data.available_online;
inputLangganan.value = data.langganan ?? "";
inputKodeAkses.value = data.kode_akses ?? "";
modalTitle.textContent = `Edit Tarif Stiker - ${data.tarif_id}`;
}
function collectPayload() {
return {
tarif_id: inputTarifId.value === "" ? null : Number(inputTarifId.value),
id_mobil: inputIdMobil.value.trim(),
jenis_langganan: inputJenisLangganan.value,
tarif: inputTarif.value === "" ? null : Number(inputTarif.value),
last_member: inputLastMember.value.trim() || null,
tgl_akhir: inputTglAkhir.value.trim() || null,
jenis: inputJenis.value.trim() || null,
jenis_member: inputJenisMember.value.trim() || null,
hitung_awal_bulan: inputHitungAwalBulan.value === "" ? null : Number(inputHitungAwalBulan.value),
available_online: inputAvailableOnline.checked,
langganan: inputLangganan.value || null,
kode_akses: inputKodeAkses.value.trim() || null,
};
}
// ============== EVENT: Buka modal tambah ==============
btnTambah.addEventListener("click", () => {
resetForm();
modal.show();
});
// ============== EVENT: Klik header "Tarif ID" untuk toggle sort ==============
if (thTarifId) {
thTarifId.addEventListener("click", () => {
sortDirection = sortDirection === "asc" ? "desc" : "asc";
renderAll();
});
}
// ============== EVENT: Edit / Hapus (delegasi) ==============
tbody.addEventListener("click", async (e) => {
const editBtn = e.target.closest(".btn-edit");
const hapusBtn = e.target.closest(".btn-hapus");
if (editBtn) {
const tarifId = editBtn.dataset.tarifId;
const existing = cacheData.find((r) => String(r.tarif_id) === String(tarifId));
if (existing) {
clearFormErrors();
fillForm(existing);
modal.show();
}
}
if (hapusBtn) {
const tarifId = hapusBtn.dataset.tarifId;
const tanya = await Swal.fire({
title: `Hapus tarif stiker #${tarifId}?`,
icon: "warning",
showCancelButton: true,
confirmButtonText: "Ya, hapus",
cancelButtonText: "Batal",
confirmButtonColor: "#dc3545",
});
if (!tanya.isConfirmed) return;
await handleOptimisticDelete(tarifId);
}
});
/**
* ============== OPTIMISTIC DELETE ==============
* Langsung hapus baris dari cache + DOM, baru kirim request.
* Kalau gagal, baris dikembalikan ke posisi semula.
*/
async function handleOptimisticDelete(tarifId) {
const index = cacheData.findIndex((r) => String(r.tarif_id) === String(tarifId));
if (index === -1) return;
const backup = cacheData[index];
// 1. Update UI seketika (optimis)
cacheData.splice(index, 1);
renderAll();
try {
// 2. Kirim request ke server di belakang layar
await apiFetch(`${ENDPOINT}/${encodeURIComponent(tarifId)}`, { method: "DELETE" });
// 3a. Sukses -> tidak perlu apa-apa, state sudah benar
info("success", "Berhasil", `Tarif stiker #${tarifId} dihapus.`);
} catch (err) {
// 3b. Gagal -> ROLLBACK: kembalikan data ke posisi semula
cacheData.splice(index, 0, backup);
renderAll();
info("error", "Gagal menghapus data", "Perubahan dibatalkan: " + err.message);
}
}
// ============== EVENT: Submit form (create / update) — OPTIMISTIC ==============
form.addEventListener("submit", async (e) => {
e.preventDefault();
clearFormErrors();
const payload = collectPayload();
const isEdit = inputMode.value === "edit";
if (isEdit) {
await handleOptimisticUpdate(payload);
} else {
await handleOptimisticCreate(payload);
}
});
/**
* ============== OPTIMISTIC CREATE ==============
* Baris baru langsung ditampilkan dengan ID sementara (pending),
* baru "disegel" pakai tarif_id asli setelah server merespons sukses.
*/
async function handleOptimisticCreate(payload) {
// ID sementara negatif supaya gak bentrok sama tarif_id asli (yang selalu positif).
const tempId = --tempIdCounter;
const optimisticRow = {
...payload,
tarif_id: payload.tarif_id ?? tempId,
stasiun_kerja: null, // belum tau nama stasiun kerja sebelum konfirmasi server
};
// 1. Tampilkan baris baru seketika (pudar, menandakan "pending")
cacheData.unshift(optimisticRow);
renderAll();
modal.hide();
btnSimpan.disabled = true;
btnSimpan.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Menyimpan...';
try {
// 2. Kirim ke server
const json = await apiFetch(ENDPOINT, { method: "POST", body: payload });
// 3a. Sukses -> ganti data optimis dengan data asli dari server
const idx = cacheData.findIndex((r) => r.tarif_id === optimisticRow.tarif_id);
if (idx !== -1) {
cacheData[idx] = json.data;
renderAll();
}
info("success", "Berhasil", "Tarif stiker baru ditambahkan.");
} catch (err) {
// 3b. Gagal -> ROLLBACK: buang baris optimis dari cache
cacheData = cacheData.filter((r) => r.tarif_id !== optimisticRow.tarif_id);
renderAll();
if (err.status === 422 && err.payload?.errors) {
// Buka lagi modal supaya user bisa perbaiki input
modal.show();
showFormErrors(err.payload.errors);
} else {
info("error", "Gagal menyimpan data", "Perubahan dibatalkan: " + err.message);
}
} finally {
btnSimpan.disabled = false;
btnSimpan.textContent = "Simpan";
}
}
/**
* ============== OPTIMISTIC UPDATE ==============
* Baris langsung diperbarui di DOM, baru dikonfirmasi ke server.
* Kalau gagal, baris dikembalikan ke data lama (backup).
*/
async function handleOptimisticUpdate(payload) {
const tarifId = inputTarifIdOriginal.value;
const index = cacheData.findIndex((r) => String(r.tarif_id) === String(tarifId));
if (index === -1) return;
const backup = { ...cacheData[index] };
// Cari nama stasiun kerja dari dropdown untuk tampilan sementara (opsional, kosmetik saja)
const selectedOption = inputLangganan.selectedOptions[0];
const optimisticStasiunNama =
selectedOption && selectedOption.value ? selectedOption.textContent.replace(/\s*\([^)]*\)\s*$/, "") : null;
// 1. Update UI seketika
cacheData[index] = {
...backup,
...payload,
tarif_id: backup.tarif_id, // tarif_id tidak berubah
stasiun_kerja: payload.langganan ? { kode: payload.langganan, nama: optimisticStasiunNama } : null,
};
renderAll();
modal.hide();
btnSimpan.disabled = true;
btnSimpan.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Menyimpan...';
try {
// 2. Kirim ke server
const json = await apiFetch(`${ENDPOINT}/${encodeURIComponent(tarifId)}`, {
method: "PUT",
body: payload,
});
// 3a. Sukses -> selaraskan dengan data resmi dari server
const idx = cacheData.findIndex((r) => String(r.tarif_id) === String(tarifId));
if (idx !== -1) {
cacheData[idx] = json.data;
renderAll();
}
info("success", "Berhasil", "Perubahan tarif stiker disimpan.");
} catch (err) {
// 3b. Gagal -> ROLLBACK ke data lama
const idx = cacheData.findIndex((r) => String(r.tarif_id) === String(tarifId));
if (idx !== -1) {
cacheData[idx] = backup;
renderAll();
}
if (err.status === 422 && err.payload?.errors) {
fillForm(backup);
modal.show();
showFormErrors(err.payload.errors);
} else {
info("error", "Gagal memperbarui data", "Perubahan dibatalkan: " + err.message);
}
} finally {
btnSimpan.disabled = false;
btnSimpan.textContent = "Simpan";
}
}
// ============== INIT ==============
document.addEventListener("DOMContentLoaded", loadData);
if (document.readyState !== "loading") {
loadData();
}
})();