XV

Klinik Sehat Harmoni

Memuat aplikasi...

`; } function printMedicalRecord(recordIndex) { const data = state.medicalRecordData || {}; const record = arr(data.records)[recordIndex]; if (!record) { toast('Data rekam medis belum siap untuk dicetak.', false); return; } const html = buildMedicalRecordPrintHtml(record); const win = window.open('', '_blank', 'width=900,height=700'); if (!win) { toast('Popup cetak diblokir browser. Izinkan popup untuk aplikasi ini.', false); return; } win.document.open(); win.document.write(html); win.document.close(); win.focus(); setTimeout(() => { win.print(); }, 500); } function renderClients() { const rows = arr(state.adminClients && state.adminClients.rows); const editId = state.edit.client; const e = editId ? byId(rows, editId) : {}; const keyword = state.adminClients ? (state.adminClients.keyword || '') : ''; const searched = !!(state.adminClients && state.adminClients.searched); const editForm = editId ? ` ${formWrap('clientForm', ` ${input('name','Nama Klien','text',e.name || '')} ${input('birth_date','Tanggal Lahir','date',e.birth_date || '')} ${select('gender','Jenis Kelamin',[ {value:'',label:'-'}, {value:'Laki-laki',label:'Laki-laki'}, {value:'Perempuan',label:'Perempuan'} ], e.gender || '')} ${input('phone','Telepon','text',e.phone || '')} ${input('address','Alamat','text',e.address || '')} ${input('notes','Catatan','text',e.notes || '')}
`, 'Update Data Klien' )} ` : `

Master Data Klien

Menu ini khusus untuk mencari dan mengubah data master klien. Tambah klien baru hanya melalui menu Pendaftaran agar otomatis dibuatkan kunjungan.

`; return ` ${editForm}

Agar aplikasi ringan, menu Klien tidak memuat semua klien otomatis. Cari klien lama lalu klik Edit untuk mengubah data.

${ rows.length ? table(['Kode','Nama','Telepon','Gender','Aksi'], rows.map(c => [ esc(c.client_code), esc(c.name), esc(c.phone), esc(c.gender), `` ])) : empty(searched ? 'Klien tidak ditemukan.' : 'Belum ada data ditampilkan. Silakan cari klien.') }
`; } async function adminSearchClients() { const inputEl = $('adminClientKeyword'); const keyword = inputEl ? inputEl.value.trim() : ''; state.adminClients.keyword = keyword; state.adminClients.searched = true; if (keyword.length < 2) { state.adminClients.rows = []; renderShell(); toast('Ketik minimal 2 karakter untuk mencari klien.', false); return; } const res = await call('searchClientsAdmin', keyword, 30); if (res && res.success) { state.adminClients.rows = arr(res.data); renderShell(); } } function adminClearClients() { state.adminClients.keyword = ''; state.adminClients.rows = []; state.adminClients.searched = false; state.edit.client = null; renderShell(); } function renderNakes() { const allowedRoles = ['nakes','terapis','dokter','akupunkturis','akupunktur','sinshe','tcm','praktisi','asisten terapis']; const excludedRoles = ['kasir','frontliner','front line','front-line','admin','administrator','resepsionis','reception']; const rows = arr(state.data.nakes) .filter(n => String(n.status || 'Aktif') === 'Aktif') .filter(n => !n.is_deleted) .filter(n => { const role = String(n.role || '').trim().toLowerCase(); const name = String(n.name || '').trim().toLowerCase(); if (excludedRoles.includes(role)) return false; if (excludedRoles.some(x => name === x || name.startsWith(x + ' '))) return false; if (!role) return true; return allowedRoles.some(x => role.includes(x)); }); const editId = state.edit.nakes; const e = editId ? byId(arr(state.data.nakes), editId) : {}; return ` ${formWrap('nakesForm', ` ${input('name','Nama Nakes','text',e.name || '')} ${input('phone','Telepon','text',e.phone || '')} ${input('competencies','Kompetensi','text',e.competencies || '')} ${input('base_salary','Gaji Bulanan','number',e.base_salary || 2000000)} ${input('allowance_per_attendance','Tunjangan/Hadir','number',e.allowance_per_attendance || 80000)} ${input('default_commission','Komisi Default','number',e.default_commission || 30000)} ${select('role','Role',[ {value:'Nakes',label:'Nakes'}, {value:'Terapis',label:'Terapis'}, {value:'Dokter',label:'Dokter'}, {value:'Akupunkturis',label:'Akupunkturis'}, {value:'Sinshe',label:'Sinshe'} ], e.role || 'Nakes')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')} ${ editId ? `
` : '' } `, editId ? 'Update Nakes' : 'Simpan' )}
Menu ini hanya menampilkan tenaga klinis. Kasir, Frontliner, Admin, dan data yang dinonaktifkan tidak ditampilkan di daftar Nakes.
${ rows.length ? table(['Nama','Role','Telepon','Kompetensi','Gaji','Tunjangan','Status','Aksi'], rows.map(n => [ esc(n.name), esc(n.role || 'Nakes'), esc(n.phone), esc(n.competencies), rupiah(n.base_salary), rupiah(n.allowance_per_attendance), esc(n.status), actionButtons('nakes', n.id, 'deleteNakes') ])) : empty('Belum ada data nakes aktif.') } `; } function renderServices() { const rows = arr(state.data.services).filter(s => !s.is_deleted); const editId = state.edit.service; const e = editId ? byId(rows, editId) : {}; return ` ${formWrap('serviceForm', ` ${input('name','Nama Tindakan','text',e.name || e.service_name || '')} ${input('price','Tarif','number',e.price || e.service_price || '')} ${input('commission','Komisi Nakes','number',e.commission || e.commission_amount || '')} ${input('duration','Durasi Menit','number',e.duration || e.duration_minutes || '')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')} ${ editId ? `
` : '' } `, editId ? 'Update Tindakan' : 'Simpan' )} ${ rows.length ? table(['Tindakan','Tarif','Komisi','Durasi','Status','Aksi'], rows.map(s => [ esc(s.name || s.service_name), rupiah(s.price || s.service_price), rupiah(s.commission || s.commission_amount), esc(s.duration || s.duration_minutes || 0) + ' menit', esc(s.status), actionButtons('service', s.id, 'deleteService') ])) : empty('Belum ada data tindakan.') } `; } function renderBhp() { const rows = bhpRows(); const editId = state.edit.bhp; const e = editId ? byId(rows, editId) : {}; return ` ${formWrap('bhpForm', ` ${input('name','Nama BHP','text',e.name || '')} ${input('unit','Satuan','text',e.unit || 'pcs')} ${input('current_stock','Stok Saat Ini','number',e.current_stock || '')} ${input('minimum_stock','Minimum Stok','number',e.minimum_stock || '')} ${input('cost_price','Harga Modal Opsional','number',e.cost_price || '')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')} ${ editId ? `
` : '' } `, editId ? 'Update BHP' : 'Simpan' )} ${ rows.length ? table(['BHP','Stok','Minimum','Status','Aksi'], rows.map(b => [ esc(b.name), `${esc(b.current_stock)} ${esc(b.unit)}`, esc(b.minimum_stock), esc(b.status), actionButtons('bhp', b.id, 'deleteBhpItem') ])) : empty('Belum ada data BHP.') } `; } function renderUsers() { const rows = arr(state.data.users); const editId = state.edit.user; const e = editId ? byId(rows, editId) : {}; return ` ${formWrap('userForm', ` ${input('name','Nama Pengguna','text',e.name || '')} ${input('username','Username','text',e.username || '')} ${input('password', editId ? 'Password Baru, kosongkan jika tidak diganti' : 'Password','password','')} ${select('role','Role',[ {value:'Admin',label:'Admin'}, {value:'Kasir',label:'Kasir'}, {value:'Frontliner',label:'Frontliner'}, {value:'Nakes',label:'Nakes'} ], e.role || 'Kasir')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')} ${ editId ? `
` : '' } `, editId ? 'Update Pengguna' : 'Simpan' )} ${ rows.length ? table(['Nama','Username','Role','Status','Aksi'], rows.map(u => [ esc(u.name), esc(u.username), esc(u.role), esc(u.status), actionButtons('user', u.id, 'deleteUser') ])) : empty('Belum ada pengguna.') } `; } function renderAttendance() { const allRows = arr(state.data.attendance); const editId = state.edit.attendance; const e = editId ? byId(allRows, editId) : {}; const currentIdentity = currentAttendanceIdentity(); const isAdmin = state.user && state.user.role === 'Admin'; const lockedDate = e.attendance_date || today(); const todayAttendance = currentIdentity.id ? allRows.find(a => String(a.nakes_id) === String(currentIdentity.id) && String(a.attendance_date).slice(0, 10) === today()) : null; const statusDefault = e.status || (todayAttendance ? todayAttendance.status : 'Tidak Hadir'); const nakesOptions = arr(state.data.nakes).map(n => ({ value: n.id, label: n.name })); const nakesField = !isAdmin ? ` ` : select('nakes_id','Nakes / Petugas',nakesOptions,e.nakes_id || ''); const dateField = ` `; const allowanceField = isAdmin ? input('allowance_amount','Tunjangan','number',e.allowance_amount || 80000) : ''; const rows = isAdmin ? allRows : allRows.filter(a => currentIdentity.id && String(a.nakes_id) === String(currentIdentity.id)); const tableHeaders = isAdmin ? ['Tanggal','Nakes','Status','Masuk','Pulang','Tunjangan','Catatan','Aksi'] : ['Tanggal','Nakes','Status','Masuk','Pulang','Catatan']; const tableRows = rows.map(a => { const base = [ esc(a.attendance_date), esc(attendanceName(a.nakes_id) || ''), esc(a.status), esc(formatTime(a.check_in_at)), esc(formatTime(a.check_out_at)) ]; if (isAdmin) base.push(rupiah(a.allowance_amount)); base.push(esc(a.notes)); if (isAdmin) { base.push(actionButtons('attendance', a.id, 'deleteAttendance')); } return base; }); return ` ${formWrap('attendanceForm', ` ${nakesField} ${dateField} ${select('status','Status',[ {value:'Tidak Hadir',label:'Tidak Hadir'}, {value:'Hadir',label:'Hadir'}, {value:'Pulang',label:'Pulang'} ], statusDefault)} ${allowanceField} ${input('notes','Catatan','text',e.notes || '')} ${ editId && isAdmin ? `
` : '' } `, editId ? 'Update Kehadiran' : 'Simpan' )} ${ rows.length ? table(tableHeaders, tableRows) : empty('Belum ada data kehadiran.') } `; } function formatTime(value) { if (!value) return '-'; const d = new Date(value); if (isNaN(d.getTime())) return '-'; return d.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' }); } function actionButtons(type, id, deleteFn) { return `
`; } function editData(type, id) { state.edit[type] = id; renderShell(); setTimeout(() => window.scrollTo({ top: 0, behavior: 'smooth' }), 100); } function cancelEdit(type) { state.edit[type] = null; renderShell(); } async function submitForm(e, id) { e.preventDefault(); const data = fd(e.target); const map = { clientForm: 'saveClient', nakesForm: 'saveNakes', serviceForm: 'saveService', bhpForm: 'saveBhpItem', userForm: 'saveUser', attendanceForm: 'saveAttendance', settingsForm: 'saveSettings' }; const editMap = { clientForm: 'client', nakesForm: 'nakes', serviceForm: 'service', bhpForm: 'bhp', userForm: 'user', attendanceForm: 'attendance' }; const fn = map[id]; if (!fn) { toast('Form tidak dikenali: ' + id, false); return; } const res = await call(fn, data, state.user.id); if (res.success) { toast(res.message || 'Data berhasil disimpan'); const editKey = editMap[id]; if (editKey) state.edit[editKey] = null; e.target.reset(); if (id === 'clientForm') { if (data.id && state.adminClients && Array.isArray(state.adminClients.rows)) { const idx = state.adminClients.rows.findIndex(c => String(c.id) === String(data.id)); if (idx >= 0) state.adminClients.rows[idx] = Object.assign({}, state.adminClients.rows[idx], data); } else { state.adminClients.rows = []; state.adminClients.searched = false; } renderShell(); } else if (id === 'attendanceForm') { await loadWorkflowData(true, { silent: true, force: true }); } else { await loadData(); if (id === 'settingsForm') { await loadPublicSettings(); } } } } async function del(fn, id) { if (!confirm('Yakin ingin menghapus data ini?')) return; const map = { deleteClient: 'Clients', deleteNakes: 'Nakes', deleteService: 'Services', deleteBhpItem: 'BHP_Items', deleteUser: 'Users', deleteAttendance: 'Attendance' }; const sheetName = map[fn]; if (!sheetName) { toast('Fungsi hapus tidak dikenali: ' + fn, false); return; } const res = await call('removeData', sheetName, id, state.user.id); if (res.success) { toast(res.message || 'Data berhasil dihapus'); if (fn === 'deleteClient') { if (state.adminClients && Array.isArray(state.adminClients.rows)) { state.adminClients.rows = state.adminClients.rows.filter(c => String(c.id) !== String(id)); } renderShell(); } else { await loadData(); } } } function renderReports() { return `
${input('reportDate','Tanggal Laporan Harian','date',today())}
${input('reportMonth','Bulan Laporan','month',ym())}
${empty('Pilih laporan untuk ditampilkan.')}
`; } window.dailyReport = async function dailyReport() { try { const resultEl = $('reportResult'); if (resultEl) { resultEl.innerHTML = '
Memuat laporan harian...
'; } let date = $('reportDate') ? $('reportDate').value : ''; if (!date) date = today(); const res = await call('getReportDaily', date); if (res && res.success && res.data) { renderReportResult(res.data); } else if (resultEl) { resultEl.innerHTML = '
' + esc((res && res.message) || 'Laporan harian gagal dimuat.') + '
'; } } catch (err) { toast((err && err.message) ? err.message : String(err), false); } }; window.closeDaily = async function closeDaily() { const date = $('reportDate') ? $('reportDate').value : today(); const notes = $('closingNotes') ? $('closingNotes').value : ''; if (!date) { toast('Tanggal laporan harian wajib diisi.', false); return; } if (!confirm('Klosing harian tanggal ' + date + '? Setelah diklosing, tanggal ini akan tercatat sebagai tutup harian.')) { return; } const res = await call('closeDailyReport', date, state.user.id, notes); if (res.success) { toast(res.message || 'Klosing harian berhasil.'); renderReportResult(res.data); } else if (res.data && res.data.report) { renderReportResult(res.data.report); } }; window.monthlyReport = async function monthlyReport() { try { const resultEl = $('reportResult'); if (resultEl) { resultEl.innerHTML = '
Memuat laporan bulanan...
'; } const value = $('reportMonth') ? $('reportMonth').value : ym(); const parts = String(value || ym()).split('-'); const res = await call('getReportMonthly', parts[0], parts[1]); if (res && res.success && res.data) { renderReportResult(res.data); } else if (resultEl) { resultEl.innerHTML = '
' + esc((res && res.message) || 'Laporan bulanan gagal dimuat.') + '
'; } } catch (err) { toast((err && err.message) ? err.message : String(err), false); } }; function renderClosingInfo(r) { r = r || {}; const closing = r.closing || null; const openVisits = arr(r.open_visits); if (!r.period || !r.period.is_daily) return ''; if (closing && closing.id) { return `
Sudah Klosing Harian
Tanggal: ${esc(closing.closing_date)} • Waktu: ${esc(closing.closed_at || '-')} • Total: ${rupiah(closing.total_revenue)}
${closing.notes ? `
Catatan: ${esc(closing.notes)}
` : ''}
`; } if (openVisits.length) { return `
Belum Bisa Klosing
Masih ada ${openVisits.length} kunjungan yang belum selesai/terbayar.
${table(['No Visit','Klien','Status','Pembayaran'], openVisits.map(v => [ esc(v.visit_number), esc(v.client_name || v.client_id), esc(v.visit_status), esc(v.payment_status) ]))}
`; } return `
Data harian siap diklosing. Klik tombol Klosing di atas untuk menutup laporan harian.
`; } function renderReportResult(r) { r = r || {}; r.summary = r.summary || {}; r.by_service = arr(r.by_service); r.by_nakes = arr(r.by_nakes); r.by_payment = arr(r.by_payment); r.bhp_usage = arr(r.bhp_usage); r.transactions = arr(r.transactions); r.transactions_paid = arr(r.transactions_paid); r.transactions_package = arr(r.transactions_package); r.transactions_mixed = arr(r.transactions_mixed); r.transactions_canceled = arr(r.transactions_canceled); const reportEl = $('reportResult'); if (!reportEl) return; const txTable = function(title, description, rows, emptyText) { return `

${title}

