feat: refresh UI and add sidebar navigation

This commit is contained in:
GuavaPopper
2026-06-10 18:16:46 +07:00
parent c4157cec8d
commit d85d066f6c
14 changed files with 1636 additions and 603 deletions
+126 -91
View File
@@ -21,76 +21,15 @@
body { font-weight: 500; background-color: #f8fafc; }
h1, h2, h3, h4, h5, h6, .btn, .card-title, .stat-value { font-weight: 700 !important; letter-spacing: -0.01em; }
</style>
<link rel="stylesheet" href="{{ asset('css/genesis.css') }}">
</head>
<body class="min-h-screen">
<body class="min-h-screen app-body">
<!-- ═══ NAVBAR ═══════════════════════════════════════════════════════════ -->
<div class="navbar bg-base-100 border-b border-base-200 shadow-sm sticky top-0 z-[1000]">
<div class="flex-1">
<a href="{{ url('/') }}" class="btn btn-ghost text-lg font-bold gap-2 text-primary">
<i class="fa-solid fa-house-chimney-crack"></i>
WebGIS Pemetaan Kemiskinan
</a>
</div>
<div class="flex-none">
<ul class="menu menu-horizontal px-1 gap-1">
<li><a href="{{ url('/map') }}" class="font-medium"><i class="fa-solid fa-map"></i> Peta</a></li>
<li><a href="{{ url('/data') }}" class="font-semibold bg-primary/10 text-primary rounded-lg"><i class="fa-solid fa-table-list"></i> Data Tabular</a></li>
<li>
<a href="{{ url('/dashboard') }}" class="font-medium">
<i class="fa-solid fa-chart-bar"></i> Dashboard
</a>
</li>
<li><a href="{{ url('/permohonan') }}" class="font-medium"><i class="fa-solid fa-right-left"></i> Transfer</a></li>
@if(auth()->user()->hasRole('administrator'))
<li>
<a href="{{ url('/admin/users') }}" class="font-medium">
<i class="fa-solid fa-users-gear"></i> Admin
</a>
</li>
<li>
<a href="{{ url('/admin/logs') }}" class="font-medium">
<i class="fa-solid fa-clock-rotate-left"></i> Log
</a>
</li>
<li>
<a href="{{ url('/admin/settings') }}" class="font-medium">
<i class="fa-solid fa-sliders"></i> Pengaturan
</a>
</li>
@endif
</ul>
<!-- User dropdown -->
<div class="dropdown dropdown-end ml-2">
<div tabindex="0" role="button" class="btn btn-ghost gap-2">
<div class="avatar placeholder">
<div class="bg-primary text-primary-content rounded-full w-8">
<span class="text-xs font-bold">{{ substr(auth()->user()->name, 0, 1) }}</span>
</div>
</div>
<span class="hidden sm:inline text-sm font-semibold">{{ auth()->user()->name }}</span>
<i class="fa-solid fa-chevron-down text-xs opacity-50"></i>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-100 rounded-box z-50 w-52 p-2 shadow-lg border border-base-200 mt-2">
<li class="menu-title">
<span class="text-xs text-base-content/50 capitalize">{{ str_replace('_', ' ', auth()->user()->getRoleNames()->first() ?? 'user') }}</span>
</li>
<li>
<form method="POST" action="{{ route('logout') }}">
@csrf
<button type="submit" class="text-error w-full text-left flex items-center gap-2">
<i class="fa-solid fa-right-from-bracket"></i>
Keluar
</button>
</form>
</li>
</ul>
</div>
</div>
</div>
@include('partials.sidebar')
<!-- ═══ MAIN CONTENT ════════════════════════════════════════════════════ -->
<main class="max-w-screen-xl mx-auto px-4 py-8">
<main class="app-main">
<div class="max-w-screen-xl mx-auto px-4 py-8">
<!-- Header -->
<div class="flex flex-wrap items-center justify-between gap-4 mb-8">
@@ -145,9 +84,17 @@
</div>
<!-- Tabs -->
<div role="tablist" class="tabs tabs-lifted tabs-lg mb-6">
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🕌 Rumah Ibadah" checked />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
<div class="genesis-tabs">
<div class="genesis-tab-list" role="tablist" aria-label="Jenis data">
<button type="button" class="genesis-tab-button is-active" id="tab_ibadah" role="tab" aria-selected="true" aria-controls="panel_ibadah" onclick="setDataTab('ibadah')">
Rumah Ibadah
</button>
<button type="button" class="genesis-tab-button" id="tab_miskin" role="tab" aria-selected="false" aria-controls="panel_miskin" onclick="setDataTab('miskin')">
Rumah Miskin
</button>
</div>
<div id="panel_ibadah" role="tabpanel" aria-labelledby="tab_ibadah" class="genesis-tab-panel">
@if(auth()->user()->hasRole('administrator'))
<div id="bulkBar_ibadah" class="hidden alert alert-warning py-2 mb-2 flex items-center justify-between">
<span class="text-sm font-semibold"><span id="bulkCount_ibadah">0</span> item terpilih</span>
@@ -175,8 +122,7 @@
</div>
</div>
<input type="radio" name="data_tabs" role="tab" class="tab font-bold" aria-label="🏠 Rumah Miskin" />
<div role="tabpanel" class="tab-content bg-base-100 border-base-300 rounded-box p-6 shadow-sm">
<div id="panel_miskin" role="tabpanel" aria-labelledby="tab_miskin" class="genesis-tab-panel hidden">
@if(auth()->user()->hasRole('administrator'))
<div id="bulkBar_miskin" class="hidden alert alert-warning py-2 mb-2 flex items-center justify-between">
<span class="text-sm font-semibold"><span id="bulkCount_miskin">0</span> item terpilih</span>
@@ -204,6 +150,7 @@
</div>
</div>
</div>
</div>
</main>
<!-- Modal: Edit Rumah Ibadah -->
@@ -374,8 +321,9 @@
@if(auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan'))
<div class="collapse collapse-arrow border border-base-300 rounded-lg">
<input type="checkbox" id="toggleFormAnggota">
<div class="collapse-title text-sm font-bold"><i class="fa-solid fa-plus mr-2 text-info"></i>Tambah Anggota</div>
<div class="collapse-title text-sm font-bold"><i class="fa-solid fa-user-pen mr-2 text-info"></i><span id="formAnggotaTitle">Tambah Anggota</span></div>
<div class="collapse-content">
<input type="hidden" id="anggota_edit_id">
<div class="grid grid-cols-2 gap-3 mt-2">
<div class="form-control col-span-2">
<label class="label"><span class="label-text font-bold">Nama</span></label>
@@ -423,7 +371,10 @@
</select>
</div>
</div>
<button onclick="simpanAnggota()" class="btn btn-info btn-sm mt-3 w-full"><i class="fa-solid fa-floppy-disk mr-1"></i>Simpan Anggota</button>
<div class="flex gap-2 mt-3">
<button onclick="resetAnggotaForm()" class="btn btn-ghost btn-sm flex-1">Reset</button>
<button onclick="simpanAnggota()" class="btn btn-info btn-sm flex-1"><i class="fa-solid fa-floppy-disk mr-1"></i><span id="btnSimpanAnggotaText">Simpan Anggota</span></button>
</div>
</div>
</div>
@endif
@@ -495,9 +446,21 @@
let selectedMiskin = new Set();
let ibadahData = [], miskinData = [];
let activeDataTab = 'ibadah';
document.addEventListener('DOMContentLoaded', loadAllData);
const searchInput = document.getElementById('searchInput');
function setDataTab(type) {
activeDataTab = type;
const isIbadah = type === 'ibadah';
document.getElementById('tab_ibadah').classList.toggle('is-active', isIbadah);
document.getElementById('tab_miskin').classList.toggle('is-active', !isIbadah);
document.getElementById('tab_ibadah').setAttribute('aria-selected', isIbadah ? 'true' : 'false');
document.getElementById('tab_miskin').setAttribute('aria-selected', isIbadah ? 'false' : 'true');
document.getElementById('panel_ibadah').classList.toggle('hidden', !isIbadah);
document.getElementById('panel_miskin').classList.toggle('hidden', isIbadah);
}
async function loadAllData() {
try {
const [ri, rm] = await Promise.all([
@@ -592,10 +555,9 @@
}
function exportCSV() {
const activeTab = document.querySelector('input[name="data_tabs"]:checked').getAttribute('aria-label');
let csvContent = "data:text/csv;charset=utf-8,";
if (activeTab.includes("Ibadah")) {
if (activeDataTab === 'ibadah') {
csvContent += "ID,Nama,Jenis,Alamat,Radius,Jemaah,Pengurus,Telepon,Lat,Lon\n";
ibadahData.forEach(i => {
csvContent += `${i.id},"${i.nama}","${i.jenis}","${i.alamat}",${i.radius},${i.jumlah_jemaah},"${i.pengurus}","${i.telepon}",${i.latitude},${i.longitude}\n`;
@@ -837,20 +799,22 @@
// ── ANGGOTA KELUARGA ───────────────────────────────────────────────────
let currentAnggotaRumah = null;
let anggotaData = [];
const canEditAnggota = {{ auth()->user()->hasRole('administrator') || auth()->user()->hasRole('petugas_pendataan') ? 'true' : 'false' }};
async function openAnggotaModal(idRumah) {
currentAnggotaRumah = idRumah;
const rm = miskinData.find(m => m.id_rumah === idRumah);
document.getElementById('anggota_rumah_label').textContent = `ID: ${idRumah} — ${rm?.nama_kk || '-'}`;
resetAnggotaForm();
document.getElementById('modal_anggota').showModal();
await loadAnggota(idRumah);
}
async function loadAnggota(idRumah) {
const res = await fetch(`{{ url('/api/miskin') }}/${encodeURIComponent(idRumah)}/anggota`, { headers: { 'X-CSRF-TOKEN': csrfToken } });
const list = await res.json();
document.getElementById('bodyAnggota').innerHTML = list.length ? list.map(a => `
anggotaData = await res.json();
document.getElementById('bodyAnggota').innerHTML = anggotaData.length ? anggotaData.map(a => `
<tr>
<td class="font-semibold">${esc(a.nama)}</td>
<td class="capitalize text-xs">${esc(a.hubungan.replace('_', ' '))}</td>
@@ -858,12 +822,45 @@
<td class="text-xs">${a.umur !== null ? esc(String(a.umur)) + ' th' : '-'}</td>
<td class="capitalize text-xs">${a.pendidikan ? esc(a.pendidikan.replace(/_/g, ' ')) : '-'}</td>
<td><span class="badge badge-xs ${a.status_hidup === 'hidup' ? 'badge-success' : 'badge-error'}">${esc(a.status_hidup)}</span></td>
<td>${canEditAnggota ? `<button onclick="deleteAnggota(${a.id})" class="btn btn-xs btn-ghost text-error"><i class="fa-solid fa-trash"></i></button>` : ''}</td>
<td>${canEditAnggota ? `<div class="flex justify-end gap-1">
<button onclick="editAnggota(${a.id})" class="btn btn-xs btn-ghost text-warning" title="Edit"><i class="fa-solid fa-pen"></i></button>
<button onclick="deleteAnggota(${a.id})" class="btn btn-xs btn-ghost text-error" title="Hapus"><i class="fa-solid fa-trash"></i></button>
</div>` : ''}</td>
</tr>`).join('')
: '<tr><td colspan="7" class="text-center py-4 opacity-30 italic">Belum ada anggota</td></tr>';
}
function resetAnggotaForm() {
const editId = document.getElementById('anggota_edit_id');
if (!editId) return;
editId.value = '';
document.getElementById('formAnggotaTitle').textContent = 'Tambah Anggota';
document.getElementById('btnSimpanAnggotaText').textContent = 'Simpan Anggota';
document.getElementById('anggota_nama').value = '';
document.getElementById('anggota_hubungan').value = 'lainnya';
document.getElementById('anggota_kelamin').value = '';
document.getElementById('anggota_tgl_lahir').value = '';
document.getElementById('anggota_pendidikan').value = '';
document.getElementById('anggota_status').value = 'hidup';
}
function editAnggota(id) {
const item = anggotaData.find(a => Number(a.id) === Number(id));
if (!item) return;
document.getElementById('anggota_edit_id').value = item.id;
document.getElementById('formAnggotaTitle').textContent = 'Edit Anggota';
document.getElementById('btnSimpanAnggotaText').textContent = 'Simpan Perubahan';
document.getElementById('anggota_nama').value = item.nama || '';
document.getElementById('anggota_hubungan').value = item.hubungan || 'lainnya';
document.getElementById('anggota_kelamin').value = item.jenis_kelamin || '';
document.getElementById('anggota_tgl_lahir').value = item.tanggal_lahir || '';
document.getElementById('anggota_pendidikan').value = item.pendidikan || '';
document.getElementById('anggota_status').value = item.status_hidup || 'hidup';
document.getElementById('toggleFormAnggota').checked = true;
}
async function simpanAnggota() {
const editId = document.getElementById('anggota_edit_id')?.value || '';
const body = {
nama: document.getElementById('anggota_nama').value.trim(),
hubungan: document.getElementById('anggota_hubungan').value,
@@ -873,14 +870,22 @@
status_hidup: document.getElementById('anggota_status').value,
};
if (!body.nama) { showToast('Nama wajib diisi', 'error'); return; }
const res = await fetch(`{{ url('/api/miskin') }}/${encodeURIComponent(currentAnggotaRumah)}/anggota`, {
method: 'POST',
const url = editId
? `{{ url('/api/miskin') }}/${encodeURIComponent(currentAnggotaRumah)}/anggota/${editId}`
: `{{ url('/api/miskin') }}/${encodeURIComponent(currentAnggotaRumah)}/anggota`;
const res = await fetch(url, {
method: editId ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
body: JSON.stringify(body),
});
const json = await res.json();
if (json.success) { showToast('Anggota ditambahkan', 'success'); loadAnggota(currentAnggotaRumah); }
else showToast('Gagal menambah anggota', 'error');
if (json.success) {
showToast(editId ? 'Anggota diperbarui' : 'Anggota ditambahkan', 'success');
resetAnggotaForm();
loadAnggota(currentAnggotaRumah);
} else {
showToast(editId ? 'Gagal memperbarui anggota' : 'Gagal menambah anggota', 'error');
}
}
async function deleteAnggota(id) {
@@ -896,6 +901,8 @@
// ── RIWAYAT BANTUAN ────────────────────────────────────────────────────
let currentBantuanRumah = null;
const canDeleteBantuan = {{ auth()->user()->hasRole('administrator') ? 'true' : 'false' }};
const currentUserId = Number({{ auth()->id() ?? 0 }});
const isPengurusIbadah = {{ auth()->user()->hasRole('pengurus_ibadah') ? 'true' : 'false' }};
async function openBantuanModal(idRumah) {
currentBantuanRumah = idRumah;
@@ -904,8 +911,11 @@
const sel = document.getElementById('bantuan_ibadah');
if (sel) {
const availableIbadah = isPengurusIbadah
? ibadahData.filter(ib => Number(ib.user_id) === currentUserId)
: ibadahData;
sel.innerHTML = '<option value="">-- Pilih (opsional) --</option>' +
ibadahData.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
availableIbadah.map(ib => `<option value="${ib.id}">${esc(ib.nama)}</option>`).join('');
}
const tgl = document.getElementById('bantuan_tanggal');
@@ -958,14 +968,31 @@
}
// ── NOTIFIKASI PERUBAHAN DATA ────────────────────────────────────────
function updateTransferBadge(count) {
document.querySelectorAll('a[href$="/permohonan"]').forEach(link => {
let badge = link.querySelector('.transfer-pending-badge');
if (count > 0) {
if (!badge) {
badge = document.createElement('span');
badge.className = 'badge badge-error badge-xs transfer-pending-badge';
link.appendChild(document.createTextNode(' '));
link.appendChild(badge);
}
badge.textContent = count;
badge.classList.remove('hidden');
} else if (badge) {
badge.classList.add('hidden');
}
});
}
(function initNotifPolling() {
const POLL_MS = 30000;
const KEY_IBADAH = 'notif_latest_ibadah';
const KEY_MISKIN = 'notif_latest_miskin';
const KEY_SEEN = 'notif_latest_seen';
async function pollNotifications() {
try {
const since = localStorage.getItem(KEY_IBADAH) || new Date(0).toISOString();
const since = localStorage.getItem(KEY_SEEN) || new Date(0).toISOString();
const res = await fetch(
`{{ url('/api/notifications') }}?since=${encodeURIComponent(since)}`,
{ headers: { 'X-CSRF-TOKEN': csrfToken } }
@@ -973,22 +1000,30 @@
if (!res.ok) return;
const data = await res.json();
if (data.new_ibadah > 0 || data.new_miskin > 0) {
updateTransferBadge(Number(data.pending_transfer || 0));
const hasChanges = data.new_ibadah > 0 || data.new_miskin > 0 || data.new_anggota > 0 || data.new_bantuan > 0 || data.new_transfer > 0;
if (hasChanges) {
if (data.new_ibadah > 0) showToast(`${data.new_ibadah} rumah ibadah baru ditambahkan`, 'info');
if (data.new_miskin > 0) showToast(`${data.new_miskin} keluarga miskin baru ditambahkan`, 'info');
if (data.new_anggota > 0) showToast(`${data.new_anggota} anggota keluarga baru ditambahkan`, 'info');
if (data.new_bantuan > 0) showToast(`${data.new_bantuan} bantuan baru dicatat`, 'info');
if (data.new_transfer > 0) showToast(`${data.new_transfer} permohonan transfer baru`, 'info');
const fn = typeof loadAllData !== 'undefined' ? loadAllData
: typeof loadData !== 'undefined' ? loadData
: null;
if (fn) fn();
}
if (data.latest_ibadah) localStorage.setItem(KEY_IBADAH, data.latest_ibadah);
if (data.latest_miskin) localStorage.setItem(KEY_MISKIN, data.latest_miskin);
const latest = [data.latest_ibadah, data.latest_miskin, data.latest_anggota, data.latest_bantuan, data.latest_transfer]
.filter(Boolean)
.sort()
.pop();
if (latest) localStorage.setItem(KEY_SEEN, latest);
} catch (e) { /* silent */ }
}
document.addEventListener('DOMContentLoaded', () => {
if (!localStorage.getItem(KEY_IBADAH)) localStorage.setItem(KEY_IBADAH, new Date().toISOString());
if (!localStorage.getItem(KEY_MISKIN)) localStorage.setItem(KEY_MISKIN, new Date().toISOString());
if (!localStorage.getItem(KEY_SEEN)) localStorage.setItem(KEY_SEEN, new Date().toISOString());
setInterval(pollNotifications, POLL_MS);
});
})();