From 7dec98530923081fa8668a61347a244dbb081052 Mon Sep 17 00:00:00 2001 From: pand03-CEO Date: Wed, 14 Oct 2026 12:59:35 +0700 Subject: [PATCH] Track public JS yg dipakai view (tooltips, dhcp, stasiunkerja, tarifstiker, member-details/extend) biar ikut ke-deploy --- public/js/setup/dhcp.js | 382 +++++++++++++++++ public/js/setup/stasiunkerja.js | 354 ++++++++++++++++ public/js/setup/tarifstiker.js | 514 +++++++++++++++++++++++ public/js/stikerMember/member-details.js | 27 ++ public/js/stikerMember/member-extend.js | 466 ++++++++++++++++++++ public/js/tooltips.js | 30 ++ 6 files changed, 1773 insertions(+) create mode 100644 public/js/setup/dhcp.js create mode 100644 public/js/setup/stasiunkerja.js create mode 100644 public/js/setup/tarifstiker.js create mode 100644 public/js/stikerMember/member-details.js create mode 100644 public/js/stikerMember/member-extend.js create mode 100644 public/js/tooltips.js diff --git a/public/js/setup/dhcp.js b/public/js/setup/dhcp.js new file mode 100644 index 0000000..98791f6 --- /dev/null +++ b/public/js/setup/dhcp.js @@ -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 = '' + "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 = '' + "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 = '' + "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 = ` + + + Running + + `; + } else { + badge.innerHTML = ` + + + Stopped + + `; + } + + // ACTIVE CLIENTS + clients.innerHTML = + (data.reservations_count ?? data.active_clients ?? 0) + + ' registered'; + + // 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 = ` +
+ Loading... +
+ `; + + 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 = ` +
+ No DHCP events found. +
+ `; + + 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 ` +
+ ${escaped} +
+ `; + }) + .join(""); + }) + + .catch((error) => { + console.error("DHCP logs error:", error); + + container.innerHTML = ` +
+ Failed to load DHCP logs. +
+ `; + }); +} + +function escapeHtml(value) { + const div = document.createElement("div"); + + div.textContent = value; + + return div.innerHTML; +} + +refreshDhcpStatus(); +loadDhcpLogs(); +setInterval(refreshDhcpStatus, 5000, loadDhcpLogs, 5000); diff --git a/public/js/setup/stasiunkerja.js b/public/js/setup/stasiunkerja.js new file mode 100644 index 0000000..cd496c6 --- /dev/null +++ b/public/js/setup/stasiunkerja.js @@ -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 . + * Pastikan tag ini ADA di layout utama: + * + * 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 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 ` + ${text}`; + } + + /** + * 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, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } + + /** + * Render baris tabel dari array data. + */ + function renderTable(rows) { + tbody.innerHTML = ""; + + if (!rows.length) { + tbody.innerHTML = ` + + + Belum ada data stasiun kerja. + + `; + return; + } + + rows.forEach((row) => { + const tr = document.createElement("tr"); + tr.innerHTML = ` + ${escapeHtml(row.kode)} + ${escapeHtml(row.nama)} + ${escapeHtml(row.margin)} + ${escapeHtml(row.spv)} + ${escapeHtml(row.target)} + ${escapeHtml(row.id_lokasi)} + ${escapeHtml(row.lantai)} + ${ + row.status == 1 + ? 'Aktif' + : 'Nonaktif' + } + + ${escapeHtml(row.alamat)} + ${escapeHtml(row.bank)} + ${escapeHtml(row.telp)} + ${escapeHtml(row.kode_akses)} + + + + + `; + 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 = ` + + + Gagal memuat data: ${escapeHtml(err.message)} + + `; + } + } + + /** + * 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 = '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(); + } +})(); diff --git a/public/js/setup/tarifstiker.js b/public/js/setup/tarifstiker.js new file mode 100644 index 0000000..95e3632 --- /dev/null +++ b/public/js/setup/tarifstiker.js @@ -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 ` + ${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(); + } +})(); diff --git a/public/js/stikerMember/member-details.js b/public/js/stikerMember/member-details.js new file mode 100644 index 0000000..fcbbc91 --- /dev/null +++ b/public/js/stikerMember/member-details.js @@ -0,0 +1,27 @@ +$(document).ready(function () { + $("#example tbody").on("click", "tr", function () { + console.log($("#example tbody tr").length); + const notrans = $(this).data("notrans"); + + // hapus semua highlight + $("#example tbody tr").removeClass("selected"); + + // highlight semua row dalam 1 grup + $(`#example tbody tr[data-notrans="${notrans}"]`).addClass("selected"); + + // ambil data dari row pertama grup + const firstRow = $(`#example tbody tr[data-notrans="${notrans}"]`).first(); + + selectedRowData = { + notrans: firstRow.data("notrans"), + }; + + console.log("Selected detail trans :", selectedRowData); + + $("#rowActions").removeClass("d-none"); + }); + + $(".detail-button").on("click", function () { + $("#modal-details").show(); + }); +}); diff --git a/public/js/stikerMember/member-extend.js b/public/js/stikerMember/member-extend.js new file mode 100644 index 0000000..b872fb8 --- /dev/null +++ b/public/js/stikerMember/member-extend.js @@ -0,0 +1,466 @@ +$(document).ready(function () { + // var vehicle = @json($vehicleSelect); + + // let selectedRowData = null; + let harga = 0; + + $("#example tbody").on("click", "tr", function () { + console.log($("#example tbody tr").length); + const notrans = $(this).data("notrans"); + + // hapus semua highlight + $("#example tbody tr").removeClass("selected"); + + // highlight semua row dalam 1 grup + $(`#example tbody tr[data-notrans="${notrans}"]`).addClass("selected"); + + // ambil data dari row pertama grup + const firstRow = $(`#example tbody tr[data-notrans="${notrans}"]`).first(); + + selectedRowData = { + notrans: firstRow.data("notrans"), + }; + + console.log("Selected transaksi:", selectedRowData); + + $("#rowActions").removeClass("d-none"); + }); + + $(document).on("click", ".extend-button", function () { + console.log("Tombol extend ditekan"); + if (!selectedRowData) { + alert("Pilih data terlebih dahulu"); + return; + } + var rawId = $(this).data("id"); + // let id = rawId.substring(0, rawId.indexOf('/')); + let id = selectedRowData.notrans; + var kendaraan = vehicle; + console.log("Data ID : ", id); + let actionUrl = $(this).data("url"); + + // var actionUrl = '{{ route("getStikerMember.show", ":id") }}'; + // actionUrl = actionUrl.replace(":id", "" + encodeURIComponent(id)); + actionUrl = actionUrl.replace(":id", "" + encodeURIComponent(id)); + console.log("Action URL : ", actionUrl); + // $('#extend-member').attr('action'), actionUrl; + $.ajax({ + url: actionUrl, + type: "GET", + success: function (r) { + console.log("Datanya : ", r); + if (r) { + $("#extend-member").val(""); + $("#list-nopol-ext").empty(); + $("#list-card-ext").empty(); + $(".notransnya").val(r.notrans); + $(".namanya").val(r.nama); + $(".address").val(r.alamat); + $("#periode-ext").val( + `${moment(r.awal).format("DD-MM-YYYY HH:mm")} - ${moment(r.akhir).format("DD-MM-YYYY HH:mm")}`, + ); + $("#ext-jenisLang") + .val( + $("#ext-jenisLang option") + .filter(function () { + return $(this).text().trim() === r.jmember; + }) + .val(), + ) + .trigger("change") + .prop("disabled", true); + $("#ext-jenisLang-value").val(r.jenis_langganan); + $("#ext-department") + .val( + $("#ext-department option") + .filter(function () { + return $(this).text().trim() === r.dept; + }) + .val(), + ) + .trigger("change") + .prop("disabled", true); + $("#ext-department-value").val(r.unit_kerja); + $("#member-ext").val(r.unit_kerja); + $("#department-ext").val(r.jenis_langganan); + var rows = r.stiker_details; + if (rows.length !== 0) { + rows.forEach((row) => { + var vehicleName = String(row.jenis_mobil)?.nama || "Unknown"; + harga += Number(row.tarif); + $("#list-nopol-ext").append(` + + ${row.nopol} + + ${row.vehicle} + + ${row.jenis_member} - ${row.tarif} + ${row.merk} + + + + + `); + }); + $(".harga") + .val(harga ?? r.harga ?? 0) + .prop("disabled", true); + } else { + $("#list-nopol-ext").append(` + + Tidak ada data + Tidak ada data + Tidak ada data + Tidak ada data + + `); + } + + var rowss = r.memberCard; + if (rowss.length !== 0) { + rowss.forEach((ro) => { + $("#list-card-ext").append(` + + ${ro.no_card} + ${ro.no_urut} + + // + // + // `); + // }); + + // // ========================= + // // TAB 3 - KARTU + // // ========================= + // $('#list-card').empty(); + + // res.kartu.forEach(k => { + // $('#list-card').append(` + // + // ${k.kartu} + // ${k.nourut} + // + // + // + // + // `); + // }); + + // } + + // $('#modal-member').on('hidden.bs.modal', function () { + // $(this).find('form')[0].reset(); + // $('#list-nopol').empty(); + // $('#list-card').empty(); + // }); +}); diff --git a/public/js/tooltips.js b/public/js/tooltips.js new file mode 100644 index 0000000..38d0f87 --- /dev/null +++ b/public/js/tooltips.js @@ -0,0 +1,30 @@ +(function ($) { + "use strict"; + + $(function () { + /* Code for attribute data-custom-class for adding custom class to tooltip */ + if (typeof $.fn.tooltip.Constructor === "undefined") { + throw new Error("Bootstrap Tooltip must be included first!"); + } + + var Tooltip = $.fn.tooltip.Constructor; + + // add customClass option to Bootstrap Tooltip + $.extend(Tooltip.Default, { + customClass: "", + }); + + var _show = Tooltip.prototype.show; + + Tooltip.prototype.show = function () { + // invoke parent method + _show.apply(this, Array.prototype.slice.apply(arguments)); + + if (this.config.customClass) { + var tip = this.getTipElement(); + $(tip).addClass(this.config.customClass); + } + }; + $('[data-bs-toggle="tooltip"]').tooltip(); + }); +})(jQuery);