SIPESAN Pro - Cloud Edition
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
:root {
--app-primary: #4f46e5;
--app-bg: #f7f9fa;
--text-main: #1c1c1c;
--text-sub: #677489;
}
* { -webkit-tap-highlight-color: transparent; box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
padding: 0;
background-color: #f0f2f5;
font-family: 'Plus Jakarta Sans', sans-serif;
color: var(--text-main);
overflow: hidden;
}
body { display: flex; justify-content: center; }
.desktop-sidebar {
width: 280px;
background: white;
height: 100vh;
border-right: 1px solid #edf2f7;
padding: 32px 24px;
display: none;
flex-direction: column;
flex-shrink: 0;
}
@media (min-width: 1024px) { .desktop-sidebar { display: flex; } }
.side-link {
display: flex;
align-items: center;
padding: 14px 18px;
border-radius: 16px;
color: var(--text-sub);
font-weight: 600;
transition: all 0.3s ease;
margin-bottom: 8px;
gap: 12px;
cursor: pointer;
}
.side-link.active {
background: var(--app-primary);
color: white;
box-shadow: 0 10px 20px rgba(79, 70, 229, 0.2);
}
.app-shell {
width: 100%;
max-width: 480px;
height: 100vh;
background-color: var(--app-bg);
position: relative;
display: flex;
flex-direction: column;
box-shadow: 0 0 100px rgba(0,0,0,0.05);
}
header {
background: white;
padding: 24px 24px 16px;
border-bottom: 1px solid #f1f4f9;
flex-shrink: 0;
z-index: 50;
}
.scroll-area {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 20px 20px 120px;
-webkit-overflow-scrolling: touch;
}
.bottom-nav {
flex-shrink: 0;
height: 85px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
display: flex;
justify-content: space-around;
align-items: center;
padding-bottom: env(safe-area-inset-bottom);
border-top: 1px solid #f1f4f9;
z-index: 100;
}
.nav-btn {
display: flex;
flex-direction: column;
align-items: center;
color: #94a3b8;
font-size: 10px;
font-weight: 700;
gap: 6px;
cursor: pointer;
padding: 10px;
flex: 1;
}
.nav-btn.active { color: var(--app-primary); }
.fab-main {
width: 56px;
height: 56px;
background: var(--app-primary);
border-radius: 18px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 22px;
margin-top: -40px;
box-shadow: 0 12px 24px rgba(79, 70, 229, 0.3);
border: 4px solid var(--app-bg);
}
.app-card {
background: white;
border-radius: 24px;
padding: 20px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.02);
margin-bottom: 16px;
}
.inner-tabs {
display: flex;
background: #f1f5f9;
padding: 4px;
border-radius: 16px;
margin-bottom: 20px;
}
.inner-tab-btn {
flex: 1;
padding: 10px;
text-align: center;
font-size: 11px;
font-weight: 700;
color: #94a3b8;
border-radius: 12px;
cursor: pointer;
}
.inner-tab-btn.active {
background: white;
color: var(--app-primary);
box-shadow: 0 2px 6px rgba(0,0,0,0.05);
}
.form-input {
width: 100%;
background: #f8fafc;
border: 2px solid #f1f5f9;
border-radius: 16px;
padding: 14px 16px;
font-size: 14px;
font-weight: 600;
outline: none;
transition: border-color 0.2s;
}
.form-input:focus { border-color: var(--app-primary); }
.auth-container {
position: fixed;
inset: 0;
background: #f8fafc;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.hidden { display: none !important; }
#ui-loading {
position: fixed;
inset: 0;
z-index: 3000;
background: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: opacity 0.5s ease;
}
.btn-wa {
background: #25D366;
color: white;
padding: 6px 12px;
border-radius: 10px;
font-size: 10px;
font-weight: 800;
display: inline-flex;
align-items: center;
gap: 6px;
}
.santri-selector-list {
max-height: 180px;
overflow-y: auto;
border: 2px solid #f1f5f9;
border-radius: 16px;
padding: 10px;
background: #f8fafc;
}
.santri-item-check {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.santri-item-check:hover { background: #f1f5f9; }
.santri-item-check input {
width: 18px;
height: 18px;
accent-color: var(--app-primary);
}
SIPESAN.
Sistem Perizinan Santri
Santri Di Luar
LIHAT SEMUA
Menunggu Keluar
Menunggu Masuk
Input Izin (Individual/Massal)
Registrasi Santri
Upload Massal Data Master
Format: Nama,Kelas (satu per baris)
import { initializeApp } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-app.js";
import { getAuth, signInAnonymously, onAuthStateChanged, signInWithCustomToken } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-auth.js";
import { getFirestore, doc, setDoc, getDoc, collection, onSnapshot, addDoc, updateDoc, deleteDoc, query } from "https://www.gstatic.com/firebasejs/11.6.1/firebase-firestore.js";
const firebaseConfig = JSON.parse(__firebase_config);
const app = initializeApp(firebaseConfig);
const auth = getAuth(app);
const db = getFirestore(app);
const appId = typeof __app_id !== 'undefined' ? __app_id : 'sipesan-cloud-v4';
let masterSantri = [];
let izinData = [];
let gateMode = 'EXIT';
let currentUserRole = null;
async function initAuth() {
try {
if (typeof __initial_auth_token !== 'undefined' && __initial_auth_token) {
await signInWithCustomToken(auth, __initial_auth_token);
} else {
await signInAnonymously(auth);
}
} catch (err) {
console.error("Auth Error:", err);
}
}
onAuthStateChanged(auth, (user) => {
document.getElementById('ui-loading').classList.add('hidden');
if (user) {
if (!currentUserRole) document.getElementById('view-auth').classList.remove('hidden');
startSync();
} else {
initAuth();
}
});
function startSync() {
const santriRef = collection(db, 'artifacts', appId, 'public', 'data', 'master_santri');
const izinRef = collection(db, 'artifacts', appId, 'public', 'data', 'izin_santri');
onSnapshot(santriRef, (snap) => {
masterSantri = snap.docs.map(d => ({ id: d.id, ...d.data() }));
renderAll();
updateClassFilters();
}, (err) => console.error(err));
onSnapshot(izinRef, (snap) => {
izinData = snap.docs.map(d => ({ id: d.id, ...d.data() }));
renderAll();
}, (err) => console.error(err));
}
window.showNotif = (txt) => {
const n = document.getElementById('notif');
document.getElementById('notif-text').innerText = txt;
n.classList.remove('hidden');
setTimeout(() => n.classList.add('hidden'), 2500);
};
window.handleLoginSubmit = (role) => {
currentUserRole = role;
document.getElementById('view-auth').classList.add('hidden');
document.getElementById('main-app').classList.remove('hidden');
const isSatpam = role === 'SATPAM';
document.getElementById('side-data').classList.toggle('hidden', isSatpam);
document.getElementById('nav-data').classList.toggle('hidden', isSatpam);
document.getElementById('nav-fab').classList.toggle('hidden', isSatpam);
document.getElementById('user-role-badge').innerText = role;
changeTab('home', 'Dashboard');
};
window.handleLogout = () => {
currentUserRole = null;
document.getElementById('main-app').classList.add('hidden');
document.getElementById('view-auth').classList.remove('hidden');
};
window.changeTab = (id, title) => {
if (currentUserRole === 'SATPAM' && id === 'data') return;
document.querySelectorAll('.tab-pane').forEach(p => p.classList.add('hidden'));
document.getElementById('view-' + id).classList.remove('hidden');
document.getElementById('tab-title').innerText = title;
document.querySelectorAll('.nav-btn, .side-link').forEach(el => el.classList.remove('active'));
document.getElementById('nav-' + id)?.classList.add('active');
document.getElementById('side-' + id)?.classList.add('active');
renderAll();
};
window.openAddSantriModal = () => document.getElementById('modal-add-santri').classList.remove('hidden');
window.openBulkUpload = () => document.getElementById('modal-bulk').classList.remove('hidden');
window.closeModal = (id) => document.getElementById(id).classList.add('hidden');
window.processBulkUpload = async () => {
const text = document.getElementById('bulk-data').value;
const lines = text.split('\n').filter(l => l.trim().includes(','));
if(lines.length === 0) return showNotif("Format salah");
try {
for (const line of lines) {
const [nama, kelas] = line.split(',').map(s => s.trim());
if(nama && kelas) {
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'master_santri'), {
nama, kelas, wa: '628', createdAt: new Date().toISOString()
});
}
}
document.getElementById('bulk-data').value = '';
closeModal('modal-bulk');
showNotif(`${lines.length} data master diproses`);
} catch (e) { showNotif("Gagal upload."); }
};
window.saveSantri = async () => {
const nama = document.getElementById('add-nama').value;
const kelas = document.getElementById('add-kelas').value;
const wa = document.getElementById('add-wa').value;
if(!nama || !kelas) return showNotif("Data wajib diisi");
try {
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'master_santri'), {
nama, kelas, wa: wa || '628', createdAt: new Date().toISOString()
});
closeModal('modal-add-santri');
showNotif("Santri terdaftar");
} catch (e) { showNotif("Gagal simpan."); }
};
window.updateClassFilters = () => {
const classes = [...new Set(masterSantri.map(s => s.kelas))].sort();
const filter = document.getElementById('filter-kelas-izin');
const current = filter.value;
filter.innerHTML = 'Semua Kelas' + classes.map(c => `${c}`).join('');
};
window.openIzinModal = () => {
document.getElementById('filter-kelas-izin').value = "";
updateIzinSantriList();
document.getElementById('modal-izin').classList.remove('hidden');
};
window.updateIzinSantriList = () => {
const selectedClass = document.getElementById('filter-kelas-izin').value;
const listEl = document.getElementById('form-santri-list');
const santriDiLuarIds = izinData.map(i => i.santriId);
let filtered = masterSantri.filter(s => !santriDiLuarIds.includes(s.id));
if (selectedClass) filtered = filtered.filter(s => s.kelas === selectedClass);
filtered.sort((a,b) => a.nama.localeCompare(b.nama));
if (filtered.length === 0) {
listEl.innerHTML = '
Tidak ada santri tersedia
';
return;
}
listEl.innerHTML = filtered.map(s => `
`).join('');
};
window.submitIzin = async () => {
const checkboxes = document.querySelectorAll('input[name="santri-select"]:checked');
const selectedIds = Array.from(checkboxes).map(cb => cb.value);
const tgl = document.getElementById('form-tgl').value;
const jam = document.getElementById('form-jam').value;
const alasan = document.getElementById('form-alasan').value;
if(selectedIds.length === 0 || !tgl || !jam || !alasan) {
return showNotif("Lengkapi data dan pilih santri");
}
try {
for (const sId of selectedIds) {
const s = masterSantri.find(x => x.id === sId);
if (s) {
await addDoc(collection(db, 'artifacts', appId, 'public', 'data', 'izin_santri'), {
santriId: s.id, nama: s.nama, kelas: s.kelas, wa: s.wa, alasan,
deadline: `${tgl} ${jam}`, status: 'WAITING_EXIT', createdAt: new Date().toISOString()
});
}
}
closeModal('modal-izin');
showNotif(`${selectedIds.length} Izin berhasil diproses`);
document.getElementById('form-alasan').value = "";
document.getElementById('form-tgl').value = "";
document.getElementById('form-jam').value = "";
} catch (e) { showNotif("Gagal memproses izin."); }
};
window.confirmGate = async (id, type) => {
const ref = doc(db, 'artifacts', appId, 'public', 'data', 'izin_santri', id);
try {
if(type === 'OUT') {
await updateDoc(ref, { status: 'OUTSIDE', exitAt: new Date().toISOString() });
} else {
await deleteDoc(ref);
}
showNotif("Berhasil diperbarui");
} catch (e) { showNotif("Gagal."); }
};
window.setGateMode = (mode) => {
gateMode = mode;
document.getElementById('gate-tab-exit').classList.toggle('active', mode === 'EXIT');
document.getElementById('gate-tab-entry').classList.toggle('active', mode === 'ENTRY');
renderAll();
};
window.sendWA = (nama, wa) => {
const msg = window.encodeURIComponent(`Assalamu'alaikum ayah/bunda... mohon informasi apakah mas ${nama} saat ini masih di rumah? Karena sudah melewati batas jam kembali ke pondok.`);
const target = wa && wa.startsWith('62') ? wa : '628';
window.open(`https://wa.me/${target}?text=${msg}`, '_blank');
};
function renderAll() {
const search = document.getElementById('search-santri')?.value.toLowerCase() || '';
const now = new Date();
const filteredSantri = masterSantri.filter(s => s.nama.toLowerCase().includes(search));
const outside = izinData.filter(i => i.status === 'OUTSIDE');
const late = outside.filter(i => new Date(i.deadline) {
const isLate = new Date(i.deadline) < now;
return `
${i.nama}
Deadline: ${i.deadline.split(' ')[1]}
${i.kelas}
${isLate ? `
` : ''}
`;
}).join('') || '
Tidak ada santri di luar
';
}
const santriListEl = document.getElementById('santri-list');
if(santriListEl) {
santriListEl.innerHTML = filteredSantri.map(s => `
`).join('') || '
Data Kosong
';
}
const gateEl = document.getElementById('gate-queue');
if(gateEl) {
const q = izinData.filter(i => gateMode === 'EXIT' ? i.status === 'WAITING_EXIT' : i.status === 'OUTSIDE');
gateEl.innerHTML = q.map(i => `
${i.nama}
${i.kelas} • ${i.alasan}
${i.deadline.split(' ')[1]}
DEADLINE
`).join('') || `
`;
}
}