${description ? `

${description}

` : ''}
${ rows.length ? table(['Invoice','Visit','Tanggal','Klien','Total','Status'], rows.map(t => [ esc(t.invoice_number), esc(t.visit_number), esc(t.transaction_date), esc(t.client_name), rupiah(t.total_amount), esc(t.report_payment_status || t.payment_status) ])) : empty(emptyText || 'Tidak ada data.') }
`; }; reportEl.innerHTML = `
${card('Kedatangan', r.summary.visits || 0, 'Users')} ${card('Sukses Berbayar', r.summary.paid_transactions || 0, 'Receipt')} ${card('Paket', r.summary.package_transactions || 0, 'Ticket')} ${card('Campuran', r.summary.mixed_transactions || 0, 'SplitSquareHorizontal')} ${card('Batal', r.summary.canceled_transactions || 0, 'XCircle')} ${card('Omset Uang Masuk', rupiah(r.summary.revenue || 0), 'Banknote')} ${card('Belum Selesai', r.summary.open_visit_count || 0, 'Clock')}
${renderClosingInfo(r)}
${table(['Tindakan Sukses','Jumlah'], arr(r.by_service).map(x => [esc(x.name), x.value]))}
${table(['Nakes','Jumlah Tindakan Sukses'], arr(r.by_nakes).map(x => [esc(x.name), x.value]))}
${table(['Metode Pembayaran Uang Masuk','Total'], arr(r.by_payment).map(x => [esc(x.name), rupiah(x.value)]))}
${table(['BHP Terpakai Transaksi Sukses','Jumlah'], arr(r.bhp_usage).map(x => [esc(x.name), x.value]))}
${txTable('Sukses Berbayar', 'Transaksi yang menghasilkan uang masuk. Masuk ke omset.', r.transactions_paid, 'Tidak ada transaksi berbayar.')} ${txTable('Paket', 'Tindakan ditutup dengan potong token paket. Tidak masuk omset uang.', r.transactions_package, 'Tidak ada transaksi paket.')} ${txTable('Campuran', 'Sebagian tindakan memakai paket dan sebagian bayar normal. Hanya uang masuk yang dihitung omset.', r.transactions_mixed, 'Tidak ada transaksi campuran.')} ${txTable('Batal / Dibatalkan', 'Tidak masuk omset, komisi, maupun jumlah tindakan sukses. Tetap ditampilkan untuk audit.', r.transactions_canceled, 'Tidak ada transaksi batal.')} `; if (window.lucide) lucide.createIcons(); } function renderPayroll() { return `
${input('payrollMonth','Periode','month',ym())}
${empty('Klik Hitung THP untuk melihat payroll.')}
`; } async function calcPayroll() { const period = $('payrollMonth').value; const res = await call('getPayroll', period, state.user); if (res.success) { state.payrollRows = res.data || []; $('payrollResult').innerHTML = table( ['Nakes','Gaji','Hari Hadir','Tunjangan','Komisi','Jumlah Tindakan','THP'], state.payrollRows.map(p => [ esc(p.nakes_name), rupiah(p.base_salary), p.attendance_days, rupiah(p.attendance_allowance), rupiah(p.total_commission), p.total_actions || p.action_count || 0, rupiah(p.take_home_pay) ]) ); } } function renderSettings() { const s = state.data.settings || {}; return ` ${formWrap('settingsForm', ` ${input('clinic_name','Nama Klinik','text',s.clinic_name || '')} ${input('clinic_slogan','Slogan Klinik','text',s.clinic_slogan || 'Selogan Jarum Dewa Song')} ${input('clinic_address','Alamat Klinik','text',s.clinic_address || '')} ${input('clinic_phone','Telepon Klinik','text',s.clinic_phone || '')} ${input('clinic_logo_url','URL Logo Publik / Google Drive','url',s.clinic_logo_url || '')} ${input('default_doctor_name','Nama Dokter Default','text',s.default_doctor_name || 'David Sungahandra')} ${select('allow_stock_override','Izinkan Transaksi Saat Stok Kurang',[ {value:'false',label:'Tidak'}, {value:'true',label:'Ya'} ], s.allow_stock_override || 'false')} ${select('nakes_payroll_access','Nakes Boleh Melihat THP',[ {value:'false',label:'Tidak, batasi akses nakes'}, {value:'true',label:'Ya, tampilkan menu THP untuk nakes'} ], s.nakes_payroll_access || 'false')} `, 'Simpan Pengaturan' )}
Catatan:
Alur sistem: Daftar → Pemeriksaan/Tindakan → Siap Bayar → Selesai. Status bayar: Belum Bayar → Lunas.
`; } function renderBackup() { return `

Backup Database

Unduh seluruh data Google Sheets dalam format JSON sebagai cadangan.

Hapus Data Test / Dummy

Khusus Admin. Masukkan nama, ID klien, atau nomor telepon data test. Sistem akan menghapus data terkait dan mengembalikan stok BHP yang pernah terpakai.

