Togoomas-Digital

Solusi Praktis Pengelolaan Serikat Tolong Menolong

Sistem Digital STM

Kelola STM lebih mudah, transparan, dan teratur.

Satu platform untuk membantu Pengurus dan Anggota STM mengelola data anggota, kas, iuran, berita duka, serta informasi pembayaran.

Dashboard STM Amanah Live
Ringkasan Kas STMKeuangan & donasi terkini
Transparan
Saldo TerkiniRp 24.850.000Anggota Aktif: 128
Dana MasukRp 8.450.000Bulan ini
TunggakanRp 1.250.000Perlu ditindaklanjuti
Transaksi TerakhirLihat semua
Iuran anggotaHari ini, 09:42+ Rp 500.000
Biaya kegiatanKemarin, 15:20- Rp 250.000
Siap digunakan di ponsel

Akses informasi STM kapan saja.

Anggota dapat melihat kartu digital, tagihan, QR pembayaran, dan histori aktivitas langsung dari perangkat masing-masing.

Tampilan responsif Data lebih teratur
Pembayaran lebih praktis

Pengurus dan anggota terhubung dalam satu aplikasi.

Catat pembayaran iuran langsung dari meja pelayanan, lihat status transaksi, dan buat administrasi STM lebih cepat serta menyenangkan.

Foto pengurus dan anggota melakukan pembayaran iuran di meja pelayanan STM

Fitur pendukung STM

Dirancang mengikuti kebutuhan operasional STM sehari-hari.

Dashboard Kas

Pantau saldo, pemasukan, pengeluaran, iuran, dan tunggakan.

Data Anggota

Kelola identitas, status, QR anggota, dan impor data masal.

Berita Duka

Buat informasi duka dan aktivasi iuran secara tertata.

Portal Anggota

Akses kartu digital, tagihan, histori, dan konfirmasi transfer.

Info Aplikasi

  1. Persiapan aplikasi. Pengurus masuk ke demo, membuka menu Pengaturan, lalu mengisi nama STM, logo, tarif uang pangkal, iuran duka, biaya administrasi, biaya lain-lain, serta rekening pembayaran.
  2. Penyimpanan database dan berkas aplikasi. Database aplikasi web ini akan tersimpan di Google Sheets milik admin, sehingga data anggota, transaksi kas, iuran, tunggakan, berita duka, histori pembayaran, konfirmasi transfer, serta pengaturan aplikasi dapat diakses dan dikelola admin dengan baik. Sementara itu, foto profil anggota, bukti transfer, logo STM, lampiran KTP, lampiran Kartu Keluarga, surat keputusan pemutihan, dan data atau dokumen pendukung lainnya akan diunggah serta disimpan di Google Drive admin. Aplikasi menggunakan tautan file untuk menampilkan data sesuai kebutuhan, sedangkan akses dan pengelolaan database maupun berkas tetap berada pada admin.
  3. Menambahkan anggota. Data anggota dapat ditambahkan satu per satu melalui menu Anggota Baru atau sekaligus melalui impor massal menggunakan file TXT, CSV, atau TSV. Pastikan setiap baris berisi Nomor Anggota, Nama, No. HP, dan Alamat.
  4. Memeriksa data anggota. Pengurus dapat mencari nama, melihat kartu identitas digital, membuat QR identitas, dan memeriksa status iuran serta tunggakan setiap anggota.
  5. Mencatat keuangan. Setiap pemasukan, pembayaran iuran, donasi, dan pengeluaran dicatat pada menu Kas agar saldo dan riwayat transaksi tetap transparan.
  6. Mengelola berita duka. Pengurus membuat informasi duka, memilih anggota atau keluarga yang terkait, lalu mengaktifkan iuran duka jika diperlukan. Pesan dapat disalin untuk dibagikan melalui WhatsApp.
  7. Mengelola pembayaran. Anggota melihat tagihan melalui Portal Anggota, membayar secara langsung atau transfer, kemudian mengirim bukti transfer untuk diperiksa pengurus.
  8. Konfirmasi dan pembaruan data. Pengurus memeriksa bukti transfer, mencatat pembayaran, memperbarui saldo, serta melihat histori aktivitas anggota.
  9. Mengelola anggota yang berhenti. Jika anggota berhenti, buka menu Anggota, pilih Anggota Keluar, lalu tentukan anggota dan simpan alasan atau catatannya. Data anggota, histori pembayaran, dan tunggakan lama tetap tersimpan untuk keperluan administrasi, sedangkan tagihan baru otomatis dihentikan.
  10. SOP pemutihan tunggakan anggota tidak aktif. Pemutihan hanya dilakukan jika ada persetujuan pengurus. Pertama, pastikan anggota sudah berstatus tidak aktif dan periksa jumlah tunggakannya. Kedua, pilih menu Pemutihan dan centang anggota yang disetujui. Ketiga, lampirkan surat keputusan pengurus dalam format PDF atau foto sebagai bukti. Keempat, periksa kembali total tunggakan yang dipilih, lalu jalankan pemutihan. Sistem akan mengubah tunggakan menjadi nol, menyimpan riwayat pemutihan, dan mempertahankan data anggota sebagai arsip.
  11. Menjaga data tetap tertib. Simpan bukti keputusan dan catatan anggota secara lengkap. Jangan memutihkan tunggakan tanpa persetujuan pengurus atau lampiran dokumentasi, agar setiap perubahan memiliki dasar dan dapat dipertanggungjawabkan.
const APP_URL = configuredAppUrl || currentWebAppUrl || "https://script.google.com/macros/s/AKfycbxlSZExqdmDzhf61WCe-PPUdcc0aoeuJ6dNbHrHbzor-AE7uwhbpNdjfZxJjWh7ai59/exec"; // Logo publik dan logo STM dikelola terpisah. const PUBLIC_HEADER_LOGO_URL = 'https://i.ibb.co.com/Y7H9LG3p/togoomas.jpg'; // Mode lokal hanya aktif jika diminta secara eksplisit. const PREVIEW_MODE = false; const BACKEND_ENABLED = !PREVIEW_MODE && /^https:\/\/script\.google\.com\/macros\/s\/[^\s]+\/exec$/i.test(APP_URL); // DUMMY DATA STATE FOR PREVIEW MODE (3-5 Items Max) let state = { currentUser: null, // { role: 'admin' | 'member', data: null } anggota: [ { id: "STM-001", nama: "H. Ahmad Subagja", phone: "081234567890", alamat: "RT 01 / Lingkungan I", foto: "", iuran: 450000, tunggakan: 0, status: "Lunas", aktif: true, passwordPortal: "" }, { id: "STM-002", nama: "Ibu Nurhayati Nainggolan", phone: "081375738273", alamat: "Jalan Hutasuhut No.28 / Lingkungan II", foto: "", iuran: 150000, tunggakan: 30000, status: "Tunggakan 1 Bln", aktif: true, passwordPortal: "" }, { id: "STM-003", nama: "Bapak Herman", phone: "081311223344", alamat: "RT 03 / Lingkungan II", foto: "", iuran: 300000, tunggakan: 0, status: "Lunas", aktif: true, passwordPortal: "" } ], kas: [ { id: 1, tanggal: "15/09/2026", jenis: "masuk", ket: "Iuran Bulanan Warga RT 01", nominal: 450000 }, { id: 2, tanggal: "14/09/2026", jenis: "keluar", ket: "Pengurusan Jenazah: Kain Kafan & Papan", nominal: 350000 }, { id: 3, tanggal: "10/09/2026", jenis: "masuk", ket: "Donasi Hamba Allah (Infaq Musibah)", nominal: 1000000 } ], beritaDuka: [], aktivitasAnggota: [], konfirmasiTransfer: [], riwayatPemutihan: [], infoAwalAnggota: { jumlahAnggotaSTM: 0, jumlahAnggotaDataAwal: 0, uangPangkal: 0, jumlahPemberiUangPangkal: 0, saldoIuran: 0, saldoAdministrasiLainnya: 0 }, pengaturan: { iuranDuka: 25000, uangPangkal: 0, administrasi: 0, biayaLainnya: 0, rekeningPembayaran: '', namaBank: '', nomorRekening: '', atasNama: '', namaSTM: 'STM AMANAH', logoSTM: '' } }; // INITIALIZATION window.addEventListener('DOMContentLoaded', () => { initApp(); setupMemberPaymentButton(); document.querySelectorAll('[id^="modal-"]').forEach(modal => { modal.addEventListener('click', event => { if (event.target === modal) closeModal(modal.id); }); }); }); document.addEventListener('keydown', event => { if (event.key === 'Escape') closeAllModals(); }); // Delegasi event untuk elemen dinamis agar tidak membuat handler inline berulang. document.addEventListener('click', event => { const button = event.target.closest('[data-action="edit-kas"]'); if (button) editKasTransaction(button.dataset.id); const deleteButton = event.target.closest('[data-action="delete-kas"]'); if (deleteButton) deleteKasTransaction(deleteButton.dataset.id); }); function initApp() { applyStmBranding(); updateDashboardUI(); if (BACKEND_ENABLED) fetchBackendData(); } // Memuat state backend tanpa merusak data contoh ketika server gagal. async function fetchBackendData() { if (!BACKEND_ENABLED) { showToast('URL database belum valid. Aplikasi memakai data lokal.', 'warning'); return; } try { const response = await fetch(`${APP_URL}?action=getData&_=${Date.now()}`, { method: 'GET', cache: 'no-store', redirect: 'follow' }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const data = await response.json(); if (data.status !== 'success') throw new Error(data.message || 'Respons backend tidak valid.'); state.anggota = Array.isArray(data.anggota) ? data.anggota : []; state.kas = Array.isArray(data.kas) ? data.kas : []; state.beritaDuka = Array.isArray(data.beritaDuka) ? data.beritaDuka : []; state.aktivitasAnggota = Array.isArray(data.aktivitasAnggota) ? data.aktivitasAnggota : []; state.konfirmasiTransfer = Array.isArray(data.konfirmasiTransfer) ? data.konfirmasiTransfer : []; state.riwayatPemutihan = Array.isArray(data.riwayatPemutihan) ? data.riwayatPemutihan : []; state.infoAwalAnggota = data.infoAwalAnggota || {}; state.pengaturan = { ...state.pengaturan, ...(data.pengaturan || {}) }; applyStmBranding(); updateDashboardUI(); showToast('Data berhasil dimuat dari database.', 'check'); } catch (error) { console.error('Gagal memuat database:', { url: APP_URL, error }); applyStmBranding(); updateDashboardUI(); showToast(`Database belum terhubung: ${error.message || 'periksa URL Web App'}`, 'warning'); } } let backendSaveTimer = null; function saveBackendData(immediate = false) { if (!BACKEND_ENABLED) return Promise.resolve({ skipped: true }); clearTimeout(backendSaveTimer); const send = () => fetch(APP_URL, { method: 'POST', // text/plain menghindari preflight CORS pada Web App Apps Script. headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body: JSON.stringify({ action: 'saveData', data: state }), redirect: 'follow' }).then(response => { if (!response.ok) throw new Error(`HTTP ${response.status}`); return response.json(); }).then(result => { if (!result || result.status !== 'success') throw new Error(result?.message || 'Data gagal disimpan.'); return result; }); if (immediate) return send().catch(error => { console.error('Gagal menyimpan database:', error); showToast('Perubahan belum tersimpan ke database.', 'warning'); throw error; }); return new Promise(resolve => { backendSaveTimer = setTimeout(() => send().then(resolve).catch(resolve), 500); }); } function persistState() { saveBackendData(false); } // ROLE TOGGLE AT LOGIN function switchLoginRole(role) { const btnAdmin = document.getElementById('tab-login-admin'); const btnMember = document.getElementById('tab-login-member'); const formAdmin = document.getElementById('form-login-admin'); const formMember = document.getElementById('form-login-member'); if (role === 'admin') { btnAdmin.className = "py-2.5 rounded-xl transition bg-white text-brand-700 shadow-sm font-bold"; btnMember.className = "py-2.5 rounded-xl transition text-gray-500 hover:text-gray-800 font-medium"; formAdmin.classList.remove('hidden'); formMember.classList.add('hidden'); } else { btnMember.className = "py-2.5 rounded-xl transition bg-white text-brand-700 shadow-sm font-bold"; btnAdmin.className = "py-2.5 rounded-xl transition text-gray-500 hover:text-gray-800 font-medium"; formMember.classList.remove('hidden'); formAdmin.classList.add('hidden'); } } // Tampilkan halaman login setelah pengguna memilih masuk dari landing page. function showLoginPage(role = 'admin') { document.getElementById('sec-landing')?.classList.add('hidden'); document.getElementById('sec-login')?.classList.remove('hidden'); switchLoginRole(role); document.getElementById('main-container')?.scrollTo({ top: 0, behavior: 'smooth' }); const firstInput = role === 'member' ? document.getElementById('login-member-id') : document.getElementById('login-user'); firstInput?.focus(); } // LOGIN HANDLERS function handleLoginAdmin(e) { e.preventDefault(); const u = document.getElementById('login-user').value.trim(); const p = document.getElementById('login-pass').value.trim(); if (u === 'admin' && p === '123') { state.currentUser = { role: 'admin', name: 'Pengurus STM' }; applyStmBranding(); document.getElementById('user-role-label').innerText = 'Pengurus Admin'; document.getElementById('main-header').classList.remove('member-portal-header'); document.getElementById('sec-login').classList.add('hidden'); document.getElementById('nav-pengurus').classList.remove('hidden'); document.getElementById('header-action-row').classList.remove('hidden'); document.getElementById('btn-logout').classList.remove('hidden'); document.getElementById('btn-info-aplikasi').classList.remove('hidden'); document.getElementById('btn-member-portal').classList.add('hidden'); switchTab('dashboard'); showToast("Selamat datang Pengurus STM!", "check"); } else { showToast("Username / Password Admin Salah!", "warning"); } } function handleLoginMember(e) { e.preventDefault(); const query = document.getElementById('login-member-id').value.trim().toLowerCase(); const found = state.anggota.find(m => m.aktif !== false && ( String(m.id ?? '').toLowerCase() === query || String(m.phone ?? '').includes(query) ) ); if (found) { state.currentUser = { role: 'member', data: found }; applyStmBranding(); document.getElementById('user-role-label').innerText = 'Portal Anggota'; document.getElementById('main-header').classList.add('member-portal-header'); document.getElementById('sec-login').classList.add('hidden'); document.getElementById('nav-pengurus').classList.add('hidden'); document.getElementById('header-action-row').classList.remove('hidden'); document.getElementById('btn-logout').classList.remove('hidden'); document.getElementById('btn-info-aplikasi').classList.remove('hidden'); document.getElementById('btn-member-portal').classList.remove('hidden'); renderPortalMember(found, { showPrivateLogin: !found.passwordPortal }); switchTab('portal-member'); showToast(`Selamat datang, ${found.nama}`, "check"); } else { showToast("ID Anggota / No HP tidak ditemukan!", "warning"); } } function logout() { persistState(); state.currentUser = null; applyStmBranding(); document.getElementById('main-header').classList.remove('member-portal-header'); document.getElementById('sec-login').classList.remove('hidden'); document.getElementById('sec-landing')?.classList.add('hidden'); document.getElementById('nav-pengurus').classList.add('hidden'); document.getElementById('header-action-row').classList.add('hidden'); document.getElementById('btn-logout').classList.add('hidden'); document.getElementById('btn-info-aplikasi').classList.add('hidden'); document.getElementById('btn-member-portal').classList.add('hidden'); document.querySelectorAll('main > section').forEach(s => s.classList.add('hidden')); document.getElementById('sec-login').classList.remove('hidden'); showToast("Berhasil keluar", "info"); } function openMemberPortalShortcut() { if (state.currentUser?.role !== 'member' || !state.currentUser.data) return; renderPortalMember(state.currentUser.data, { showPrivateLogin: true }); switchTab('portal-member'); document.getElementById('member-private-gate')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); } // TAB NAVIGATION function switchTab(tab) { const views = ['dashboard', 'anggota', 'kas', 'broadcast', 'pengaturan', 'portal-member']; views.forEach(v => { const el = document.getElementById(`view-${v}`); if (el) el.classList.add('hidden'); }); const target = document.getElementById(`view-${tab}`); if (target) target.classList.remove('hidden'); // Update Nav Active State ['dashboard', 'anggota', 'kas', 'broadcast', 'pengaturan'].forEach(n => { const navBtn = document.getElementById(`nav-btn-${n}`); if (navBtn) { if (n === tab) { navBtn.className = "flex flex-col items-center py-1.5 text-brand-600 font-bold transition"; } else { navBtn.className = "flex flex-col items-center py-1.5 text-gray-400 hover:text-brand-600 font-medium transition"; } } }); if (tab === 'dashboard') updateDashboardUI(); if (tab === 'anggota') { renderAnggotaList(); renderAnggotaKeluarList(); renderAnggotaBaruRequirements(); renderTransferConfirmations(); } if (tab === 'kas') renderKasList(); if (tab === 'pengaturan') renderPengaturan(); } // DASHBOARD COMPUTATION & RENDER function updateDashboardUI() { const kas = Array.isArray(state.kas) ? state.kas : []; const anggota = Array.isArray(state.anggota) ? state.anggota : []; const totals = kas.reduce((result, tx) => { const jenis = tx?.jenis === 'keluar' ? 'keluar' : 'masuk'; result[jenis] += Number(tx?.nominal) || 0; return result; }, { masuk: 0, keluar: 0 }); const infoAwal = state.infoAwalAnggota || {}; const saldoAwalPangkal = Math.max(0, Number(infoAwal.uangPangkal) || 0); const saldoAwalIuran = Math.max(0, Number(infoAwal.saldoIuran) || 0); const saldoAwalAdministrasi = Math.max(0, Number(infoAwal.saldoAdministrasiLainnya) || 0); const saldoAwalTotal = saldoAwalPangkal + saldoAwalIuran + saldoAwalAdministrasi; const totalMasuk = totals.masuk + saldoAwalTotal; const totalKeluar = totals.keluar; const saldo = totalMasuk - totalKeluar; // Total uang pangkal dan biaya administrasi diambil dari kewajiban // pendaftaran anggota baru, bukan dari keterangan transaksi kas. const registrationTotals = anggota.reduce((result, member) => { const initial = member?.kewajibanAwal || {}; const uangPangkal = Math.max(0, Number(initial.uangPangkal) || 0); const administrasi = Math.max(0, Number(initial.administrasi) || 0); const biayaLainnya = Math.max(0, Number(initial.biayaLainnya) || 0); result.pangkal += uangPangkal; result.administrasiLainnya += administrasi + biayaLainnya; if (Object.keys(initial).length > 0) result.jumlahPendaftar += 1; return result; }, { pangkal: 0, administrasiLainnya: 0, jumlahPendaftar: 0 }); // Total iuran tetap dihitung dari pemasukan kas anggota. const incomeByCategory = kas.reduce((result, tx) => { if (tx?.jenis !== 'masuk') return result; const ket = String(tx?.ket || '').toLowerCase(); const kategori = String(tx?.kategori || tx?.category || '').toLowerCase(); const sumber = String(tx?.sumber || '').toLowerCase(); const text = `${ket} ${kategori} ${sumber}`; const nominal = Number(tx?.nominal) || 0; if (/iuran|tunggakan|duka|pembayaran anggota|pembayaran iuran|transfer manual/.test(text)) { result.iuran += nominal; } return result; }, { iuran: 0 }); const totalTunggakan = anggota.reduce( (total, member) => total + Math.max(0, Number(member?.tunggakan) || 0), 0 ); const totalAnggotaAktif = anggota.reduce( (total, member) => total + (member?.aktif !== false ? 1 : 0), 0 ); // Perbarui hanya elemen yang tersedia agar fungsi aman saat dipanggil sebelum UI lengkap. const dashboardValues = { 'dash-saldo': formatRupiah(saldo), 'dash-masuk': formatRupiah(totalMasuk), 'dash-keluar': formatRupiah(totalKeluar), 'dash-total-anggota': totalAnggotaAktif, 'dash-tunggakan': formatRupiah(totalTunggakan), 'dash-uang-pangkal': formatRupiah(registrationTotals.pangkal + saldoAwalPangkal), 'dash-jumlah-pendaftar': `${registrationTotals.jumlahPendaftar + (Number(infoAwal.jumlahPemberiUangPangkal) || 0)} anggota`, 'dash-jumlah-anggota-awal': `${Number(infoAwal.jumlahAnggotaSTM) || 0} anggota`, 'dash-jumlah-data-awal': `${Number(infoAwal.jumlahAnggotaDataAwal) || 0} anggota`, 'dash-iuran': formatRupiah(incomeByCategory.iuran + saldoAwalIuran), 'dash-administrasi-lainnya': formatRupiah(registrationTotals.administrasiLainnya + saldoAwalAdministrasi) }; Object.entries(dashboardValues).forEach(([id, value]) => { const element = document.getElementById(id); if (element) element.innerText = value; }); const txContainer = document.getElementById('dash-tx-list'); if (!txContainer) return; txContainer.innerHTML = kas.slice(-3).reverse().map(tx => `

