/** * 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 ` ${text}`; } /** * 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, "'"); } /** * Render satu baris 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 = ` ${escapeHtml(row.tarif_id)} ${escapeHtml(row.id_mobil)} ${escapeHtml(row.jenis_langganan)} ${escapeHtml(row.tarif)} ${escapeHtml(row.jenis)} ${escapeHtml(row.jenis_member)} ${escapeHtml(row.stasiun_kerja?.nama ?? row.langganan ?? "-")} ${ row.available_online ? 'Ya' : 'Tidak' } ${escapeHtml(row.kode_akses)} `; return tr; } function renderEmptyState() { tbody.innerHTML = ` Belum ada data tarif stiker. `; } 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 = ` Gagal memuat data: ${escapeHtml(err.message)} `; } } 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 = '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 = '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(); } })();