Catatan: Jika data sudah masuk klosing, sistem akan membuka ulang klosing tanggal terkait agar Bapak bisa melakukan klosing ulang dengan angka yang benar.
`; } async function adminDeleteTestData() { const keywordEl = $('dummyDeleteKeyword'); const resultEl = $('dummyDeleteResult'); const keyword = keywordEl ? keywordEl.value.trim() : ''; if (keyword.length < 3) { toast('Masukkan minimal 3 karakter kata kunci.', false); return; } const confirmText = `Yakin hapus seluruh data test/dummy terkait "${keyword}"? Data kunjungan, transaksi, rekam medis, paket, dan BHP terkait akan dihapus.`; if (!confirm(confirmText)) return; if (resultEl) { resultEl.innerHTML = '
Memproses penghapusan data test/dummy...
'; } const res = await call('adminDeleteTestClientData', keyword, state.user.id); if (!res.success) { if (resultEl) { resultEl.innerHTML = `
${esc(res.message || 'Gagal menghapus data.')}
`; } return; } const data = res.data || {}; const deleted = data.deleted || {}; const restored = data.restored_stock || {}; const deletedClients = arr(data.deleted_clients).map(c => `${c.name || ''} (${c.client_code || c.id || ''})`).join(', '); const restoredRows = Object.keys(restored).map(k => `
  • ${esc(k)}: +${esc(restored[k])}
  • `).join(''); if (resultEl) { resultEl.innerHTML = `
    Berhasil dihapus
    Klien: ${esc(deletedClients || '-')}
    Clients: ${deleted.clients || 0}
    Visits: ${deleted.visits || 0}
    Transactions: ${deleted.transactions || 0}
    Transaction Items: ${deleted.transaction_items || 0}
    Medical Notes: ${deleted.medical_notes || 0}
    BHP Usage: ${deleted.bhp_usage || 0}
    Package Usage: ${deleted.package_usages || 0}
    Daily Closings dibuka ulang: ${deleted.daily_closings || 0}
    ${ restoredRows ? `
    Stok dikembalikan:
    ` : '' }
    `; } await loadData(); } async function backup() { const res = await call('exportBackup'); if (!res.success) return; const blob = new Blob([res.data.content], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = res.data.filename; a.click(); URL.revokeObjectURL(a.href); } function showFatalError(err) { console.error(err); document.getElementById('app').innerHTML = `

    Aplikasi gagal dimuat

    Ada error JavaScript di halaman.

    ${String(err.message || err)}
    `; } /* ========================================================= * OVERRIDE v35 — fast master service, dashboard count, payroll labels * ========================================================= */ function mergeSmallPageDataV16(res) { const data = res && res.data ? res.data : {}; const pd = data.pageData || res.pageData || {}; if (!pd || typeof pd !== 'object') return false; Object.keys(pd).forEach(k => { state.data[k] = pd[k]; }); return true; } async function submitForm(e, id) { e.preventDefault(); const data = fd(e.target); const map = { clientForm: 'saveClient', nakesForm: 'saveNakes', serviceForm: 'saveService', bhpForm: 'saveBhpItem', userForm: 'saveUser', attendanceForm: 'saveAttendance', settingsForm: 'saveSettings' }; const editMap = { clientForm: 'client', nakesForm: 'nakes', serviceForm: 'service', bhpForm: 'bhp', userForm: 'user', attendanceForm: 'attendance' }; const fn = map[id]; if (!fn) { toast('Form tidak dikenali: ' + id, false); return; } const res = await call(fn, data, state.user.id); if (res.success) { toast(res.message || 'Data berhasil disimpan'); const editKey = editMap[id]; if (editKey) state.edit[editKey] = null; e.target.reset(); if (id === 'serviceForm') { if (!mergeSmallPageDataV16(res)) { const row = res.data && (res.data.row || res.data.saved); if (row && row.id) { const rows = arr(state.data.services).filter(x => String(x.id) !== String(row.id)); state.data.services = rows.concat([row]); } } renderShell(); return; } if (id === 'nakesForm' || id === 'userForm') { mergeSmallPageDataV16(res); renderShell(); return; } if (id === 'clientForm') { if (data.id && state.adminClients && Array.isArray(state.adminClients.rows)) { const idx = state.adminClients.rows.findIndex(c => String(c.id) === String(data.id)); if (idx >= 0) state.adminClients.rows[idx] = Object.assign({}, state.adminClients.rows[idx], data); } else { state.adminClients.rows = []; state.adminClients.searched = false; } renderShell(); return; } if (id === 'attendanceForm') { await loadWorkflowData(true, { silent: true, force: true }); return; } await loadData(); if (id === 'settingsForm') await loadPublicSettings(); } } async function del(fn, id) { if (!confirm('Yakin ingin menghapus/menonaktifkan data ini?')) return; const map = { deleteClient: 'Clients', deleteNakes: 'Nakes', deleteService: 'Services', deleteBhpItem: 'BHP_Items', deleteUser: 'Users', deleteAttendance: 'Attendance' }; const sheetName = map[fn]; if (!sheetName) { toast('Fungsi hapus tidak dikenali: ' + fn, false); return; } const res = await call('removeData', sheetName, id, state.user.id); if (res.success) { toast(res.message || 'Data berhasil dihapus/dinonaktifkan'); if (fn === 'deleteService') { if (!mergeSmallPageDataV16(res)) { state.data.services = arr(state.data.services).map(s => { if (String(s.id) === String(id)) return Object.assign({}, s, { status: 'Nonaktif', is_deleted: true }); return s; }); } renderShell(); return; } if (fn === 'deleteNakes') { if (!mergeSmallPageDataV16(res)) { state.data.nakes = arr(state.data.nakes).map(n => { if (String(n.id) === String(id)) return Object.assign({}, n, { status: 'Nonaktif', is_deleted: true }); return n; }); } renderShell(); return; } if (fn === 'deleteClient') { if (state.adminClients && Array.isArray(state.adminClients.rows)) { state.adminClients.rows = state.adminClients.rows.filter(c => String(c.id) !== String(id)); } renderShell(); return; } await loadData(); } } function dashboardCardsHtml(d) { d = d || {}; return [ card('Kunjungan Hari Ini', d.visits_today || 0, 'Users', d.today_date || today()), card('Antrian Pasien', d.waiting_nakes || 0, 'ClipboardPlus'), card('Siap Bayar', d.waiting_payment || 0, 'BadgeDollarSign'), card('Omset Hari Ini', rupiah(d.today_revenue || 0), 'Banknote'), card('Omset Bulan Ini', rupiah(d.month_revenue || 0), 'TrendingUp') ].join(''); } function renderServices() { const rows = arr(state.data.services).filter(s => !s.is_deleted); const editId = state.edit.service; const e = editId ? byId(rows, editId) : {}; return ` ${formWrap('serviceForm', ` ${input('name','Nama Tindakan','text',e.name || e.service_name || '')} ${input('price','Tarif','number',Number(e.price || e.service_price || 0) || '')} ${input('commission','Komisi Nakes','number',Number(e.commission || e.commission_amount || 0) || '')} ${input('duration','Durasi Menit','number',Number(e.duration || e.duration_minutes || 0) || '')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')} ${ editId ? `
    ` : '' } `, editId ? 'Update Tindakan' : 'Simpan' )} ${ rows.length ? table(['Tindakan','Tarif','Komisi','Durasi','Status','Aksi'], rows.map(s => [ esc(s.name || s.service_name), rupiah(Number(s.price || s.service_price || 0)), rupiah(Number(s.commission || s.commission_amount || 0)), esc(s.duration || s.duration_minutes || 0) + ' menit', esc(s.status), actionButtons('service', s.id, 'deleteService') ])) : empty('Belum ada data tindakan.') } `; } function renderDashboard() { const d = state.data.dashboard || {}; const showPaymentQueue = state.user && ['Admin', 'Kasir'].includes(state.user.role); const queue = arr(state.data.visits).filter(v => v.payment_status === 'Siap Bayar' || (v.visit_status === 'Selesai Tindakan' && v.payment_status !== 'Lunas') ); if (d) d.waiting_payment = queue.length; return `
    ${dashboardCardsHtml(d)}

    Antrian Pasien

    ${renderMiniVisitTable(arr(state.data.visits).filter(v => ['Daftar','Pemeriksaan','Tindakan','Antri Pemeriksaan','Masuk Ruang Periksa'].includes(v.visit_status)))}
    ${ showPaymentQueue ? `

    Menunggu Pembayaran

    ${renderMiniVisitTable(queue)}
    ` : '' }
    `; } async function calcPayroll() { const period = $('payrollMonth').value; const res = await call('getPayroll', period, state.user); if (res.success) { state.payrollRows = res.data || []; $('payrollResult').innerHTML = table( ['Nakes','Gaji','Hari Hadir','Tunjangan','Komisi','Jumlah Tindakan','THP'], state.payrollRows.map(p => [ esc(p.nakes_name || p.staff_name || '-'), rupiah(p.base_salary || p.gaji_bulanan || 0), p.attendance_days || p.hari_hadir || 0, rupiah(p.attendance_allowance || p.tunjangan_makan_transport || 0), rupiah(p.total_commission || p.total_komisi || 0), p.total_actions || p.action_count || 0, rupiah(p.take_home_pay || 0) ]) ); } } /* ========================================================= * OVERRIDE v35 — payment display price from service master * ========================================================= */ function getPaymentItemPriceV17(item) { item = item || {}; const service = byId(state.data.services, item.service_id); const servicePrice = Number( Number(service.price || 0) > 0 ? service.price : Number(service.service_price || 0) > 0 ? service.service_price : 0 ) || 0; const itemPrice = Number( Number(item.unit_price || 0) > 0 ? item.unit_price : Number(item.price || 0) > 0 ? item.price : Number(item.service_price || 0) > 0 ? item.service_price : 0 ) || 0; return itemPrice > 0 ? itemPrice : servicePrice; } function paymentNormalTotalV17(visitId) { return paymentVisitItems(visitId).reduce((sum, item) => { const qty = Number(item.qty || item.quantity || 1) || 1; return sum + getPaymentItemPriceV17(item) * qty; }, 0); } function renderPaymentActionSelect(visit, item, idx) { const service = byId(state.data.services, item.service_id); const serviceName = service.name || item.service_name || item.service_id || 'Tindakan'; const normalPrice = getPaymentItemPriceV17(item); const balances = activeBalancesForService(visit.client_id, item.service_id); const modeId = `linepay_mode_${visit.id}_${item.id}`; const balanceId = `linepay_balance_${visit.id}_${item.id}`; const balanceWrapId = `linepay_balance_wrap_${visit.id}_${item.id}`; return `
    ${esc(serviceName)} — ${rupiah(normalPrice)}
    ${ balances.length ? '' : `
    Tidak ada paket aktif untuk ${esc(serviceName)}.
    ` }
    `; } function renderPaymentEstimate(visitId) { const items = paymentVisitItems(visitId); let normalTotal = 0; let coveredCount = 0; let unselectedCount = 0; items.forEach(it => { const mode = document.getElementById(`linepay_mode_${visitId}_${it.id}`)?.value || ''; const normalPrice = getPaymentItemPriceV17(it); if (mode === 'package') { coveredCount++; } else if (mode === 'normal') { normalTotal += normalPrice; } else { unselectedCount++; } }); const methodBlock = document.getElementById('payment_method_block_' + visitId); if (methodBlock) { methodBlock.classList.toggle('hidden', normalTotal <= 0); } const el = document.getElementById('payment_estimate_' + visitId); if (el) { el.innerHTML = `
    Total bayar: ${rupiah(normalTotal)}
    Bayar normal: ${rupiah(normalTotal)}
    Pakai paket: ${coveredCount} tindakan
    ${unselectedCount ? `Belum dipilih: ${unselectedCount} tindakan` : ''}
    `; } } function renderPayments() { const todayDate = today(); const waiting = arr(state.data.visits).filter(v => v.payment_status === 'Siap Bayar' || (v.visit_status === 'Selesai Tindakan' && v.payment_status !== 'Lunas')); const paid = arr(state.data.visits) .filter(v => ['Lunas','Terbayar'].includes(v.payment_status) && String(v.transaction_date || '').slice(0, 10) === todayDate) .slice() .reverse(); const activeVisit = state.activePaymentVisitId ? byId(state.data.visits, state.activePaymentVisitId) : {}; return `

    Daftar Pasien Siap Bayar

    ${ waiting.length ? table(['No Visit','Klien','Ruang','Tanggal','Tagihan Normal','Aksi'], waiting.map(v => [ esc(v.visit_number), esc(v.client_name), esc(v.room_name || '-'), esc(v.visit_date), rupiah(paymentNormalTotalV17(v.id)), `` ])) : empty('Tidak ada pasien menunggu pembayaran.') }
    ${activeVisit.id ? renderPaymentDetail(activeVisit) : ''} ${state.lastInvoice ? renderInvoice(state.lastInvoice) : ''}

    Riwayat Lunas Hari Ini

    Hanya transaksi lunas tanggal hari ini agar menu pembayaran tetap ringan.

    ${ paid.length ? table(['No Visit','Klien','Invoice','Total','Status','Aksi'], paid.map(v => [ esc(v.visit_number), esc(v.client_name), esc(v.invoice_number), rupiah(v.total_amount), esc(v.payment_status), `` ])) : empty('Belum ada pembayaran lunas hari ini.') }
    `; } /* ========================================================= * OVERRIDE v35 — UI price from actual transaction_items schema * ========================================================= */ function getPaymentItemPriceV18(item) { item = item || {}; const service = byId(state.data.services, item.service_id); const itemPrice = Number( Number(item.service_price || 0) > 0 ? item.service_price : Number(item.normal_amount || 0) > 0 ? item.normal_amount : Number(item.paid_amount || 0) > 0 ? item.paid_amount : 0 ) || 0; const servicePrice = Number( Number(service.price || 0) > 0 ? service.price : Number(service.service_price || 0) > 0 ? service.service_price : 0 ) || 0; return itemPrice > 0 ? itemPrice : servicePrice; } function paymentNormalTotalV17(visitId) { return paymentVisitItems(visitId).reduce((sum, item) => { const qty = Number(item.qty || 1) || 1; return sum + getPaymentItemPriceV18(item) * qty; }, 0); } function getPaymentItemPriceV17(item) { return getPaymentItemPriceV18(item); } function renderPaymentActionSelect(visit, item, idx) { const service = byId(state.data.services, item.service_id); const serviceName = service.name || item.service_name || item.service_id || 'Tindakan'; const normalPrice = getPaymentItemPriceV18(item); const balances = activeBalancesForService(visit.client_id, item.service_id); const modeId = `linepay_mode_${visit.id}_${item.id}`; const balanceId = `linepay_balance_${visit.id}_${item.id}`; const balanceWrapId = `linepay_balance_wrap_${visit.id}_${item.id}`; return `
    ${esc(serviceName)} — ${rupiah(normalPrice)}
    ${ balances.length ? '' : `
    Tidak ada paket aktif untuk ${esc(serviceName)}.
    ` }
    `; } function renderPaymentEstimate(visitId) { const items = paymentVisitItems(visitId); let normalTotal = 0; let coveredCount = 0; let unselectedCount = 0; items.forEach(it => { const mode = document.getElementById(`linepay_mode_${visitId}_${it.id}`)?.value || ''; const normalPrice = getPaymentItemPriceV18(it); if (mode === 'package') { coveredCount++; } else if (mode === 'normal') { normalTotal += normalPrice; } else { unselectedCount++; } }); const methodBlock = document.getElementById('payment_method_block_' + visitId); if (methodBlock) { methodBlock.classList.toggle('hidden', normalTotal <= 0); } const el = document.getElementById('payment_estimate_' + visitId); if (el) { el.innerHTML = `
    Total bayar: ${rupiah(normalTotal)}
    Bayar normal: ${rupiah(normalTotal)}
    Pakai paket: ${coveredCount} tindakan
    ${unselectedCount ? `Belum dipilih: ${unselectedCount} tindakan` : ''}
    `; } } /* ========================================================= * OVERRIDE v35 — payment UI actual schema alias * ========================================================= */ function getPaymentItemPriceV19(item) { item = item || {}; const service = byId(state.data.services, item.service_id); const itemPrice = Number( Number(item.service_price || 0) > 0 ? item.service_price : Number(item.normal_amount || 0) > 0 ? item.normal_amount : Number(item.paid_amount || 0) > 0 ? item.paid_amount : 0 ) || 0; const servicePrice = Number( Number(service.price || 0) > 0 ? service.price : Number(service.service_price || 0) > 0 ? service.service_price : 0 ) || 0; return itemPrice > 0 ? itemPrice : servicePrice; } function getPaymentItemPriceV18(item) { return getPaymentItemPriceV19(item); } function getPaymentItemPriceV17(item) { return getPaymentItemPriceV19(item); } function paymentNormalTotalV17(visitId) { return paymentVisitItems(visitId).reduce((sum, item) => { const qty = Number(item.qty || 1) || 1; return sum + getPaymentItemPriceV19(item) * qty; }, 0); } /* ========================================================= * OVERRIDE v35 — BHP UI field mapping * ========================================================= */ function bhpRows() { const rows = Array.isArray(state.data.bhpItems) ? state.data.bhpItems : Array.isArray(state.data.bhp) ? state.data.bhp : []; return rows.map(b => { const stock = Number( b.stock_qty !== undefined && b.stock_qty !== null && b.stock_qty !== '' ? b.stock_qty : b.current_stock !== undefined && b.current_stock !== null && b.current_stock !== '' ? b.current_stock : 0 ) || 0; const minStock = Number( b.min_stock !== undefined && b.min_stock !== null && b.min_stock !== '' ? b.min_stock : b.minimum_stock !== undefined && b.minimum_stock !== null && b.minimum_stock !== '' ? b.minimum_stock : 0 ) || 0; return Object.assign({}, b, { name: b.name || b.item_name || '', item_name: b.item_name || b.name || '', current_stock: stock, stock_qty: stock, minimum_stock: minStock, min_stock: minStock, cost_price: Number(b.cost_price || 0) || 0 }); }); } function renderBhp() { const rows = bhpRows(); const editId = state.edit.bhp; const e = editId ? byId(rows, editId) : {}; return ` ${formWrap('bhpForm', ` ${input('name','Nama BHP','text',e.name || e.item_name || '')} ${input('unit','Satuan','text',e.unit || 'pcs')} ${input('current_stock','Stok Saat Ini','number',e.current_stock ?? e.stock_qty ?? '')} ${input('minimum_stock','Minimum Stok','number',e.minimum_stock ?? e.min_stock ?? '')} ${input('cost_price','Harga Modal Opsional','number',e.cost_price ?? '')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')} ${ editId ? `
    ` : '' } `, editId ? 'Update BHP' : 'Simpan' )} ${ rows.length ? table(['BHP','Stok','Minimum','Status','Aksi'], rows.map(b => [ esc(b.name || b.item_name), `${esc(b.current_stock)} ${esc(b.unit)}`, esc(b.minimum_stock), esc(b.status), actionButtons('bhp', b.id, 'deleteBhpItem') ])) : empty('Belum ada data BHP.') } `; } /* ========================================================= * OVERRIDE v35 — registration date validation, nakes item IDs/BHP, settings note * ========================================================= */ function isValidYmdClient(value) { value = String(value || '').trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const y = Number(value.slice(0, 4)); const m = Number(value.slice(5, 7)); const d = Number(value.slice(8, 10)); if (y < 1900 || y > 2100) return false; const dt = new Date(value + 'T00:00:00'); return !isNaN(dt.getTime()) && dt.getFullYear() === y && dt.getMonth() + 1 === m && dt.getDate() === d; } function validateRegistrationDates(data) { const nowYear = Number(today().slice(0, 4)); if (data.visit_date && !isValidYmdClient(data.visit_date)) { toast('Tanggal kunjungan tidak valid.', false); return false; } if (data.visit_date) { const y = Number(String(data.visit_date).slice(0, 4)); if (y < 2020 || y > nowYear + 1) { toast('Tahun kunjungan tidak valid.', false); return false; } } if (data.new_client_birth_date) { if (!isValidYmdClient(data.new_client_birth_date)) { toast('Tanggal lahir tidak valid. Gunakan tahun 1900 sampai tahun berjalan.', false); return false; } const y = Number(String(data.new_client_birth_date).slice(0, 4)); if (y < 1900 || y > nowYear || data.new_client_birth_date > today()) { toast('Tanggal lahir tidak boleh melebihi hari ini dan harus antara 1900 sampai tahun berjalan.', false); return false; } } return true; } async function saveRegistration(e) { e.preventDefault(); const data = fd(e.target); if (!data.client_id && !data.new_client_name) { toast('Pilih pasien lama atau isi nama pasien baru.', false); return; } if (!validateRegistrationDates(data)) return; const res = await call('saveVisitRegistration', data, state.user.id); if (res.success) { toast(res.message); e.target.reset(); state.registration.mode = 'search'; state.registration.selectedClient = null; state.registration.searchResults = []; state.registration.lastKeyword = ''; await loadWorkflowData(); } } function renderSelectedClientForRegistration(settings, client) { return `
    Klien Terpilih

    ${esc(client.name)}

    ID: ${esc(client.client_code || '-')}
    Telepon: ${esc(client.phone || '-')}
    Tanggal lahir: ${esc(client.birth_date || '-')}
    Alamat: ${esc(client.address || '-')}
    Kunjungan terakhir: ${esc(client.last_visit_date || '-')}
    ${input('visit_date','Tanggal Kunjungan','date',today(),`min="2020-01-01" max="${today()}"`)} ${input('doctor_name','Dokter Tujuan','text',settings.default_doctor_name || 'David Sungahandra')}
    `; } function renderNewClientRegistrationForm(settings) { return `

    Daftar Pasien Baru

    Gunakan form ini jika pasien tidak ditemukan pada pencarian pasien lama.

    ${input('visit_date','Tanggal Kunjungan','date',today(),`min="2020-01-01" max="${today()}"`)} ${input('doctor_name','Dokter Tujuan','text',settings.default_doctor_name || 'David Sungahandra')}
    Data Klien Baru
    ${input('new_client_name','Nama Klien Baru')} ${input('new_client_birth_date','Tanggal Lahir','date','',`min="1900-01-01" max="${today()}"`)} ${select('new_client_gender','Jenis Kelamin',[ {value:'',label:'-'}, {value:'Laki-laki',label:'Laki-laki'}, {value:'Perempuan',label:'Perempuan'} ])} ${input('new_client_phone','Telepon')} ${input('new_client_address','Alamat')} ${input('new_client_notes','Catatan')}
    `; } function hydrateNakesItems(visitId) { const items = arr(state.data.transactionItems).filter(it => it.visit_id === visitId); const draftBhp = arr(state.data.draftBhpUsage); if (!items.length) { return [{ id: '', service_id: '', nakes_id: '', notes: '', bhp: [] }]; } return items.map(it => ({ id: it.id || '', service_id: it.service_id || '', nakes_id: it.nakes_id || it.staff_id || '', client_package_id: it.client_package_id || '', notes: it.notes || '', bhp: draftBhp .filter(u => u.transaction_item_id === it.id || u.visit_id === visitId) .map(u => ({ bhp_item_id: u.bhp_item_id, quantity_used: Number(u.qty || u.quantity_used || 0) })) })); } async function saveNakesAction(e, actionStatus = 'draft') { e.preventDefault(); const data = fd(e.target); const cleanedItems = arr(state.nakesItems) .map(item => ({ id: item.id || '', service_id: item.service_id || '', nakes_id: item.nakes_id || '', notes: item.notes || '', bhp: arr(item.bhp).filter(b => b.bhp_item_id && Number(b.quantity_used || 0) > 0) })) .filter(item => item.id || item.service_id || item.nakes_id || item.notes || item.bhp.length); if (!cleanedItems.length) { toast('Minimal pilih satu tindakan.', false); return; } for (let i = 0; i < cleanedItems.length; i++) { if (!cleanedItems[i].service_id) { toast('Tindakan baris ' + (i + 1) + ' belum dipilih.', false); return; } if (!cleanedItems[i].nakes_id) { toast('Pelaksana tindakan baris ' + (i + 1) + ' belum dipilih.', false); return; } } if (actionStatus === 'finish' && !data.improvement_status) { toast('Status perbaikan wajib diisi sebelum klik Selesai.', false); return; } const payload = { visit_id: data.visit_id, doctor_name: data.doctor_name, room_name: data.room_name, blood_pressure: data.blood_pressure, bpm: data.bpm, current_complaint: data.current_complaint, syndrome_status: data.syndrome_status, improvement_status: data.improvement_status, action_notes: data.action_notes, action_status: actionStatus, items: cleanedItems }; const res = await call('saveNakesVisitAction', payload, state.user.id); if (res.success) { toast(res.message); mergeResponsePageData(res); if (actionStatus === 'finish') { delete state.medicalDraft[state.activeVisitId]; state.activeVisitId = ''; state.nakesItems = [{ id: '', service_id: '', nakes_id: '', notes: '', bhp: [] }]; await loadWorkflowData(false); } else { await loadWorkflowData(false); } renderShell(); } } function renderSettings() { const s = state.data.settings || {}; return ` ${formWrap('settingsForm', ` ${input('clinic_name','Nama Klinik','text',s.clinic_name || '')} ${input('clinic_slogan','Slogan Klinik','text',s.clinic_slogan || 'Selogan Jarum Dewa Song')} ${input('clinic_address','Alamat Klinik','text',s.clinic_address || '')} ${input('clinic_phone','Telepon Klinik','text',s.clinic_phone || '')} ${input('clinic_logo_url','URL Logo Publik / Google Drive','url',s.clinic_logo_url || '')} ${input('default_doctor_name','Nama Dokter Default','text',s.default_doctor_name || 'David Sungahandra')} ${select('allow_stock_override','Izinkan Transaksi Saat Stok Kurang',[ {value:'false',label:'Tidak'}, {value:'true',label:'Ya'} ], s.allow_stock_override || 'false')} ${select('nakes_payroll_access','Nakes Boleh Melihat THP',[ {value:'false',label:'Tidak, batasi akses nakes'}, {value:'true',label:'Ya, tampilkan menu THP untuk nakes'} ], s.nakes_payroll_access || 'false')} `, 'Simpan Pengaturan' )}
    Keterangan:
    Menu ini digunakan untuk mengatur identitas klinik, tampilan invoice, dan pengaturan operasional dasar.
    `; } /* ========================================================= * OVERRIDE v35 — Pemeriksaan dropdown layout + medical sync * ========================================================= */ function getMedicalNoteForVisit(visitId) { return arr(state.data.medicalNotes) .find(m => String(m.visit_id) === String(visitId)) || {}; } function getVisitMedicalDraft(visit) { visit = visit || {}; const id = visit.id || ''; const note = getMedicalNoteForVisit(id); const draft = state.medicalDraft[id] || {}; return Object.assign({ doctor_name: visit.doctor_name || state.data.settings?.default_doctor_name || 'David Sungahandra', room_name: note.room_name || visit.room_name || visit.room || '', blood_pressure: note.blood_pressure || visit.blood_pressure || '', bpm: note.bpm || visit.bpm || '', current_complaint: note.current_complaint || visit.current_complaint || visit.main_complaint || '', syndrome_status: note.syndrome_status || visit.syndrome_status || '', improvement_status: note.improvement_status || visit.improvement_status || '', action_notes: note.action_notes || visit.action_notes || '' }, draft); } function openNakesVisit(id) { state.activeVisitId = id; state.nakesItems = hydrateNakesItems(id); const visit = byId(state.data.visits, id); state.medicalDraft[id] = getVisitMedicalDraft(visit); renderShell(); setTimeout(() => { const el = document.getElementById('nakes-medical-form-anchor'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 50); } function renderNakesQueueDropdown(visits) { return `
    Daftar pasien dibuat dropdown agar area anamnesa tetap dekat dan tidak perlu scroll jauh.
    `; } function renderSelectedVisitSidePanel(visit) { if (!visit || !visit.id) { return `
    ${empty('Pilih pasien dari dropdown untuk melihat resume dan riwayat terapi.')}
    `; } return `
    ${renderClientQuickSummary(visit)} ${renderMedicalHistory(visit)} ${renderClientPackageInfo(visit)}
    `; } function renderNakesBoard() { const visits = arr(state.data.visits).filter(v => ['Daftar','Pemeriksaan','Tindakan','Antri Pemeriksaan','Masuk Ruang Periksa'].includes(String(v.visit_status || '')) ); const activeVisit = byId(state.data.visits, state.activeVisitId) || {}; return ` ${renderNakesQueueDropdown(visits)}
    ${renderSelectedVisitSidePanel(activeVisit)}
    ${ activeVisit.id ? renderNakesActionForm(activeVisit) : empty('Pilih pasien dari dropdown di atas untuk mulai mengisi anamnesa, rekam medis, dan tindakan.') }
    `; } function renderNakesActionForm(visit) { const med = getVisitMedicalDraft(visit); return `

    Anamnesa, Rekam Medis & Tindakan

    ${esc(visit.visit_number)} • ${esc(visit.client_name)} ${med.room_name ? ' • Ruang ' + esc(med.room_name) : ''}

    ${input('doctor_name','Dokter Pelaksana','text',med.doctor_name || visit.doctor_name || state.data.settings?.default_doctor_name || 'David Sungahandra','readonly')} ${input('room_name','Ruangan','text',med.room_name || '', 'placeholder="Contoh: Ruang 3" oninput="captureMedicalDraft()"')} ${input('blood_pressure','Tensi','text',med.blood_pressure || '', 'placeholder="120/80" oninput="captureMedicalDraft()"')} ${input('bpm','BPM','number',med.bpm || '', 'placeholder="80" oninput="captureMedicalDraft()"')}
    ${input('current_complaint','Keluhan Saat Ini','text',med.current_complaint || '', 'oninput="captureMedicalDraft()"')} ${input('syndrome_status','Status Sindrom','text',med.syndrome_status || '', 'oninput="captureMedicalDraft()"')} ${input('improvement_status','Status Perbaikan Setelah Tindakan','text',med.improvement_status || '', 'oninput="captureMedicalDraft()"')} ${input('action_notes','Catatan Tindakan','text',med.action_notes || '', 'oninput="captureMedicalDraft()"')}

    Tindakan & BHP

    ${state.nakesItems.map(renderNakesItem).join('')}
    `; } async function saveMedicalRecordDraft(e) { e.preventDefault(); const data = fd(e.target); const payload = { visit_id: data.visit_id, doctor_name: data.doctor_name, room_name: data.room_name, blood_pressure: data.blood_pressure, bpm: data.bpm, current_complaint: data.current_complaint, syndrome_status: data.syndrome_status, improvement_status: data.improvement_status, action_notes: data.action_notes, action_status: 'medical' }; const res = await call('saveMedicalRecordDraft', payload, state.user.id); if (res.success) { toast(res.message || 'Rekam medis tersimpan.'); if (res.data && res.data.pageData) { const pd = res.data.pageData; if (Array.isArray(pd.visits)) { pd.visits.forEach(v => { const idx = arr(state.data.visits).findIndex(x => String(x.id) === String(v.id)); if (idx >= 0) state.data.visits[idx] = Object.assign({}, state.data.visits[idx], v); else state.data.visits.unshift(v); }); } if (Array.isArray(pd.medicalNotes)) { pd.medicalNotes.forEach(n => { const idx = arr(state.data.medicalNotes).findIndex(x => String(x.id) === String(n.id) || String(x.visit_id) === String(n.visit_id)); if (idx >= 0) state.data.medicalNotes[idx] = Object.assign({}, state.data.medicalNotes[idx], n); else { if (!Array.isArray(state.data.medicalNotes)) state.data.medicalNotes = []; state.data.medicalNotes.unshift(n); } }); } } const visit = byId(state.data.visits, payload.visit_id); if (visit && visit.id) { visit.visit_status = 'Pemeriksaan'; visit.payment_status = visit.payment_status || 'Belum Bayar'; visit.room_name = payload.room_name || visit.room_name || ''; visit.room = payload.room_name || visit.room || ''; visit.blood_pressure = payload.blood_pressure || ''; visit.bpm = payload.bpm || ''; visit.current_complaint = payload.current_complaint || ''; visit.main_complaint = payload.current_complaint || visit.main_complaint || ''; visit.syndrome_status = payload.syndrome_status || ''; visit.improvement_status = payload.improvement_status || ''; visit.action_notes = payload.action_notes || ''; } state.medicalDraft[payload.visit_id] = Object.assign({}, state.medicalDraft[payload.visit_id] || {}, payload); await loadWorkflowData(false); renderShell(); } } /* ========================================================= * OVERRIDE v35 — faster UI save/payment, room, token matching, invoice header * ========================================================= */ function serviceKeyV23(x) { return String(x || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim(); } function serviceCompatibleV23(serviceIdA, serviceNameA, serviceIdB, serviceNameB) { if (String(serviceIdA || '') && String(serviceIdA) === String(serviceIdB || '')) return true; const a = serviceKeyV23(serviceNameA || serviceIdA); const b = serviceKeyV23(serviceNameB || serviceIdB); if (a && b && a === b) return true; const families = ['akupunktur', 'tuina', 'bekam', 'massage', 'limfatik', 'viber', 'herbal']; return families.some(k => a.includes(k) && b.includes(k)); } function roomOfVisitV23(v) { return v?.room_name || v?.room || '-'; } function visitTotalV23(visitId) { return paymentVisitItems(visitId).reduce((sum, it) => { const qty = Number(it.qty || 1) || 1; return sum + getPaymentItemPriceV23(it) * qty; }, 0); } function getPaymentItemPriceV23(item) { item = item || {}; const service = byId(state.data.services, item.service_id); const itemPrice = Number( Number(item.service_price || 0) > 0 ? item.service_price : Number(item.normal_amount || 0) > 0 ? item.normal_amount : 0 ) || 0; const servicePrice = Number( Number(service.price || 0) > 0 ? service.price : Number(service.service_price || 0) > 0 ? service.service_price : 0 ) || 0; return itemPrice > 0 ? itemPrice : servicePrice; } function getPaymentItemPriceV19(item) { return getPaymentItemPriceV23(item); } function getPaymentItemPriceV18(item) { return getPaymentItemPriceV23(item); } function getPaymentItemPriceV17(item) { return getPaymentItemPriceV23(item); } function paymentNormalTotalV17(visitId) { return visitTotalV23(visitId); } function activeBalancesForService(clientId, serviceId) { const service = byId(state.data.services, serviceId); const serviceName = service.name || service.service_name || serviceId; const seen = {}; return arr(state.data.clientPackageBalances) .filter(b => { const cp = byId(state.data.clientPackages, b.client_package_id); if (!cp || !cp.id) return false; const endDate = String(cp.end_date || cp.expired_date || ''); const remaining = Number(b.remaining_quota || b.quota_remaining || 0); return String(b.client_id) === String(clientId) && serviceCompatibleV23(serviceId, serviceName, b.service_id, b.service_name) && String(b.status || 'Aktif') === 'Aktif' && String(cp.status || 'Aktif') === 'Aktif' && (!endDate || endDate >= today()) && remaining > 0; }) .sort((a, b) => { const ar = Number(a.remaining_quota || a.quota_remaining || 0); const br = Number(b.remaining_quota || b.quota_remaining || 0); if (br !== ar) return br - ar; return String(b.updated_at || b.created_at || '').localeCompare(String(a.updated_at || a.created_at || '')); }) .filter(b => { const key = `${b.client_package_id}::${b.service_id}`; if (seen[key]) return false; seen[key] = true; return true; }) .map(b => { const cp = byId(state.data.clientPackages, b.client_package_id); const master = byId(state.data.packageMasters, b.package_master_id || b.package_id || cp.package_master_id || cp.package_id); const balService = byId(state.data.services, b.service_id); return Object.assign({}, b, { package_name: cp.package_name || master.package_name || b.package_name || 'Paket', remaining_quota: Number(b.remaining_quota || b.quota_remaining || 0), initial_quota: Number(b.initial_quota || b.quota_total || 0), service_name: b.service_name || balService.name || balService.service_name || b.service_id }); }); } function mergePageDataLightV23(res) { const pd = res?.data?.pageData || res?.pageData || {}; if (!pd || typeof pd !== 'object') return; if (Array.isArray(pd.visits)) { if (!Array.isArray(state.data.visits)) state.data.visits = []; pd.visits.forEach(v => { const idx = state.data.visits.findIndex(x => String(x.id) === String(v.id)); if (idx >= 0) state.data.visits[idx] = Object.assign({}, state.data.visits[idx], v); else state.data.visits.unshift(v); }); } if (Array.isArray(pd.medicalNotes)) { if (!Array.isArray(state.data.medicalNotes)) state.data.medicalNotes = []; pd.medicalNotes.forEach(n => { const idx = state.data.medicalNotes.findIndex(x => String(x.id) === String(n.id) || String(x.visit_id) === String(n.visit_id)); if (idx >= 0) state.data.medicalNotes[idx] = Object.assign({}, state.data.medicalNotes[idx], n); else state.data.medicalNotes.unshift(n); }); } if (Array.isArray(pd.transactionItems)) { if (!Array.isArray(state.data.transactionItems)) state.data.transactionItems = []; const visitIds = [...new Set(pd.transactionItems.map(x => String(x.visit_id || '')).filter(Boolean))]; // Replace items for these visits to avoid duplicate UI rows. if (visitIds.length) { state.data.transactionItems = state.data.transactionItems.filter(x => !visitIds.includes(String(x.visit_id || ''))); } state.data.transactionItems = state.data.transactionItems.concat(pd.transactionItems); } if (Array.isArray(pd.bhpItems)) state.data.bhpItems = pd.bhpItems; if (Array.isArray(pd.draftBhpUsage)) { const visitIds = [...new Set(pd.draftBhpUsage.map(x => String(x.visit_id || '')).filter(Boolean))]; if (!Array.isArray(state.data.draftBhpUsage)) state.data.draftBhpUsage = []; if (visitIds.length) state.data.draftBhpUsage = state.data.draftBhpUsage.filter(x => !visitIds.includes(String(x.visit_id || ''))); state.data.draftBhpUsage = state.data.draftBhpUsage.concat(pd.draftBhpUsage); } } async function saveMedicalRecordDraft(e) { e.preventDefault(); const btn = e.submitter || document.querySelector('button[onclick="saveMedicalRecordDraftFromButton()"]'); const oldText = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Menyimpan...'; } try { const data = fd(e.target); const payload = { visit_id: data.visit_id, doctor_name: data.doctor_name, room_name: data.room_name, blood_pressure: data.blood_pressure, bpm: data.bpm, current_complaint: data.current_complaint, syndrome_status: data.syndrome_status, improvement_status: data.improvement_status, action_notes: data.action_notes, action_status: 'medical' }; const res = await call('saveMedicalRecordDraft', payload, state.user.id); if (res.success) { toast(res.message || 'Rekam medis tersimpan.'); mergePageDataLightV23(res); const visit = byId(state.data.visits, payload.visit_id); if (visit && visit.id) { visit.visit_status = 'Pemeriksaan'; visit.payment_status = visit.payment_status || 'Belum Bayar'; visit.room_name = payload.room_name || visit.room_name || ''; visit.room = payload.room_name || visit.room || ''; visit.blood_pressure = payload.blood_pressure || ''; visit.bpm = payload.bpm || ''; visit.current_complaint = payload.current_complaint || ''; visit.main_complaint = payload.current_complaint || visit.main_complaint || ''; visit.syndrome_status = payload.syndrome_status || ''; visit.improvement_status = payload.improvement_status || ''; visit.action_notes = payload.action_notes || ''; } state.medicalDraft[payload.visit_id] = Object.assign({}, state.medicalDraft[payload.visit_id] || {}, payload); renderShell(); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = oldText || 'Simpan Rekam Medis'; } } } async function saveNakesAction(e, actionStatus = 'draft') { e.preventDefault(); const form = e.target; const btn = actionStatus === 'finish' ? [...form.querySelectorAll('button')].find(b => b.textContent.includes('Selesai')) : [...form.querySelectorAll('button')].find(b => b.textContent.includes('Simpan Draft')); const oldText = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = actionStatus === 'finish' ? 'Menyelesaikan...' : 'Menyimpan...'; } try { const data = fd(form); const cleanedItems = arr(state.nakesItems) .map(item => ({ service_id: item.service_id || '', nakes_id: item.nakes_id || '', notes: item.notes || '', bhp: arr(item.bhp).filter(b => b.bhp_item_id && Number(b.quantity_used || 0) > 0) })) .filter(item => item.service_id || item.nakes_id || item.notes || item.bhp.length); if (!cleanedItems.length) { toast('Minimal pilih satu tindakan.', false); return; } for (let i = 0; i < cleanedItems.length; i++) { if (!cleanedItems[i].service_id) { toast('Tindakan baris ' + (i + 1) + ' belum dipilih.', false); return; } if (!cleanedItems[i].nakes_id) { toast('Pelaksana tindakan baris ' + (i + 1) + ' belum dipilih.', false); return; } } if (actionStatus === 'finish' && !data.improvement_status) { toast('Status perbaikan wajib diisi sebelum klik Selesai.', false); return; } const payload = { visit_id: data.visit_id, doctor_name: data.doctor_name, room_name: data.room_name, blood_pressure: data.blood_pressure, bpm: data.bpm, current_complaint: data.current_complaint, syndrome_status: data.syndrome_status, improvement_status: data.improvement_status, action_notes: data.action_notes, action_status: actionStatus, items: cleanedItems }; const res = await call('saveNakesVisitAction', payload, state.user.id); if (res.success) { toast(res.message); mergePageDataLightV23(res); const visit = byId(state.data.visits, payload.visit_id); if (visit && visit.id) { visit.room_name = payload.room_name || visit.room_name || ''; visit.room = payload.room_name || visit.room || ''; visit.current_complaint = payload.current_complaint || visit.current_complaint || ''; visit.visit_status = res.data?.visit_status || (actionStatus === 'finish' ? 'Selesai Tindakan' : 'Tindakan'); visit.payment_status = res.data?.payment_status || (actionStatus === 'finish' ? 'Siap Bayar' : 'Belum Bayar'); visit.total_amount = res.data?.calculated_total || visitTotalV23(visit.id); } if (actionStatus === 'finish') { delete state.medicalDraft[payload.visit_id]; state.activeVisitId = ''; state.nakesItems = [{ id: '', service_id: '', nakes_id: '', notes: '', bhp: [] }]; } else { state.medicalDraft[payload.visit_id] = Object.assign({}, state.medicalDraft[payload.visit_id] || {}, payload); state.nakesItems = arr(res.data?.items).length ? arr(res.data.items).map(it => ({ id: it.id, service_id: it.service_id, nakes_id: it.staff_id || '', notes: it.notes || '', bhp: [] })) : state.nakesItems; } renderShell(); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = oldText || (actionStatus === 'finish' ? 'Selesai Tindakan' : 'Simpan Draft'); } } } function renderPaymentActionSelect(visit, item, idx) { const service = byId(state.data.services, item.service_id); const serviceName = service.name || service.service_name || item.service_name || item.service_id || 'Tindakan'; const normalPrice = getPaymentItemPriceV23(item); const balances = activeBalancesForService(visit.client_id, item.service_id); const modeId = `linepay_mode_${visit.id}_${item.id}`; const balanceId = `linepay_balance_${visit.id}_${item.id}`; const balanceWrapId = `linepay_balance_wrap_${visit.id}_${item.id}`; return `
    ${esc(serviceName)} — ${rupiah(normalPrice)}
    ${ balances.length ? '' : `
    Tidak ada saldo paket aktif yang cocok untuk ${esc(serviceName)}.
    ` }
    `; } function renderPaymentEstimate(visitId) { const items = paymentVisitItems(visitId); let normalTotal = 0; let coveredCount = 0; let unselectedCount = 0; items.forEach(it => { const mode = document.getElementById(`linepay_mode_${visitId}_${it.id}`)?.value || ''; const normalPrice = getPaymentItemPriceV23(it); if (mode === 'package') coveredCount++; else if (mode === 'normal') normalTotal += normalPrice; else unselectedCount++; }); const methodBlock = document.getElementById('payment_method_block_' + visitId); if (methodBlock) methodBlock.classList.toggle('hidden', normalTotal <= 0); const el = document.getElementById('payment_estimate_' + visitId); if (el) { el.innerHTML = `
    Total bayar: ${rupiah(normalTotal)}
    Bayar normal: ${rupiah(normalTotal)}
    Pakai paket: ${coveredCount} tindakan
    ${unselectedCount ? `Belum dipilih: ${unselectedCount} tindakan` : ''}
    `; } } function renderPayments() { const todayDate = today(); const waiting = arr(state.data.visits).filter(v => v.payment_status === 'Siap Bayar' || (v.visit_status === 'Selesai Tindakan' && v.payment_status !== 'Lunas') ); const paid = arr(state.data.visits) .filter(v => ['Lunas','Terbayar'].includes(v.payment_status) && String(v.transaction_date || '').slice(0, 10) === todayDate) .slice() .reverse(); const activeVisit = state.activePaymentVisitId ? byId(state.data.visits, state.activePaymentVisitId) : {}; return `

    Daftar Pasien Siap Bayar

    ${ waiting.length ? table(['No Visit','Klien','Ruang','Tanggal','Tagihan Normal','Aksi'], waiting.map(v => [ esc(v.visit_number), esc(v.client_name), esc(roomOfVisitV23(v)), esc(v.visit_date), rupiah(visitTotalV23(v.id) || v.total_amount || 0), `` ])) : empty('Tidak ada pasien menunggu pembayaran.') }
    ${activeVisit.id ? renderPaymentDetail(activeVisit) : ''} ${state.lastInvoice ? renderInvoice(state.lastInvoice) : ''}

    Riwayat Lunas Hari Ini

    Hanya transaksi lunas tanggal hari ini agar menu pembayaran tetap ringan.

    ${ paid.length ? table(['No Visit','Klien','Invoice','Total','Status','Aksi'], paid.map(v => [ esc(v.visit_number), esc(v.client_name), esc(v.invoice_number), rupiah(v.total_amount), esc(v.payment_status), `` ])) : empty('Belum ada pembayaran lunas hari ini.') }
    `; } function renderPaymentDetail(visit) { const items = paymentVisitItems(visit.id).filter(it => String(it.item_status || 'Aktif') !== 'Lunas' && it.is_deleted !== true); const client = byId(state.data.clients, visit.client_id); const methodId = `pay_method_detail_${visit.id}`; return `

    Pembayaran: ${esc(visit.client_name)}

    ${esc(visit.visit_number)} • Ruang ${esc(roomOfVisitV23(visit))}
    ${client.phone ? `
    Telp: ${esc(client.phone)}
    ` : ''}
    ${ items.length ? `
    ${ items.map((it, i) => { const service = byId(state.data.services, it.service_id); return ` `; }).join('') }
    Nama Tindakan Pelaksana / Asisten Cara Bayar
    ${esc(service.name || service.service_name || it.service_name || it.service_id)} ${esc(getPerformerLabel(it.nakes_id || it.staff_id))} ${renderPaymentActionSelect(visit, it, i)}
    Pilih cara bayar per tindakan.
    ` : empty('Tidak ada tindakan aktif. Jika terlihat tidak sesuai, kembali ke Pemeriksaan dan simpan ulang tindakan.') }
    `; } async function payVisitDetail(visitId) { const btn = document.getElementById('btn_pay_' + visitId); const oldText = btn ? btn.innerHTML : ''; const paymentOptions = buildPaymentOptionsForVisit(visitId); const hasUnselected = arr(paymentOptions.line_items).some(x => !x.mode); if (hasUnselected) { toast('Pilih cara bayar untuk semua tindakan terlebih dahulu.', false); return; } const methodBlock = document.getElementById('payment_method_block_' + visitId); const method = methodBlock && !methodBlock.classList.contains('hidden') ? (document.getElementById('pay_method_detail_' + visitId)?.value || 'Tunai') : 'Token Paket'; if (btn) { btn.disabled = true; btn.innerHTML = 'Memproses pembayaran...'; } try { const res = await call('completePayment', visitId, method, state.user.id, paymentOptions); if (res.success) { toast(res.message || 'Pembayaran berhasil.'); mergePageDataLightV23(res); const visit = byId(state.data.visits, visitId); if (visit && visit.id) { visit.visit_status = 'Selesai'; visit.payment_status = 'Lunas'; visit.total_amount = res.data?.total_amount || res.data?.transaction?.total_amount || 0; visit.invoice_number = res.data?.transaction?.invoice_number || visit.invoice_number || ''; visit.transaction_date = today(); } if (Array.isArray(state.data.transactionItems)) { state.data.transactionItems = state.data.transactionItems.filter(it => String(it.visit_id) !== String(visitId)); } state.lastInvoice = res.data?.invoice || res.data; state.activePaymentVisitId = ''; state.page = 'payments'; renderShell(); setTimeout(() => document.getElementById('printArea')?.scrollIntoView({ behavior: 'smooth' }), 100); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = oldText || 'Konfirmasi Pembayaran'; } } } function renderInvoice(inv) { const t = inv.transaction || {}; const c = inv.client || {}; const ca = inv.cashier || {}; const s = inv.settings || inv.clinic || state.data.settings || state.publicSettings || {}; const clinicName = s.clinic_name || appSettings().clinic_name || 'Klinik Sehat Harmoni'; const clinicAddress = s.clinic_address || appSettings().clinic_address || ''; const clinicPhone = s.clinic_phone || appSettings().clinic_phone || ''; const statusText = t.payment_status || t.status || ''; const invoiceItems = arr(inv.items); return `
    ${s.clinic_logo_url ? `` : ''}
    ${esc(clinicName)}

    INVOICE

    ${esc(clinicAddress)}${clinicPhone ? `
    ${esc(clinicPhone)}` : ''}
    ${esc(t.invoice_number || '')}
    ${esc(t.transaction_date || '')}
    ${statusText ? `
    Status Pembayaran:
    ${esc(statusText)}
    ` : ''}
    Nama Klien: ${esc(c.name || inv.visit?.client_name || '')}
    Kasir: ${esc(ca.name || t.cashier_name || state.user.name || 'Administrator')}
    Metode Pembayaran: ${esc(t.payment_method || '')}
    Total: ${rupiah(t.total_amount)}
    ${ invoiceItems.length ? invoiceItems.map((it, i) => ` `).join('') : `` }
    No Tindakan Pelaksana / Asisten Tarif
    ${i + 1} ${esc(it.service_name || it.name || '')} ${esc(it.staff_name || it.performer_name || '-')} ${rupiah(it.service_price || it.normal_amount || it.price || 0)}
    Tidak ada item.
    Total Pembayaran: ${rupiah(t.total_amount)}
    Terima kasih atas kunjungannya. Semoga sehat selalu.
    `; } /* ========================================================= * OVERRIDE v35 — invoice print button + package lazy tab UI * ========================================================= */ function printInvoiceV24() { const el = document.getElementById('printArea'); if (!el) { toast('Invoice belum terbuka.', false); return; } window.print(); } function renderInvoice(inv) { const t = inv.transaction || {}; const c = inv.client || {}; const ca = inv.cashier || {}; const s = inv.settings || inv.clinic || state.data.settings || state.publicSettings || {}; const clinicName = s.clinic_name || appSettings().clinic_name || 'Klinik Sehat Harmoni'; const clinicAddress = s.clinic_address || appSettings().clinic_address || ''; const clinicPhone = s.clinic_phone || appSettings().clinic_phone || ''; const statusText = t.payment_status || t.status || ''; const invoiceItems = arr(inv.items); return `
    ${s.clinic_logo_url ? `` : ''}
    ${esc(clinicName)}

    INVOICE

    ${esc(clinicAddress)}${clinicPhone ? `
    ${esc(clinicPhone)}` : ''}
    No Invoice
    ${esc(t.invoice_number || '-')}
    Tanggal
    ${esc(t.transaction_date || '-')}
    Status Pembayaran
    ${esc(statusText || '-')}
    Nama Klien: ${esc(c.name || inv.visit?.client_name || '')}
    Kasir: ${esc(ca.name || t.cashier_name || state.user.name || 'Administrator')}
    Metode Pembayaran: ${esc(t.payment_method || '')}
    Total: ${rupiah(t.total_amount)}
    ${ invoiceItems.length ? invoiceItems.map((it, i) => ` `).join('') : `` }
    No Tindakan Pelaksana / Asisten Tarif
    ${i + 1} ${esc(it.service_name || it.name || '')} ${esc(it.staff_name || it.performer_name || it.nakes_name || '-')} ${rupiah(it.service_price || it.normal_amount || it.price || 0)}
    Tidak ada item.
    Total Pembayaran: ${rupiah(t.total_amount)}
    Terima kasih atas kunjungannya. Semoga sehat selalu.
    `; } function activePackageTabV24() { if (!state.packageTab) state.packageTab = 'master'; return state.packageTab; } async function loadPackageTabV24(tab, shouldRender = true) { state.packageTab = tab || activePackageTabV24(); const res = await call('getPackagePageData', { mode: state.packageTab, user: state.user }, { __silent: true }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.packageLastFetch = Date.now(); if (shouldRender) renderShell(); } return res; } async function refreshPackagePageFast(shouldRender = true, mode) { return loadPackageTabV24(mode || activePackageTabV24(), shouldRender); } function packageTabButtonV24(tab, label) { const active = activePackageTabV24() === tab; return ` `; } function renderPackageTopV24() { return `

    Paket / Promo

    Data paket dibuka per bagian agar halaman tidak memuat semua saldo klien sekaligus.

    ${packageTabButtonV24('balances', 'Saldo Paket')} ${packageTabButtonV24('create', 'Buat Promo')} ${packageTabButtonV24('sale', 'Jual Paket')}
    `; } function renderPackageMasterListV24() { const packageMasters = arr(state.data.packageMasters); return `

    Daftar Master Paket

    ${ packageMasters.length ? table(['Nama Paket','Total Token','Detail Kuota','Harga','Masa Berlaku','Status','Aksi'], packageMasters.map(p => [ esc(p.package_name), esc(p.total_sessions || sumPackageMasterQuota(p.id)), packageMasterItemDetail(p.id), rupiah(p.package_price), `${esc(p.valid_days)} hari`, `${esc(p.status)}`, `
    ${ String(p.status) === 'Aktif' ? `` : `` }
    ` ])) : empty('Belum ada master paket.') }
    `; } function renderPackageCreateV24() { const services = arr(state.data.services).filter(s => s.status === 'Aktif'); const packageMasters = arr(state.data.packageMasters); const packageMasterItems = arr(state.data.packageMasterItems); const editId = state.edit.packageMaster; const editing = editId ? byId(packageMasters, editId) : {}; let editingItems = packageMasterItems.filter(x => String(x.package_id) === String(editId)); if (editId && !editingItems.length && editing.included_service_ids) { const oldIds = String(editing.included_service_ids || '').split(',').map(x => x.trim()).filter(Boolean); editingItems = oldIds.map(sid => ({ service_id: sid, service_name: byId(state.data.services, sid).name || sid, quota: Number(editing.total_sessions || 0) || 1 })); } return `

    ${editId ? 'Edit Promo / Paket' : 'Buat Promo / Paket Baru'}

    Isi kuota per tindakan. Contoh: Akupunktur 5 token + Tuina 2 token.

    ${ editId ? `` : '' }
    ${input('package_name','Nama Paket','text',editing.package_name || '','placeholder="Paket Akupunktur Kesehatan 5" required')} ${input('package_price','Harga Paket','number',editing.package_price || '','min="0" required')} ${input('valid_days','Masa Berlaku Hari','number',editing.valid_days || '45','min="1" required')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], editing.status || 'Aktif')}
    Kuota Token per Tindakan
    Isi angka 0 jika tindakan tidak termasuk paket.
    Total token: 0
    ${ services.length ? services.map(s => { const item = editingItems.find(x => String(x.service_id) === String(s.id)) || {}; return ` `; }).join('') : `
    Belum ada master tindakan aktif.
    ` }
    `; } function renderPackageSaleV24() { const packageMasters = arr(state.data.packageMasters); const packageSale = state.packageSaleSearch || { keyword: '', results: [], selectedClient: null, searched: false }; const packageOptions = [{ value: '', label: 'Pilih paket' }].concat( packageMasters .filter(p => p.status === 'Aktif') .map(p => ({ value: p.id, label: `${p.package_name} - ${rupiah(p.package_price)} / ${p.total_sessions || sumPackageMasterQuota(p.id)} token` })) ); return `

    Jual Paket untuk Pasien

    ${ packageSale.selectedClient ? `
    Klien dipilih: ${esc(packageSale.selectedClient.name)} ${esc(packageSale.selectedClient.phone || packageSale.selectedClient.client_code || '')}
    ` : '' } ${ packageSale.searched ? `
    ${ packageSale.results && packageSale.results.length ? packageSale.results.map(c => ` `).join('') : `
    Tidak ada hasil.
    ` }
    ` : `

    Klien tidak diload semua agar halaman tetap ringan.

    ` }
    ${select('package_id','Paket',packageOptions)} ${input('purchase_date','Tanggal Beli','date',today())} ${select('payment_method','Metode Bayar',[ {value:'Tunai',label:'Tunai'}, {value:'Transfer bank',label:'Transfer bank'}, {value:'QRIS',label:'QRIS'}, {value:'Kartu debit',label:'Kartu debit'}, {value:'Kartu kredit',label:'Kartu kredit'} ], 'Tunai')}
    `; } function renderPackageBalancesV24() { const balances = arr(state.data.clientPackageBalances); const activeBalancePackageIds = new Set( balances .filter(b => String(b.status || 'Aktif') === 'Aktif' && Number(b.remaining_quota || b.quota_remaining || 0) > 0) .map(b => String(b.client_package_id || '')) .filter(Boolean) ); const clientPackages = arr(state.data.clientPackages) .filter(p => String(p.status || 'Aktif') === 'Aktif') .filter(p => activeBalancePackageIds.has(String(p.id))) .slice() .reverse(); return `

    Saldo Paket Aktif Klien

    ${ clientPackages.length ? table(['Klien','Paket','Sisa Token','Detail Token','Berlaku','Harga','Status','Aksi'], clientPackages.map(p => { const client = byId(state.data.clients, p.client_id); const master = byId(state.data.packageMasters, p.package_id || p.package_master_id); const b = balances .filter(x => String(x.client_package_id) === String(p.id)) .filter(x => String(x.status || 'Aktif') === 'Aktif') .filter(x => Number(x.remaining_quota || x.quota_remaining || 0) > 0); const detail = b.length ? b.map(x => `${esc(x.service_name || x.service_id)}: ${esc(x.remaining_quota || x.quota_remaining || 0)}/${esc(x.initial_quota || x.quota_total || 0)}`).join('
    ') : '-'; const remaining = b.reduce((sum, x) => sum + Number(x.remaining_quota || x.quota_remaining || 0), 0); const total = b.reduce((sum, x) => sum + Number(x.initial_quota || x.quota_total || 0), 0); return [ esc(client.name || p.client_name || p.client_id), esc(p.package_name || master.package_name || p.package_id || p.package_master_id), `${remaining} / ${total}`, detail, esc(p.end_date || p.expired_date || '-'), rupiah(p.purchase_price || p.package_price), esc(p.status), String(p.status) === 'Aktif' ? `` : '-' ]; })) : empty('Belum ada paket klien aktif atau data saldo belum diload. Klik Refresh Saldo.') }
    `; } function renderPackages() { const tab = activePackageTabV24(); return ` ${renderPackageTopV24()} ${renderPackageMasterListV24()} ${ tab === 'create' ? renderPackageCreateV24() : tab === 'sale' ? renderPackageSaleV24() : tab === 'balances' ? renderPackageBalancesV24() : `
    Pilih Saldo Paket, Buat Promo, atau Jual Paket.
    ` } `; } function editPackageMaster(id) { state.edit.packageMaster = id; state.packageTab = 'create'; renderShell(); setTimeout(() => { document.getElementById('packageMasterForm')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); updatePackageTokenTotal(); }, 80); } function cancelPackageMasterEdit() { state.edit.packageMaster = null; state.packageTab = 'master'; renderShell(); } async function sellPackageFromForm(e) { e.preventDefault(); const btn = e.submitter || e.target.querySelector('button[type="submit"]'); const oldText = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Menjual paket...'; } try { const data = fd(e.target); const selectedClient = state.packageSaleSearch && state.packageSaleSearch.selectedClient; if (selectedClient && selectedClient.id) data.client_id = selectedClient.id; if (!data.client_id || !data.package_id) { toast('Klien dan paket wajib dipilih.', false); return; } const res = await call('sellClientPackage', data, state.user.id); if (res.success) { toast(res.message); state.edit.packageMaster = null; state.packageSaleSearch = { keyword: '', results: [], selectedClient: null, searched: false }; e.target.reset(); if (mergeResponsePageData(res)) { state.packageTab = 'balances'; renderShell(); } else { await refreshPackagePageFast(true, 'balances'); } } } finally { if (btn) { btn.disabled = false; btn.innerHTML = oldText || 'Jual Paket'; } } } async function go(page) { captureMedicalDraft(); if (!allowed(page)) state.page = defaultPageForRole(); else state.page = page; renderShell(); if (state.page === 'packages') { if (!state.packageTab) state.packageTab = 'master'; loadPackageTabV24(state.packageTab === 'balances' ? 'balances' : 'master', true); } else if (['nakesboard', 'payments', 'registration', 'dashboard'].includes(state.page)) { if (state.page === 'payments') loadPaymentData(true, { silent: true, force: true }); else loadWorkflowData(true, { silent: true, force: true }); } } /* ========================================================= * OVERRIDE v35 — present nakes only + payment history lazy * ========================================================= */ function presentNakesRowsV25() { const todayValue = today(); if (Array.isArray(state.data.presentNakes) && state.data.presentNakes.length) { return state.data.presentNakes.filter(n => String(n.status || 'Aktif') === 'Aktif'); } const presentIds = new Set( arr(state.data.attendance) .filter(a => String(a.attendance_date || '').slice(0, 10) === todayValue && String(a.status || '').toLowerCase() === 'hadir' && !a.check_out_at ) .map(a => String(a.nakes_id)) ); return arr(state.data.nakes) .filter(n => String(n.status || 'Aktif') === 'Aktif' && presentIds.has(String(n.id))); } function pelaksanaOptions() { const options = [{ value: '', label: 'Pilih pelaksana' }]; const seen = new Set(['']); presentNakesRowsV25().forEach(n => { if (!seen.has(String(n.id))) { options.push({ value: n.id, label: n.name + ' (Hadir)' }); seen.add(String(n.id)); } }); // Fallback hanya untuk nakes yang sedang login, bila attendance baru saja tercatat // tetapi data presentNakes belum refresh. if (state.user && state.user.role === 'Nakes') { const current = currentAttendanceIdentity(); const todayValue = today(); const isPresent = arr(state.data.attendance).some(a => String(a.nakes_id) === String(current.id) && String(a.attendance_date || '').slice(0, 10) === todayValue && String(a.status || '').toLowerCase() === 'hadir' && !a.check_out_at ); if (isPresent && current.id && !seen.has(String(current.id))) { options.push({ value: current.id, label: current.name + ' (Hadir)' }); } } if (options.length === 1) { options.push({ value: '', label: 'Tidak ada nakes hadir' }); } return options; } async function loadPaymentData(shouldRender = true, options = {}) { const silent = options.silent !== undefined ? !!options.silent : true; const force = !!options.force; const nowMs = Date.now(); if (!force && state.paymentLastFetch && (nowMs - state.paymentLastFetch) < 3000) { if (shouldRender) renderShell(); return { success: true, data: state.data, message: 'Data pembayaran masih fresh.' }; } const res = await call('getPaymentPageData', { mode: 'queue', user: state.user }, { __silent: silent }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.paymentLastFetch = Date.now(); if (shouldRender) renderShell(); } return res; } async function loadPaymentHistoryV25() { const btn = document.getElementById('btnPaymentHistoryV25'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat riwayat...'; } try { const res = await call('getPaymentPageData', { mode: 'history', user: state.user }, { __silent: true }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.paymentHistoryLoaded = true; renderShell(); } else { toast(res.message || 'Gagal memuat riwayat lunas.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Riwayat Lunas'; } } } function renderPaymentHistoryV25() { if (!state.paymentHistoryLoaded) { return `

    Riwayat Lunas Hari Ini

    Tidak diload otomatis agar menu pembayaran tetap ringan.

    `; } const rows = arr(state.data.paymentHistory); return `

    Riwayat Lunas Hari Ini

    Dimuat setelah tombol diklik.

    ${ rows.length ? table(['No Visit','Klien','Invoice','Metode','Uang Masuk','Token','Status','Aksi'], rows.map(r => [ esc(r.visit_number), esc(r.client_name), esc(r.invoice_number), esc(r.payment_method), rupiah(r.paid_amount || r.total_amount || 0), rupiah(r.package_value_used || 0), esc(r.payment_status), r.visit_id ? `` : '-' ])) : empty('Belum ada pembayaran lunas hari ini.') }
    `; } function renderPayments() { const waiting = arr(state.data.visits).filter(v => v.payment_status === 'Siap Bayar' || (v.visit_status === 'Selesai Tindakan' && v.payment_status !== 'Lunas') ); const activeVisit = state.activePaymentVisitId ? byId(state.data.visits, state.activePaymentVisitId) : {}; return `

    Daftar Pasien Siap Bayar

    Antrian siap bayar tetap diload saat menu Pembayaran dibuka.

    ${ waiting.length ? table(['No Visit','Klien','Ruang','Tanggal','Tagihan Normal','Aksi'], waiting.map(v => [ esc(v.visit_number), esc(v.client_name), esc(roomOfVisitV25(v)), esc(v.visit_date), rupiah(visitTotalV25(v.id) || v.total_amount || 0), `` ])) : empty('Tidak ada pasien menunggu pembayaran.') }
    ${activeVisit.id ? renderPaymentDetail(activeVisit) : ''} ${state.lastInvoice ? renderInvoice(state.lastInvoice) : ''} ${renderPaymentHistoryV25()} `; } function roomOfVisitV25(v) { return v?.room_name || v?.room || '-'; } async function go(page) { captureMedicalDraft(); if (!allowed(page)) state.page = defaultPageForRole(); else state.page = page; renderShell(); if (state.page === 'packages') { if (!state.packageTab) state.packageTab = 'master'; loadPackageTabV24(state.packageTab === 'balances' ? 'balances' : 'master', true); } else if (state.page === 'payments') { state.paymentHistoryLoaded = false; state.data.paymentHistory = []; loadPaymentData(true, { silent: true, force: true }); } else if (['nakesboard', 'registration', 'dashboard'].includes(state.page)) { loadWorkflowData(true, { silent: true, force: true }); } } async function logout() { const currentUser = state.user; if (currentUser && ['Nakes', 'Kasir', 'Frontliner'].includes(currentUser.role)) { await call('logoutAttendance', currentUser.id, { __silent: true }); } localStorage.removeItem('klinik_user'); state.user = null; state.data = {}; state.page = 'dashboard'; state.paymentHistoryLoaded = false; renderLogin(); } /* ========================================================= * OVERRIDE v35 — no duplicate login load + lazy nakes visit detail * ========================================================= */ function applyPageDataV26(pd) { if (!pd || typeof pd !== 'object') return; state.data = state.data || {}; const mergeById = (key, rows) => { if (!Array.isArray(rows)) return; if (!Array.isArray(state.data[key])) state.data[key] = []; rows.forEach(r => { if (!r || !r.id) return; const idx = state.data[key].findIndex(x => String(x.id) === String(r.id)); if (idx >= 0) state.data[key][idx] = Object.assign({}, state.data[key][idx], r); else state.data[key].unshift(r); }); }; mergeById('clients', pd.clients); mergeById('visits', pd.visits); mergeById('medicalNotes', pd.medicalNotes || pd.medical_notes); mergeById('transactionItems', pd.transactionItems || pd.transaction_items); mergeById('draftBhpUsage', pd.draftBhpUsage || pd.draft_bhp_usage); mergeById('bhpUsage', pd.bhpUsage || pd.bhp_usage); ['settings','dashboard','services','nakes','staff','presentNakes','attendance','bhpItems','transactions','users','packageMasters','packageMasterItems','clientPackages','clientPackageBalances','paymentHistory'].forEach(k => { if (pd[k] !== undefined) state.data[k] = pd[k]; }); if (pd.medical_notes && !pd.medicalNotes) state.data.medicalNotes = state.data.medicalNotes || pd.medical_notes; if (pd.transaction_items && !pd.transactionItems) state.data.transactionItems = state.data.transactionItems || pd.transaction_items; } async function init() { if (!state.user) { await loadPublicSettings(); renderLogin(); return; } // v35: tampilkan shell dulu, lalu load data hanya 1x. // Tidak lagi loadPublicSettings + getStartupData + getWorkflowData beruntun. renderShell(); await loadData(true, { startup: true, silent: true }); } async function loadData(shouldRender = true, options = {}) { const useStartup = !!options.startup; const silent = options.silent !== undefined ? !!options.silent : true; const loader = useStartup ? 'getStartupData' : 'getBootstrapData'; const started = performance.now(); const res = await call(loader, state.user, { __silent: silent }); if (res && res.success) { applyPageDataV26(res.data || {}); state.workflowLastFetch = Date.now(); state.lastLoadMs = Math.round(performance.now() - started); updateDocumentTitle(); if (shouldRender) renderShell(); } return res; } async function loadWorkflowData(shouldRender = true, options = {}) { const silent = !!options.silent; const force = !!options.force; const nowMs = Date.now(); // v35: cache antrian 20 detik, kecuali tombol refresh dipencet. if (!force && state.workflowLastFetch && (nowMs - state.workflowLastFetch) < 20000) { if (shouldRender) renderShell(); return { success: true, data: state.data, message: 'Data workflow masih fresh.' }; } if (state.workflowFetchPromise) return state.workflowFetchPromise; const started = performance.now(); state.workflowFetchPromise = call('getWorkflowData', state.user, { __silent: silent }) .then(res => { if (res && res.success) { applyPageDataV26(res.data || {}); state.workflowLastFetch = Date.now(); state.lastWorkflowMs = Math.round(performance.now() - started); if (shouldRender) renderShell(); } return res; }) .finally(() => { state.workflowFetchPromise = null; }); return state.workflowFetchPromise; } async function loadNakesVisitDetailV26(visitId, shouldRender = true) { visitId = String(visitId || '').trim(); if (!visitId) return; if (!state.nakesDetailLoaded) state.nakesDetailLoaded = {}; if (!state.nakesDetailLoading) state.nakesDetailLoading = {}; if (state.nakesDetailLoaded[visitId] || state.nakesDetailLoading[visitId]) return; state.nakesDetailLoading[visitId] = true; const res = await call('getWorkflowData', { mode: 'nakes_detail', visit_id: visitId, user: state.user }, { __silent: true }); state.nakesDetailLoading[visitId] = false; if (res && res.success) { applyPageDataV26(res.data || {}); state.nakesDetailLoaded[visitId] = true; if (state.activeVisitId === visitId) { state.nakesItems = hydrateNakesItems(visitId); } if (shouldRender) renderShell(); } } function openNakesVisit(id) { state.activeVisitId = id; state.nakesItems = hydrateNakesItems(id); const visit = byId(state.data.visits, id); state.medicalDraft[id] = getVisitMedicalDraft(visit); renderShell(); loadNakesVisitDetailV26(id, true); setTimeout(() => { const el = document.getElementById('nakes-medical-form-anchor'); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 50); } function renderNakesActionForm(visit) { const med = getVisitMedicalDraft(visit); const loadingDetail = state.nakesDetailLoading && state.nakesDetailLoading[visit.id]; return `

    Anamnesa, Rekam Medis & Tindakan

    ${esc(visit.visit_number)} • ${esc(visit.client_name)} ${med.room_name ? ' • Ruang ' + esc(med.room_name) : ''}

    ${loadingDetail ? `
    Memuat detail rekam medis...
    ` : ''}
    ${input('doctor_name','Dokter Pelaksana','text',med.doctor_name || visit.doctor_name || state.data.settings?.default_doctor_name || 'David Sungahandra','readonly')} ${input('room_name','Ruangan','text',med.room_name || '', 'placeholder="Contoh: Ruang 3" oninput="captureMedicalDraft()"')} ${input('blood_pressure','Tensi','text',med.blood_pressure || '', 'placeholder="120/80" oninput="captureMedicalDraft()"')} ${input('bpm','BPM','number',med.bpm || '', 'placeholder="80" oninput="captureMedicalDraft()"')}
    ${input('current_complaint','Keluhan Saat Ini','text',med.current_complaint || '', 'oninput="captureMedicalDraft()"')} ${input('syndrome_status','Status Sindrom','text',med.syndrome_status || '', 'oninput="captureMedicalDraft()"')} ${input('improvement_status','Status Perbaikan Setelah Tindakan','text',med.improvement_status || '', 'oninput="captureMedicalDraft()"')} ${input('action_notes','Catatan Tindakan','text',med.action_notes || '', 'oninput="captureMedicalDraft()"')}

    Tindakan & BHP

    ${state.nakesItems.map(renderNakesItem).join('')}
    `; } async function go(page) { captureMedicalDraft(); if (!allowed(page)) state.page = defaultPageForRole(); else state.page = page; renderShell(); if (state.page === 'packages') { if (!state.packageTab) state.packageTab = 'master'; loadPackageTabV24(state.packageTab === 'balances' ? 'balances' : 'master', true); } else if (state.page === 'payments') { state.paymentHistoryLoaded = false; state.data.paymentHistory = []; loadPaymentData(true, { silent: true, force: true }); } else if (['nakesboard', 'registration', 'dashboard'].includes(state.page)) { // v35: jangan force reload setiap pindah menu. Refresh manual tetap force. loadWorkflowData(true, { silent: true, force: false }); } } /* ========================================================= * OVERRIDE v35 — Lazy Attendance Log UI * Menu Kehadiran loads list only after [Lihat Kehadiran]. * ========================================================= */ function attendanceNameV27(id) { id = String(id || '').trim(); const nakes = byId(state.data.nakes, id) || byId(state.data.staff, id) || byId(state.data.presentNakes, id); return nakes.name || nakes.staff_name || nakes.nakes_name || id || '-'; } async function loadAttendanceLogV27(mode = 'today') { const btn = document.getElementById('btnLoadAttendanceV27'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat kehadiran...'; } try { const payload = { mode: mode, month: document.getElementById('attendanceMonthV27')?.value || ym(), start_date: document.getElementById('attendanceDateV27')?.value || today() }; const res = await call('getAttendancePageData', payload, { __silent: true }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.attendanceLogLoaded = true; state.attendanceLogMode = mode; renderShell(); } else { toast((res && res.message) || 'Gagal memuat log kehadiran.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Kehadiran'; } } } function renderAttendanceLogTableV27() { const rows = arr(state.data.attendance); const isAdmin = state.user && state.user.role === 'Admin'; const filtered = isAdmin ? rows : rows.filter(a => { const current = currentAttendanceIdentity(); return current.id && String(a.nakes_id || a.staff_id || '') === String(current.id); }); if (!filtered.length) { return empty('Belum ada data kehadiran pada periode ini.'); } return table( ['Tanggal','Nakes','Status','Masuk','Pulang','Tunjangan','Catatan'], filtered.map(a => [ esc(String(a.attendance_date || '').slice(0, 10)), esc(attendanceNameV27(a.nakes_id || a.staff_id || '')), esc(a.status || ''), esc(formatTime(a.check_in_at)), esc(formatTime(a.check_out_at)), rupiah(a.allowance_amount || 0), esc(a.notes || '') ]) ); } function renderAttendance() { const presentRows = presentNakesRowsV25 ? presentNakesRowsV25() : arr(state.data.presentNakes); return `

    Kehadiran Nakes

    Login role Nakes otomatis dianggap hadir. Menu ini adalah log kehadiran, bukan beban load awal.

    Nakes hadir sekarang
    ${ presentRows.length ? presentRows.map(n => esc(n.name || n.staff_name || n.id)).join(', ') : 'Belum ada nakes yang tercatat hadir/login.' }
    ${input('attendanceDateV27','Tanggal','date',today())} ${input('attendanceMonthV27','Bulan','month',ym())}
    ${ state.attendanceLogLoaded ? renderAttendanceLogTableV27() : empty('Klik tombol Lihat Kehadiran untuk memuat log absensi.') }
    `; } /* ========================================================= * OVERRIDE v35 — Present Nakes UI robust mapping + Attendance call fix * ========================================================= */ function resolveStaffFromAttendancePersonV28(personId) { personId = String(personId || '').trim(); if (!personId) return null; const allStaff = arr(state.data.nakes).concat(arr(state.data.staff)); const uniqueStaff = []; const seen = new Set(); allStaff.forEach(s => { if (s && s.id && !seen.has(String(s.id))) { uniqueStaff.push(s); seen.add(String(s.id)); } }); let found = uniqueStaff.find(s => String(s.id) === personId); if (found) return found; let userId = personId; if (userId.startsWith('USER:')) userId = userId.slice(5); const user = arr(state.data.users).find(u => String(u.id || '') === userId || String(u.username || '') === userId || String(u.name || '') === userId ); const normalize = value => String(value || '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .replace(/\s+/g, ' ') .trim(); if (user) { const uName = normalize(user.name); const uUsername = normalize(user.username); found = uniqueStaff.find(s => { const sName = normalize(s.name || s.staff_name || s.nakes_name); const sUsername = normalize(s.username); return (uName && sName && uName === sName) || (uUsername && sUsername && uUsername === sUsername) || (uUsername && sName && uUsername === sName); }); if (found) return found; } const pNorm = normalize(personId.replace(/^USER:/, '')); return uniqueStaff.find(s => { const sName = normalize(s.name || s.staff_name || s.nakes_name); const sUsername = normalize(s.username); return pNorm && (pNorm === sName || pNorm === sUsername); }) || null; } function presentNakesRowsV25() { if (Array.isArray(state.data.presentNakes) && state.data.presentNakes.length) { return state.data.presentNakes.filter(n => String(n.status || 'Aktif') === 'Aktif'); } const present = {}; const todayValue = today(); arr(state.data.attendance) .filter(a => String(a.attendance_date || '').slice(0, 10) === todayValue && String(a.status || '').toLowerCase() === 'hadir' && !a.check_out_at ) .forEach(a => { const ids = [ a.nakes_id, a.staff_id, a.user_id, a.employee_id ].map(x => String(x || '').trim()).filter(Boolean); ids.forEach(id => { const staff = resolveStaffFromAttendancePersonV28(id); if (staff && staff.id) present[String(staff.id)] = staff; }); }); const rows = Object.values(present); if (rows.length) return rows; // Fallback khusus kalau yang sedang login adalah Nakes dan baris attendance baru // belum masuk cache halaman. if (state.user && state.user.role === 'Nakes') { const current = currentAttendanceIdentity(); if (current.id) { return [{ id: current.id, name: current.name, status: 'Aktif' }]; } } return []; } function pelaksanaOptions() { const options = [{ value: '', label: 'Pilih pelaksana' }]; const seen = new Set(['']); presentNakesRowsV25().forEach(n => { if (!n || !n.id) return; if (!seen.has(String(n.id))) { options.push({ value: n.id, label: (n.name || n.staff_name || n.nakes_name || n.id) + ' (Hadir)' }); seen.add(String(n.id)); } }); if (options.length === 1) { options.push({ value: '', label: 'Tidak ada nakes hadir' }); } return options; } async function loadAttendanceLogV27(mode = 'today') { const btn = document.getElementById('btnLoadAttendanceV27'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat kehadiran...'; } try { const payload = { mode: mode, month: document.getElementById('attendanceMonthV27')?.value || ym(), start_date: document.getElementById('attendanceDateV27')?.value || today() }; const res = await call('getAttendancePageData', payload, { __silent: true }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.attendanceLogLoaded = true; state.attendanceLogMode = mode; renderShell(); } else { toast((res && res.message) || 'Gagal memuat log kehadiran.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Kehadiran'; } } } function renderAttendance() { const presentRows = presentNakesRowsV25(); return `

    Kehadiran Nakes

    Login role Nakes otomatis dianggap hadir. Menu ini adalah log kehadiran, bukan beban load awal.

    Nakes hadir sekarang
    ${ presentRows.length ? presentRows.map(n => esc(n.name || n.staff_name || n.nakes_name || n.id)).join(', ') : 'Belum ada nakes yang tercatat hadir/login.' }
    ${input('attendanceDateV27','Tanggal','date',today())} ${input('attendanceMonthV27','Bulan','month',ym())}
    ${ state.attendanceLogLoaded ? renderAttendanceLogTableV27() : empty('Klik tombol Lihat Kehadiran untuk memuat log absensi.') }
    `; } /* ========================================================= * OVERRIDE v35 — doctor fallback, attendance list, BHP lazy, speed * ========================================================= */ function normalizePersonTextV30(value) { return String(value || '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function resolveStaffFromAttendancePersonV30(personId) { personId = String(personId || '').trim(); if (!personId) return null; const allStaff = arr(state.data.nakes).concat(arr(state.data.staff)); const uniqueStaff = []; const seenStaff = new Set(); allStaff.forEach(s => { if (s && s.id && !seenStaff.has(String(s.id))) { uniqueStaff.push(s); seenStaff.add(String(s.id)); } }); let found = uniqueStaff.find(s => String(s.id) === personId); if (found) return found; let userId = personId; if (userId.startsWith('USER:')) userId = userId.slice(5); const user = arr(state.data.users).find(u => String(u.id || '') === userId || String(u.username || '') === userId || String(u.name || '') === userId ); if (user) { const uName = normalizePersonTextV30(user.name); const uUsername = normalizePersonTextV30(user.username); found = uniqueStaff.find(s => { const sName = normalizePersonTextV30(s.name || s.staff_name || s.nakes_name); const sUsername = normalizePersonTextV30(s.username); return (uName && sName && uName === sName) || (uUsername && sUsername && uUsername === sUsername) || (uUsername && sName && uUsername === sName); }); if (found) return found; } const pNorm = normalizePersonTextV30(personId.replace(/^USER:/, '')); return uniqueStaff.find(s => { const sName = normalizePersonTextV30(s.name || s.staff_name || s.nakes_name); const sUsername = normalizePersonTextV30(s.username); return pNorm && (pNorm === sName || pNorm === sUsername); }) || null; } function presentNakesRowsV25() { if (Array.isArray(state.data.presentNakes) && state.data.presentNakes.length) { return state.data.presentNakes.filter(n => String(n.status || 'Aktif') === 'Aktif'); } const present = {}; const todayValue = today(); arr(state.data.attendance) .filter(a => String(a.attendance_date || '').slice(0, 10) === todayValue && String(a.status || '').toLowerCase() === 'hadir' && !a.check_out_at ) .forEach(a => { const ids = [ a.nakes_id, a.staff_id, a.user_id, a.employee_id ].map(x => String(x || '').trim()).filter(Boolean); ids.forEach(id => { const staff = resolveStaffFromAttendancePersonV30(id); if (staff && staff.id) present[String(staff.id)] = staff; }); }); const rows = Object.values(present); if (rows.length) return rows; if (state.user && state.user.role === 'Nakes') { const current = currentAttendanceIdentity(); if (current.id) { return [{ id: current.id, name: current.name || state.user.name, status: 'Aktif' }]; } } return []; } function getDefaultDoctorNameV30() { return state.data?.settings?.default_doctor_name || state.publicSettings?.default_doctor_name || appSettings()?.default_doctor_name || 'David Sungahandra'; } function pelaksanaOptions() { const doctorName = getDefaultDoctorNameV30(); const doctorValue = 'DOCTOR::' + doctorName; const options = [ { value: doctorValue, label: doctorName + ' (Dokter / tanpa asisten)' } ]; const seen = new Set([doctorValue]); presentNakesRowsV25().forEach(n => { if (!n || !n.id) return; if (!seen.has(String(n.id))) { options.push({ value: n.id, label: (n.name || n.staff_name || n.nakes_name || n.id) + ' (Nakes hadir)' }); seen.add(String(n.id)); } }); return options; } function attendanceNameV27(id) { id = String(id || '').trim(); const row = arr(state.data.attendance).find(a => String(a.nakes_id || '') === id || String(a.staff_id || '') === id || String(a.user_id || '') === id || String(a.employee_id || '') === id ); if (row && (row.display_name || row.staff_name || row.nakes_name)) { return row.display_name || row.staff_name || row.nakes_name; } const nakes = byId(state.data.nakes, id) || byId(state.data.staff, id) || byId(state.data.presentNakes, id); return nakes.name || nakes.staff_name || nakes.nakes_name || id || '-'; } async function loadAttendanceLogV30(mode = 'today') { const btn = document.getElementById('btnLoadAttendanceV30') || document.getElementById('btnLoadAttendanceV27'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat kehadiran...'; } try { const payload = { mode: mode, month: document.getElementById('attendanceMonthV30')?.value || document.getElementById('attendanceMonthV27')?.value || ym(), start_date: mode === 'date' ? (document.getElementById('attendanceDateV30')?.value || document.getElementById('attendanceDateV27')?.value || today()) : today() }; const res = await call('getAttendancePageData', payload, { __silent: true }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.attendanceLogLoaded = true; state.attendanceLogMode = mode; renderShell(); } else { toast((res && res.message) || 'Gagal memuat log kehadiran.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Kehadiran'; } } } function renderAttendanceLogTableV27() { const rows = arr(state.data.attendanceDisplayRows).length ? arr(state.data.attendanceDisplayRows) : arr(state.data.attendance); if (!rows.length) { return empty('Belum ada data kehadiran pada periode ini.'); } return table( ['Tanggal','Nakes','Status','Masuk','Pulang','Tunjangan','Catatan'], rows.map(a => [ esc(String(a.attendance_date || '').slice(0, 10)), esc(a.display_name || a.staff_name || a.nakes_name || attendanceNameV27(a.nakes_id || a.staff_id || a.user_id || '')), esc(a.status || ''), esc(formatTime(a.check_in_at)), esc(formatTime(a.check_out_at)), rupiah(a.allowance_amount || 0), esc(a.notes || '') ]) ); } function renderAttendance() { const presentRows = presentNakesRowsV25(); return `

    Kehadiran Nakes

    Login role Nakes otomatis dianggap hadir. Tombol ini memuat log absensi hanya saat dibutuhkan.

    Nakes hadir sekarang
    ${ presentRows.length ? presentRows.map(n => esc(n.name || n.staff_name || n.nakes_name || n.id)).join(', ') : 'Belum ada nakes yang tercatat hadir/login.' }
    ${input('attendanceDateV30','Tanggal','date',today())} ${input('attendanceMonthV30','Bulan','month',ym())}
    ${ state.attendanceLogLoaded ? renderAttendanceLogTableV27() : empty('Klik tombol Lihat Kehadiran untuk memuat log absensi.') }
    `; } async function ensureBhpLoadedV30(force = false) { if (!force && arr(state.data.bhpItems).length) return true; if (state.bhpLoading) return false; state.bhpLoading = true; try { const res = await call('getBhpItemsData', { __silent: true }); if (res && res.success) { const data = res.data || {}; state.data.bhpItems = data.bhpItems || data.bhp || []; state.data.bhp = data.bhp || data.bhpItems || []; return true; } return false; } finally { state.bhpLoading = false; } } function renderBhp() { const rows = bhpRows(); if (!rows.length) { setTimeout(() => ensureBhpLoadedV30(false).then(ok => { if (ok && state.page === 'bhp') renderShell(); }), 50); return `

    BHP/Stok

    Data BHP dimuat terpisah agar login awal lebih cepat.

    ${empty('Memuat data BHP/Stok...')}
    `; } const editId = state.edit.bhp; const e = editId ? byId(rows, editId) : {}; return ` ${formWrap('bhpForm', ` ${input('name','Nama BHP','text',e.name || '')} ${input('unit','Satuan','text',e.unit || 'pcs')} ${input('current_stock','Stok Saat Ini','number',e.current_stock || '')} ${input('minimum_stock','Minimum Stok','number',e.minimum_stock || '')} ${input('cost_price','Harga Modal Opsional','number',e.cost_price || '')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')} ${ editId ? `
    ` : '' } `, editId ? 'Update BHP' : 'Simpan' )} ${ table(['BHP','Stok','Minimum','Status','Aksi'], rows.map(b => [ esc(b.name), `${esc(b.current_stock)} ${esc(b.unit || '')}`, esc(b.minimum_stock || 0), esc(b.status), ` ` ])) } `; } function renderNakesItem(item, idx) { const services = [{ value: '', label: 'Pilih tindakan' }].concat( arr(state.data.services) .filter(s => s.status === 'Aktif') .map(s => ({ value: s.id, label: `${s.name}` })) ); const nakesOptions = pelaksanaOptions(); const bhps = bhpRows().filter(b => b.status === 'Aktif'); if (!bhps.length && !state.bhpLoading) { setTimeout(() => ensureBhpLoadedV30(false).then(ok => { if (ok && state.page === 'nakesboard') renderShell(); }), 50); } const serviceSelect = select(`service_id_${idx}`,'Tindakan',services,item.service_id) .replace(' { const row = arr(item.bhp).find(x => x.bhp_item_id === b.id) || {}; return ` `; }).join('') : `
    Data BHP sedang dimuat. Form tindakan tetap bisa diisi.
    `; return `
    ${serviceSelect} ${nakesSelect} ${notesInput}
    BHP digunakan (tidak masuk tagihan)
    ${bhpHtml}
    `; } async function go(page) { captureMedicalDraft(); if (!allowed(page)) state.page = defaultPageForRole(); else state.page = page; renderShell(); if (state.page === 'packages') { if (!state.packageTab) state.packageTab = 'master'; loadPackageTabV24(state.packageTab === 'balances' ? 'balances' : 'master', true); } else if (state.page === 'payments') { state.paymentHistoryLoaded = false; state.data.paymentHistory = []; loadPaymentData(true, { silent: true, force: true }); } else if (state.page === 'bhp') { ensureBhpLoadedV30(false).then(ok => { if (ok && state.page === 'bhp') renderShell(); }); } else if (state.page === 'nakesboard') { loadWorkflowData(true, { silent: true, force: true }).then(() => { ensureBhpLoadedV30(false).then(ok => { if (ok && state.page === 'nakesboard') renderShell(); }); }); } else if (['registration', 'dashboard'].includes(state.page)) { loadWorkflowData(true, { silent: true, force: true }); } } /* ========================================================= * OVERRIDE v35 — cancel refresh + users lazy + backup/dummy UX * ========================================================= */ async function cancelVisitFromRegistration(visitId, clientName) { if (!visitId) { toast('Visit tidak ditemukan.', false); return; } const reason = prompt('Alasan pembatalan kunjungan ' + (clientName || 'pasien') + ':', 'Pasien batal datang'); if (reason === null) return; const okCancel = confirm('Yakin membatalkan kunjungan ini?'); if (!okCancel) return; const res = await call('cancelVisit', visitId, reason || '', state.user.id); if (res.success) { toast(res.message || 'Kunjungan berhasil dibatalkan.'); state.data.visits = arr(state.data.visits).filter(v => String(v.id) !== String(visitId)); if (state.data.dashboard) { state.data.dashboard.waiting_nakes = arr(state.data.visits).filter(v => ['Daftar','Pemeriksaan','Tindakan','Antri Pemeriksaan','Masuk Ruang Periksa'].includes(String(v.visit_status || '')) ).length; state.data.dashboard.waiting_payment = arr(state.data.visits).filter(v => String(v.payment_status || '') === 'Siap Bayar' || String(v.visit_status || '') === 'Selesai Tindakan' ).length; state.data.dashboard.visits_today = arr(state.data.visits).filter(v => String(v.visit_date || '').slice(0,10) === today() && String(v.visit_status || '') !== 'Batal' ).length; } if (state.activeVisitId === visitId) state.activeVisitId = ''; if (state.activePaymentVisitId === visitId) state.activePaymentVisitId = ''; renderShell(); // Force refresh setelah UI langsung dibersihkan. loadWorkflowData(true, { silent: true, force: true }); } } async function loadUsersPageV31(mode = 'list') { const btn = document.getElementById('btnLoadUsersV31'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat pengguna...'; } try { const res = await call('getUsersPageData', { mode: mode, user: state.user }, { __silent: true }); if (res && res.success) { state.data.users = arr(res.data.users); state.usersLoaded = true; state.userMode = mode === 'add' ? 'add' : 'list'; renderShell(); } else { toast((res && res.message) || 'Gagal memuat pengguna.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Pengguna'; } } } function renderUserFormV31() { const rows = arr(state.data.users); const editId = state.edit.user; const e = editId ? byId(rows, editId) : {}; return formWrap('userForm', ` ${input('name','Nama Pengguna','text',e.name || '')} ${input('username','Username','text',e.username || '')} ${input('password', editId ? 'Password Baru, kosongkan jika tidak diganti' : 'Password','password','')} ${select('role','Role',[ {value:'Admin',label:'Admin'}, {value:'Kasir',label:'Kasir'}, {value:'Frontliner',label:'Frontliner'}, {value:'Nakes',label:'Nakes'} ], e.role || 'Kasir')} ${select('status','Status',[ {value:'Aktif',label:'Aktif'}, {value:'Nonaktif',label:'Nonaktif'} ], e.status || 'Aktif')}
    `, editId ? 'Update Pengguna' : 'Tambahkan Pengguna' ); } function editUserV31(id) { state.edit.user = id; state.userMode = 'edit'; renderShell(); setTimeout(() => document.getElementById('userForm')?.scrollIntoView({ behavior: 'smooth', block: 'start' }), 80); } function cancelUserEditV31() { state.edit.user = null; state.userMode = state.usersLoaded ? 'list' : ''; renderShell(); } function renderUsers() { const rows = arr(state.data.users); const loaded = !!state.usersLoaded || rows.length > 0; const mode = state.userMode || ''; return `

    Pengguna Sistem

    List pengguna tidak diload di awal agar login dan dashboard tetap cepat.

    ${ mode === 'add' || mode === 'edit' ? renderUserFormV31() : '' }

    Daftar Pengguna

    ${ loaded ? ( rows.length ? table(['Nama','Username','Role','Status','Aksi'], rows.map(u => [ esc(u.name), esc(u.username), esc(u.role), esc(u.status), `
    ` ])) : empty('Belum ada pengguna.') ) : empty('Klik tombol Lihat Pengguna untuk memuat daftar pengguna.') }
    `; } async function submitForm(e, id) { e.preventDefault(); const data = fd(e.target); const map = { clientForm: 'saveClient', nakesForm: 'saveNakes', serviceForm: 'saveService', bhpForm: 'saveBhpItem', userForm: 'saveUser', attendanceForm: 'saveAttendance', settingsForm: 'saveSettings' }; const editMap = { clientForm: 'client', nakesForm: 'nakes', serviceForm: 'service', bhpForm: 'bhp', userForm: 'user', attendanceForm: 'attendance' }; const fn = map[id]; if (!fn) { toast('Form tidak dikenali: ' + id, false); return; } const res = await call(fn, data, state.user.id); if (res.success) { toast(res.message || 'Data berhasil disimpan'); const editKey = editMap[id]; if (editKey) state.edit[editKey] = null; e.target.reset(); if (id === 'userForm') { if (res.data && res.data.pageData && Array.isArray(res.data.pageData.users)) { state.data.users = res.data.pageData.users; } else { await loadUsersPageV31('list'); return; } state.usersLoaded = true; state.userMode = 'list'; renderShell(); return; } if (id === 'serviceForm' || id === 'nakesForm') { if (!mergeSmallPageDataV16(res)) { await loadWorkflowData(true, { silent: true, force: true }); } else { renderShell(); } return; } if (id === 'clientForm') { if (data.id && state.adminClients && Array.isArray(state.adminClients.rows)) { const idx = state.adminClients.rows.findIndex(c => String(c.id) === String(data.id)); if (idx >= 0) state.adminClients.rows[idx] = Object.assign({}, state.adminClients.rows[idx], data); } else { state.adminClients.rows = []; state.adminClients.searched = false; } renderShell(); return; } if (id === 'attendanceForm') { await loadWorkflowData(true, { silent: true, force: true }); return; } await loadWorkflowData(true, { silent: true, force: true }); if (id === 'settingsForm') await loadPublicSettings(); } } async function del(fn, id) { if (!confirm('Yakin ingin menghapus/menonaktifkan data ini?')) return; const map = { deleteClient: 'Clients', deleteNakes: 'Nakes', deleteService: 'Services', deleteBhpItem: 'BHP_Items', deleteUser: 'Users', deleteAttendance: 'Attendance' }; const sheetName = map[fn]; if (!sheetName) { toast('Fungsi hapus tidak dikenali: ' + fn, false); return; } const res = await call('removeData', sheetName, id, state.user.id); if (res.success) { toast(res.message || 'Data berhasil dihapus/dinonaktifkan'); if (fn === 'deleteUser') { state.data.users = arr(state.data.users).map(u => { if (String(u.id) === String(id)) return Object.assign({}, u, { status: 'Nonaktif', is_deleted: true }); return u; }); renderShell(); return; } if (fn === 'deleteService' || fn === 'deleteNakes' || fn === 'deleteBhpItem') { if (!mergeSmallPageDataV16(res)) { await loadWorkflowData(true, { silent: true, force: true }); } else { renderShell(); } return; } await loadWorkflowData(true, { silent: true, force: true }); } } async function backup() { const btn = event && event.target ? event.target : null; const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Menyiapkan backup...'; } try { const res = await call('exportBackup', { __silent: true }); if (!res.success) { toast(res.message || 'Gagal membuat backup.', false); return; } const content = res.data && res.data.content ? res.data.content : JSON.stringify(res.data || {}, null, 2); const filename = res.data && res.data.filename ? res.data.filename : 'backup-simrs-klinik.json'; const blob = new Blob([content], { type: 'application/json;charset=utf-8' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); toast('Backup berhasil dibuat.'); } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Unduh Backup'; } } } async function adminDeleteTestData() { const keywordEl = $('dummyDeleteKeyword'); const resultEl = $('dummyDeleteResult'); const keyword = keywordEl ? keywordEl.value.trim() : ''; if (keyword.length < 3) { toast('Masukkan minimal 3 karakter kata kunci.', false); return; } const confirmText = `Yakin hapus/soft-delete data test/dummy terkait "${keyword}"? Data tidak di-DROP, tetapi visit/transaksi/paket akan dibatalkan dan stok BHP aktif akan dikembalikan.`; if (!confirm(confirmText)) return; if (resultEl) { resultEl.innerHTML = '
    Memproses penghapusan data test/dummy...
    '; } const res = await call('adminDeleteTestClientData', keyword, state.user.id); if (!res.success) { if (resultEl) { resultEl.innerHTML = `
    ${esc(res.message || 'Gagal menghapus data.')}
    `; } return; } const data = res.data || {}; const deleted = data.deleted || {}; const restored = data.restored_stock || {}; const deletedClients = arr(data.deleted_clients).map(c => `${c.name || ''} (${c.client_code || c.id || ''})`).join(', '); const restoredRows = Object.keys(restored).map(k => `
  • ${esc(k)}: +${esc(restored[k])}
  • `).join(''); if (resultEl) { resultEl.innerHTML = `
    Berhasil diproses
    Klien: ${esc(deletedClients || '-')}
    ${esc(data.note || '')}
    Clients: ${deleted.clients || 0}
    Visits: ${deleted.visits || 0}
    Transactions: ${deleted.transactions || 0}
    Transaction Items: ${deleted.transaction_items || 0}
    Medical Notes: ${deleted.medical_notes || 0}
    BHP Usage: ${deleted.bhp_usage || 0}
    Package Usage: ${deleted.package_usages || 0}
    Client Packages: ${deleted.client_packages || 0}
    ${ restoredRows ? `
    Stok dikembalikan:
      ${restoredRows}
    ` : '' }
    `; } await loadWorkflowData(true, { silent: true, force: true }); } function renderBackup() { return `

    Backup Database

    Unduh data Supabase dalam format JSON sebagai cadangan.

    Hapus Data Test / Dummy

    Khusus Admin. Ini melakukan soft-delete/pembatalan data terkait kata kunci. Tidak DROP table.

    Catatan: gunakan hanya untuk data test/dummy. Sistem akan membatalkan visit/transaksi/paket terkait dan mencoba mengembalikan stok BHP aktif.
    `; } async function go(page) { captureMedicalDraft(); if (!allowed(page)) state.page = defaultPageForRole(); else state.page = page; if (state.page !== 'users') { state.userMode = state.userMode || ''; } renderShell(); if (state.page === 'packages') { if (typeof loadPackageTabV24 === 'function') { if (!state.packageTab) state.packageTab = 'master'; loadPackageTabV24(state.packageTab === 'balances' ? 'balances' : 'master', true); } else { refreshPackagePageFast(true); } } else if (state.page === 'payments') { if (state.data) state.data.paymentHistory = []; state.paymentHistoryLoaded = false; loadPaymentData(true, { silent: true, force: true }); } else if (['nakesboard', 'registration', 'dashboard'].includes(state.page)) { loadWorkflowData(true, { silent: true, force: true }); } } /* ========================================================= * OVERRIDE v35 — users fallback + canceled report table details * ========================================================= */ async function loadUsersPageV31(mode = 'list') { const btn = document.getElementById('btnLoadUsersV31'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat pengguna...'; } try { let res = await call('getUsersPageData', { mode: mode, user: state.user }, { __silent: true }); // Fallback bila server.js lama belum overwrite/restart dan belum mengizinkan getUsersPageData. if (!res || !res.success) { const msg = String((res && res.message) || ''); if (msg.indexOf('tidak diizinkan') >= 0 || msg.indexOf('getUsersPageData') >= 0) { toast('Server masih memakai allow-list lama. Mencoba fallback pengguna...', false); const wf = await call('getWorkflowData', state.user, { __silent: true }); if (wf && wf.success && wf.data && Array.isArray(wf.data.users)) { res = { success: true, data: { users: wf.data.users }, message: 'Daftar pengguna loaded via fallback.' }; } } } if (res && res.success) { state.data.users = arr(res.data.users); state.usersLoaded = true; state.userMode = mode === 'add' ? 'add' : 'list'; renderShell(); } else { toast((res && res.message) || 'Gagal memuat pengguna.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Pengguna'; } } } function renderReportResult(r) { r = r || {}; r.summary = r.summary || {}; r.by_service = arr(r.by_service); r.by_nakes = arr(r.by_nakes); r.by_payment = arr(r.by_payment); r.bhp_usage = arr(r.bhp_usage); r.transactions = arr(r.transactions); r.transactions_paid = arr(r.transactions_paid); r.transactions_package = arr(r.transactions_package); r.transactions_mixed = arr(r.transactions_mixed); r.transactions_canceled = arr(r.transactions_canceled); r.visits_canceled = arr(r.visits_canceled); const reportEl = $('reportResult'); if (!reportEl) return; const txTable = function(title, description, rows, emptyText) { return `

    ${title}

    ${description ? `

    ${description}

    ` : ''}
    ${ rows.length ? table(['Invoice','Visit','Tanggal','Klien','Total','Status'], rows.map(t => [ esc(t.invoice_number || '-'), esc(t.visit_number || t.visit_id || '-'), esc(t.transaction_date || '-'), esc(t.client_name || '-'), rupiah(t.total_amount || 0), esc(t.report_payment_status || t.payment_status || t.status || '-') ])) : empty(emptyText || 'Tidak ada data.') }
    `; }; const canceledTable = function(rows) { return `

    Batal / Dibatalkan

    Menampilkan siapa pasien yang batal, alasan, dan petugas yang membatalkan untuk audit.

    ${ rows.length ? table(['Visit','Tanggal','Klien','Status Visit','Status Bayar','Alasan','Dibatalkan Oleh'], rows.map(t => [ esc(t.visit_number || t.visit_id || '-'), esc(t.transaction_date || t.visit_date || '-'), esc(t.client_name || t.client_id || '-'), esc(t.visit_status || t.report_payment_status || '-'), esc(t.payment_status || '-'), esc(t.cancel_reason || t.delete_reason || '-'), esc(t.cancelled_by_name || t.cancelled_by || t.deleted_by || '-') ])) : empty('Tidak ada transaksi/kunjungan batal.') }
    `; }; reportEl.innerHTML = `
    ${card('Kedatangan', r.summary.visits || 0, 'Users')} ${card('Sukses Berbayar', r.summary.paid_transactions || 0, 'Receipt')} ${card('Paket', r.summary.package_transactions || 0, 'Ticket')} ${card('Campuran', r.summary.mixed_transactions || 0, 'SplitSquareHorizontal')} ${card('Batal', r.summary.canceled_transactions || 0, 'XCircle')} ${card('Omset Uang Masuk', rupiah(r.summary.revenue || 0), 'Banknote')} ${card('Belum Selesai', r.summary.open_visit_count || 0, 'Clock')}
    ${renderClosingInfo(r)}
    ${table(['Tindakan Sukses','Jumlah'], arr(r.by_service).map(x => [esc(x.name), x.value]))}
    ${table(['Nakes','Jumlah Tindakan Sukses'], arr(r.by_nakes).map(x => [esc(x.name), x.value]))}
    ${table(['Metode Pembayaran Uang Masuk','Total'], arr(r.by_payment).map(x => [esc(x.name), rupiah(x.value)]))}
    ${table(['BHP Terpakai Transaksi Sukses','Jumlah'], arr(r.bhp_usage).map(x => [esc(x.name), x.value]))}
    ${txTable('Sukses Berbayar', 'Transaksi yang menghasilkan uang masuk. Masuk ke omset.', r.transactions_paid, 'Tidak ada transaksi berbayar.')} ${txTable('Paket', 'Tindakan ditutup dengan potong token paket. Tidak masuk omset uang.', r.transactions_package, 'Tidak ada transaksi paket.')} ${txTable('Campuran', 'Sebagian tindakan memakai paket dan sebagian bayar normal. Hanya uang masuk yang dihitung omset.', r.transactions_mixed, 'Tidak ada transaksi campuran.')} ${canceledTable(r.transactions_canceled)} `; if (window.lucide) lucide.createIcons(); } /* ========================================================= * OVERRIDE v35 — Attendance Login/Logout Log UI * ========================================================= */ function attendanceDurationLabelV33(minutes) { if (minutes === null || minutes === undefined || minutes === '') return '-'; minutes = Number(minutes || 0); if (!minutes) return '0 menit'; const h = Math.floor(minutes / 60); const m = minutes % 60; if (h && m) return `${h} jam ${m} menit`; if (h) return `${h} jam`; return `${m} menit`; } function attendanceTimeLabelV33(value) { if (!value) return '-'; return formatTime(value); } async function loadAttendanceLogV33(mode = 'recent') { const btn = document.getElementById('btnLoadAttendanceV33') || document.getElementById('btnLoadAttendanceV30') || document.getElementById('btnLoadAttendanceV27'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat log...'; } try { const payload = { mode: mode, month: document.getElementById('attendanceMonthV33')?.value || document.getElementById('attendanceMonthV30')?.value || ym(), start_date: document.getElementById('attendanceDateV33')?.value || document.getElementById('attendanceDateV30')?.value || today() }; const res = await call('getAttendancePageData', payload, { __silent: true }); if (res && res.success) { const data = res.data || {}; state.data = Object.assign({}, state.data || {}, data); state.data.attendanceDisplayRows = data.attendanceSessions || data.attendanceDisplayRows || data.attendance || []; state.attendanceLogLoaded = true; state.attendanceLogMode = mode; renderShell(); } else { toast((res && res.message) || 'Gagal memuat log kehadiran.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Kehadiran'; } } } // Kompatibilitas tombol lama. async function loadAttendanceLogV30(mode = 'recent') { if (mode === 'today') mode = 'recent'; return loadAttendanceLogV33(mode); } async function loadAttendanceLogV27(mode = 'recent') { if (mode === 'today') mode = 'recent'; return loadAttendanceLogV33(mode); } function renderAttendanceLogTableV27() { const rows = arr(state.data.attendanceDisplayRows).length ? arr(state.data.attendanceDisplayRows) : arr(state.data.attendanceSessions).length ? arr(state.data.attendanceSessions) : arr(state.data.attendance); if (!rows.length) { return empty('Belum ada data login/logout pada periode ini. Pastikan nakes login melalui akun role Nakes.'); } return table( ['Tanggal','Nakes/Karyawan','Login','Logout','Durasi','Status','Tunjangan','Catatan'], rows.map(a => { const loginAt = a.check_in_at || a.login_at || a.created_at || ''; const logoutAt = a.check_out_at || a.logout_at || ''; return [ esc(String(a.attendance_date || '').slice(0, 10)), esc(a.display_name || a.staff_name || a.nakes_name || attendanceNameV27(a.nakes_id || a.staff_id || a.user_id || '')), esc(attendanceTimeLabelV33(loginAt)), logoutAt ? esc(attendanceTimeLabelV33(logoutAt)) : 'Masih login / hadir', esc(attendanceDurationLabelV33(a.duration_minutes)), esc(a.status || ''), rupiah(a.allowance_amount || 0), esc(a.notes || '') ]; }) ); } function renderAttendance() { const presentRows = presentNakesRowsV25(); const modeLabel = state.attendanceLogMode === 'month' ? 'Bulanan' : state.attendanceLogMode === 'date' ? 'Tanggal tertentu' : 'Terbaru'; return `

    Kehadiran Karyawan / Nakes

    Login role Nakes otomatis membuat log masuk. Logout mengisi jam pulang. Admin melihat semua karyawan.

    Nakes hadir sekarang
    ${ presentRows.length ? presentRows.map(n => esc(n.name || n.staff_name || n.nakes_name || n.id)).join(', ') : 'Belum ada nakes yang tercatat hadir/login.' }
    ${input('attendanceDateV33','Tanggal','date',today())} ${input('attendanceMonthV33','Bulan','month',ym())}
    Mode: ${esc(modeLabel)}
    ${ state.attendanceLogLoaded ? renderAttendanceLogTableV27() : empty('Klik Lihat Kehadiran untuk memuat log: kapan login dan kapan logout.') }
    `; } /* ========================================================= * OVERRIDE v35 — Frontend WIB date + doctor + present nakes dropdown * ========================================================= */ function wibPartsV34(dateObj) { const fmt = new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Jakarta', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }); const parts = fmt.formatToParts(dateObj || new Date()); const map = {}; parts.forEach(p => { map[p.type] = p.value; }); return map; } function today() { const p = wibPartsV34(new Date()); return `${p.year}-${p.month}-${p.day}`; } function ym() { return today().slice(0, 7); } function normalizePersonTextV34(value) { return String(value || '') .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } function getDefaultDoctorNameV34() { return state.data?.settings?.default_doctor_name || state.publicSettings?.default_doctor_name || appSettings()?.default_doctor_name || 'David Sungahandra'; } function resolveStaffFromAttendancePersonV34(personId) { personId = String(personId || '').trim(); if (!personId) return null; const rows = []; const seen = new Set(); arr(state.data.nakes).concat(arr(state.data.staff)).forEach(s => { if (s && s.id && !seen.has(String(s.id))) { rows.push(s); seen.add(String(s.id)); } }); let found = rows.find(s => String(s.id || '') === personId); if (found) return found; let userId = personId; if (userId.startsWith('USER:')) userId = userId.slice(5); const user = arr(state.data.users).find(u => String(u.id || '') === userId || String(u.username || '') === userId || String(u.name || '') === userId ); if (user) { const uName = normalizePersonTextV34(user.name); const uUsername = normalizePersonTextV34(user.username); found = rows.find(s => { const sName = normalizePersonTextV34(s.name || s.staff_name || s.nakes_name); const sUsername = normalizePersonTextV34(s.username); return (uName && sName && uName === sName) || (uUsername && sUsername && uUsername === sUsername) || (uUsername && sName && uUsername === sName); }); if (found) return found; } const pNorm = normalizePersonTextV34(personId.replace(/^USER:/, '')); return rows.find(s => { const sName = normalizePersonTextV34(s.name || s.staff_name || s.nakes_name); const sUsername = normalizePersonTextV34(s.username); return pNorm && (pNorm === sName || pNorm === sUsername); }) || null; } function currentNakesRow() { const userName = normalizePersonTextV34(state.user && state.user.name); const userUsername = normalizePersonTextV34(state.user && state.user.username); const userStaffId = String(state.user && (state.user.staff_id || state.user.nakes_id) || '').trim(); return arr(state.data.nakes).concat(arr(state.data.staff)).find(n => { const nId = String(n.id || '').trim(); const nName = normalizePersonTextV34(n.name || n.staff_name || n.nakes_name); const nUsername = normalizePersonTextV34(n.username); return (userStaffId && nId === userStaffId) || (userName && nName && nName === userName) || (userUsername && nUsername && nUsername === userUsername) || (userUsername && nName && nName === userUsername); }) || {}; } function currentAttendanceIdentity() { const nk = currentNakesRow(); if (nk && nk.id) { return { id: nk.id, name: nk.name || nk.staff_name || nk.nakes_name, role: 'Nakes', isMasterNakes: true }; } return { id: 'USER:' + (state.user ? state.user.id : ''), name: state.user ? state.user.name : '', role: state.user ? state.user.role : '', isMasterNakes: false }; } function presentNakesRowsV25() { const present = {}; const todayValue = today(); arr(state.data.presentNakes).forEach(n => { if (n && n.id && String(n.status || 'Aktif') === 'Aktif') present[String(n.id)] = n; }); arr(state.data.attendance) .filter(a => String(a.status || '').toLowerCase() === 'hadir' && !a.check_out_at && ( String(a.attendance_date || '').slice(0, 10) === todayValue || // Fallback untuk sesi semalam yang masih login karena bug UTC/H-1 lama. true ) ) .forEach(a => { const ids = [a.nakes_id, a.staff_id, a.user_id, a.employee_id] .map(x => String(x || '').trim()) .filter(Boolean); ids.forEach(id => { const staff = resolveStaffFromAttendancePersonV34(id); if (staff && staff.id) present[String(staff.id)] = staff; }); }); if (state.user && state.user.role === 'Nakes') { const current = currentAttendanceIdentity(); if (current.id) { present[String(current.id)] = { id: current.id, name: current.name || state.user.name, status: 'Aktif' }; } } return Object.values(present) .filter(n => n && n.id && String(n.status || 'Aktif') === 'Aktif') .sort((a, b) => String(a.name || '').localeCompare(String(b.name || ''))); } function pelaksanaOptions() { const doctorName = getDefaultDoctorNameV34(); const doctorValue = 'DOCTOR::' + doctorName; const options = [ { value: doctorValue, label: doctorName + ' (Dokter / tanpa asisten)' } ]; const seen = new Set([doctorValue]); presentNakesRowsV25().forEach(n => { if (!n || !n.id) return; const id = String(n.id); if (!seen.has(id)) { options.push({ value: id, label: (n.name || n.staff_name || n.nakes_name || id) + ' (Nakes hadir)' }); seen.add(id); } }); return options; } function attendanceNameV27(id) { id = String(id || '').trim(); const row = arr(state.data.attendance).find(a => String(a.nakes_id || '') === id || String(a.staff_id || '') === id || String(a.user_id || '') === id || String(a.employee_id || '') === id ); if (row && (row.display_name || row.staff_name || row.nakes_name)) { return row.display_name || row.staff_name || row.nakes_name; } const staff = resolveStaffFromAttendancePersonV34(id) || byId(state.data.presentNakes, id); return staff?.name || staff?.staff_name || staff?.nakes_name || id || '-'; } async function loadAttendanceLogV30(mode = 'recent') { const btn = document.getElementById('btnLoadAttendanceV30') || document.getElementById('btnLoadAttendanceV27'); const old = btn ? btn.innerHTML : ''; if (btn) { btn.disabled = true; btn.innerHTML = 'Memuat kehadiran...'; } try { const payload = { mode: mode, month: document.getElementById('attendanceMonthV30')?.value || document.getElementById('attendanceMonthV27')?.value || ym(), start_date: document.getElementById('attendanceDateV30')?.value || document.getElementById('attendanceDateV27')?.value || today() }; const res = await call('getAttendancePageData', payload, { __silent: true }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.attendanceLogLoaded = true; state.attendanceLogMode = mode; renderShell(); } else { toast((res && res.message) || 'Gagal memuat log kehadiran.', false); } } finally { if (btn) { btn.disabled = false; btn.innerHTML = old || 'Lihat Kehadiran'; } } } /* ========================================================= * OVERRIDE v35 — unlock Pembayaran menu + role alias hardening * Fix for cases where role text is Administrator/Admin/Kasir/Cashier or stale state. * ========================================================= */ function roleKeyV35(role) { const r = String(role || '').trim().toLowerCase().replace(/\s+/g, ' '); if (['admin', 'administrator', 'superadmin', 'super admin'].includes(r)) return 'Admin'; if (['kasir', 'cashier', 'keuangan', 'finance'].includes(r)) return 'Kasir'; if (['frontliner', 'front line', 'frontline', 'pendaftaran'].includes(r)) return 'Frontliner'; if (['nakes', 'dokter', 'doctor', 'terapis', 'therapist'].includes(r)) return 'Nakes'; return role || 'Admin'; } function currentRoleV35() { if (!state.user) return ''; return roleKeyV35(state.user.role || state.user.user_role || state.user.level || 'Admin'); } function allowed(page) { if (!state.user) return false; const role = currentRoleV35(); // Admin selalu boleh membuka semua menu operasional. if (role === 'Admin') return true; // Kasir wajib boleh membuka pembayaran walaupun role text dari DB berbeda. if (role === 'Kasir' && ['dashboard', 'registration', 'payments', 'packages', 'clients', 'reports'].includes(page)) return true; // Nakes hanya pemeriksaan dan pendaftaran; THP sesuai setting. if (role === 'Nakes' && page === 'payroll') return nakesCanSeePayroll(); return arr(roles[role]).includes(page); } function defaultPageForRole() { if (!state.user) return 'dashboard'; const role = currentRoleV35(); if (role === 'Nakes') return 'nakesboard'; if (role === 'Frontliner') return 'registration'; if (role === 'Kasir') return 'payments'; if (role === 'Admin') return 'dashboard'; const roleMenus = arr(roles[role]); return roleMenus[0] || 'dashboard'; } async function go(page) { captureMedicalDraft(); const role = currentRoleV35(); if (!allowed(page)) { toast('Menu tidak tersedia untuk role: ' + (state.user?.role || role || '-'), false); state.page = defaultPageForRole(); } else { state.page = page; } renderShell(); if (state.page === 'packages') { if (typeof loadPackageTabV24 === 'function') { if (!state.packageTab) state.packageTab = 'master'; loadPackageTabV24(state.packageTab === 'balances' ? 'balances' : 'master', true); } else if (typeof refreshPackagePageFast === 'function') { refreshPackagePageFast(true); } } else if (state.page === 'payments') { state.paymentHistoryLoaded = false; if (state.data) state.data.paymentHistory = []; toast('Memuat antrian pembayaran...', true); loadPaymentData(true, { silent: true, force: true }); } else if (['nakesboard', 'registration', 'dashboard'].includes(state.page)) { loadWorkflowData(true, { silent: true, force: true }); } } async function loadPaymentData(shouldRender = true, options = {}) { const silent = options.silent !== undefined ? !!options.silent : true; const force = !!options.force; const nowMs = Date.now(); if (!force && state.paymentLastFetch && (nowMs - state.paymentLastFetch) < 3000) { if (shouldRender) renderShell(); return { success: true, data: state.data, message: 'Data pembayaran masih fresh.' }; } const res = await call('getPaymentPageData', { mode: 'queue', user: state.user }, { __silent: silent }); if (res && res.success) { state.data = Object.assign({}, state.data || {}, res.data || {}); state.paymentLastFetch = Date.now(); if (shouldRender) renderShell(); } else if (res && !res.success) { toast(res.message || 'Gagal memuat menu pembayaran.', false); } return res; } function renderPaymentLockedHintV35() { return `
    Menu pembayaran belum bisa dibuka.
    Cek role login. Admin dan Kasir boleh membuka Pembayaran. Role saat ini: ${esc(state.user?.role || '-')}
    `; } /* ========================================================= * OVERRIDE v37 — payment menu hard unlock * Fix: * - Pembayaran bisa diklik meskipun role di localStorage/DB terbaca aneh. * - Admin/Administrator/admin username selalu dianggap Admin. * - Kasir/Cashier selalu boleh buka Pembayaran. * - Jika tetap terkunci, tombol pembayaran tetap tampil dan go() force-open. * ========================================================= */ function normalizeRoleTextV37(v) { return String(v || '') .trim() .toLowerCase() .replace(/[_\-]+/g, ' ') .replace(/\s+/g, ' '); } function isAdminLikeV37(user) { user = user || {}; const role = normalizeRoleTextV37(user.role || user.user_role || user.level || ''); const username = normalizeRoleTextV37(user.username || ''); const name = normalizeRoleTextV37(user.name || ''); return ['admin','administrator','superadmin','super admin','owner','pemilik'].includes(role) || ['admin','administrator'].includes(username) || ['administrator','admin'].includes(name); } function isCashierLikeV37(user) { user = user || {}; const role = normalizeRoleTextV37(user.role || user.user_role || user.level || ''); const username = normalizeRoleTextV37(user.username || ''); const name = normalizeRoleTextV37(user.name || ''); return ['kasir','cashier','keuangan','finance'].includes(role) || ['kasir','cashier'].includes(username) || ['kasir','cashier'].includes(name); } function isNakesLikeV37(user) { user = user || {}; const role = normalizeRoleTextV37(user.role || user.user_role || user.level || ''); return ['nakes','dokter','doctor','terapis','therapist'].includes(role); } function roleKeyV35(role) { const r = normalizeRoleTextV37(role); if (['admin','administrator','superadmin','super admin','owner','pemilik'].includes(r)) return 'Admin'; if (['kasir','cashier','keuangan','finance'].includes(r)) return 'Kasir'; if (['frontliner','front line','frontline','pendaftaran'].includes(r)) return 'Frontliner'; if (['nakes','dokter','doctor','terapis','therapist'].includes(r)) return 'Nakes'; return role || 'Admin'; } function currentRoleV35() { if (!state.user) return ''; if (isAdminLikeV37(state.user)) return 'Admin'; if (isCashierLikeV37(state.user)) return 'Kasir'; return roleKeyV35(state.user.role || state.user.user_role || state.user.level || 'Admin'); } function paymentMenuEmergencyAllowedV37(page) { if (page !== 'payments') return false; if (!state.user) return false; // Khusus supaya menu pembayaran tidak pernah terkunci untuk admin/kasir. return isAdminLikeV37(state.user) || isCashierLikeV37(state.user) || currentRoleV35() === 'Admin' || currentRoleV35() === 'Kasir'; } function allowed(page) { if (!state.user) return false; const role = currentRoleV35(); if (paymentMenuEmergencyAllowedV37(page)) return true; if (role === 'Admin') return true; if (role === 'Kasir' && ['dashboard','registration','payments','packages','clients','reports'].includes(page)) return true; if (role === 'Nakes' && page === 'payroll') return nakesCanSeePayroll(); return arr(roles[role]).includes(page); } function defaultPageForRole() { if (!state.user) return 'dashboard'; const role = currentRoleV35(); if (role === 'Nakes') return 'nakesboard'; if (role === 'Frontliner') return 'registration'; if (role === 'Kasir') return 'payments'; if (role === 'Admin') return 'dashboard'; const roleMenus = arr(roles[role]); return roleMenus[0] || 'dashboard'; } async function openPaymentsV37() { state.page = 'payments'; state.paymentHistoryLoaded = false; if (state.data) state.data.paymentHistory = []; renderShell(); const res = await loadPaymentData(true, { silent: true, force: true }); if (res && !res.success) { toast(res.message || 'Gagal memuat menu pembayaran.', false); } } async function go(page) { captureMedicalDraft(); if (page === 'payments' && paymentMenuEmergencyAllowedV37(page)) { await openPaymentsV37(); return; } if (!allowed(page)) { toast('Menu tidak tersedia untuk role: ' + (state.user?.role || currentRoleV35() || '-'), false); state.page = defaultPageForRole(); } else { state.page = page; } renderShell(); if (state.page === 'packages') { if (typeof loadPackageTabV24 === 'function') { if (!state.packageTab) state.packageTab = 'master'; loadPackageTabV24(state.packageTab === 'balances' ? 'balances' : 'master', true); } else if (typeof refreshPackagePageFast === 'function') { refreshPackagePageFast(true); } } else if (state.page === 'payments') { await openPaymentsV37(); } else if (['nakesboard','registration','dashboard'].includes(state.page)) { loadWorkflowData(true, { silent: true, force: true }); } } function TEST_FRONTEND_V37_PAYMENT_ROLE() { return { user: state.user, role_raw: state.user && state.user.role, role_current: currentRoleV35(), is_admin_like: isAdminLikeV37(state.user), is_cashier_like: isCashierLikeV37(state.user), payment_allowed: allowed('payments'), payment_emergency_allowed: paymentMenuEmergencyAllowedV37('payments') }; } try { init().catch(showFatalError); } catch (err) { showFatalError(err); }