${escapeHTML(tx.ket)}

${escapeHTML(tx.tanggal)}
${tx.jenis === 'masuk' ? '+' : '-'} ${formatRupiah(tx.nominal)}
`).join(''); } // ANGGOTA LIST RENDER let anggotaSearchFrame = 0; // Batasi render saat pengguna mengetik cepat agar DOM tidak dibuat berulang kali. function scheduleRenderAnggotaList() { cancelAnimationFrame(anggotaSearchFrame); anggotaSearchFrame = requestAnimationFrame(() => { anggotaSearchFrame = 0; renderAnggotaList(); }); } function renderAnggotaList() { const search = document.getElementById('search-anggota'); const container = document.getElementById('anggota-container'); if (!search || !container) return; const query = search.value.trim().toLowerCase(); const filtered = (Array.isArray(state.anggota) ? state.anggota : []).filter(a => a.aktif !== false).filter(a => String(a.nama).toLowerCase().includes(query) || String(a.id).toLowerCase().includes(query) || String(a.phone).toLowerCase().includes(query) ); if (filtered.length === 0) { container.innerHTML = `
Tidak ada data anggota.
`; return; } container.innerHTML = filtered.map(a => `
${getDriveDisplayUrl(a.foto) ? `Foto ${escapeHTML(a.nama)}` : `${escapeHTML(String(a.nama).substring(0, 2).toUpperCase())}`}

${escapeHTML(a.nama)}

${escapeHTML(a.id)}

${escapeHTML(a.phone)} • ${escapeHTML(a.alamat)}

${Number(a.tunggakan) > 0 ? `` : ''}
`).join(''); } function openDetailAnggotaFromButton(button) { const memberId = button?.dataset?.memberQrId; if (!memberId) { showToast('ID anggota tidak ditemukan.', 'warning'); return false; } try { openDetailAnggota(memberId); return false; } catch (error) { console.error('Gagal membuka QR identitas anggota:', error); showToast('Kartu QR anggota gagal dibuka.', 'warning'); return false; } } // Render anggota yang sudah keluar; datanya tetap dipertahankan agar tunggakan tidak hilang. function renderAnggotaKeluarList() { const container = document.getElementById('anggota-keluar-container'); if (!container) return; const members = (Array.isArray(state.anggota) ? state.anggota : []) .filter(member => member.aktif === false); if (!members.length) { container.innerHTML = '

Belum ada anggota yang keluar.

'; return; } container.innerHTML = members.map(member => `
${getDriveDisplayUrl(member.foto) ? `Foto ${escapeHTML(member.nama)}` : `${escapeHTML(String(member.nama || '').substring(0, 2).toUpperCase())}`}

${escapeHTML(member.nama)}

${escapeHTML(member.id)}

Keluar: ${escapeHTML(member.tanggalKeluar || '-')}

${Number(member.tunggakan) > 0 ? 'ADA TUNGGAKAN' : 'TIDAK ADA TUNGGAKAN'}
No. HP${escapeHTML(member.phone || '-')}
Tunggakan${formatRupiah(member.tunggakan)}
${member.catatanKeluar ? `

Catatan: ${escapeHTML(member.catatanKeluar)}

` : ''}
`).join(''); } // Foto anggota: validasi ukuran asli dan kompresi otomatis di browser. async function resizeMemberPhoto(file) { const maxUploadSize = 2 * 1024 * 1024; if (!file || !file.type.startsWith('image/')) { throw new Error('Silakan pilih file gambar yang valid.'); } if (file.size > maxUploadSize) { throw new Error('Ukuran foto maksimal 2 MB.'); } const imageUrl = URL.createObjectURL(file); try { const image = await new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error('Foto tidak dapat dibaca.')); img.src = imageUrl; }); const maxDimension = 1000; const scale = Math.min(1, maxDimension / Math.max(image.naturalWidth, image.naturalHeight)); const canvas = document.createElement('canvas'); canvas.width = Math.max(1, Math.round(image.naturalWidth * scale)); canvas.height = Math.max(1, Math.round(image.naturalHeight * scale)); canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height); let quality = 0.82; let result = canvas.toDataURL('image/jpeg', quality); while (result.length > 450000 && quality > 0.45) { quality -= 0.08; result = canvas.toDataURL('image/jpeg', quality); } return result; } finally { URL.revokeObjectURL(imageUrl); } } async function handlePhotoSelection(event, targetId) { const file = event.target.files && event.target.files[0]; const status = document.getElementById('new-member-photo-status'); if (!file) return; try { const photoData = await resizeMemberPhoto(file); document.getElementById(targetId).value = photoData; if (status) { status.textContent = 'Foto siap digunakan pada kartu anggota.'; status.className = 'text-[10px] text-emerald-600 mt-1'; } } catch (error) { event.target.value = ''; document.getElementById(targetId).value = ''; if (status) { status.textContent = error.message; status.className = 'text-[10px] text-rose-600 mt-1'; } showToast(error.message, 'warning'); } } async function uploadImageAssetToDrive(data, options = {}) { if (!data || !BACKEND_ENABLED) return data; const response = await fetch(APP_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body: JSON.stringify({ action: 'uploadImageAsset', data, mimeType: 'image/jpeg', jenis: options.jenis || 'profil', referensiId: options.referensiId || '', referensiNama: options.referensiNama || '', fileName: options.fileName || `stm-foto-${Date.now()}.jpg` }), redirect: 'follow' }); if (!response.ok) throw new Error(`Upload foto gagal (HTTP ${response.status}).`); const result = await response.json(); if (result.status !== 'success' || !result.url) { throw new Error(result.message || 'Foto gagal disimpan ke Google Drive.'); } return result.displayUrl || result.url; } function getDriveDisplayUrl(url) { const value = String(url || '').trim(); if (!value) return ''; // File ID dapat tersimpan sebagai URL Drive lama, URL thumbnail, // atau langsung sebagai ID. Gunakan endpoint thumbnail untuk . const fileIdMatch = value.match(/[?&]id=([^&]+)/) || value.match(/\/d\/([^/]+)/); const fileId = fileIdMatch ? fileIdMatch[1] : (/^[a-zA-Z0-9_-]{20,}$/.test(value) ? value : ''); return fileId ? `https://drive.google.com/thumbnail?id=${encodeURIComponent(fileId)}&sz=w1000` : value; } async function handlePortalPhotoUpload(event) { const file = event.target.files && event.target.files[0]; if (!file || state.currentUser?.role !== 'member') return; try { const member = state.anggota.find(item => item.id === state.currentUser.data.id); if (!member) throw new Error('Data anggota tidak ditemukan.'); const localPhoto = await resizeMemberPhoto(file); member.foto = await uploadImageAssetToDrive(localPhoto, { jenis: 'profil', referensiId: member.id, referensiNama: member.nama, fileName: `foto-profil-${member.id}-${Date.now()}.jpg` }); state.currentUser.data = member; persistState(); renderPortalMember(member); showToast('Foto kartu anggota berhasil disimpan ke Google Drive.', 'check'); } catch (error) { showToast(error.message, 'warning'); } finally { event.target.value = ''; } } async function handleMemberPhotoUpload(event, memberId) { const file = event.target.files && event.target.files[0]; if (!file) return; try { const member = state.anggota.find(item => String(item.id) === String(memberId)); if (!member) throw new Error('Data anggota tidak ditemukan.'); const localPhoto = await resizeMemberPhoto(file); member.foto = await uploadImageAssetToDrive(localPhoto, { jenis: 'profil', referensiId: member.id, referensiNama: member.nama, fileName: `foto-profil-${member.id}-${Date.now()}.jpg` }); persistState(); renderAnggotaList(); if (state.currentUser?.role === 'member' && state.currentUser.data?.id === member.id) { state.currentUser.data = member; renderPortalMember(member); } showToast(`Foto ${member.nama} berhasil disimpan.`, 'check'); } catch (error) { showToast(error.message, 'warning'); } finally { event.target.value = ''; } } // KAS LIST RENDER function renderKasList() { const container = document.getElementById('kas-history-list'); if (!container) return; const kas = Array.isArray(state.kas) ? state.kas : []; container.innerHTML = kas.slice().reverse().map(k => { const isIncome = k?.jenis === 'masuk'; const transactionId = escapeHTML(String(k?.id ?? '')); return `

