KosManager
Menghubungkan...
`; const blob=new Blob([html],{type:'text/html;charset=utf-8'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } function downloadCSV(data,filename){ const csv=data.map(row=>row.map(cell=>'"'+String(cell).replace(/"/g,'""')+'"').join(',')).join('\n'); const blob=new Blob([csv],{type:'text/csv;charset=utf-8;'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // ── SORT KAMAR NATURALLY (A1, A2, A9, A10, B1, B2, etc) ── function sortKamarNatural(a, b) { // Use localeCompare with numeric option for natural sorting return a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'}); } // ── TAGIHAN ── function tagStatusInfo(t){ const total=Number(t.jumlah)||0; const dibayar=t.jumlah_bayar!==undefined&&t.jumlah_bayar!==null ? Number(t.jumlah_bayar) : (t.status==='lunas'?total:0); if(dibayar>=total&&total>0)return{status:'lunas',cls:'bg',label:'✓ Lunas',dibayar,sisa:0}; // Cek jatuh tempo const jt=t.jatuh_tempo||''; const telat=jt&&today()>jt&&dibayar0&&dibayarsortKamarNatural(a.kamar,b.kamar)); const blnList=[...new Set(tagihan.map(t=>t.bulan))]; if(!blnList.includes(bulanIni()))blnList.unshift(bulanIni()); const tabs=blnList.map((b,i)=>'
'+b+'
').join(''); const rows=tagihan.map(t=>{ const si=tagStatusInfo(t); const jtInfo=t.jatuh_tempo&&si.status!=='lunas'?'
Jatuh tempo: '+fmtTgl(t.jatuh_tempo)+'':''; const sisaInfo=si.status==='kurang'?'
Bayar: '+fmt(si.dibayar)+' · Sisa: '+fmt(si.sisa)+'':''; const proratedInfo=t.is_prorated?'
📊 '+t.prorated_detail+'':''; const actBtns=si.status==='belum'||si.status==='telat' ?'' :si.status==='kurang' ?'' :''; return''+t.penghuni+''+t.kamar+''+fmt(t.jumlah)+sisaInfo+proratedInfo+''+fmtTgl(t.tgl)+jtInfo+''+si.label+'
'+actBtns+'
'; }).join(''); const mcRows=tagihan.map(t=>{ const si=tagStatusInfo(t); const actBtns=si.status==='belum'||si.status==='telat' ?'' :si.status==='kurang' ?'' :''; const sisaBadge=si.status==='kurang'?'
💸 Sudah bayar '+fmt(si.dibayar)+' — sisa '+fmt(si.sisa)+'
':''; const telatBadge=(si.telat)?'
🔴 Telat! Jatuh tempo '+fmtTgl(t.jatuh_tempo)+'
':''; const proratedBadge=t.is_prorated?'
📊 '+t.prorated_detail+'
':''; return''; }).join(''); const btnGen=''; const btnBlast=''; return'
'+btnGen+btnBlast+'
'+tabs+'
'+(rows||'')+'
PenghuniKamarJumlahTgl BayarStatus
Belum ada tagihan — klik 🗓 Generate untuk buat tagihan bulan ini
'+mcRows+'

Total terkumpul:
'; } // Auto-filter tab aktif setelah render function initTagihanTab(){ const firstTab=document.querySelector('#ct .tab.active'); const firstBulan=(document.querySelector('#ct .tab')||{}).textContent||bulanIni(); if(firstTab)filterTag(firstTab,firstTab.textContent); else{ // fallback: tampilkan semua row bulan ini document.querySelectorAll('#tb-tag tr').forEach(r=>r.style.display=''); document.querySelectorAll('#mc-tag .mc').forEach(r=>r.style.display=''); } } window.filterTag=function(tabEl,bulan){ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));tabEl.classList.add('active'); let tot=0; document.querySelectorAll('#tb-tag tr').forEach(r=>{const show=r.dataset.bulan===bulan;r.style.display=show?'':'none';if(show){const t=C.tagihan.find(x=>x.kamar===r.cells[1]?.textContent.trim()&&x.bulan===bulan&&x.penghuni===r.cells[0]?.textContent.trim());if(t)tot+=(Number(t.jumlah_bayar)||(t.status==='lunas'?Number(t.jumlah)||0:0));}}); document.querySelectorAll('#mc-tag .mc').forEach(r=>{r.style.display=r.dataset.bulan===bulan?'':'none'}); const te=el('tot-tag');if(te)te.textContent=fmt(tot); }; window.editKamarTagihan=async function(id, kamarSekarang){ const kamOpts=C.kamar.map(k=>'').join(''); _mt='edit_kamar_tagihan';_eid=id; el('m-title').textContent='📍 Perbaiki Kamar Tagihan'; el('m-body').innerHTML= '
⚠️ Gunakan ini hanya untuk koreksi data yang tidak sinkron.
' +'
'; el('mo').classList.add('open'); }; window.openBayarTagihan=function(id){ const t=C.tagihan.find(x=>x.id===id);if(!t)return; const dibayar=Number(t.jumlah_bayar)||0; const total=Number(t.jumlah)||0; const sisa=total-dibayar; _mt='bayar_tagihan';_eid=id; el('m-title').textContent='💳 Catat Pembayaran'; el('m-body').innerHTML= '
' +'
'+t.penghuni+' — Kamar '+t.kamar+'
' +'
Periode: '+t.bulan+'
' +(dibayar>0?'
Sudah bayar: '+fmt(dibayar)+' · Sisa: '+fmt(sisa)+'
':'') +'
' +'
' +'
' +'' +'
' +'
' +'
'; el('mo').classList.add('open'); setTimeout(()=>previewBayar(total,dibayar),100); }; window.previewBayar=function(total,sudahBayar){ const inp=el('f-bayar-jml');const hint=el('bayar-hint');if(!inp||!hint)return; const d=Number(inp.value)||0;const totalBayar=sudahBayar+d; if(d<=0){hint.style.display='none';return;} hint.style.display='block'; if(totalBayar>=total){hint.style.background='var(--green2)';hint.style.color='var(--green3)';hint.textContent='✅ Akan Lunas — Total dibayar: '+fmt(totalBayar);} else{hint.style.background='var(--amber2)';hint.style.color='var(--amber)';hint.textContent='⚠️ Kurang Bayar — Sisa: '+fmt(total-totalBayar);} }; window.undoPembayaran=async function(id){ const t=C.tagihan.find(x=>x.id===id);if(!t)return; kosConfirm({icon:'↩️',msg:'Reset pembayaran '+t.penghuni+'?',sub:'Periode '+t.bulan+' — status kembali ke Belum Bayar.',okLabel:'Reset'},async()=>{ await fsUpd('tagihan',id,{status:'belum',jumlah_bayar:0,tgl:'-'}); const k=C.kamar.find(x=>x.nomor===t.kamar);if(k&&k.status==='terisi')await fsUpd('kamar',k.id,{status:'telat'}); await addLog('↩ Undo pembayaran '+t.penghuni+' ('+t.kamar+') '+t.bulan,'amber'); await loadAll();render();toast('↩ Pembayaran di-undo','success'); }); }; window.openEditBayaran=function(id){ const t=C.tagihan.find(x=>x.id===id);if(!t)return; const dibayar=Number(t.jumlah_bayar)||0; const total=Number(t.jumlah)||0; _mt='edit_bayaran';_eid=id; el('m-title').textContent='✏️ Edit Pembayaran'; el('m-body').innerHTML= '
Total tagihan: '+fmt(total)+'
' +'
' +'
' +'' +'
' +'
' +'
'; el('mo').classList.add('open'); setTimeout(()=>{const h=el('bayar-hint');if(h&&dibayar>0){h.style.display='block';previewBayar(total,0);}},100); }; // ── PENGELUARAN ── function rPengeluaran(){ const pengeluaran=filterByProperty(C.pengeluaran); const maintenance=filterByProperty(C.maintenance); const tot=pengeluaran.reduce((s,p)=>s+(p.jumlah||0),0); const byKat={};pengeluaran.forEach(p=>{byKat[p.kategori]=(byKat[p.kategori]||0)+(p.jumlah||0)}); const katRows=Object.entries(byKat).map(([k,v])=>'
'+k+''+fmt(v)+'
').join(''); const rows=pengeluaran.map(p=>''+fmtTgl(p.tgl)+''+p.deskripsi+''+p.kategori+''+fmt(p.jumlah)+'
').join(''); const mRows=maintenance.map(m=>''+m.kamar+''+m.isu+''+fmtTgl(m.tgl)+''+m.status+''+(m.status==='proses'?'':'')+'').join(''); const mcExp=pengeluaran.map(p=>'
'+p.deskripsi+''+fmt(p.jumlah)+'
Kategori'+p.kategori+'
Tanggal'+fmtTgl(p.tgl)+'
').join(''); const mcMaint=maintenance.map(m=>'
Kamar '+m.kamar+''+m.status+'
Masalah'+m.isu+'
'+(m.status==='proses'?'
':'')+'
').join(''); return'
Ringkasan Kategori
'+(katRows||'

Belum ada pengeluaran

')+(tot>0?'
Total'+fmt(tot)+'
':'')+'
'+ '
Maintenance
'+(maintenance.length===0?'
🔧

Tidak ada laporan

':'
'+mRows+'
KamarMasalahTglStatus
'+mcMaint+'
')+'
'+ '
Semua Pengeluaran
'+(pengeluaran.length===0?'
💸

Belum ada pengeluaran

':'
'+rows+'
TanggalKeteranganKategoriJumlah
'+mcExp+'
')+'
'; } window.hapusExp=function(id){kosConfirm({icon:'🗑',msg:'Hapus pengeluaran?',okLabel:'Hapus',danger:true},async()=>{await fsDel('pengeluaran',id);await loadAll();render();toast('Dihapus','success');});}; window.selesaiM=async function(id){await fsUpd('maintenance',id,{status:'selesai'});await addLog('Maintenance selesai','green');await loadAll();render();toast('Selesai','success')}; // ── LAPORAN ── function rLaporan(){ const tagihan=filterByProperty(C.tagihan); const pengeluaran=filterByProperty(C.pengeluaran); const kamar=filterByProperty(C.kamar); // Ambil semua bulan yang ada const allBulan=[...new Set([...tagihan.map(t=>t.bulan),...pengeluaran.map(p=>{ if(!p.tgl)return''; const d=new Date(p.tgl); return['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'][d.getMonth()]+' '+d.getFullYear(); })].filter(Boolean))].sort().reverse(); const bulanFilter=window._laporanBulan||bulanIni(); const tgBln=tagihan.filter(t=>t.bulan===bulanFilter); const expBln=pengeluaran.filter(p=>{ if(!p.tgl)return false; const d=new Date(p.tgl); const b=['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember'][d.getMonth()]+' '+d.getFullYear(); return b===bulanFilter; }); const masuk=tgBln.reduce((s,t)=>s+(Number(t.jumlah_bayar)||(t.status==='lunas'?Number(t.jumlah)||0:0)),0); const tagTotal=tgBln.reduce((s,t)=>s+(Number(t.jumlah)||0),0); const keluar=expBln.reduce((s,p)=>s+(p.jumlah||0),0); const net=masuk-keluar; const lunas=tgBln.filter(t=>t.status==='lunas').length; const belum=tgBln.filter(t=>t.status!=='lunas').length; const bulanOpts=allBulan.map(b=>'').join(''); const pmRows=tgBln.filter(t=>t.status==='lunas'||Number(t.jumlah_bayar)>0).map(t=>{ const bayar=Number(t.jumlah_bayar)||(t.status==='lunas'?Number(t.jumlah):0); return'
'+t.penghuni+' · '+t.kamar+''+fmt(bayar)+'
'; }).join(''); const pkRows=expBln.map(p=>'
'+p.deskripsi+' '+p.kategori+''+fmt(p.jumlah)+'
').join(''); // Per tipe kamar (all time) const allTipe=[...new Set(kamar.map(k=>k.tipe))]; const tipeRows=allTipe.map(tipe=>{ const km=kamar.filter(k=>k.tipe===tipe); if(!km.length)return''; const t=km.filter(k=>k.status!=='kosong'&&k.status!=='booked').length; const h=km[0]?km[0].harga:0; const pct=Math.round(t/km.length*100); return''+tipe+''+fmt(h)+'/bln'+km.length+''+t+'/'+km.length+' ('+pct+'%)'+fmt(t*h)+''; }).join(''); return'
' +'
📊 Laporan Bulan:
' +'' +'' +'
' +'
' +'
Pemasukan
'+fmt(masuk)+'
'+lunas+'/'+tgBln.length+' lunas · '+fmt(tagTotal)+' total tagihan
' +'
Pengeluaran
'+fmt(keluar)+'
'+expBln.length+' transaksi
' +'
Keuntungan Bersih
'+fmt(net)+'
'+(net>=0?'Surplus':'Defisit')+'
' +'
Belum Bayar
'+belum+'
kamar belum lunas
' +'
' +'
Per Tipe Kamar
'+(tipeRows||'')+'
TipeHargaTotalHunianEst. Pendapatan
Belum ada data
' +'
' +'
Pemasukan '+bulanFilter+'
'+(pmRows||'

Belum ada pembayaran

')+'
Total Terkumpul'+fmt(masuk)+'
' +'
Pengeluaran '+bulanFilter+'
'+(pkRows||'

Tidak ada pengeluaran

')+'
Total'+fmt(keluar)+'
' +'
'; } // ── LOG ── function rLog(){ const log=filterByProperty(C.log); const rows=log.slice(0,50).map(l=>'
'+l.text+'
'+fmtTime(l.ts)+'
').join(''); return'
Riwayat Aktivitas
50 aktivitas terakhir
'+(rows||'
🕐

Belum ada aktivitas

')+'
'; } // ── EXPORT PDF ── window.exportPDF=function(){ const tagihan=filterByProperty(C.tagihan); const pengeluaran=filterByProperty(C.pengeluaran); const masuk=tagihan.filter(t=>t.status==='lunas').reduce((s,t)=>s+(t.jumlah||0),0); const keluar=pengeluaran.reduce((s,p)=>s+(p.jumlah||0),0); const net=masuk-keluar; const rows=tagihan.filter(t=>t.status==='lunas').map(t=>''+t.penghuni+''+t.kamar+''+t.bulan+''+fmt(t.jumlah)+'').join(''); const erows=pengeluaran.map(p=>''+fmtTgl(p.tgl)+''+p.deskripsi+''+p.kategori+''+fmt(p.jumlah)+'').join(''); const propName=currentPropertyId==='all'?'Semua Properti':(C.properties.find(x=>x.id===currentPropertyId)?.nama||''); const html='Laporan KosManager

Laporan Keuangan KosManager

Properti: '+propName+'
Dicetak: '+new Date().toLocaleDateString('id-ID',{weekday:'long',year:'numeric',month:'long',day:'numeric'})+'

Total Pemasukan
'+fmt(masuk)+'
Total Pengeluaran
'+fmt(keluar)+'
Keuntungan Bersih
'+fmt(net)+'

Rincian Pemasukan

'+(rows||'')+'
PenghuniKamarPeriodeJumlah
Belum ada data

Rincian Pengeluaran

'+(erows||'')+'
TanggalKeteranganKategoriJumlah
Belum ada data
'; const w=window.open('','_blank');if(w){w.document.write(html);w.document.close();setTimeout(()=>w.print(),500);} }; // ── NOTES ── window.openNote=function(refType,refId,refName){ const note=C.notes.find(n=>n.refType===refType&&n.refId===refId); el('m-title').textContent='Catatan — '+refName; el('m-body').innerHTML='
'; window._noteMeta={refType,refId,refName,existingId:note?note.id:null}; window._modalType='note'; el('m-save').style.display=''; el('mo').classList.add('open'); }; // ── MODAL ── let _mt='',_eid=null; window.openMo=function(type,id){ _mt=type;_eid=id||null;window._modalType=''; el('m-save').style.display=''; let title='',body=''; if(type==='kamar'){ title=id?'Edit Kamar':'Tambah Kamar'; const k=id?C.kamar.find(x=>x.id===id)||{}:{}; const jmlK=k.jumlah_orang||1; el('m-save').style.display=''; el('m-title').textContent=title; el('m-body').innerHTML=(function(){ let b=''; // ── Baris 1: Nomor + Tipe ── b+='
'; b+='
'; b+='
'; b+='
'; const katOpts=C.kategori.map(kg=>'').join(''); b+='
'; b+='
'; // ── Baris 2: Fasilitas ── b+='
'; // ── Divider ── b+='
'; // ── Baris 3: Harga + Deposit ── b+='
'; b+='
'; b+='
'; b+='
'; // ── Baris 4: Jumlah Penghuni ── b+='
'; b+='
'; // ── Nominal Tambahan (muncul jika 2 orang) ── b+='
'; // ── Preview Harga ── b+='
'; // ── Data Penghuni ke-2 ── b+='
'; b+='
👤 Data Penghuni ke-2
'; b+='
'; b+='
'; b+='
'; b+='
'; b+='
'; return b; })() el('mo').classList.add('open'); setTimeout(updKamarPreview,100); return } else if(type==='penghuni'){ title=id?'Edit Penghuni':'Tambah Penghuni'; const p=id?C.penghuni.find(x=>x.id===id)||{}:{}; const propId=currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId; const kamarFiltered=C.kamar.filter(k=>(k.property_id===propId||k.property_id===p.property_id)&&(k.status==='kosong'||k.status==='booked'||k.nomor===(p.kamar||''))); const kamOpts=kamarFiltered.map(k=>{ const statusBadge=k.status==='booked'?' 🟡':''; return''; }).join(''); if(!kamOpts&&!id){el('m-title').textContent='Tidak Ada Kamar Kosong';el('m-body').innerHTML='
⚠️ Semua kamar sudah terisi atau belum ada kamar terdaftar di properti ini.
';el('m-save').style.display='none';el('mo').classList.add('open');return} body='
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'; } else if(type==='tagihan'){ title='Catat Pembayaran'; const propId=currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId; const penghuniFiltered=C.penghuni.filter(p=>p.property_id===propId||currentPropertyId==='all'); const opts=penghuniFiltered.map(p=>{const k=C.kamar.find(x=>x.nomor===p.kamar);return''}).join(''); if(!opts){el('m-title').textContent='Catat Pembayaran';el('m-body').innerHTML='
⚠️ Belum ada penghuni terdaftar di properti ini.
';el('m-save').style.display='none';el('mo').classList.add('open');return} body='
'; } else if(type==='pengeluaran'){ title=id?'Edit Pengeluaran':'Tambah Pengeluaran'; const pe=id?C.pengeluaran.find(x=>x.id===id)||{}:{}; body='
'; } else if(type==='maintenance'){ title='Laporan Maintenance'; const propId=currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId; const kamarFiltered=C.kamar.filter(k=>k.property_id===propId||currentPropertyId==='all'); const kamOpts=kamarFiltered.map(k=>'').join(''); body='
'; } else if(type==='property'){ title='Tambah Properti Baru'; body='
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'; } el('m-title').textContent=title;el('m-body').innerHTML=body;el('mo').classList.add('open'); if(type==='tagihan')setTimeout(autoHarga,100);if(type==='penghuni')setTimeout(updTagihanPreview,100); }; window.autoHarga=function(){const pe=el('f-pgh');if(!pe)return;const p=C.penghuni.find(x=>x.id===pe.value);if(!p)return;const k=C.kamar.find(x=>x.nomor===p.kamar);const je=el('f-jml');if(je&&k){const extra=(p.jumlah_orang||1)===2?(k.nominal_tambahan||300000):0;je.value=(k.harga||0)+extra}}; window.sendWA=function(idx){ try{ if(!window._waMessages||!window._waMessages[idx]){ toast('Error: Message not found','error'); return; } const{hp,msg}=window._waMessages[idx]; if(!hp){ toast('No HP tidak ada','error'); return; } // iOS Safari PWA compatible approach const url='https://wa.me/'+hp+'?text='+encodeURIComponent(msg); // Try window.open first const win=window.open(url,'_blank'); // Fallback: if blocked, try location.href if(!win||win.closed||typeof win.closed=='undefined'){ window.location.href=url; } }catch(e){ console.error('sendWA error:',e); toast('Error membuka WhatsApp','error'); } }; window.sendWAPenghuni=function(penghuniId){ try{ const p=C.penghuni.find(x=>x.id===penghuniId); if(!p){toast('Penghuni tidak ditemukan','error');return;} const hp=(p.hp||'').replace(/[^0-9]/g,'').replace(/^0/,'62'); if(!hp){toast('No HP tidak ada','error');return;} // Generate message dynamically const k=C.kamar.find(x=>x.nomor===p.kamar); const kamarHarga=k?k.harga:0; const extra=(p.jumlah_orang||1)===2?(k?.nominal_tambahan||300000):0; const totalHarga=kamarHarga+extra; const tagihan=C.tagihan.filter(t=>t.penghuniId===p.id||t.penghuni===p.nama); const latestUnpaid=tagihan.filter(t=>t.status!=='lunas').sort((a,b)=>b.bulan.localeCompare(a.bulan))[0]; let msg='Halo '+p.nama+' 👋\n\n'; if(latestUnpaid){ const si=tagStatusInfo(latestUnpaid); msg+='Ini reminder tagihan kos bulan *'+latestUnpaid.bulan+'*:\n'; msg+='💰 Tagihan: *Rp '+latestUnpaid.jumlah.toLocaleString('id-ID')+'*\n'; if(si.status==='kurang'){ msg+='✅ Sudah bayar: '+fmt(si.dibayar)+'\n'; msg+='⚠️ Sisa: *'+fmt(si.sisa)+'*\n'; } if(latestUnpaid.jatuh_tempo){ msg+='📅 Jatuh tempo: '+fmtTgl(latestUnpaid.jatuh_tempo)+'\n'; } msg+='\nMohon segera dilunasi ya. Terima kasih 🙏'; }else{ msg+='Terima kasih sudah rutin membayar kos! 🏠\n\n'; msg+='Harga sewa: *Rp '+totalHarga.toLocaleString('id-ID')+'* per bulan\n'; msg+='Kamar: *'+p.kamar+'*\n\n'; msg+='Jika ada pertanyaan, silakan hubungi kami. Terima kasih 🙏'; } // Open WhatsApp const url='https://wa.me/'+hp+'?text='+encodeURIComponent(msg); window.location.href=url; }catch(e){ console.error('sendWAPenghuni error:',e); toast('Error membuka WhatsApp','error'); } }; window.toggleOrg2=function(){ const v=parseInt((el('f-jml-org')||{value:'1'}).value)||1; const sec=el('org2-section'); if(sec)sec.style.display=v===2?'':'none'; }; window.updTagihanPreview=function(){ const kamSel=el('f-kamar');const jmlOrg=parseInt((el('f-jml-org')||{value:'1'}).value)||1; if(!kamSel)return;const k=C.kamar.find(x=>x.nomor===kamSel.value);if(!k)return; const base=k.harga||0;const extra=jmlOrg===2?(k.nominal_tambahan||300000):0;const total=base+extra; const dep=parseInt((el('f-deposit')||{value:'500000'}).value)||0; const prev=el('preview-tagihan'); if(!prev)return; prev.innerHTML= '
'+ '
Harga Kamar
'+fmt(base)+'
'+ (jmlOrg===2?'
Biaya Berdua
+'+fmt(extra)+'
':'')+ '
Total/Bulan
'+fmt(total)+'
'+ '
Deposit
'+fmt(dep)+'
'+ '
'; }; window.toggleKamarOrg2=function(){ const v=parseInt((el('f-jml-org')||{value:'1'}).value)||1; const s=el('org2-section');if(s)s.style.display=v===2?'':'none'; const n=el('nominal-tambahan-section');if(n)n.style.display=v===2?'':'none'; }; window.updKamarPreview=function(){ const h=parseInt((el('f-harga')||{value:'0'}).value)||0; const j=parseInt((el('f-jml-org')||{value:'1'}).value)||1; const d=parseInt((el('f-deposit')||{value:'500000'}).value)||0; const extra=j===2?parseInt((el('f-nominal-tambahan')||{value:'300000'}).value)||300000:0; const total=h+extra; const p=el('preview-kamar');if(!p)return; let html=''; html+='
'; html+='Harga kamar'; html+=''+fmt(h)+'/bln'; html+='
'; if(j===2){ html+='
'; html+='Biaya berdua'; html+='+ '+fmt(extra)+''; html+='
'; } html+='
'; html+='
'; html+='Total/bulan'; html+=''+fmt(total)+''; html+='
'; html+='
'; html+='Deposit'; html+=''+fmt(d)+''; html+='
'; p.innerHTML=html; }; window.openKelolKat=function(){ el('m-title').textContent='Kelola Kategori'; window._modalType='kategori'; _mt=''; _eid=null; el('m-save').style.display='none'; function renderKatList(){ const body=el('m-body'); let html=C.kategori.length===0?'

Belum ada kategori.

':''; C.kategori.forEach((kg,i)=>{ html+='
' +''+kg.nama+'' +'' +'' +'
'; }); html+='
' +'
Tambah Kategori Baru
' +'
' +'' +'' +'
'; body.innerHTML=html; body.querySelector('#btn-tambah-kat').onclick=tambahKat; body.querySelectorAll('[data-action]').forEach(btn=>{ const idx=parseInt(btn.dataset.kid); const kg=C.kategori[idx]; if(!kg)return; if(btn.dataset.action==='edit')btn.onclick=()=>editKat(kg.id); if(btn.dataset.action==='del')btn.onclick=()=>hapusKat(kg.id,kg.nama); }); } renderKatList(); el('mo').classList.add('open'); const _md2=el('mo').querySelector('.modal'); if(_md2){_md2.classList.add('anim-in');setTimeout(()=>_md2.classList.remove('anim-in'),300);} window._renderKatList=renderKatList; }; window.tambahKat=async function(){ const nama=(val('f-kat-nama')||'').trim(); if(!nama){toast('Nama kategori wajib diisi','error');return} if(C.kategori.find(k=>k.nama===nama)){toast('Kategori sudah ada','error');return} await fsAdd('kategori',{nama,urutan:C.kategori.length}); await addLog('Kategori "'+nama+'" ditambahkan','blue'); await loadAll();window._renderKatList&&window._renderKatList();render();toast('Kategori ditambahkan','success'); }; window.hapusKat=async function(id,nama){ kosConfirm({icon:'🗑',msg:'Hapus kategori '+nama+'?',sub:'Kamar yang pakai kategori ini akan masuk ke Lainnya.',okLabel:'Hapus',danger:true},async()=>{ await fsDel('kategori',id); const affected=C.kamar.filter(k=>k.kategori===nama); for(const k of affected)await fsUpd('kamar',k.id,{kategori:''}); await addLog('Kategori "'+nama+'" dihapus','red'); await loadAll();window._renderKatList&&window._renderKatList();render();toast('Kategori dihapus','success'); }); }; window.editKat=function(id){ const kg=C.kategori.find(x=>x.id===id);if(!kg)return; const newNama=prompt('Ubah nama kategori:',kg.nama); if(!newNama||newNama.trim()===kg.nama)return; (async()=>{ await fsUpd('kategori',id,{nama:newNama.trim()}); const affected=C.kamar.filter(k=>k.kategori===kg.nama); for(const k of affected)await fsUpd('kamar',k.id,{kategori:newNama.trim()}); await loadAll();window._renderKatList&&window._renderKatList();render();toast('Kategori diperbarui','success'); })(); }; window.openKelolTipe=function(){ el('m-title').textContent='Kelola Tipe Kamar'; window._modalType='tipe'; _mt='';_eid=null; el('m-save').style.display='none'; function renderTipeList(){ const body=el('m-body'); let html=C.tipe_kamar.length===0?'

Belum ada tipe.

':''; C.tipe_kamar.forEach((tp,i)=>{ html+='
' +''+tp.nama+'' +'' +(tp.id?'':'default') +'
'; }); html+='
' +'
Tambah Tipe Baru
' +'
' +'' +'' +'
'; body.innerHTML=html; body.querySelector('#btn-tambah-tipe').onclick=tambahTipe; body.querySelectorAll('[data-action]').forEach(btn=>{ const idx=parseInt(btn.dataset.tid); const tp=C.tipe_kamar[idx]; if(!tp)return; if(btn.dataset.action==='edit')btn.onclick=()=>editTipe(tp.id,tp.nama); if(btn.dataset.action==='del')btn.onclick=()=>hapusTipe(tp.id,tp.nama); }); } renderTipeList(); el('mo').classList.add('open'); window._renderTipeList=renderTipeList; }; window.tambahTipe=async function(){ const nama=(val('f-tipe-nama')||'').trim(); if(!nama){toast('Nama tipe wajib diisi','error');return} if(C.tipe_kamar.find(t=>t.nama===nama)){toast('Tipe sudah ada','error');return} await fsAdd('tipe_kamar',{nama,urutan:C.tipe_kamar.length}); await loadAll();window._renderTipeList&&window._renderTipeList();toast('Tipe ditambahkan','success'); }; window.hapusTipe=async function(id,nama){ if(!id){toast('Tipe default tidak bisa dihapus','error');return} kosConfirm({icon:'🗑',msg:'Hapus tipe '+nama+'?',sub:'Kamar dengan tipe ini akan tetap ada.',okLabel:'Hapus',danger:true},async()=>{ await fsDel('tipe_kamar',id); await loadAll();window._renderTipeList&&window._renderTipeList();toast('Tipe dihapus','success'); }); }; window.editTipe=function(id,nama){ const newNama=prompt('Ubah nama tipe:',nama); if(!newNama||newNama.trim()===nama)return; (async()=>{ if(id){ await fsUpd('tipe_kamar',id,{nama:newNama.trim()}); } else { // default tipe - tambah sebagai custom await fsAdd('tipe_kamar',{nama:newNama.trim(),urutan:C.tipe_kamar.length}); } const affected=C.kamar.filter(k=>k.tipe===nama); for(const k of affected)await fsUpd('kamar',k.id,{tipe:newNama.trim()}); await loadAll();window._renderTipeList&&window._renderTipeList();toast('Tipe diperbarui','success'); })(); }; window.closeMo=function(){el('mo').classList.remove('open');_mt='';_eid=null;window._modalType=''}; window.closeDo=function(){el('do').classList.remove('open')}; // ── FITUR DP / BOOKING ── let _dpNomor=''; window.openModalDP=function(kamarId,kamarNomor){ const k=C.kamar.find(x=>x.id===kamarId);if(!k)return; _mt='dp_kamar';_eid=kamarId;_dpNomor=kamarNomor; el('m-title').textContent='🟡 Booking Kamar '+kamarNomor; el('m-save').style.display=''; el('m-save').textContent='Simpan Booking'; el('m-body').innerHTML= '
' +'📋 Kamar ini akan ditandai Booked / Dipesan dan tidak bisa dipesan orang lain.' +'
' +'
' +'
' +'
' +'
' +'
' +'
'; el('mo').classList.add('open'); const md=el('mo').querySelector('.modal'); if(md){md.classList.add('anim-in');setTimeout(()=>md.classList.remove('anim-in'),300);} }; window.cancelBooking=async function(kamarId,kamarNomor){ kosConfirm({icon:'❌',msg:'Batalkan booking kamar '+kamarNomor+'?',sub:'Kamar akan kembali ke status Kosong.',okLabel:'Batalkan',danger:true},async()=>{ await fsUpd('kamar',kamarId,{status:'kosong',dp_nama:'',dp_hp:'',dp_nominal:0,dp_masuk:'',dp_tgl:''}); await addLog('❌ Booking kamar '+kamarNomor+' dibatalkan','red'); await loadAll();render();toast('Booking dibatalkan','success'); }); }; // ── SAVE ── window.saveForm=async function(){ if(window._modalType==='note'){ const isi=(val('f-note')||'').trim(); const{refType,refId,refName,existingId}=window._noteMeta||{}; if(existingId){if(isi)await fsUpd('notes',existingId,{isi});else await fsDel('notes',existingId)} else if(isi)await fsAdd('notes',{refType,refId,refName,isi}); await addLog('Catatan '+refName+' diperbarui','blue'); await loadAll();closeMo();render();toast('Catatan disimpan','success');return; } const sb=el('m-save');sb.textContent='Menyimpan...';sb.disabled=true; try{ if(_mt==='kamar'){ const nomor=(val('f-nomor')||'').trim();const harga=parseInt(val('f-harga'))||0; if(!nomor){toast('Nomor kamar wajib','error');return} if(harga<=0){toast('Harga harus > 0','error');return} const propId=currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId; // Check duplicate within same property only if(C.kamar.find(x=>x.nomor===nomor&&x.id!==_eid&&x.property_id===propId)){toast('Nomor kamar sudah ada di properti ini','error');return} const jmlOrgK=parseInt(val('f-jml-org'))||1; const depositK=parseInt(val('f-deposit'))||0; const nominalTambahan=jmlOrgK===2?parseInt(val('f-nominal-tambahan'))||300000:0; const obj={nomor,tipe:val('f-tipe'),harga,fasilitas:val('f-fasilitas'), jumlah_orang:jmlOrgK,deposit:depositK, kategori:val('f-kategori')||'', nominal_tambahan:nominalTambahan, nama2:jmlOrgK===2?(val('f-nama2')||'').trim():'', hp2:jmlOrgK===2?val('f-hp2'):'', ktp2:jmlOrgK===2?val('f-ktp2'):'', property_id:propId }; if(_eid){const k=C.kamar.find(x=>x.id===_eid);await fsUpd('kamar',_eid,{...obj,status:k.status});await addLog('Kamar '+nomor+' diperbarui','blue',propId)} else{await fsAdd('kamar',{...obj,status:'kosong'});await addLog('Kamar '+nomor+' ditambahkan','green',propId)} toast(_eid?'Kamar diperbarui':'Kamar ditambahkan','success'); } else if(_mt==='penghuni'){ const nama=(val('f-nama')||'').trim();const kamar=val('f-kamar');const hp=(val('f-hp')||'').trim();const masuk=val('f-masuk'); if(!nama){toast('Nama wajib diisi','error');return} if(!kamar){toast('Pilih kamar','error');return} const jmlOrg=parseInt(val('f-jml-org'))||1;const deposit=parseInt(val('f-deposit'))||0; const kamObj=C.kamar.find(x=>x.nomor===kamar); const propId=kamObj?.property_id||(currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId); const obj={nama,kamar,hp,masuk,ktp:val('f-ktp'),status:'aktif',jumlah_orang:jmlOrg,deposit, kontrak_selesai:val('f-kontrak')||'', nama2:jmlOrg===2?(val('f-nama2')||'').trim():'', hp2:jmlOrg===2?val('f-hp2'):'', ktp2:jmlOrg===2?val('f-ktp2'):'', property_id:propId }; if(_eid){ const p=C.penghuni.find(x=>x.id===_eid); if(p.kamar!==kamar){ // Update status kamar lama & baru const lk=C.kamar.find(x=>x.nomor===p.kamar);if(lk)await fsUpd('kamar',lk.id,{status:'kosong'}); const bk=C.kamar.find(x=>x.nomor===kamar); if(bk){ // If moving to booked room, clear booking data if(bk.status==='booked'){ await fsUpd('kamar',bk.id,{status:'terisi',dp_nama:'',dp_hp:'',dp_nominal:0,dp_masuk:'',dp_tgl:''}); await addLog('🟡→✅ '+nama+' masuk kamar '+kamar+' (booking confirmed)','green',propId); }else{ await fsUpd('kamar',bk.id,{status:'terisi'}); } } // Sinkron semua tagihan penghuni ini → update field kamar const tagihanPgh=C.tagihan.filter(t=>t.penghuniId===_eid); for(const t of tagihanPgh){await fsUpd('tagihan',t.id,{kamar});} await addLog(nama+' pindah kamar '+p.kamar+' → '+kamar,'blue',propId); } await fsUpd('penghuni',_eid,obj);await addLog(nama+' data diperbarui','blue',propId); } else { const nid=await fsAdd('penghuni',obj); const k=C.kamar.find(x=>x.nomor===kamar); if(k){ // If room was booked, clear booking data when penghuni moves in if(k.status==='booked'){ await fsUpd('kamar',k.id,{status:'terisi',dp_nama:'',dp_hp:'',dp_nominal:0,dp_masuk:'',dp_tgl:''}); await addLog('🟡→✅ '+nama+' masuk kamar '+kamar+' (booking confirmed)','green',propId); }else{ await fsUpd('kamar',k.id,{status:'terisi'}); } } const extra=jmlOrg===2?(k?.nominal_tambahan||300000):0; const hargaKamar=k?k.harga:0; const hargaTotal=hargaKamar+extra; // Calculate prorated billing const prorate=calculateProrated(masuk,hargaKamar,extra); const nm=['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember']; const masukDate=new Date(masuk); const masukDay=masukDate.getDate(); const masukMonth=masukDate.getMonth(); const masukYear=masukDate.getFullYear(); // Create current month tagihan (unless skipMonth) if(!prorate.skipMonth){ const bulanSekarang=nm[masukMonth]+' '+masukYear; const jumlahSekarang=prorate.isProrated?prorate.jumlahProrated:hargaTotal; const jatuhTempoSekarang=prorate.isProrated ?masukYear+'-'+String(masukMonth+1).padStart(2,'0')+'-'+String(masukDay).padStart(2,'0') :masukYear+'-'+String(masukMonth+1).padStart(2,'0')+'-01'; const tagihanData={ penghuniId:nid, penghuni:nama, kamar, bulan:bulanSekarang, jumlah:jumlahSekarang, status:'belum', tgl:'-', jatuh_tempo:jatuhTempoSekarang, createdAt:new Date().toISOString(), property_id:propId }; // Add prorated fields if applicable if(prorate.isProrated){ tagihanData.is_prorated=true; tagihanData.jumlah_hari=prorate.jumlahHari; tagihanData.harga_per_hari=prorate.hargaPerHari; tagihanData.prorated_detail=prorate.proratedDetail; } await fsAdd('tagihan',tagihanData); } // Auto-create next month tagihan (full amount, due tanggal 1) const nextMonth=(masukMonth+1)%12; const nextYear=masukMonth===11?masukYear+1:masukYear; const bulanDepan=nm[nextMonth]+' '+nextYear; const jatuhTempoDepan=nextYear+'-'+String(nextMonth+1).padStart(2,'0')+'-01'; await fsAdd('tagihan',{ penghuniId:nid, penghuni:nama, kamar, bulan:bulanDepan, jumlah:hargaTotal, status:'belum', tgl:'-', jatuh_tempo:jatuhTempoDepan, createdAt:new Date().toISOString(), property_id:propId }); if(deposit>0)await addLog(nama+' masuk kamar '+kamar+' · deposit '+fmt(deposit),'green',propId); else await addLog(nama+' masuk kamar '+kamar,'green',propId); } toast(_eid?'Data diperbarui':'Penghuni ditambahkan','success'); } else if(_mt==='blast_wa'){ const bln=val('f-blast-bln'); const target=val('f-blast-target'); const penghuni=filterByProperty(C.penghuni); const tagihan=filterByProperty(C.tagihan); let list=[]; if(target==='semua'){ list=penghuni.map(p=>({nama:p.nama,kamar:p.kamar,hp:p.hp,jumlah:0,tagihan:null})); } else { const belumTag=tagihan.filter(t=>t.bulan===bln&&(t.status==='belum'||t.status==='telat'||t.status==='kurang')); const sudahIds=tagihan.filter(t=>t.bulan===bln).map(t=>t.penghuniId||t.penghuni); const noTagihan=penghuni.filter(p=>!sudahIds.includes(p.id)&&!sudahIds.includes(p.nama)); list=[ ...belumTag.map(t=>{const p=penghuni.find(x=>x.id===t.penghuniId||x.nama===t.penghuni);return{nama:t.penghuni,kamar:t.kamar,hp:p?.hp||'',jumlah:t.jumlah,tagihan:t};}), ...noTagihan.map(p=>({nama:p.nama,kamar:p.kamar,hp:p.hp||'',jumlah:0,tagihan:null})) ]; } if(list.length===0){closeMo();toast('✅ Tidak ada yang perlu ditagih','success');return} // Tampilkan daftar WA el('m-title').textContent='📱 Blast WA — '+bln; el('m-save').style.display='none'; // Store messages globally window._waMessages=[]; let html='
Tap tombol 📱 WA untuk buka WhatsApp langsung
'; list.forEach((x,idx)=>{ const hp=(x.hp||'').replace(/[^0-9]/g,'').replace(/^0/,'62'); const si=x.tagihan?tagStatusInfo(x.tagihan):null; const msgText='Halo '+x.nama+' 👋\n\n' +'Ini reminder tagihan kos bulan *'+bln+'*'+(x.jumlah>0?':\n💰 Tagihan: *Rp '+x.jumlah.toLocaleString('id-ID')+'*':'.') +(si&&si.status==='kurang'?'\n✅ Sudah bayar: '+fmt(si.dibayar)+'\n⚠️ Sisa: *'+fmt(si.sisa)+'*':'') +(x.tagihan?.jatuh_tempo?'\n📅 Jatuh tempo: '+fmtTgl(x.tagihan.jatuh_tempo):'') +'\n\nMohon segera dilunasi ya. Terima kasih 🙏'; window._waMessages.push({hp,msg:msgText}); html+='
' +'
' +'
'+x.nama+'
' +'
'+x.kamar+(x.jumlah>0?' · '+fmt(x.jumlah):'')+(si&&si.status==='kurang'?' · sisa '+fmt(si.sisa):'')+'
' +'
' +(hp ?'' :'No HP kosong') +'
'; }); el('m-body').innerHTML=html; // Add event listeners after render setTimeout(()=>{ document.querySelectorAll('.wa-btn').forEach(btn=>{ btn.addEventListener('click',function(e){ e.preventDefault(); const idx=parseInt(this.getAttribute('data-wa-idx')); if(window._waMessages&&window._waMessages[idx]){ const{hp,msg}=window._waMessages[idx]; const url='https://wa.me/'+hp+'?text='+encodeURIComponent(msg); // iOS PWA compatible: just navigate window.location.href=url; } }); }); },100); return; // jangan closeMo } else if(_mt==='blast_wa_tagihan'||_mt==='blast_wa_penghuni'){ generateBlastExcel(); return; } else if(_mt==='generate_tagihan'){ const bln=val('f-gen-bln'); if(!bln){toast('Pilih bulan dulu','error');return} const nm=['Januari','Februari','Maret','April','Mei','Juni','Juli','Agustus','September','Oktober','November','Desember']; const parts=bln.split(' '); const mIdx=nm.indexOf(parts[0]); const yr=parseInt(parts[1]); const penghuni=filterByProperty(C.penghuni); const tagihan=filterByProperty(C.tagihan); const sudahAda=tagihan.filter(t=>t.bulan===bln).map(t=>t.penghuniId||t.penghuni); const belumAda=penghuni.filter(p=>!sudahAda.includes(p.id)&&!sudahAda.includes(p.nama)); if(belumAda.length===0){closeMo();toast('✅ Semua penghuni sudah punya tagihan '+bln,'success');return} let count=0; for(const p of belumAda){ const k=C.kamar.find(x=>x.nomor===p.kamar); const extra=(p.jumlah_orang||1)===2?(k?.nominal_tambahan||300000):0; const hargaKamar=k?k.harga:0; const hargaTotal=hargaKamar+extra; const propId=p.property_id||(currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId); // Check if this is the first month after penghuni moves in const masuk=p.masuk||''; // ✅ CORRECT FIELD NAME! const masukDate=masuk?new Date(masuk):null; let useProrated=false; let jumlahFinal=hargaTotal; let jatuhTempoFinal=yr+'-'+String(mIdx+1).padStart(2,'0')+'-01'; let proratedFields={}; // If penghuni has move-in date and it's in the same month we're generating if(masukDate&&masukDate.getMonth()===mIdx&&masukDate.getFullYear()===yr){ const prorate=calculateProrated(masuk,hargaKamar,extra); if(prorate.isProrated&&!prorate.skipMonth){ useProrated=true; jumlahFinal=prorate.jumlahProrated; const masukDay=masukDate.getDate(); jatuhTempoFinal=yr+'-'+String(mIdx+1).padStart(2,'0')+'-'+String(masukDay).padStart(2,'0'); proratedFields={ is_prorated:true, jumlah_hari:prorate.jumlahHari, harga_per_hari:prorate.hargaPerHari, prorated_detail:prorate.proratedDetail }; } } // Create tagihan with prorated amount if applicable await fsAdd('tagihan',{ penghuniId:p.id, penghuni:p.nama, kamar:p.kamar, bulan:bln, jumlah:jumlahFinal, jumlah_bayar:0, status:'belum', tgl:'-', jatuh_tempo:jatuhTempoFinal, createdAt:new Date().toISOString(), property_id:propId, ...proratedFields }); count++; } await addLog('🗓 Generate '+count+' tagihan '+bln,'green',currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId); toast('✅ '+count+' tagihan '+bln+' berhasil dibuat!','success'); } else if(_mt==='edit_kamar_tagihan'){ const kamarBaru=val('f-kamar-tag'); if(!kamarBaru){toast('Pilih kamar','error');return} await fsUpd('tagihan',_eid,{kamar:kamarBaru}); await addLog('✏️ Koreksi kamar tagihan → '+kamarBaru,'blue'); toast('✅ Kamar tagihan diperbarui ke '+kamarBaru,'success'); } else if(_mt==='bayar_tagihan'){ const t=C.tagihan.find(x=>x.id===_eid);if(!t){toast('Data tidak ditemukan','error');return} const tambahan=parseInt(val('f-bayar-jml'))||0; if(tambahan<=0){toast('Jumlah harus > 0','error');return} const sudahBayar=Number(t.jumlah_bayar)||0; const total=Number(t.jumlah)||0; const totalBayar=sudahBayar+tambahan; const status=totalBayar>=total?'lunas':'kurang'; const tgl=val('f-bayar-tgl')||today(); const catatan=val('f-bayar-catatan')||''; await fsUpd('tagihan',_eid,{status,jumlah_bayar:totalBayar,tgl,catatan}); if(status==='lunas'){ const k=C.kamar.find(x=>x.nomor===t.kamar);if(k&&k.status==='telat')await fsUpd('kamar',k.id,{status:'terisi'}); await addLog('✅ '+t.penghuni+' ('+t.kamar+') lunas '+t.bulan,'green'); toast(t.penghuni+' lunas! ✅','success'); } else { await addLog('⚠ '+t.penghuni+' bayar sebagian '+fmt(tambahan)+' ('+t.bulan+')','amber'); toast('Bayar sebagian dicatat ⚠','success'); } } else if(_mt==='edit_bayaran'){ const t=C.tagihan.find(x=>x.id===_eid);if(!t){toast('Data tidak ditemukan','error');return} const jml=parseInt(val('f-edit-jml'))||0; if(jml<0){toast('Jumlah tidak valid','error');return} const total=Number(t.jumlah)||0; const status=jml<=0?'belum':jml>=total?'lunas':'kurang'; const tgl=val('f-edit-tgl')||today(); const catatan=val('f-edit-catatan')||''; await fsUpd('tagihan',_eid,{status,jumlah_bayar:jml,tgl,catatan}); if(status==='lunas'){const k=C.kamar.find(x=>x.nomor===t.kamar);if(k&&k.status==='telat')await fsUpd('kamar',k.id,{status:'terisi'});} await addLog('✏ Edit bayaran '+t.penghuni+' ('+t.kamar+') → '+fmt(jml),'blue'); toast('Pembayaran diperbarui','success'); } else if(_mt==='dp_kamar'){ const nama=(val('f-dp-nama')||'').trim(); const hp=(val('f-dp-hp')||'').trim(); const nominal=parseInt(val('f-dp-nominal'))||0; const masuk=val('f-dp-masuk')||''; if(!nama){toast('Nama calon penghuni wajib diisi','error');return} if(nominal<=0){toast('Nominal DP harus > 0','error');return} await fsUpd('kamar',_eid,{status:'booked',dp_nama:nama,dp_hp:hp,dp_nominal:nominal,dp_masuk:masuk,dp_tgl:today()}); await addLog('🟡 Kamar '+_dpNomor+' di-booking oleh '+nama+' · DP '+fmt(nominal),'amber'); toast('Kamar '+_dpNomor+' berhasil di-booking! 🟡','success'); } else if(_mt==='tagihan'){ const pghId=val('f-pgh');const pgh=C.penghuni.find(x=>x.id===pghId); if(!pgh){toast('Pilih penghuni','error');return} const bln=(val('f-bln')||'').trim();const jml=parseInt(val('f-jml'))||0; if(!bln){toast('Periode wajib diisi','error');return} if(jml<=0){toast('Jumlah harus > 0','error');return} const propId=pgh.property_id||(currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId); const ex=C.tagihan.find(t=>t.penghuniId===pghId&&t.bulan===bln); if(ex)await fsUpd('tagihan',ex.id,{status:'lunas',jumlah:jml,jumlah_bayar:jml,tgl:val('f-tgl')}); else await fsAdd('tagihan',{penghuniId:pghId,penghuni:pgh.nama,kamar:pgh.kamar,bulan:bln,jumlah:jml,jumlah_bayar:jml,status:'lunas',tgl:val('f-tgl'),createdAt:new Date().toISOString(),property_id:propId}); const k=C.kamar.find(x=>x.nomor===pgh.kamar);if(k&&k.status==='telat')await fsUpd('kamar',k.id,{status:'terisi'}); await addLog(pgh.nama+' bayar '+bln+' '+fmt(jml),'green',propId); toast('Pembayaran dicatat','success'); } else if(_mt==='pengeluaran'){ const desk=(val('f-desk')||'').trim();const jml=parseInt(val('f-jml'))||0; if(!desk){toast('Deskripsi wajib','error');return} if(jml<=0){toast('Jumlah harus > 0','error');return} const propId=currentPropertyId==='all'?(C.properties[0]?.id||''):currentPropertyId; const obj={deskripsi:desk,kategori:val('f-kat'),jumlah:jml,tgl:val('f-tgl'),property_id:propId}; if(_eid)await fsUpd('pengeluaran',_eid,obj);else await fsAdd('pengeluaran',obj); await addLog(desk+' '+fmt(jml),'amber',propId); toast(_eid?'Diperbarui':'Ditambahkan','success'); } else if(_mt==='maintenance'){ const isu=(val('f-misu')||'').trim();if(!isu){toast('Masalah wajib diisi','error');return} await fsAdd('maintenance',{kamar:val('f-mkamar'),isu,tgl:val('f-mtgl'),catatan:val('f-mcatat'),status:'proses',property_id:currentPropertyId==='all'?C.properties[0]?.id:currentPropertyId}); await addLog('Maintenance kamar '+val('f-mkamar')+': '+isu,'amber',currentPropertyId==='all'?C.properties[0]?.id:currentPropertyId); toast('Laporan dibuat','success'); } else if(_mt==='property'){ const nama=(val('f-prop-nama')||'').trim(); if(!nama){toast('Nama properti wajib diisi','error');return} const hasDefault=C.properties.some(p=>p.is_default); const obj={ nama, alamat:val('f-prop-alamat')||'', no_hp:val('f-prop-hp')||'', maps_url:val('f-prop-maps')||'', bank_nama:val('f-prop-bank')||'', bank_rekening:val('f-prop-rek')||'', bank_an:val('f-prop-an')||'', is_default:!hasDefault, created_at:new Date().toISOString() }; const newPropId=await fsAdd('properties',obj); await addLog('Properti '+nama+' ditambahkan','green',newPropId); toast('Properti ditambahkan!','success'); } else if(_mt==='transfer_kamar'){ const newPropId=val('f-transfer-prop'); if(!newPropId){toast('Pilih properti tujuan','error');return} const k=C.kamar.find(x=>x.id===window._transferKamarId); if(!k){toast('Kamar tidak ditemukan','error');return} const newProp=C.properties.find(p=>p.id===newPropId); await fsUpd('kamar',k.id,{property_id:newPropId}); await addLog('Kamar '+k.nomor+' dipindah ke '+newProp.nama,'blue',newPropId); toast('Kamar '+k.nomor+' berhasil dipindah!','success'); } else if(_mt==='transfer_penghuni'){ const newPropId=val('f-transfer-prop'); if(!newPropId){toast('Pilih properti tujuan','error');return} const p=C.penghuni.find(x=>x.id===window._transferPenghuniId); if(!p){toast('Penghuni tidak ditemukan','error');return} const newProp=C.properties.find(pr=>pr.id===newPropId); // Transfer penghuni await fsUpd('penghuni',p.id,{property_id:newPropId}); // Transfer kamar const k=C.kamar.find(x=>x.nomor===p.kamar); if(k)await fsUpd('kamar',k.id,{property_id:newPropId}); // Transfer semua tagihan const tagihan=C.tagihan.filter(t=>t.penghuniId===p.id); for(const t of tagihan){ await fsUpd('tagihan',t.id,{property_id:newPropId}); } await addLog('Transfer '+p.nama+' ke '+newProp.nama+' ('+tagihan.length+' tagihan)','blue',newPropId); toast(p.nama+' berhasil dipindah ke '+newProp.nama+'!','success'); } else if(_mt==='bulk_transfer_kamar'){ const fromProp=val('f-bulk-from'); const toProp=val('f-bulk-to'); if(!fromProp||!toProp){toast('Pilih properti asal dan tujuan','error');return} if(fromProp===toProp){toast('Properti asal dan tujuan harus berbeda','error');return} const selectedKamar=Array.from(document.querySelectorAll('.kamar-check:checked')).map(cb=>cb.value); if(selectedKamar.length===0){toast('Pilih minimal 1 kamar','error');return} const toPropObj=C.properties.find(p=>p.id===toProp); for(const kid of selectedKamar){ await fsUpd('kamar',kid,{property_id:toProp}); } await addLog('Transfer massal '+selectedKamar.length+' kamar ke '+toPropObj.nama,'blue',toProp); toast(selectedKamar.length+' kamar berhasil dipindah!','success'); } else if(_mt==='bulk_transfer_penghuni'){ const fromProp=val('f-bulk-from'); const toProp=val('f-bulk-to'); if(!fromProp||!toProp){toast('Pilih properti asal dan tujuan','error');return} if(fromProp===toProp){toast('Properti asal dan tujuan harus berbeda','error');return} const selectedPenghuni=Array.from(document.querySelectorAll('.penghuni-check:checked')).map(cb=>cb.value); if(selectedPenghuni.length===0){toast('Pilih minimal 1 penghuni','error');return} const toPropObj=C.properties.find(p=>p.id===toProp); let totalTagihan=0; for(const pid of selectedPenghuni){ // Transfer penghuni await fsUpd('penghuni',pid,{property_id:toProp}); // Transfer kamar const pgh=C.penghuni.find(x=>x.id===pid); if(pgh){ const k=C.kamar.find(x=>x.nomor===pgh.kamar); if(k)await fsUpd('kamar',k.id,{property_id:toProp}); } // Transfer tagihan const tagihan=C.tagihan.filter(t=>t.penghuniId===pid); for(const t of tagihan){ await fsUpd('tagihan',t.id,{property_id:toProp}); totalTagihan++; } } await addLog('Transfer massal '+selectedPenghuni.length+' penghuni + '+totalTagihan+' tagihan ke '+toPropObj.nama,'blue',toProp); toast(selectedPenghuni.length+' penghuni berhasil dipindah!','success'); } await loadAll();closeMo();render(); }catch(err){toast('Error: '+err.message,'error')} finally{sb.textContent='Simpan';sb.disabled=false} }; // ── RESET ── window.resetData=function(){ kosConfirm({icon:'⚠️',msg:'HAPUS SEMUA DATA?',sub:'Kamar, penghuni, tagihan, pengeluaran, log. Tidak bisa dibatalkan!',okLabel:'Lanjut',danger:true},()=>{ kosConfirm({icon:'🚨',msg:'Konfirmasi Terakhir',sub:'Semua data akan hilang permanen. Yakin?',okLabel:'HAPUS SEMUA',danger:true},async()=>{ try{ toast('Menghapus data...',''); for(const col of['kamar','penghuni','tagihan','pengeluaran','maintenance','log','notes','kategori','tipe_kamar']){ const docs=await fsGet(col); for(const d of docs)await fsDel(col,d.id); } C={kamar:[],penghuni:[],tagihan:[],pengeluaran:[],maintenance:[],log:[],notes:[],kategori:[],tipe_kamar:[],settings:{}}; render();toast('Semua data dihapus','success'); }catch(e){toast('Gagal: '+e.message,'error')} }); }); }; el('mo').addEventListener('click',e=>{if(e.target===el('mo'))closeMo()}); el('mo').addEventListener('touchstart',()=>stopCounters(),{passive:true}); el('do').addEventListener('click',e=>{if(e.target===el('do'))closeDo()}); document.addEventListener('keydown',e=>{if(e.key==='Escape'){closeMo();closeDo()}}); // ── PIN SYSTEM ── let _pin=''; let _pinMode=''; // 'enter' | 'setup' | 'change' | 'confirm' let _pinNew=''; let _pinWrong=0; function showPinScreen(mode){ _pinMode=mode;_pin=''; updPinDots(); el('pin-err').textContent=''; const ps=el('pin-screen'); ps.style.display='flex'; if(mode==='setup'){ el('pin-title').textContent='Buat PIN Baru'; el('pin-sub').textContent='Masukkan 6 digit PIN baru kamu'; el('pin-forgot').style.display='none'; } else if(mode==='confirm'){ el('pin-title').textContent='Konfirmasi PIN'; el('pin-sub').textContent='Masukkan PIN sekali lagi untuk konfirmasi'; el('pin-forgot').style.display='none'; } else if(mode==='change'){ el('pin-title').textContent='PIN Lama'; el('pin-sub').textContent='Masukkan PIN lama kamu dulu'; el('pin-forgot').style.display='block'; } else { el('pin-title').textContent='Masukkan PIN'; el('pin-sub').textContent='Masukkan 6 digit PIN kamu'; el('pin-forgot').style.display='block'; } } function hidePinScreen(){ el('pin-screen').style.display='none'; } function updPinDots(){ const dots=document.querySelectorAll('.pd'); dots.forEach((d,i)=>{ d.classList.remove('filled','error'); if(i<_pin.length)d.classList.add('filled'); }); } function pinShake(){ const dots=el('pin-dots'); document.querySelectorAll('.pd').forEach(d=>d.classList.add('error')); dots.style.animation='pinShake .4s ease'; setTimeout(()=>{ dots.style.animation=''; document.querySelectorAll('.pd').forEach(d=>d.classList.remove('error')); _pin='';updPinDots(); },500); } window.pinInput=function(n){ if(_pin.length>=6)return; _pin+=String(n); updPinDots(); if(_pin.length===6)setTimeout(checkPin,120); }; window.pinDel=function(){if(_pin.length>0){_pin=_pin.slice(0,-1);updPinDots()}}; window.pinClear=function(){_pin='';updPinDots()}; async function checkPin(){ if(_pinMode==='enter'){ const saved=await getPin(); if(_pin===saved){ _pinWrong=0;hidePinScreen(); } else { _pinWrong++; el('pin-err').textContent='PIN salah'+((_pinWrong>2)?' ('+_pinWrong+'x)':''); pinShake(); } } else if(_pinMode==='setup'){ _pinNew=_pin;_pin=''; showPinScreen('confirm'); } else if(_pinMode==='confirm'){ if(_pin===_pinNew){ await savePin(_pin); hidePinScreen(); toast('PIN berhasil dibuat','success'); } else { el('pin-err').textContent='PIN tidak sama, coba lagi'; pinShake(); setTimeout(()=>showPinScreen('setup'),600); } } else if(_pinMode==='change'){ const saved=await getPin(); if(_pin===saved){ _pin='';showPinScreen('setup'); } else { el('pin-err').textContent='PIN lama salah'; pinShake(); } } } async function getPin(){ try{ const r=await fetch(BASE+'/settings/pin'); if(!r.ok)return null; const d=await r.json(); return d.fields&&d.fields.value?d.fields.value.stringValue:null; }catch(e){return null} } async function savePin(pin){ await fetch(BASE+'/settings/pin',{ method:'PATCH', headers:{'Content-Type':'application/json'}, body:JSON.stringify({fields:{value:{stringValue:pin}}}) }); } window.resetPin=async function(){ kosConfirm({icon:'🔐',msg:'Reset PIN?',sub:'Kamu perlu membuat PIN baru setelah ini.',okLabel:'Reset',danger:true},async()=>{ await fetch(BASE+'/settings/pin',{method:'DELETE'}); showPinScreen('setup'); }); }; window.changePinFlow=function(){ showPinScreen('change'); }; // ── SETTINGS PAGE ── function rSettings(){ const s=C.settings||{}; const prop=getCurrentProperty(); // Property Management Section let propSection='
🏢 Kelola Properti
Daftar lokasi kos yang Anda kelola
'; if(C.properties.length>0){ propSection+='
'; C.properties.forEach(p=>{ const kamarCount=C.kamar.filter(k=>k.property_id===p.id).length; const isDefault=p.is_default?' DEFAULT':''; const isCurrent=p.id===currentPropertyId?' style="border:2px solid var(--green)"':''; propSection+='
'+p.nama+isDefault+''+kamarCount+' kamar
'; propSection+='
'; propSection+='
Alamat'+(p.alamat||'-')+'
'; propSection+='
Kontak'+(p.no_hp||'-')+'
'; propSection+='
'; propSection+=''; if(!p.is_default)propSection+=''; propSection+='
'; }); propSection+='
'; } propSection+=''; propSection+='
'; // Bulk Transfer Section (only show if multiple properties exist) let bulkSection=''; if(C.properties.length>1){ bulkSection='
🔄 Transfer Data Antar Properti
Pindahkan kamar & penghuni secara massal
'+ '
'+ ''+ ''+ '
'; } // Info Kos Section (only show if property selected) let infoSection=''; if(C.properties.length>0&¤tPropertyId!=='all'){ infoSection='
Info Kos - '+prop.nama+'
Ditampilkan di halaman publik calon penghuni
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
Info Pembayaran
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'; }else if(C.properties.length===0){ infoSection='
⚠️ Belum ada properti. Klik tombol "Tambah Properti Baru" di atas untuk mulai.
'; }else{ infoSection='
💡 Pilih properti spesifik dari dropdown di atas untuk edit info properti.
'; } // Migration & Admin Tools Section const migrationSection='
⚙️ Migration & Admin Tools
Tools untuk update sistem dan migrasi data
'+ '
'+ ''+ '
'+ '
'+ 'Migrasi ini akan mengubah semua jatuh tempo tagihan dari tanggal 10 ke tanggal 1.'+ '
'; return propSection+bulkSection+infoSection+migrationSection+ '
Kelola Kategori & Tipe
'+ '
'+ ''+ ''+ '
'; } window.savePropertySettings=async function(){ if(currentPropertyId==='all'||C.properties.length===0){toast('Pilih properti spesifik dulu','error');return} if(!C.properties.find(p=>p.id===currentPropertyId)){toast('Properti tidak ditemukan','error');return} const obj={ nama:val('s-nama'), alamat:val('s-alamat'), no_hp:val('s-wa'), maps_url:val('s-maps'), bank_nama:val('s-bank'), bank_rekening:val('s-rek'), bank_an:val('s-namarek') }; if(!obj.nama||!obj.no_hp||!obj.bank_nama||!obj.bank_rekening){toast('Nama, HP, bank, dan rekening wajib diisi','error');return} try{ await fsUpd('properties',currentPropertyId,obj); await addLog('Info properti '+obj.nama+' diperbarui','blue',currentPropertyId); await loadAll(); toast('Info properti disimpan! ✓','success'); setTimeout(()=>render(),300); }catch(err){ console.error('Save property error:',err); toast('Gagal simpan: '+err.message,'error'); } }; window.editProperty=function(id){ const p=C.properties.find(x=>x.id===id); if(!p)return; el('m-title').textContent='Edit Properti'; el('m-body').innerHTML= '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'+ '
'; window._editPropertyId=id; el('m-save').textContent='Simpan'; el('m-save').onclick=async function(){ const upd={ nama:val('f-prop-nama'), alamat:val('f-prop-alamat'), no_hp:val('f-prop-hp'), bank_nama:val('f-prop-bank'), bank_rekening:val('f-prop-rek'), bank_an:val('f-prop-an') }; if(!upd.nama){toast('Nama properti wajib diisi','error');return} try{ await fsUpd('properties',id,upd); await addLog('Properti '+upd.nama+' diupdate','blue'); await loadAll(); closeMo(); render(); toast('Properti diupdate','success'); }catch(err){ toast('Gagal update: '+err.message,'error'); } }; el('mo').classList.add('open'); }; window.deleteProperty=function(id){ const p=C.properties.find(x=>x.id===id); if(!p)return; const kamarCount=C.kamar.filter(k=>k.property_id===id).length; if(kamarCount>0){ toast('Properti masih punya '+kamarCount+' kamar. Hapus kamar dulu atau pindahkan ke properti lain.','error'); return; } kosConfirm({icon:'🗑',msg:'Hapus Properti '+p.nama+'?',sub:'Properti ini tidak punya kamar.',okLabel:'Hapus',danger:true},async()=>{ try{ await fsDel('properties',id); await addLog('Properti '+p.nama+' dihapus','red'); await loadAll(); if(currentPropertyId===id)currentPropertyId='all'; render(); toast('Properti dihapus','success'); }catch(err){ toast('Gagal hapus: '+err.message,'error'); } }); }; window.copyPubLink=function(){ const link=el('pub-link')?.textContent||''; if(navigator.clipboard)navigator.clipboard.writeText(link).then(()=>toast('Link disalin!','success')); }; // ── UPLOAD FOTO KAMAR ── const STORAGE_BASE='https://firebasestorage.googleapis.com/v0/b/kos-manager-93c43.firebasestorage.app/o'; window.uploadFotoKamar=async function(kamarId, kamarNomor){ const input=document.createElement('input'); input.type='file';input.accept='image/*'; input.onchange=async function(){ const file=input.files[0];if(!file)return; if(file.size>5*1024*1024){toast('Foto maksimal 5MB','error');return} toast('Mengupload foto...',''); try{ const filename='kamar_'+kamarId+'_'+Date.now()+'.'+file.name.split('.').pop(); const uploadUrl=STORAGE_BASE+'?uploadType=media&name='+encodeURIComponent('kamar/'+filename); const res=await fetch(uploadUrl,{method:'POST',headers:{'Content-Type':file.type},body:file}); if(!res.ok)throw new Error('Upload gagal: '+res.status); const photoUrl=STORAGE_BASE+'/'+encodeURIComponent('kamar/'+filename)+'?alt=media'; await fsUpd('kamar',kamarId,{foto:photoUrl}); await addLog('Foto kamar '+kamarNomor+' diupload','blue'); await loadAll();render();toast('Foto berhasil diupload!','success'); }catch(e){toast('Gagal upload: '+e.message,'error')} }; input.click(); }; window.hapusFotoKamar=async function(kamarId,kamarNomor){ kosConfirm({icon:'🖼',msg:'Hapus foto kamar '+kamarNomor+'?',okLabel:'Hapus',danger:true},async()=>{ await fsUpd('kamar',kamarId,{foto:''}); await loadAll();render();toast('Foto dihapus','success'); }); }; // ── DARK MODE ── function applyTheme(dark){ document.documentElement.setAttribute('data-theme', dark?'dark':'light'); const btn=el('dm-btn'); if(btn)btn.textContent=dark?'☀️':'🌙'; // fix PIN screen & loading bg el('pin-screen').style.background=dark?'var(--surf)':'var(--surf)'; el('ls').style.background='var(--surf)'; } window.toggleDark=function(){ const isDark=document.documentElement.getAttribute('data-theme')==='dark'; const next=!isDark; applyTheme(next); try{localStorage.setItem('km-theme',next?'dark':'light')}catch(e){} }; // Load saved theme sebelum render apapun (function(){ try{ const saved=localStorage.getItem('km-theme'); if(saved==='dark')applyTheme(true); }catch(e){} })(); // ── INIT ── (async function init(){ el('ls-msg').textContent='Memuat data...'; try{ // Cek PIN dulu const savedPin=await getPin(); if(!savedPin){ // Belum ada PIN - setup dulu el('ls').style.display='none'; showPinScreen('setup'); // Tunggu PIN selesai di-setup, lalu load data await new Promise(resolve=>{ const orig=hidePinScreen; hidePinScreen=function(){ el('pin-screen').style.display='none'; el('ls').style.display='flex'; hidePinScreen=orig; resolve(); }; }); el('ls-msg').textContent='Memuat data...'; } else { // Ada PIN - minta input el('ls').style.display='none'; showPinScreen('enter'); await new Promise(resolve=>{ const orig=hidePinScreen; hidePinScreen=function(){ el('pin-screen').style.display='none'; el('ls').style.display='flex'; hidePinScreen=orig; resolve(); }; }); el('ls-msg').textContent='Memuat data...'; } await loadAll(); el('ls').style.display='none'; el('app').style.display='flex'; const cfg=pageCfg[page]; if(cfg&&cfg.btn){el('tr').innerHTML='';el('mb').onclick=cfg.btn.fn} render(); }catch(e){ el('ls').innerHTML='
⚠️
Gagal Terhubung
'+e.message+'
'; } })(); // Service Worker if('serviceWorker' in navigator){ window.addEventListener('load',()=>{ navigator.serviceWorker.register('/sw.js?v=10').catch(()=>{}); }); }