Track public JS yg dipakai view (tooltips, dhcp, stasiunkerja, tarifstiker, member-details/extend) biar ikut ke-deploy
This commit is contained in:
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user