${escapeHTML(k?.ket)}

${escapeHTML(k?.tanggal)} • ${isIncome ? 'Pemasukan' : 'Pengeluaran'}

${isIncome ? '+' : '-'} ${formatRupiah(k?.nominal)}
`; }).join(''); } // RENDER PORTAL ANGGOTA MANDIRI function renderPortalMember(member, options = {}) { const showPrivateLogin = options.showPrivateLogin === true; const keepPrivateContent = state.currentUser?.role === 'member' && state.currentUser.data?.id === member.id && state.currentUser.portalUnlocked === true; document.getElementById('mportal-nama').innerText = member.nama; document.getElementById('mportal-id').innerText = `Nomor Anggota: ${member.id}`; document.getElementById('mportal-public-member-number').innerText = `Nomor Anggota: ${member.id}`; document.getElementById('mportal-alamat').innerText = member.alamat; renderQRCode(document.getElementById('mportal-public-identity-qr'), `STM-ANGGOTA|${member.id}`, 140); if (!keepPrivateContent) { lockMemberPortal({ showLogin: showPrivateLogin }); } else { document.getElementById('member-private-content')?.classList.remove('hidden'); document.getElementById('member-private-gate')?.classList.add('hidden'); } // Nomor anggota ditampilkan sebagai identitas tetap dan tidak dapat diedit. const profileId = document.getElementById('portal-member-id'); const profileName = document.getElementById('portal-member-name'); const profilePhone = document.getElementById('portal-member-phone'); const profileAddress = document.getElementById('portal-member-address'); if (profileId) profileId.value = member.id || ''; if (profileName) profileName.value = member.nama || ''; if (profilePhone) profilePhone.value = member.phone || ''; if (profileAddress) profileAddress.value = member.alamat || ''; renderMemberAdditionalInfo(member); const settings = state.pengaturan || {}; document.getElementById('mportal-bank').innerText = settings.namaBank || 'Belum diatur'; document.getElementById('mportal-account').innerText = settings.nomorRekening || 'Belum diatur'; document.getElementById('mportal-owner').innerText = settings.atasNama || 'Belum diatur'; setMemberPhoto(document.getElementById('mportal-foto'), member); document.getElementById('mportal-badge-status').innerText = member.tunggakan === 0 ? 'LUNAS' : 'ADA TUNGGAKAN'; document.getElementById('mportal-badge-status').className = member.tunggakan === 0 ? 'inline-block bg-emerald-500 text-white text-[11px] font-bold px-2 py-0.5 rounded-md' : 'inline-block bg-rose-500 text-white text-[11px] font-bold px-2 py-0.5 rounded-md'; document.getElementById('mportal-tunggakan-nominal').innerText = member.tunggakan === 0 ? 'Rp 0 (Lunas)' : `${formatRupiah(member.tunggakan)} (Tunggakan)`; const activities = getMemberActivities(member.id); document.getElementById('mportal-last-pay').innerText = activities.length ? activities[0].tanggal : '-'; renderMemberActivityList(document.getElementById('mportal-activity-list'), activities); updateOutstandingBroadcastButton(member); const paymentTarget = getPaymentTarget(); document.getElementById('mportal-qris-code').innerText = paymentTarget ? `Tujuan: ${paymentTarget}` : 'Tujuan pembayaran belum diatur'; // QR identitas dipisahkan dari QR pembayaran. renderQRCode( document.getElementById('mportal-identity-qr-box'), `STM-ANGGOTA|${member.id}`, 140 ); refreshMemberPaymentQr(); } function lockMemberPortal(options = {}) { const showLogin = options.showLogin === true; document.getElementById('member-private-content')?.classList.add('hidden'); document.getElementById('member-private-gate')?.classList.toggle('hidden', !showLogin); const member = state.currentUser?.data; const confirm = document.getElementById('member-portal-password-confirm'); const note = document.getElementById('member-private-gate-note'); if (confirm) confirm.classList.toggle('hidden', Boolean(member?.passwordPortal)); if (note) note.innerText = member?.passwordPortal ? 'Password diperlukan setiap kali membuka halaman utama portal.' : 'Buat password pribadi minimal 6 karakter untuk pertama kali.'; const password = document.getElementById('member-portal-password'); if (password) password.value = ''; hideForgotMemberPassword(); } function showMemberGateNotification(message, type = 'error') { const notification = document.getElementById('member-private-gate-notification'); if (!notification) return; notification.textContent = message; notification.className = type === 'success' ? 'rounded-xl border border-emerald-200 bg-emerald-50 px-3 py-2 text-[11px] font-semibold text-emerald-700' : 'rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] font-semibold text-rose-700'; } function clearMemberGateNotification() { const notification = document.getElementById('member-private-gate-notification'); if (notification) { notification.textContent = ''; notification.className = 'hidden rounded-xl border border-rose-200 bg-rose-50 px-3 py-2 text-[11px] font-semibold text-rose-700'; } } function showForgotMemberPassword() { clearMemberGateNotification(); document.getElementById('member-private-gate-form')?.classList.add('hidden'); document.getElementById('member-forgot-password-button')?.classList.add('hidden'); document.getElementById('member-forgot-password-form')?.classList.remove('hidden'); document.getElementById('member-recovery-id').value = state.currentUser?.data?.id || ''; document.getElementById('member-recovery-phone').value = ''; document.getElementById('member-recovery-new-password').value = ''; document.getElementById('member-recovery-confirm-password').value = ''; } function hideForgotMemberPassword() { clearMemberGateNotification(); document.getElementById('member-forgot-password-form')?.classList.add('hidden'); document.getElementById('member-private-gate-form')?.classList.remove('hidden'); document.getElementById('member-forgot-password-button')?.classList.remove('hidden'); } function resetForgottenMemberPassword(event) { event.preventDefault(); const member = state.currentUser?.role === 'member' ? state.currentUser.data : null; const id = document.getElementById('member-recovery-id').value.trim().toLowerCase(); const phone = document.getElementById('member-recovery-phone').value.trim().replace(/\D/g, ''); const password = document.getElementById('member-recovery-new-password').value; const confirmation = document.getElementById('member-recovery-confirm-password').value; const registeredPhone = String(member?.phone || '').replace(/\D/g, ''); if (!member) { showMemberGateNotification('Sesi anggota tidak ditemukan. Silakan login anggota kembali.'); return; } if (id !== String(member.id || '').trim().toLowerCase()) { showMemberGateNotification('Nomor anggota tidak sesuai dengan akun yang sedang login.'); return; } if (!phone || phone !== registeredPhone) { showMemberGateNotification('Nomor HP tidak sesuai dengan data anggota.'); return; } if (password.length < 6) { showMemberGateNotification('Password baru minimal 6 karakter.'); return; } if (password !== confirmation) { showMemberGateNotification('Konfirmasi password tidak sama dengan password baru.'); return; } member.passwordPortal = password; state.currentUser.portalUnlocked = false; persistState(); document.getElementById('member-forgot-password-form').reset(); hideForgotMemberPassword(); showToast('Password berhasil direset. Silakan login dengan password baru.', 'check'); } function unlockMemberPortal(event) { event.preventDefault(); const member = state.currentUser?.role === 'member' ? state.currentUser.data : null; const password = document.getElementById('member-portal-password').value; const confirmation = document.getElementById('member-portal-password-confirm').value; if (!member) return; clearMemberGateNotification(); if (!member.passwordPortal) { if (password.length < 6 || password !== confirmation) { showMemberGateNotification('Password minimal 6 karakter dan konfirmasi password harus sama.'); return; } member.passwordPortal = password; showToast('Password privasi berhasil dibuat.', 'check'); } else if (password !== member.passwordPortal) { showMemberGateNotification('Password privasi salah.'); return; } state.currentUser.portalUnlocked = true; document.getElementById('member-private-content')?.classList.remove('hidden'); document.getElementById('member-private-gate')?.classList.add('hidden'); renderPortalMember(member); } function saveMemberPortalPassword(event) { event.preventDefault(); const member = state.currentUser?.role === 'member' ? state.currentUser.data : null; const password = document.getElementById('member-new-password').value; const confirmation = document.getElementById('member-new-password-confirm').value; if (!member || password.length < 6 || password !== confirmation) { showToast('Password minimal 6 karakter dan harus sama.', 'warning'); return; } member.passwordPortal = password; state.currentUser.portalUnlocked = false; persistState(); document.querySelector('form[onsubmit="saveMemberPortalPassword(event)"]').reset(); lockMemberPortal({ showLogin: false }); showToast('Password privasi berhasil diubah.', 'check'); } function savePortalMemberProfile(event) { event.preventDefault(); if (state.currentUser?.role !== 'member') { showToast('Menu ini hanya tersedia untuk anggota.', 'warning'); return; } const memberId = state.currentUser.data?.id; const member = state.anggota.find(item => String(item.id) === String(memberId)); if (!member || member.aktif === false) { showToast('Data anggota tidak ditemukan atau sudah tidak aktif.', 'warning'); return; } const name = document.getElementById('portal-member-name').value.trim(); const phone = document.getElementById('portal-member-phone').value.trim(); const address = document.getElementById('portal-member-address').value.trim(); const normalizedPhone = phone.replace(/[^0-9+]/g, ''); if (!name || !normalizedPhone || !address) { showToast('Nama, nomor HP, dan alamat wajib diisi.', 'warning'); return; } const duplicatePhone = state.anggota.some(item => item !== member && String(item.phone || '').replace(/\D/g, '') === normalizedPhone.replace(/\D/g, '') ); if (duplicatePhone) { showToast('Nomor HP sudah digunakan anggota lain.', 'warning'); return; } member.nama = name; member.phone = normalizedPhone; member.alamat = address; state.currentUser.data = member; persistState(); renderPortalMember(member); showToast('Data anggota berhasil diperbarui.', 'check'); } function renderMemberAdditionalInfo(member) { const ktpInput = document.getElementById('portal-member-ktp'); const kkInput = document.getElementById('portal-member-kk'); const familyInput = document.getElementById('portal-member-family'); if (ktpInput) ktpInput.value = member.nomorKtp || ''; if (kkInput) kkInput.value = member.nomorKk || ''; if (familyInput) familyInput.value = member.keluargaTanggungan || ''; ['ktp', 'kk'].forEach(type => { const status = document.getElementById(`portal-member-${type}-file-status`); const attachment = member[`lampiran${type === 'ktp' ? 'Ktp' : 'Kk'}`]; if (status) { status.textContent = attachment?.nama ? `File tersimpan: ${attachment.nama}${attachment.url ? ' (Google Drive)' : ''}` : 'Opsional, maksimal 2 MB.'; status.className = attachment?.nama ? 'text-[10px] text-emerald-600 mt-1' : 'text-[10px] text-gray-400 mt-1'; } }); } async function readOptionalMemberAttachment(inputId, statusId, currentAttachment) { const input = document.getElementById(inputId); const status = document.getElementById(statusId); const file = input?.files?.[0]; if (!file) return currentAttachment || null; if (file.size > 2 * 1024 * 1024 || !(file.type === 'application/pdf' || file.type.startsWith('image/'))) { throw new Error('Lampiran harus berupa PDF atau foto dengan ukuran maksimal 2 MB.'); } const data = file.type.startsWith('image/') ? await resizeMemberPhoto(file) : await readFileAsDataUrl(file); if (status) { status.textContent = `File siap diunggah: ${file.name}`; status.className = 'text-[10px] text-emerald-600 mt-1'; } return { nama: file.name, tipe: file.type, data }; } async function uploadMemberAttachmentToDrive(attachment, member, jenis) { if (!attachment?.data || !BACKEND_ENABLED) return attachment; const response = await fetch(APP_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body: JSON.stringify({ action: 'uploadMemberAttachment', anggotaId: member.id, anggotaNama: member.nama, jenis, fileName: attachment.nama, mimeType: attachment.tipe, data: attachment.data }), redirect: 'follow' }); if (!response.ok) throw new Error(`Upload lampiran gagal (HTTP ${response.status}).`); const result = await response.json(); if (result.status !== 'success' || !result.url) { throw new Error(result.message || 'Lampiran gagal disimpan ke Google Drive.'); } return { nama: result.name || attachment.nama, tipe: attachment.tipe, fileId: result.fileId, url: result.url }; } async function saveMemberAdditionalInfo(event) { event.preventDefault(); if (state.currentUser?.role !== 'member') { showToast('Menu ini hanya tersedia untuk anggota.', 'warning'); return; } const member = state.anggota.find(item => String(item.id) === String(state.currentUser.data?.id)); if (!member || member.aktif === false) { showToast('Data anggota tidak ditemukan atau sudah tidak aktif.', 'warning'); return; } try { member.nomorKtp = document.getElementById('portal-member-ktp').value.trim(); member.nomorKk = document.getElementById('portal-member-kk').value.trim(); member.keluargaTanggungan = document.getElementById('portal-member-family').value.trim(); const ktpAttachment = await readOptionalMemberAttachment('portal-member-ktp-file', 'portal-member-ktp-file-status', member.lampiranKtp); const kkAttachment = await readOptionalMemberAttachment('portal-member-kk-file', 'portal-member-kk-file-status', member.lampiranKk); member.lampiranKtp = await uploadMemberAttachmentToDrive(ktpAttachment, member, 'ktp'); member.lampiranKk = await uploadMemberAttachmentToDrive(kkAttachment, member, 'kk'); state.currentUser.data = member; persistState(); renderMemberAdditionalInfo(member); document.getElementById('portal-member-ktp-file').value = ''; document.getElementById('portal-member-kk-file').value = ''; showToast('Informasi tambahan berhasil disimpan.', 'check'); } catch (error) { showToast(error.message, 'warning'); } } function refreshMemberPaymentQr() { const member = state.currentUser?.role === 'member' ? state.currentUser.data : null; const paymentTarget = getPaymentTarget(); const amountInput = document.getElementById('mportal-payment-amount'); const amount = Number(amountInput?.value) || 0; const paymentBox = document.getElementById('mportal-qris-box'); const paymentLabel = document.getElementById('mportal-qris-code'); if (!paymentBox || !paymentLabel) return; if (!paymentTarget) { paymentLabel.innerText = 'Tujuan pembayaran belum diatur'; renderQRCode(paymentBox, 'Tujuan pembayaran STM Amanah belum diatur', 140); return; } const amountText = amount > 0 ? `|NOMINAL|${amount}` : '|NOMINAL|BEBAS'; paymentLabel.innerText = amount > 0 ? `${paymentTarget} | ${formatRupiah(amount)}` : `Tujuan: ${paymentTarget} | Nominal bebas`; renderQRCode(paymentBox, `STM AMANAH|PEMBAYARAN IURAN|${paymentTarget}${amountText}|ANGGOTA|${member?.id || ''}`, 140); } // PEMBAYARAN TUNGGAKAN DARI KARTU QRIS let currentDetailMemberId = null; function setupMemberPaymentButton() { const outstanding = document.getElementById('mdetail-tunggakan'); if (!outstanding || typeof MutationObserver === 'undefined') return; const updateButton = () => { const memberId = currentDetailMemberId || document.getElementById('mdetail-id').innerText.trim(); const member = state.anggota.find(item => String(item.id) === String(memberId)); refreshMemberPaymentButton(member); }; new MutationObserver(updateButton).observe(outstanding, { childList: true, characterData: true, subtree: true }); } function refreshMemberPaymentButton(member) { const button = document.getElementById('mdetail-payment-button'); if (!button) return; const isAdmin = state.currentUser?.role === 'admin'; button.classList.toggle('hidden', !isAdmin || !member || (Number(member.tunggakan) || 0) <= 0); } function openMemberPaymentModal() { if (state.currentUser?.role !== 'admin') { showToast('Pembayaran tagihan hanya dapat dieksekusi oleh admin.', 'warning'); return; } const memberId = currentDetailMemberId || document.getElementById('mdetail-id').innerText.trim(); const member = state.anggota.find(item => String(item.id) === String(memberId)); if (!member || (Number(member.tunggakan) || 0) <= 0) return; currentDetailMemberId = member.id; document.getElementById('payment-member-name').innerText = member.nama; document.getElementById('payment-member-outstanding').innerText = formatRupiah(member.tunggakan); document.getElementById('payment-member-amount').value = member.tunggakan; document.getElementById('payment-member-amount').max = member.tunggakan; document.getElementById('modal-member-payment').classList.remove('hidden'); } function saveMemberPayment(event) { event.preventDefault(); const member = state.anggota.find(item => String(item.id) === String(currentDetailMemberId)); const amount = Number(document.getElementById('payment-member-amount').value) || 0; if (!member || amount <= 0) return; const outstanding = Number(member.tunggakan) || 0; if (amount > outstanding) { showToast('Nominal pembayaran melebihi tunggakan.', 'warning'); return; } member.tunggakan = outstanding - amount; member.status = member.tunggakan === 0 ? 'Lunas' : 'Tunggakan'; persistState(); const paymentDate = new Date().toLocaleDateString('id-ID'); const paymentId = Date.now(); state.aktivitasAnggota.push({ id: paymentId, anggotaId: member.id, tanggal: paymentDate, jenis: 'pembayaran', kategori: 'Iuran / Tunggakan', keterangan: `Pembayaran tunggakan ${member.nama}`, nominal: amount }); state.kas.push({ id: paymentId, tanggal: paymentDate, jenis: 'masuk', ket: `Pembayaran tunggakan ${member.nama}`, nominal: amount, anggotaId: member.id, sumber: 'pembayaran-anggota' }); document.getElementById('mdetail-tunggakan').innerText = formatRupiah(member.tunggakan); renderMemberActivityList(document.getElementById('mdetail-activity-list'), getMemberActivities(member.id)); document.getElementById('mdetail-status').innerText = member.tunggakan === 0 ? 'Lunas' : 'Ada Tunggakan'; document.getElementById('mdetail-status').className = member.tunggakan === 0 ? 'text-emerald-600' : 'text-rose-600'; refreshMemberPaymentButton(member); renderAnggotaList(); updateDashboardUI(); closeModal('modal-member-payment'); showToast('Pembayaran tunggakan berhasil dicatat.', 'check'); } // ADMIN SCANNER let anggotaScanner = null; let anggotaScannerRunning = false; let anggotaScannerStopping = null; async function openModalScanAnggota() { if (!state.currentUser || state.currentUser.role !== 'admin') { showToast('Fitur scan hanya tersedia untuk admin', 'warning'); return; } const modal = document.getElementById('modal-scan-anggota'); const reader = document.getElementById('scan-reader'); const codeInput = document.getElementById('scan-member-code'); const result = document.getElementById('scan-member-result'); const status = document.getElementById('scan-status'); if (!modal || !reader || !codeInput || !result || !status) return; await stopAnggotaScanner(); modal.classList.remove('hidden'); codeInput.value = ''; result.className = 'hidden'; reader.innerHTML = 'Mengaktifkan kamera...'; status.innerText = 'Meminta izin kamera...'; const fallback = document.getElementById('scan-camera-fallback'); if (fallback) { fallback.classList.add('hidden'); fallback.innerText = ''; } if (!window.isSecureContext && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') { status.innerText = 'Kamera HP memerlukan HTTPS. Gunakan pencarian manual jika halaman belum HTTPS.'; reader.innerHTML = 'Kamera tidak dapat dibuka tanpa HTTPS.'; return; } if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') { status.innerText = 'Browser tidak menyediakan kamera. Pastikan halaman dibuka melalui HTTPS.'; reader.innerHTML = 'Kamera HP memerlukan HTTPS dan izin kamera.'; return; } if (typeof Html5Qrcode === 'undefined') { status.innerText = 'Library scanner belum tersedia. Masukkan nomor anggota secara manual.'; return; } // Minta akses kamera lebih dulu agar browser HP menampilkan preview. let cameraPermissionStream = null; try { cameraPermissionStream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); cameraPermissionStream.getTracks().forEach(track => track.stop()); } catch (permissionError) { status.innerText = 'Izin kamera ditolak atau kamera sedang digunakan aplikasi lain.'; reader.innerHTML = 'Izinkan kamera pada browser HP, lalu tekan Scan Anggota lagi.'; if (fallback) { fallback.innerText = 'Kamera belum aktif. Buka izin kamera browser untuk situs ini, lalu coba lagi.'; fallback.classList.remove('hidden'); } return; } // Beri browser waktu untuk menghitung ukuran modal sebelum video dibuat. await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); try { anggotaScanner = new Html5Qrcode('scan-reader', { verbose: false }); const scannerConfig = { fps: 10, qrbox: (viewfinderWidth, viewfinderHeight) => { const size = Math.max(140, Math.min( Math.floor(Math.min(viewfinderWidth, viewfinderHeight) * 0.68), 280 )); return { width: size, height: size }; }, aspectRatio: 1, ...(typeof Html5QrcodeSupportedFormats !== 'undefined' ? { formatsToSupport: [Html5QrcodeSupportedFormats.QR_CODE] } : {}) }; const onScanSuccess = decodedText => { const scannedText = String(decodedText || '').trim(); const memberId = extractMemberIdFromQr(scannedText); // Scanner admin hanya menerima QR identitas anggota, bukan QR pembayaran. if (!memberId || memberId === scannedText) { codeInput.value = ''; status.innerText = 'QR bukan QR identitas anggota. Gunakan QR Identitas Anggota.'; result.className = 'rounded-2xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700'; result.innerText = 'QR pembayaran tidak dapat digunakan untuk mencari data anggota.'; return; } codeInput.value = memberId; findScannedAnggota(); status.innerText = 'QR terbaca. Data identitas anggota ditampilkan di bawah.'; // Biarkan modal tetap terbuka agar pengurus langsung melihat hasil identitas. stopAnggotaScanner().catch(() => {}); }; const onScanFailure = () => {}; // Gunakan ID kamera yang benar-benar terdeteksi setelah izin diberikan. // Ini lebih stabil pada Chrome Android dan Safari iPhone. let cameraStarted = false; let cameras = []; try { cameras = await Html5Qrcode.getCameras(); } catch (cameraListError) { cameras = []; } const rearCamera = cameras.find(camera => /back|rear|environment|belakang/i.test(camera.label || '')); const cameraConfig = rearCamera?.id || cameras[0]?.id || { facingMode: { ideal: 'environment' } }; try { await anggotaScanner.start( cameraConfig, scannerConfig, onScanSuccess, onScanFailure ); cameraStarted = true; anggotaScannerRunning = true; } catch (cameraStartError) { if (cameraConfig === cameras[0]?.id && cameras.length > 1) { await anggotaScanner.start( cameras[cameras.length - 1].id, scannerConfig, onScanSuccess, onScanFailure ); cameraStarted = true; anggotaScannerRunning = true; } else { throw cameraStartError; } } if (!modal.classList.contains('hidden') && cameraStarted) { status.innerText = 'Kamera aktif. Arahkan QR Identitas Anggota ke kotak scan.'; } } catch (error) { await stopAnggotaScanner(); if (!modal.classList.contains('hidden')) { const message = error?.message || ''; status.innerText = /permission|notallowed|denied/i.test(message) ? 'Izin kamera ditolak. Izinkan kamera di browser HP lalu tekan Scan Anggota lagi.' : 'Kamera tidak dapat digunakan. Pastikan HTTPS dan gunakan pencarian manual.'; } } } function findScannedAnggota() { const rawCode = document.getElementById('scan-member-code').value.trim(); if (!rawCode) { showToast('Arahkan kamera ke QR identitas atau masukkan ID anggota.', 'warning'); return; } const extractedId = extractMemberIdFromQr(rawCode); const isIdentityQr = /^STM-ANGGOTA\|[^|\s]+$/i.test(rawCode); if (rawCode.includes('|') && !isIdentityQr) { const result = document.getElementById('scan-member-result'); result.className = 'rounded-2xl border border-amber-200 bg-amber-50 p-3 text-xs text-amber-700'; result.innerText = 'QR pembayaran tidak dapat digunakan. Silakan scan QR Identitas Anggota.'; return; } const code = extractedId.toLowerCase(); const member = state.anggota.find(item => { const id = String(item.id ?? '').toLowerCase(); const phone = String(item.phone ?? '').toLowerCase(); const name = String(item.nama ?? '').toLowerCase(); return id === code || phone === code || name === code || id.includes(code) || phone.includes(code) || name.includes(code); }); const result = document.getElementById('scan-member-result'); if (!member) { result.className = 'rounded-2xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-700'; result.innerText = 'Anggota tidak ditemukan. Pastikan QR atau nomor anggota benar.'; return; } result.className = 'rounded-2xl border-2 border-emerald-300 bg-emerald-50 p-3 space-y-2'; result.innerHTML = `

Identitas QR ditemukan

${escapeHTML(member.nama)}

Nomor Anggota: ${escapeHTML(member.id)}

No. HP: ${escapeHTML(member.phone || '-')}

Alamat: ${escapeHTML(member.alamat || '-')}

${member.tunggakan > 0 ? 'ADA TUNGGAKAN' : 'LUNAS'}
Jumlah Iuran${formatRupiah(member.iuran ?? member.totalIuran ?? 0)}
Tunggakan${formatRupiah(member.tunggakan)}
`; } function extractMemberIdFromQr(value) { const text = String(value || '').trim(); const identityMatch = text.match(/^STM-ANGGOTA\|([^|\s]+)$/i); return identityMatch ? identityMatch[1].trim() : text; } function stopAnggotaScanner() { if (anggotaScannerStopping) return anggotaScannerStopping; const scanner = anggotaScanner; anggotaScanner = null; if (!scanner) { anggotaScannerRunning = false; return Promise.resolve(); } // html5-qrcode menolak stop() bila kamera belum sempat berjalan. const cleanup = async () => { try { if (anggotaScannerRunning && typeof scanner.stop === 'function') { await scanner.stop(); } } catch (error) { // Abaikan kondisi balapan saat scanner sudah berhenti/tertutup. } finally { anggotaScannerRunning = false; try { if (typeof scanner.clear === 'function') await scanner.clear(); } catch (error) { // clear() juga aman diabaikan jika elemen sudah dibersihkan. } } }; anggotaScannerStopping = cleanup().finally(() => { anggotaScannerStopping = null; }); return anggotaScannerStopping; } function closeScanAnggota() { const modal = document.getElementById('modal-scan-anggota'); if (modal) modal.classList.add('hidden'); // Tangani Promise secara eksplisit agar kegagalan stop tidak menjadi // Unhandled Promise Rejection pada browser HP. stopAnggotaScanner().catch(() => {}); const reader = document.getElementById('scan-reader'); if (reader) reader.innerHTML = 'Preview kamera akan tampil setelah izin kamera diberikan.'; } // KONFIRMASI TRANSFER MANUAL function openTransferConfirmationModal() { if (state.currentUser?.role !== 'member') return; const modal = document.getElementById('modal-transfer-confirmation'); const dateInput = document.getElementById('transfer-confirmation-date'); document.querySelector('#modal-transfer-confirmation form').reset(); dateInput.value = new Date().toISOString().slice(0, 10); modal.classList.remove('hidden'); } async function submitTransferConfirmation(event) { event.preventDefault(); const submitButton = event.submitter || document.querySelector('#modal-transfer-confirmation button[type="submit"]'); const member = state.currentUser?.role === 'member' ? state.currentUser.data : null; const file = document.getElementById('transfer-confirmation-proof')?.files?.[0]; const amount = Number(document.getElementById('transfer-confirmation-amount')?.value) || 0; const date = document.getElementById('transfer-confirmation-date')?.value; if (!member || !file || amount <= 0 || !date) { showToast('Nominal, tanggal, dan bukti transfer wajib diisi.', 'warning'); return; } if (file.size > 2 * 1024 * 1024 || !file.type.startsWith('image/')) { showToast('Bukti transfer harus berupa gambar maksimal 2 MB.', 'warning'); return; } // Anggota langsung dikembalikan ke portal agar tidak menunggu di dalam modal. // Proses upload tetap berjalan dan hasilnya diberitahukan melalui toast. closeModal('modal-transfer-confirmation'); switchTab('portal-member'); showToast('Bukti transfer sedang dikirim...', 'info'); try { if (submitButton) { submitButton.disabled = true; submitButton.classList.add('opacity-60', 'cursor-not-allowed'); submitButton.innerHTML = ' Mengirim...'; } // Kompresi hanya untuk menghemat upload; file kemudian disimpan di Drive, // bukan sebagai Base64 di sel spreadsheet. const proofData = await resizeMemberPhoto(file); let proof = proofData; if (BACKEND_ENABLED) { const uploadResponse = await fetch(APP_URL, { method: 'POST', headers: { 'Content-Type': 'text/plain;charset=utf-8' }, body: JSON.stringify({ action: 'uploadTransferProof', fileName: `bukti-transfer-${member.id}-${Date.now()}.jpg`, mimeType: 'image/jpeg', data: proofData }), redirect: 'follow' }); if (!uploadResponse.ok) throw new Error(`Upload bukti gagal (HTTP ${uploadResponse.status}).`); const uploadResult = await uploadResponse.json(); if (uploadResult.status !== 'success' || !uploadResult.url) { throw new Error(uploadResult.message || 'Bukti transfer gagal diunggah ke penyimpanan.'); } proof = uploadResult.url; } state.konfirmasiTransfer = Array.isArray(state.konfirmasiTransfer) ? state.konfirmasiTransfer : []; state.konfirmasiTransfer.push({ id: Date.now(), anggotaId: member.id, anggotaNama: member.nama, tanggal: new Date(`${date}T00:00:00`).toLocaleDateString('id-ID'), nominal: amount, bukti: proof, status: 'Menunggu pemeriksaan' }); // Simpan segera agar konfirmasi tidak hilang saat halaman ditutup. const saveResult = await saveBackendData(true); if (BACKEND_ENABLED && saveResult?.status !== 'success') { throw new Error(saveResult?.message || 'Konfirmasi gagal disimpan ke database.'); } document.querySelector('#modal-transfer-confirmation form')?.reset(); renderTransferConfirmations(); showToast('Bukti transfer berhasil dikirim ke pengurus.', 'check'); } catch (error) { console.error('Gagal mengirim konfirmasi transfer:', error); showToast(error.message || 'Bukti transfer gagal disimpan. Silakan coba lagi.', 'warning'); } finally { if (submitButton) { submitButton.disabled = false; submitButton.classList.remove('opacity-60', 'cursor-not-allowed'); submitButton.innerHTML = ' Kirim Konfirmasi'; } } } function approveTransferConfirmation(id) { const item = (state.konfirmasiTransfer || []).find(entry => Number(entry.id) === Number(id)); const member = item && state.anggota.find(entry => String(entry.id) === String(item.anggotaId)); if (!item || !member || item.status !== 'Menunggu pemeriksaan') return; const nominal = Number(item.nominal) || 0; member.iuran = (Number(member.iuran) || 0) + nominal; member.tunggakan = Math.max(0, (Number(member.tunggakan) || 0) - nominal); member.status = member.tunggakan ? `Tunggakan ${formatRupiah(member.tunggakan)}` : 'Lunas'; state.aktivitasAnggota.push({ id: Date.now(), anggotaId: member.id, tanggal: item.tanggal, jenis: 'pembayaran', kategori: 'Transfer manual', keterangan: `Transfer manual anggota ${member.nama}`, nominal }); state.kas.push({ id: Date.now(), tanggal: item.tanggal, jenis: 'masuk', ket: `Transfer manual ${member.nama} (${member.id})`, nominal, anggotaId: member.id, anggotaNama: member.nama, sumber: 'pembayaran-anggota' }); item.status = 'Diverifikasi'; persistState(); renderTransferConfirmations(); renderAnggotaList(); renderKasList(); updateDashboardUI(); showToast('Transfer diverifikasi dan dicatat sebagai pembayaran.', 'check'); } function renderTransferConfirmations() { const container = document.getElementById('transfer-confirmation-list'); if (!container) return; const items = Array.isArray(state.konfirmasiTransfer) ? state.konfirmasiTransfer.slice().reverse() : []; container.innerHTML = items.length ? items.map(item => `
${escapeHTML(item.anggotaNama)}${escapeHTML(item.status)}
${escapeHTML(item.tanggal)} · ${formatRupiah(item.nominal)}
${item.bukti ? ` Buka bukti transfer` : 'Bukti transfer tidak tersimpan.'} ${item.status === 'Menunggu pemeriksaan' ? `` : ''}
`).join('') : '

Belum ada konfirmasi transfer.

'; } // PEMUTIHAN TUNGGAKAN ANGGOTA KELUAR let pemutihanAttachment = null; function openModalPemutihan() { const container = document.getElementById('pemutihan-member-list'); const exitedMembers = (state.anggota || []).filter(member => member.aktif === false && (Number(member.tunggakan) || 0) > 0); pemutihanAttachment = null; document.getElementById('pemutihan-keputusan').value = ''; document.getElementById('pemutihan-file-status').innerText = 'PDF atau foto, maksimal 2 MB. Ukuran foto akan disesuaikan otomatis.'; container.innerHTML = exitedMembers.length ? exitedMembers.map(member => ` `).join('') : '

Tidak ada anggota keluar dengan tunggakan.

'; updatePemutihanTotal(); document.getElementById('modal-pemutihan').classList.remove('hidden'); } function updatePemutihanTotal() { const selected = [...document.querySelectorAll('.pemutihan-member-check:checked')]; const total = selected.reduce((sum, checkbox) => { const member = state.anggota.find(item => String(item.id) === String(checkbox.dataset.memberId)); return sum + (Number(member?.tunggakan) || 0); }, 0); document.getElementById('pemutihan-total').innerText = formatRupiah(total); document.getElementById('pemutihan-selected-count').innerText = selected.length ? `${selected.length} anggota dipilih.` : 'Belum ada anggota dipilih.'; updatePemutihanButton(); } async function handlePemutihanAttachment(event) { const file = event.target.files?.[0]; const status = document.getElementById('pemutihan-file-status'); pemutihanAttachment = null; if (!file) { updatePemutihanButton(); return; } if (file.size > 2 * 1024 * 1024 || !(file.type === 'application/pdf' || file.type.startsWith('image/'))) { event.target.value = ''; status.innerText = 'File harus berupa PDF atau foto dengan ukuran maksimal 2 MB.'; status.className = 'text-[10px] text-rose-600'; updatePemutihanButton(); return; } try { const data = file.type.startsWith('image/') ? await resizeMemberPhoto(file) : await readFileAsDataUrl(file); pemutihanAttachment = { nama: file.name, tipe: file.type, data }; status.innerText = `${file.name} siap dilampirkan.`; status.className = 'text-[10px] text-emerald-600'; } catch (error) { status.innerText = 'Lampiran tidak dapat dibaca.'; status.className = 'text-[10px] text-rose-600'; } updatePemutihanButton(); } function readFileAsDataUrl(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader.readAsDataURL(file); }); } function updatePemutihanButton() { const hasMember = document.querySelector('.pemutihan-member-check:checked'); const button = document.getElementById('btn-eksekusi-pemutihan'); if (!button) return; const enabled = Boolean(hasMember && pemutihanAttachment); button.disabled = !enabled; button.classList.toggle('opacity-50', !enabled); button.classList.toggle('cursor-not-allowed', !enabled); } function executePemutihan() { const selected = [...document.querySelectorAll('.pemutihan-member-check:checked')]; if (!selected.length || !pemutihanAttachment) return; const details = []; selected.forEach(checkbox => { const member = state.anggota.find(item => String(item.id) === String(checkbox.dataset.memberId)); if (!member || Number(member.tunggakan) <= 0) return; details.push({ anggotaId: member.id, nama: member.nama, nominal: Number(member.tunggakan) }); member.tunggakan = 0; member.status = 'Lunas (Pemutihan)'; }); if (!details.length) return; state.riwayatPemutihan.push({ id: Date.now(), tanggal: new Date().toLocaleDateString('id-ID'), total: details.reduce((sum, item) => sum + item.nominal, 0), anggota: details, lampiran: pemutihanAttachment }); persistState(); closeModal('modal-pemutihan'); renderAnggotaList(); renderAnggotaKeluarList(); updateDashboardUI(); showToast(`Pemutihan ${formatRupiah(details.reduce((sum, item) => sum + item.nominal, 0))} berhasil dieksekusi.`, 'check'); } // INFO AWAL ANGGOTA function openModalInfoAwalAnggota() { const info = state.infoAwalAnggota || {}; const values = { 'info-awal-uang-pangkal': info.uangPangkal, 'info-awal-pemberi-pangkal': info.jumlahPemberiUangPangkal, 'info-awal-saldo-iuran': info.saldoIuran, 'info-awal-saldo-administrasi': info.saldoAdministrasiLainnya }; Object.entries(values).forEach(([id, value]) => { const input = document.getElementById(id); if (input) input.value = Number(value) || 0; }); document.getElementById('modal-info-awal-anggota')?.classList.remove('hidden'); } function saveInfoAwalAnggota(event) { event.preventDefault(); const getNumber = id => Math.max(0, Number(document.getElementById(id)?.value) || 0); const jumlahPemberiUangPangkal = getNumber('info-awal-pemberi-pangkal'); state.infoAwalAnggota = { uangPangkal: getNumber('info-awal-uang-pangkal'), jumlahPemberiUangPangkal, saldoIuran: getNumber('info-awal-saldo-iuran'), saldoAdministrasiLainnya: getNumber('info-awal-saldo-administrasi') }; persistState(); closeModal('modal-info-awal-anggota'); updateDashboardUI(); showToast('Info awal anggota berhasil disimpan dan ditambahkan ke saldo kas.', 'check'); } // MODAL ACTIONS function openModalInfoAplikasi() { const modal = document.getElementById('modal-info-aplikasi'); if (modal) modal.classList.remove('hidden'); } function openModalAnggotaKeluar() { const select = document.getElementById('exit-member-id'); const activeMembers = (state.anggota || []).filter(member => member.aktif !== false); select.innerHTML = '' + activeMembers.map(member => `` ).join(''); document.getElementById('exit-member-note').value = ''; document.getElementById('exit-member-outstanding').innerText = 'Pilih anggota untuk melihat tunggakan.'; select.onchange = () => { const member = state.anggota.find(item => item.id === select.value); document.getElementById('exit-member-outstanding').innerText = member ? `Tunggakan yang tetap tercatat: ${formatRupiah(member.tunggakan)}` : 'Pilih anggota untuk melihat tunggakan.'; }; document.getElementById('modal-anggota-keluar').classList.remove('hidden'); } function handleAnggotaKeluar(event) { event.preventDefault(); const member = state.anggota.find(item => item.id === document.getElementById('exit-member-id').value); if (!member) return; member.aktif = false; member.active = false; member.statusKeanggotaan = 'keluar'; member.menerimaTunggakan = false; member.tunggakanBaru = 0; member.newOutstanding = 0; member.tanggalKeluar = new Date().toLocaleDateString('id-ID'); member.catatanKeluar = document.getElementById('exit-member-note').value.trim(); persistState(); closeModal('modal-anggota-keluar'); renderAnggotaList(); renderAnggotaKeluarList(); updateDashboardUI(); showToast(`${member.nama} ditandai sebagai anggota keluar. Tunggakan tetap tercatat.`, 'check'); } function openModalBulkImport() { const modal = document.getElementById('modal-bulk'); const status = document.getElementById('bulk-file-status'); if (status) status.innerText = 'Format yang didukung: .txt, .csv, atau .tsv. File Excel (.xlsx) harus diekspor sebagai CSV UTF-8.'; modal.classList.remove('hidden'); } function closeModal(id) { const modal = document.getElementById(id); if (!modal) return; modal.classList.add('hidden'); if (id === 'modal-scan-anggota') { stopAnggotaScanner().catch(() => {}); } } function closeDetailAnggota() { closeModal('modal-detail-anggota'); } function closeAllModals() { document.querySelectorAll('[id^="modal-"]').forEach(modal => modal.classList.add('hidden')); stopAnggotaScanner(); } function openNewMemberRegistration(event) { if (event) { event.preventDefault(); event.stopPropagation(); } const modal = document.getElementById('modal-anggota-baru'); const form = document.getElementById('form-anggota-baru'); const totalElement = document.getElementById('new-member-total-requirement'); if (!modal || !form) { console.error('Modal pendaftaran anggota baru tidak ditemukan.'); if (typeof showToast === 'function') { showToast('Form Anggota Baru belum tersedia. Muat ulang halaman.', 'warning'); } return false; } const settings = state && state.pengaturan ? state.pengaturan : {}; const total = [settings.uangPangkal, settings.iuranDuka, settings.administrasi, settings.biayaLainnya] .reduce((sum, value) => sum + (Number(value) || 0), 0); if (totalElement) { totalElement.innerText = `Total tunggakan awal: ${formatRupiah(total)}`; } form.reset(); modal.classList.remove('hidden'); modal.setAttribute('aria-hidden', 'false'); document.getElementById('new-member-id')?.focus(); return false; } function handleNewMemberRegistration(event) { if (event) { event.preventDefault(); event.stopPropagation(); } try { const getValue = id => String(document.getElementById(id)?.value || '').trim(); const memberId = getValue('new-member-id'); const name = getValue('new-member-name'); const phone = getValue('new-member-phone'); const address = getValue('new-member-address'); if (!memberId || !name || !phone || !address) { showToast('Nomor anggota, nama, No. HP, dan alamat wajib diisi.', 'warning'); return false; } if (!Array.isArray(state.anggota)) state.anggota = []; const normalizedId = memberId.toLowerCase(); const normalizedPhone = phone.replace(/\D/g, ''); if (state.anggota.some(item => String(item?.id || item?.nomorAnggota || '').trim().toLowerCase() === normalizedId)) { showToast('Nomor Anggota sudah digunakan.', 'warning'); return false; } if (normalizedPhone && state.anggota.some(item => String(item?.phone || '').replace(/\D/g, '') === normalizedPhone )) { showToast('No. HP sudah terdaftar.', 'warning'); return false; } const settings = state.pengaturan || {}; const uangPangkal = Math.max(0, Number(settings.uangPangkal) || 0); const iuranDuka = Math.max(0, Number(settings.iuranDuka) || 0); const administrasi = Math.max(0, Number(settings.administrasi) || 0); const biayaLainnya = Math.max(0, Number(settings.biayaLainnya) || 0); const tunggakan = uangPangkal + iuranDuka + administrasi + biayaLainnya; const member = { id: memberId, nomorAnggota: memberId, nama: name, phone, alamat: address, foto: '', aktif: true, iuran: 0, tunggakan, status: tunggakan > 0 ? `Tunggakan ${formatRupiah(tunggakan)}` : 'Lunas', kewajibanAwal: { uangPangkal, iuranDuka, administrasi, biayaLainnya } }; state.anggota.push(member); persistState(); closeModal('modal-anggota-baru'); const form = document.getElementById('form-anggota-baru'); if (form) form.reset(); renderAnggotaList(); renderAnggotaKeluarList(); updateDashboardUI(); showToast(`${name} berhasil didaftarkan dengan tunggakan awal ${formatRupiah(tunggakan)}.`, 'check'); return false; } catch (error) { console.error('Gagal mendaftarkan anggota baru:', error); showToast(`Anggota gagal didaftarkan: ${error.message || 'kesalahan aplikasi.'}`, 'warning'); return false; } } function renderAnggotaBaruRequirements() { const settings = state.pengaturan || {}; const values = { 'anggota-baru-uang-pangkal': settings.uangPangkal, 'anggota-baru-iuran-duka': settings.iuranDuka, 'anggota-baru-administrasi': settings.administrasi, 'anggota-baru-biaya-lainnya': settings.biayaLainnya }; Object.entries(values).forEach(([id, value]) => { const element = document.getElementById(id); if (element) element.innerText = formatRupiah(value); }); } function renderPengaturan() { const settings = state.pengaturan || {}; document.getElementById('setting-iuran-duka').value = Number(settings.iuranDuka) || 0; document.getElementById('setting-nama-stm').value = settings.namaSTM || 'STM AMANAH'; const logoStatus = document.getElementById('setting-logo-status'); if (logoStatus) logoStatus.innerText = settings.logoSTM ? 'Logo STM sudah tersimpan. Pilih file baru untuk menggantinya.' : 'Pilih gambar logo, maksimal 2 MB.'; document.getElementById('setting-uang-pangkal').value = Number(settings.uangPangkal) || 0; document.getElementById('setting-administrasi').value = Number(settings.administrasi) || 0; document.getElementById('setting-lainnya').value = Number(settings.biayaLainnya) || 0; document.getElementById('setting-nama-bank').value = settings.namaBank || ''; document.getElementById('setting-nomor-rekening').value = settings.nomorRekening || ''; document.getElementById('setting-atas-nama').value = settings.atasNama || ''; renderAnggotaBaruRequirements(); } function savePengaturan(event) { event.preventDefault(); state.pengaturan = { ...state.pengaturan, namaSTM: document.getElementById('setting-nama-stm').value.trim() || 'STM AMANAH', logoSTM: state.pengaturan?.logoSTM || '', iuranDuka: Math.max(0, Number(document.getElementById('setting-iuran-duka').value) || 0), uangPangkal: Math.max(0, Number(document.getElementById('setting-uang-pangkal').value) || 0), administrasi: Math.max(0, Number(document.getElementById('setting-administrasi').value) || 0), biayaLainnya: Math.max(0, Number(document.getElementById('setting-lainnya').value) || 0), namaBank: document.getElementById('setting-nama-bank').value.trim(), nomorRekening: document.getElementById('setting-nomor-rekening').value.trim(), atasNama: document.getElementById('setting-atas-nama').value.trim() }; persistState(); renderAnggotaBaruRequirements(); applyStmBranding(); if (state.currentUser?.role === 'member') renderPortalMember(state.currentUser.data); showToast('Pengaturan STM, iuran, dan biaya berhasil disimpan', 'check'); } async function handleLogoStmUpload(event) { const file = event.target.files && event.target.files[0]; const status = document.getElementById('setting-logo-status'); if (!file) return; try { const localLogo = await resizeMemberPhoto(file); state.pengaturan.logoSTM = await uploadImageAssetToDrive(localLogo, { jenis: 'logo', referensiId: 'logo-stm', referensiNama: state.pengaturan.namaSTM || 'STM Amanah', fileName: `logo-stm-${Date.now()}.jpg` }); persistState(); if (status) status.innerText = 'Logo STM tersimpan di Google Drive. Tekan Simpan Pengaturan.'; showToast('Logo STM berhasil dimuat.', 'check'); } catch (error) { event.target.value = ''; if (status) status.innerText = error.message; showToast(error.message, 'warning'); } } function applyStmBranding() { const settings = state.pengaturan || {}; const isLandingPage = !state.currentUser; const name = isLandingPage ? 'Togoomas-Digital' : (settings.namaSTM || 'STM AMANAH'); const subtitle = isLandingPage ? 'Solusi Praktis Pengelolaan Serikat Tolong Menolong' : (state.currentUser.role === 'member' ? 'Portal Anggota' : 'Pengurus Admin'); const title = document.getElementById('header-stm-name'); const roleLabel = document.getElementById('user-role-label'); const logo = document.getElementById('header-logo'); if (title) title.innerText = name; if (roleLabel) roleLabel.innerText = subtitle; if (logo) { // Landing selalu memakai logo publik; logoSTM hanya untuk halaman setelah login. const logoUrl = isLandingPage ? PUBLIC_HEADER_LOGO_URL : getDriveDisplayUrl(settings.logoSTM); logo.innerHTML = logoUrl ? `Logo ${escapeHTML(name)}` : ''; } } function openModalBeritaDuka() { const select = document.getElementById('duka-anggota-id'); select.innerHTML = '' + state.anggota.filter(member => member.aktif !== false).map(member => ``).join(''); document.getElementById('form-berita-duka').reset(); document.getElementById('duka-iuran-nominal').value = Math.max(0, Number(state.pengaturan?.iuranDuka) || 0); document.getElementById('duka-hubungan-wrap').classList.add('hidden'); updateDukaMemberInfo(); document.getElementById('modal-berita-duka').classList.remove('hidden'); } function updateDukaMemberInfo() { const select = document.getElementById('duka-anggota-id'); const info = document.getElementById('duka-anggota-info'); const relationshipCheck = document.getElementById('duka-anak-anggota'); const relationshipWrap = document.getElementById('duka-hubungan-wrap'); if (!select || !info) return; const member = state.anggota.find(item => String(item.id) === String(select.value)); if (!member) { info.className = 'rounded-xl bg-blue-50 border border-blue-100 p-3 text-[11px] text-blue-800'; info.innerText = 'Pilih anggota untuk menentukan apakah yang meninggal adalah anggota STM atau keluarganya.'; return; } info.className = 'rounded-xl bg-amber-50 border border-amber-200 p-3 text-[11px] text-amber-800'; info.innerHTML = `${escapeHTML(member.nama)} dipilih sebagai anggota terkait. Jika yang meninggal adalah ${escapeHTML(member.nama)} sendiri, pilih opsi anggota STM di bawah. Jika yang meninggal keluarga, aktifkan opsi keluarga dan isi hubungannya.`; if (relationshipCheck) relationshipCheck.checked = false; if (relationshipWrap) relationshipWrap.classList.add('hidden'); } function toggleDukaDeathType(type) { const memberCheck = document.getElementById('duka-anggota-sendiri'); const familyCheck = document.getElementById('duka-anak-anggota'); const nameInput = document.getElementById('duka-nama-meninggal'); const relationshipWrap = document.getElementById('duka-hubungan-wrap'); const member = state.anggota.find(item => String(item.id) === String(document.getElementById('duka-anggota-id')?.value)); if (type === 'member') { familyCheck.checked = false; relationshipWrap.classList.add('hidden'); if (member) nameInput.value = member.nama; nameInput.readOnly = true; nameInput.classList.add('bg-gray-100', 'cursor-not-allowed'); } else { memberCheck.checked = false; relationshipWrap.classList.remove('hidden'); nameInput.value = ''; nameInput.readOnly = false; nameInput.classList.remove('bg-gray-100', 'cursor-not-allowed'); } } function toggleHubunganDuka() { toggleDukaDeathType('family'); } function handleDukaMemberChange() { updateDukaMemberInfo(); if (document.getElementById('duka-anggota-sendiri')?.checked) { toggleDukaDeathType('member'); } } function toggleHubunganDukaLegacy() { document.getElementById('duka-hubungan-wrap').classList.toggle( 'hidden', !document.getElementById('duka-anak-anggota').checked ); } function canReceiveNewOutstanding(member) { return Boolean(member) && !isMemberExited(member); } function handleBeritaDuka(event) { event.preventDefault(); const nama = document.getElementById('duka-nama-meninggal').value.trim(); const anggotaId = document.getElementById('duka-anggota-id').value; const hubungan = document.getElementById('duka-hubungan').value.trim(); const keterangan = document.getElementById('duka-keterangan').value.trim(); const isFamilyDeath = document.getElementById('duka-anak-anggota').checked; const anggota = state.anggota.find(member => member.id === anggotaId); if (!nama) { showToast('Nama yang meninggal wajib diisi', 'warning'); return; } if (isFamilyDeath && !anggotaId) { showToast('Pilih nomor anggota untuk mengikat hubungan keluarga', 'warning'); return; } // Untuk anggota STM sendiri, nama wajib mengikuti database agar tidak salah input. const namaMeninggal = anggota && !isFamilyDeath ? anggota.nama : nama; const hubunganText = anggota ? (isFamilyDeath ? `Yang meninggal adalah keluarga anggota ${anggota.id} (${anggota.nama})${hubungan ? `, hubungan: ${hubungan}` : ''}` : `Yang meninggal adalah anggota STM ${anggota.id} (${anggota.nama})`) : 'Tidak terkait anggota terdaftar'; const msg = `*INNA LILLAHI WA INNA ILAIHI RAJI'UN*\\n\\nInformasi Duka Cita STM Amanah:\\nTelah berpulang ke rahmatullah: *${namaMeninggal}*\\n\\n${hubunganText}.${keterangan ? `\\n${keterangan}` : ''}\\n\\nSemoga almarhum/almarhumah husnul khatimah dan keluarga yang ditinggalkan diberikan ketabahan.\\n\\nMari bersama-sama bertakziah dan membantu prosesi pemakaman.`; state.beritaDuka.push({ id: Date.now(), tanggal: new Date().toLocaleDateString('id-ID'), namaMeninggal: namaMeninggal, anggotaId: anggotaId || null, anggotaNama: anggota?.nama || null, hubungan: isFamilyDeath ? (hubungan || 'Keluarga anggota') : 'Anggota STM', subjekKematian: isFamilyDeath ? 'keluarga anggota' : 'anggota STM', keterangan, iuranDuka: document.getElementById('duka-aktifkan-iuran').checked ? Math.max(0, Number(document.getElementById('duka-iuran-nominal').value) || 0) : 0 }); const iuranDuka = Math.max(0, Number(document.getElementById('duka-iuran-nominal').value) || 0); // Jika anggota STM sendiri yang meninggal, nonaktifkan keanggotaan // dan hentikan tagihan baru tanpa menghapus histori atau tunggakan lama. if (anggota && !isFamilyDeath) { anggota.aktif = false; anggota.active = false; anggota.statusKeanggotaan = 'keluar'; anggota.alasanKeluar = 'Meninggal dunia'; anggota.catatanKeluar = `Meninggal dunia${keterangan ? ` — ${keterangan}` : ''}`; anggota.tanggalKeluar = new Date().toLocaleDateString('id-ID'); anggota.menerimaTunggakan = false; anggota.tunggakanBaru = 0; anggota.newOutstanding = 0; } if (document.getElementById('duka-aktifkan-iuran').checked && iuranDuka > 0) { state.anggota.forEach(member => { // Anggota keluar, termasuk anggota yang baru meninggal, tidak menerima tagihan baru. if (!canReceiveNewOutstanding(member)) return; member.tunggakan = (Number(member.tunggakan) || 0) + iuranDuka; member.tagihanDuka = (Number(member.tagihanDuka) || 0) + iuranDuka; member.status = `Tunggakan ${formatRupiah(member.tunggakan)}`; }); renderAnggotaList(); } persistState(); renderAnggotaKeluarList(); updateDashboardUI(); copyText(msg) .then(() => { closeModal('modal-berita-duka'); switchTab('dashboard'); showToast('Berita duka berhasil dibuat dan pesannya sudah disalin', 'check'); }) .catch(() => { closeModal('modal-berita-duka'); switchTab('dashboard'); showToast('Berita duka sudah disimpan, tetapi pesan gagal disalin', 'warning'); }); } function renderDukaBroadcastOptions() { const container = document.getElementById('duka-preset-broadcast-list'); const nameInput = document.getElementById('duka-nama'); if (!container || !nameInput) return; const items = (Array.isArray(state.beritaDuka) ? state.beritaDuka.slice().reverse() : []) .filter(item => !item.biayaDukaTercatat); if (!items.length) { container.innerHTML = '

Belum ada berita duka yang menunggu pencatatan biaya.

'; nameInput.value = ''; nameInput.placeholder = 'Semua biaya duka sudah tercatat'; return; } container.innerHTML = items.map((item, index) => { const itemId = `duka-preset-${item.id || index}`; const label = `${item.namaMeninggal || '-'}${item.tanggal ? ` — ${item.tanggal}` : ''}`; return ` `; }).join(''); nameInput.value = ''; nameInput.placeholder = 'Centang nama jenazah di atas'; } function selectDukaBroadcastOption(radio) { const nameInput = document.getElementById('duka-nama'); if (!nameInput || !radio?.checked) return; nameInput.value = radio.dataset.nama || ''; } function openModalModalBiayaDuka() { resetDukaItems(); renderDukaBroadcastOptions(); document.getElementById('modal-duka').classList.remove('hidden'); } let editingKasTransactionId = null; function openModalTambahKas() { editingKasTransactionId = null; document.getElementById('form-kas').reset(); document.getElementById('kas-jenis').value = 'masuk'; setKasType('masuk'); document.querySelector('#modal-kas h3').innerText = 'Input Transaksi Kas'; document.querySelector('#form-kas button[type="submit"]').innerText = 'Simpan Transaksi'; document.getElementById('modal-kas').classList.remove('hidden'); } function editKasTransaction(id) { const transaction = (state.kas || []).find(item => String(item.id) === String(id)); if (!transaction) { showToast('Transaksi tidak ditemukan.', 'warning'); return; } editingKasTransactionId = transaction.id; document.getElementById('kas-jenis').value = transaction.jenis === 'keluar' ? 'keluar' : 'masuk'; document.getElementById('kas-ket').value = transaction.ket || ''; document.getElementById('kas-nominal').value = Number(transaction.nominal) || 0; setKasType(document.getElementById('kas-jenis').value); document.querySelector('#modal-kas h3').innerText = 'Edit Transaksi Kas'; document.querySelector('#form-kas button[type="submit"]').innerText = 'Simpan Perubahan'; document.getElementById('modal-kas').classList.remove('hidden'); } function deleteKasTransaction(id) { const index = (state.kas || []).findIndex(item => String(item.id) === String(id)); if (index < 0) { showToast('Transaksi tidak ditemukan.', 'warning'); return; } const transaction = state.kas[index]; if (!window.confirm(`Hapus transaksi "${transaction.ket || 'tanpa keterangan'}"?`)) return; state.kas.splice(index, 1); persistState(); renderKasList(); updateDashboardUI(); showToast('Transaksi berhasil dihapus dan saldo diperbarui.', 'check'); } function resetDukaItems() { document.querySelectorAll('#duka-items .duka-item-check').forEach(check => { check.checked = false; }); document.querySelectorAll('#duka-items .duka-item-amount').forEach(input => { input.value = 0; }); updateDukaTotal(); } function toggleAllDukaItems() { const checks = [...document.querySelectorAll('#duka-items .duka-item-check')]; const shouldCheck = checks.some(check => !check.checked); checks.forEach(check => { check.checked = shouldCheck; }); updateDukaTotal(); } function updateDukaTotal() { let total = 0; document.querySelectorAll('#duka-items .duka-cost-item').forEach(item => { const check = item.querySelector('.duka-item-check'); const amount = item.querySelector('.duka-item-amount'); amount.disabled = !check.checked; item.classList.toggle('is-selected', check.checked); if (check.checked) total += Math.max(0, Number(amount.value) || 0); }); document.getElementById('duka-total').innerText = formatRupiah(total); } // IMPOR FILE TEKS / CSV DARI NOTEPAD ATAU EXCEL function handleBulkFileUpload(event) { const file = event.target.files?.[0]; const status = document.getElementById('bulk-file-status'); if (!file) return; const allowed = /\.(csv|txt|tsv)$/i.test(file.name) || ['text/csv', 'text/plain'].includes(file.type); if (!allowed) { event.target.value = ''; if (status) status.innerText = 'File tidak didukung. Gunakan .txt, .csv, atau .tsv.'; showToast('Gunakan file TXT, CSV, atau TSV.', 'warning'); return; } const reader = new FileReader(); reader.onload = () => { const content = String(reader.result || '').replace(/^\uFEFF/, '').trim(); document.getElementById('bulk-input').value = content; if (status) status.innerText = `${file.name} berhasil dimuat. Periksa data, lalu tekan Proses Impor.`; }; reader.onerror = () => { if (status) status.innerText = 'File tidak dapat dibaca.'; showToast('File gagal dibaca.', 'warning'); }; reader.readAsText(file); } // BULK IMPORT PROCESSOR function processBulkImport() { const input = document.getElementById('bulk-input'); const raw = input.value.trim(); if (!raw) return showToast('Isi data atau pilih file impor terlebih dahulu', 'warning'); const lines = raw.split(/\r?\n/).map(line => line.trim()).filter(Boolean); let addedCount = 0; let skippedCount = 0; lines.forEach(line => { // Mendukung CSV koma, TSV dari Excel, dan pemisah titik koma. const delimiter = line.includes('\t') ? '\t' : (line.includes(';') ? ';' : ','); const parts = line.split(delimiter).map(value => value.trim().replace(/^['"]|['"]$/g, '')); // Format baru: Nomor Anggota, Nama, No. HP, Alamat. const importedMemberId = parts[0]; const importedName = parts[1]; const phone = parts[2]; const address = parts.slice(3).join(delimiter).trim() || 'Lingkungan STM'; const normalizedFirstColumn = String(importedMemberId || '').toLowerCase().replace(/\s+/g, ' ').trim(); if (!importedMemberId || !importedName || /^(nomor anggota|nomor|id|nama|name)(\s|,|$)/.test(normalizedFirstColumn)) { skippedCount++; return; } if (state.anggota.some(member => String(member.id || member.nomorAnggota || '').trim().toLowerCase() === importedMemberId.toLowerCase())) { skippedCount++; return; } if (phone && state.anggota.some(member => String(member.phone || '').trim() === phone)) { skippedCount++; return; } state.anggota.push({ aktif: true, id: importedMemberId, nomorAnggota: importedMemberId, nama: importedName, phone: phone || '081200000000', alamat: address, foto: '', tunggakan: 0, status: 'Lunas' }); addedCount++; }); persistState(); closeModal('modal-bulk'); input.value = ''; const fileInput = document.getElementById('bulk-file'); if (fileInput) fileInput.value = ''; renderAnggotaList(); updateDashboardUI(); showToast(`Berhasil mengimpor ${addedCount} anggota${skippedCount ? `, ${skippedCount} baris dilewati` : ''}.`, 'check'); } function bayarTunggakan(id, source = '') { const member = state.anggota.find(item => item.id === id); if (!member) return; const nominal = Number(member.tunggakan) || 0; if (nominal <= 0) { showToast('Anggota tidak memiliki tunggakan', 'info'); return; } member.iuran = (Number(member.iuran) || 0) + nominal; member.tunggakan = 0; member.tagihanDuka = 0; member.status = 'Lunas'; const paymentDate = new Date().toLocaleDateString('id-ID'); const paymentId = Date.now(); state.aktivitasAnggota.push({ id: paymentId, anggotaId: member.id, tanggal: paymentDate, jenis: 'pembayaran', kategori: 'Iuran / Tunggakan', keterangan: `Pembayaran iuran anggota ${member.nama}`, nominal }); state.kas.push({ id: paymentId, tanggal: paymentDate, jenis: 'masuk', ket: `Pembayaran iuran anggota ${member.nama} (${member.id})`, nominal, anggotaId: member.id, anggotaNama: member.nama, sumber: 'pembayaran-iuran' }); renderAnggotaList(); renderKasList(); updateDashboardUI(); if (source === 'scan') { closeModal('modal-scan-anggota'); switchTab('dashboard'); showToast(`Pembayaran iuran ${formatRupiah(nominal)} berhasil dicatat.`, 'check'); return; } const scanCode = document.getElementById('scan-member-code'); if (scanCode && scanCode.value.trim().toLowerCase() === String(member.id).toLowerCase()) { findScannedAnggota(); } if (state.currentUser?.role === 'member' && state.currentUser.data.id === member.id) { state.currentUser.data = member; renderPortalMember(member); } showToast(`Pembayaran ${formatRupiah(nominal)} berhasil dicatat`, 'check'); } function getMemberActivities(memberId) { const stored = Array.isArray(state.aktivitasAnggota) ? state.aktivitasAnggota : []; const fromKas = (Array.isArray(state.kas) ? state.kas : []) .filter(tx => String(tx?.anggotaId ?? '') === String(memberId) && tx?.sumber === 'pembayaran-anggota') .map(tx => ({ id: tx.id ?? `${tx.tanggal || ''}-${tx.nominal || 0}`, anggotaId: memberId, tanggal: tx.tanggal || '-', jenis: 'pembayaran', kategori: 'Iuran / Tunggakan', keterangan: tx.ket || tx.kategori || 'Pembayaran anggota', nominal: Number(tx.nominal) || 0 })); const merged = [ ...stored.filter(item => item && String(item.anggotaId) === String(memberId)), ...fromKas ]; const seen = new Set(); return merged .filter(item => { const key = String(item.id ?? `${item.tanggal || ''}-${item.nominal || 0}`); if (seen.has(key)) return false; seen.add(key); return true; }) .sort((a, b) => Number(b.id) - Number(a.id)); } function renderMemberActivityList(container, activities) { if (!container) return; if (!activities.length) { container.innerHTML = '

Belum ada histori aktivitas.

'; return; } container.innerHTML = activities.map(activity => `

${escapeHTML(activity.keterangan || activity.kategori)}

${escapeHTML(activity.tanggal)}
+ ${formatRupiah(activity.nominal)}
`).join(''); } function renderMemberAdditionalInfoInCard(member) { const card = document.getElementById('mdetail-additional-info'); if (!card) return; const isAdmin = state.currentUser?.role === 'admin'; card.classList.toggle('hidden', !isAdmin); if (!isAdmin) return; const setText = (id, value) => { const element = document.getElementById(id); if (element) element.textContent = value || '-'; }; const setAttachment = (id, attachment) => { const element = document.getElementById(id); if (!element) return; const attachmentUrl = attachment?.url || attachment?.data; element.innerHTML = attachmentUrl ? ` ${escapeHTML(attachment.nama || 'Lihat lampiran')}` : 'Tidak ada'; }; setText('mdetail-ktp', member.nomorKtp); setText('mdetail-kk', member.nomorKk); setText('mdetail-family', member.keluargaTanggungan); setAttachment('mdetail-ktp-file', member.lampiranKtp); setAttachment('mdetail-kk-file', member.lampiranKk); } // DETAIL ANGGOTA MODAL function openDetailAnggota(id) { // Gunakan handler aman yang tidak menjalankan logika histori sebelum modal dibuka. if (typeof window.openIdentityCardSafely === 'function') { return window.openIdentityCardSafely(id); } const memberId = String(id ?? '').replace(/^Nomor Anggota:\s*/i, '').trim(); const members = Array.isArray(state.anggota) ? state.anggota : []; const m = members.find(a => String(a.id ?? '').trim().toLowerCase() === memberId.toLowerCase()); const modal = document.getElementById('modal-detail-anggota'); if (!modal) { showToast('Modal kartu anggota tidak tersedia.', 'warning'); return false; } if (!m) { modal.classList.remove('hidden'); showToast(`Anggota ${memberId || 'ini'} tidak ditemukan.`, 'warning'); return false; } currentDetailMemberId = m.id; // Modal dibuka sebelum QR dan histori diproses agar klik selalu terlihat. modal.classList.remove('hidden'); const setText = (id, value) => { const element = document.getElementById(id); if (element) element.innerText = value ?? '-'; }; setText('mdetail-nama', m.nama); setText('mdetail-id', `Nomor Anggota: ${m.id}`); setText('mdetail-phone', `No HP: ${m.phone || '-'}`); const outstanding = Math.max(0, Number(m.tunggakan) || 0); const statusText = outstanding > 0 ? 'Tunggakan Iuran' : 'Lunas'; setText('mdetail-status', statusText); setText('mdetail-tunggakan', formatRupiah(outstanding)); // QR dibuka dan dibuat lebih dulu. Data tunggakan/histori yang rusak // tidak boleh membuat tombol QR terlihat tidak merespons. try { renderQRCode( document.getElementById('mdetail-qris-render'), `STM-ANGGOTA|${m.id}`, 130 ); } catch (error) { console.error('Gagal membuat QR identitas anggota:', error); const qrBox = document.getElementById('mdetail-qris-render'); if (qrBox) qrBox.innerHTML = 'QR tidak dapat dibuat.'; } try { setMemberPhoto(document.getElementById('mdetail-foto'), m); renderMemberActivityList( document.getElementById('mdetail-activity-list'), getMemberActivities(m.id) ); refreshMemberPaymentButton({ ...m, tunggakan: outstanding }); updateOutstandingBroadcastButton({ ...m, tunggakan: outstanding }); } catch (error) { console.error('Gagal mengisi histori kartu anggota:', error); const activityList = document.getElementById('mdetail-activity-list'); if (activityList) activityList.innerHTML = '

Histori belum tersedia.

'; refreshMemberPaymentButton({ ...m, tunggakan: outstanding }); } return false; } // SAVE DUKA PRESET function handleSaveDuka(e) { e.preventDefault(); const nama = document.getElementById('duka-nama').value.trim(); const selectedBroadcast = document.querySelector('input[name="duka-preset-broadcast"]:checked'); if (!selectedBroadcast || !nama) { showToast('Centang nama jenazah dari Broadcasting terlebih dahulu.', 'warning'); return; } const selectedItems = [...document.querySelectorAll('#duka-items .duka-cost-item')] .filter(item => item.querySelector('.duka-item-check').checked) .map(item => ({ label: item.querySelector('.duka-item-check').dataset.label, nominal: Math.max(0, Number(item.querySelector('.duka-item-amount').value) || 0) })) .filter(item => item.nominal > 0); const total = selectedItems.reduce((sum, item) => sum + item.nominal, 0); if (selectedItems.length === 0 || total < 1000) { showToast("Pilih minimal satu biaya dan isi nominalnya", "warning"); return; } const selectedBroadcastId = selectedBroadcast.value; const broadcastItem = (state.beritaDuka || []).find(item => String(item.id || '') === String(selectedBroadcastId) ); if (!broadcastItem) { showToast('Informasi berita duka tidak ditemukan.', 'warning'); return; } if (broadcastItem.biayaDukaTercatat) { showToast('Biaya untuk berita duka ini sudah tercatat.', 'warning'); return; } const today = new Date().toLocaleDateString('id-ID'); state.kas.push({ id: Date.now(), tanggal: today, jenis: "keluar", ket: `[DUKA] ${selectedItems.map(item => item.label).join(', ')} - ${nama}`, nominal: total, detailBiaya: selectedItems, beritaDukaId: broadcastItem.id }); broadcastItem.biayaDukaTercatat = true; broadcastItem.biayaDukaNominal = total; broadcastItem.biayaDukaTanggal = today; persistState(); closeModal('modal-duka'); document.getElementById('form-duka').reset(); resetDukaItems(); updateDashboardUI(); showToast(`Biaya duka ${formatRupiah(total)} berhasil dicatat`, "check"); } // KAS MASUK/KELUAR function setKasType(type) { document.getElementById('kas-jenis').value = type; if (type === 'masuk') { document.getElementById('type-masuk').className = "py-2 text-xs font-bold rounded-xl bg-emerald-600 text-white"; document.getElementById('type-keluar').className = "py-2 text-xs font-bold rounded-xl bg-gray-100 text-gray-600"; } else { document.getElementById('type-keluar').className = "py-2 text-xs font-bold rounded-xl bg-rose-600 text-white"; document.getElementById('type-masuk').className = "py-2 text-xs font-bold rounded-xl bg-gray-100 text-gray-600"; } } function handleSaveKas(e) { e.preventDefault(); const jenis = document.getElementById('kas-jenis').value; const ket = document.getElementById('kas-ket').value.trim(); const nom = parseInt(document.getElementById('kas-nominal').value, 10); if (!ket || !Number.isFinite(nom) || nom <= 0) { showToast('Keterangan dan nominal transaksi wajib diisi.', 'warning'); return; } if (editingKasTransactionId !== null) { const transaction = (state.kas || []).find(item => String(item.id) === String(editingKasTransactionId)); if (!transaction) { showToast('Transaksi tidak ditemukan.', 'warning'); return; } transaction.jenis = jenis; transaction.ket = ket; transaction.nominal = nom; editingKasTransactionId = null; showToast('Transaksi berhasil diperbarui dan saldo dihitung ulang.', 'check'); } else { state.kas.push({ id: Date.now(), tanggal: new Date().toLocaleDateString('id-ID'), jenis, ket, nominal: nom }); showToast('Transaksi kas berhasil disimpan', 'check'); } persistState(); closeModal('modal-kas'); document.getElementById('form-kas').reset(); renderKasList(); updateDashboardUI(); } // BROADCAST TAGIHAN PER ANGGOTA function getMemberWhatsappNumber(member) { return String(member?.phone || '').replace(/\D/g, '').replace(/^0+/, '62'); } function buildOutstandingMessage(member) { const settings = state.pengaturan || {}; const rekening = getPaymentTarget() || 'Rekening STM belum diatur'; const nominal = formatRupiah(Math.max(0, Number(member?.tunggakan) || 0)); return `*PEMBERITAHUAN TUNGGAKAN IURAN STM*\n\nAssalamu'alaikum, ${member.nama}.\n\nKami informasikan bahwa terdapat tunggakan iuran STM atas nama *${member.nama}* (${member.id}) sebesar *${nominal}*.\n\nMohon segera melakukan pembayaran secara cash kepada petugas STM atau transfer ke rekening resmi STM berikut:\n*${rekening}*\n\nSetelah pembayaran transfer, mohon lakukan konfirmasi dan kirim bukti pembayaran melalui kartu anggota/portal anggota agar dapat diperiksa pengurus.\n\nTerima kasih atas perhatian dan kerja samanya.\nWassalamu'alaikum.`; } function updateOutstandingBroadcastButton(member) { const hasOutstanding = Boolean(member && (Number(member.tunggakan) || 0) > 0); const button = document.getElementById('mportal-broadcast-button'); if (button) button.classList.toggle('hidden', !hasOutstanding); } function broadcastTunggakanAnggota(memberId) { const member = (state.anggota || []).find(item => String(item.id) === String(memberId)); if (!member || (Number(member.tunggakan) || 0) <= 0) { showToast('Anggota ini tidak memiliki tunggakan.', 'info'); return; } const message = buildOutstandingMessage(member); const phone = getMemberWhatsappNumber(member); const whatsappUrl = phone ? `https://api.whatsapp.com/send?phone=${phone}&text=${encodeURIComponent(message)}` : `https://api.whatsapp.com/send?text=${encodeURIComponent(message)}`; // Buka WhatsApp langsung dari klik agar tidak diblokir browser. window.open(whatsappUrl, '_blank', 'noopener'); copyText(message) .then(() => showToast('Pesan tagihan dibuat dan dibuka di WhatsApp.', 'check')) .catch(() => showToast('WhatsApp dibuka. Pesan juga dapat disalin manual.', 'info')); } // BROADCAST MESSAGE GENERATOR function generateWABroadcast(type) { let msg = ""; if (type === 'duka') { msg = `*INNA LILLAHI WA INNA ILAIHI RAJI'UN*\n\nInformasi Duka Cita Pengurus STM Amanah:\nTelah berpulang ke rahmatullah warga/anggota kita.\n\nSemoga almarhum/ah husnul khotimah dan keluarga yang ditinggalkan diberikan ketabahan.\n\n*Himbauan STM:* Mari bersama-sama bertakziah dan membantu prosesi pemakaman.`; } else if (type === 'kegiatan') { msg = `*INFORMASI KEGIATAN & WIRID STM AMANAH*\n\nDiberitahukan kepada seluruh anggota STM bahwa agenda pengajian & gotong royong akan dilaksanakan pada minggu ini.\n\nMohon kehadiran seluruh warga demi menjaga silaturahmi dan kebersamaan kita.`; } else { msg = `*PEMBERITAHUAN IURAN & KAS STM AMANAH*\n\nAssalamu'alaikum Wr. Wb.\nDiimbau kepada seluruh anggota STM untuk dapat melengkapi kewajiban iuran bulanan kas demi kelancaran dana siaga duka.\n\nPembayaran dapat dilakukan via transfer/QRIS unik anggota masing-masing. Terima kasih atas perhatiannya.`; } copyText(msg) .then(() => showToast("Teks Broadcast disalin! Tempelkan di WhatsApp", "check")) .catch(() => showToast("Teks broadcast gagal disalin", "warning")); } // HELPER UTILS function getPaymentTarget() { const settings = state.pengaturan || {}; const bank = String(settings.namaBank || '').trim(); const account = String(settings.nomorRekening || '').trim(); const owner = String(settings.atasNama || '').trim(); if (!bank || !account || !owner) return ''; return `${bank} | ${account} | a.n. ${owner}`; } const rupiahFormatter = new Intl.NumberFormat('id-ID', { style: 'currency', currency: 'IDR', minimumFractionDigits: 0, maximumFractionDigits: 0 }); function formatRupiah(num) { return rupiahFormatter.format(Number(num) || 0); } function handleProfileImageError(image) { if (!image || !image.parentElement) return; image.onerror = null; image.parentElement.innerHTML = ''; } function setMemberPhoto(container, member) { if (!container) return; const photoUrl = getDriveDisplayUrl(member?.foto); const fallback = ''; container.innerHTML = photoUrl ? `Foto ${escapeHTML(member?.nama || 'anggota')}` : fallback; } function renderQRCode(container, text, size) { if (!container) return; container.innerHTML = ''; if (typeof QRCode === 'function') { new QRCode(container, { text, width: size, height: size }); return; } // Fallback agar login dan portal tetap berfungsi tanpa library QR eksternal. const fallback = document.createElement('div'); fallback.className = 'w-full h-full flex flex-col items-center justify-center gap-2 text-center text-gray-500'; fallback.innerHTML = ` ${escapeHTML(text)} `; container.appendChild(fallback); } function escapeHTML(value) { return String(value ?? '').replace(/[&<>'"]/g, character => ({ '&': '&', '<': '<', '>': '>', "'": ''', '"': '"' }[character])); } async function copyText(text) { // Clipboard API dapat ditolak pada browser HP atau halaman non-HTTPS. // Jika gagal, gunakan metode salin lama sebagai cadangan. if (navigator.clipboard?.writeText) { try { await navigator.clipboard.writeText(text); return; } catch (error) { // Lanjutkan ke fallback agar tombol tetap berfungsi. } } const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); area.style.position = 'fixed'; area.style.left = '-9999px'; area.style.top = '0'; document.body.appendChild(area); area.focus(); area.select(); area.setSelectionRange(0, area.value.length); try { if (!document.execCommand('copy')) { throw new Error('Copy failed'); } } finally { area.remove(); } } function showToast(msg, icon = 'check') { const toast = document.getElementById('toast'); const toastMsg = document.getElementById('toast-msg'); const toastIcon = document.getElementById('toast-icon'); toastMsg.innerText = msg; const toastIconName = icon === 'check' ? 'check-circle text-emerald-400' : icon === 'info' ? 'info text-blue-400' : 'warning-circle text-amber-400'; toastIcon.className = `ph ph-${toastIconName} text-xl`; toast.classList.remove('opacity-0', 'pointer-events-none'); setTimeout(() => { toast.classList.add('opacity-0', 'pointer-events-none'); }, 2500); } /* Pengaman pembuka kartu QR: data tunggakan tidak boleh memblokir modal. */ (function () { window.openDetailAnggota = function (id) { const memberId = String(id ?? '').replace(/^Nomor Anggota:\s*/i, '').trim(); const member = (Array.isArray(state.anggota) ? state.anggota : []).find(item => String(item?.id ?? '').trim().toLowerCase() === memberId.toLowerCase() ); const modal = document.getElementById('modal-detail-anggota'); if (!modal) { showToast('Modal kartu anggota tidak tersedia.', 'warning'); return false; } if (!member) { showToast(`Anggota ${memberId || 'ini'} tidak ditemukan.`, 'warning'); return false; } // Modal dan identitas dasar selalu ditampilkan terlebih dahulu. currentDetailMemberId = member.id; modal.classList.remove('hidden'); const setText = (elementId, value) => { const element = document.getElementById(elementId); if (element) element.innerText = value ?? '-'; }; const outstanding = Math.max(0, Number(member.tunggakan) || 0); setText('mdetail-nama', member.nama); setText('mdetail-id', `Nomor Anggota: ${member.id}`); setText('mdetail-phone', `No HP: ${member.phone || '-'}`); setText('mdetail-status', outstanding > 0 ? 'Tunggakan Iuran' : 'Lunas'); setText('mdetail-tunggakan', formatRupiah(outstanding)); // QR, foto, dan histori berdiri sendiri; kegagalan salah satunya // tidak boleh menutup atau membatalkan kartu anggota. try { renderQRCode(document.getElementById('mdetail-qris-render'), `STM-ANGGOTA|${member.id}`, 130); } catch (error) { console.error('Gagal membuat QR identitas anggota:', error); } try { setMemberPhoto(document.getElementById('mdetail-foto'), member); } catch (error) { console.error('Gagal menampilkan foto anggota:', error); } try { renderMemberActivityList( document.getElementById('mdetail-activity-list'), getMemberActivities(member.id) ); } catch (error) { console.error('Gagal menampilkan histori anggota:', error); const activityList = document.getElementById('mdetail-activity-list'); if (activityList) activityList.innerHTML = '

Histori belum tersedia.

'; } refreshMemberPaymentButton({ ...member, tunggakan: outstanding }); return false; }; }());