Dashboard

Facility Preventive Maintenance β€” BizLink Technology

πŸ”„ Menghubungkan ke Airtable...
βœ…
Konfirmasi Approval
Checklist akan ditandai sebagai Approved
βœ… Dengan menekan Approve, Anda menyatakan telah memeriksa dan menyetujui hasil checklist ini atas nama Surya Pratama.
`; } function buildBarChartSVG(catStats) { const w = 820, barH = 30, gap = 10, padL = 200, padR = 30, padT = 20, padB = 30; const maxVal = Math.max(...catStats.map(c => c.total), 1); const chartW = w - padL - padR; const h = catStats.length * (barH + gap) + padT + padB; let bars = catStats.map((cat, i) => { const y = padT + i * (barH + gap); const okW = Math.round((cat.ok / maxVal) * chartW); const caW = Math.round((cat.corrected / maxVal) * chartW); const issuW = Math.round((cat.issues / maxVal) * chartW); const label = cat.label.replace(/^[^\s]+\s/,''); return ` ${cat.icon} ${label} ${cat.ok > 0 ? `` : ''} ${cat.corrected > 0 ? `` : ''} ${cat.issues > 0 ? `` : ''} ${cat.total} record`; }).join(''); const legend = ` OK Corrected Not OK`; return `
${bars}${legend}
`; } function downloadCSV(name, headers, rows) { const csv = [headers, ...rows].map(r => r.map(v => '"'+(String(v||'').replace(/"/g,'""'))+'"').join(',')).join('\n'); const blob = new Blob(['\uFEFF'+csv], {type:'text/csv;charset=utf-8'}); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name.replace(/ /g,'_')+'_'+new Date().toISOString().split('T')[0]+'.csv'; a.click(); } // ============================================= // TOAST // ============================================= function showToast(msg, isErr) { const t = document.getElementById('toast'); t.innerHTML = (isErr?'❌ ':'βœ… ')+msg; t.className = isErr ? 'err' : ''; t.style.display = 'flex'; setTimeout(() => t.style.display='none', 3000); } // ============================================= // AIRTABLE CONFIG // ============================================= const AT_TOKEN = 'patLy121pninaXWD8.1cee4a9c727a5728254017e1bba5daf3afe0a563eaba598a0262376c63003a98'; const AT_BASE = 'appnh5SjIWD2IMs30'; const AT_TABLE = 'tblNpUR3l8jwYgsWB'; const AT_URL = 'https://api.airtable.com/v0/' + AT_BASE + '/' + AT_TABLE; // GET: no Content-Type (avoids CORS preflight) const AT_GET_HEADERS = { 'Authorization': 'Bearer ' + AT_TOKEN }; // POST/PATCH: with Content-Type const AT_POST_HEADERS = { 'Authorization': 'Bearer ' + AT_TOKEN, 'Content-Type': 'application/json' }; let cloudStatus = 'idle'; // ============================================= // AIRTABLE FUNCTIONS // ============================================= async function pushToAirtable(formId, record) { setCloudStatus('saving'); try { const f = FORMS[formId]; const detail = {}; (f.items||[]).forEach(item => { detail[item.label] = record[item.id] || 'β€”'; }); (f.numbers||[]).forEach(n => { detail[n.label] = record[n.id] || 'β€”'; }); (f.measurements||[]).forEach(m => { detail[m.label] = record[m.id] || 'β€”'; }); const fields = { 'Form Type' : f.name, 'Month' : record.month || '', 'Year' : record.year || '', 'Date' : record.date || '', 'Location' : (record.hydNo && record.location) ? record.hydNo + ' β€” ' + record.location : (record.unit && record.location) ? record.unit + ' β€” ' + record.location : (record.machineId ? record.machineId + ' β€” ' + (record.location||'') : record.equipId ? record.equipId : record.location || record.panelName || record.feNo || record.hydNo || record.unit || ''), 'Inspector' : record.inspector || '', 'Status' : record.hasIssue ? 'Not OK' : 'OK', 'Comments' : record.comments || '', 'Submitted At': record.submittedAt, 'Corrective Action': record.caAction || '', 'CA By' : record.caBy || '', 'CA Date' : record.caDate || '', 'Approval Status' : record.approved ? 'Approved' : 'Pending', 'Approved By' : record.approvedBy || '', 'Approved At' : record.approvedAt || '', 'Detail Data' : JSON.stringify(detail) }; const res = await fetch(AT_URL, { method : 'POST', headers: AT_POST_HEADERS, body : JSON.stringify({ fields }), mode : 'cors' }); if(!res.ok) { const err = await res.json().catch(()=>({})); const msg = err.error?.message || ('HTTP ' + res.status); throw new Error(msg); } const data = await res.json(); record._atId = data.id; setCloudStatus('ok'); return true; } catch(e) { console.error('Airtable push error:', e); setCloudStatus('error', e.message); return false; } } async function loadFromAirtable() { setCloudStatus('loading'); try { let all = [], offset = null; do { const url = AT_URL + '?pageSize=100' + (offset ? '&offset=' + offset : ''); const res = await fetch(url, { method: 'GET', headers: AT_GET_HEADERS, mode: 'cors' }); if(!res.ok) { const err = await res.json().catch(()=>({})); let msg = 'HTTP ' + res.status; if(res.status === 401) msg = 'Token tidak valid / kedaluwarsa'; else if(res.status === 403) msg = 'Token tidak punya akses ke base ini'; else if(res.status === 404) msg = 'Base ID atau Table ID tidak ditemukan'; else msg = err.error?.message || msg; throw new Error(msg); } const data = await res.json(); all = all.concat(data.records || []); offset = data.offset; } while(offset); Object.keys(STORE).forEach(k => STORE[k] = []); all.forEach(rec => { const f = rec.fields; const formId = Object.keys(FORMS).find(k => FORMS[k].name === f['Form Type']); if(!formId) return; // Parse Detail Data JSON untuk restore field individual (q1, q2, dll) let detailObj = {}; try { detailObj = JSON.parse(f['Detail Data'] || '{}'); } catch(e) {} const formDef = FORMS[formId]; const itemFields = {}; (formDef.items||[]).forEach(item => { // Detail Data memakai label sebagai key const val = detailObj[item.label]; if(val && val !== 'β€”') itemFields[item.id] = val; }); const numFields = {}; [...(formDef.numbers||[]), ...(formDef.measurements||[])].forEach(n => { const val = detailObj[n.label]; if(val && val !== 'β€”') numFields[n.id] = val; }); const loc = f['Location'] || ''; STORE[formId].push({ ...itemFields, ...numFields, id : rec.id, _atId : rec.id, month : f['Month'] || '', year : f['Year'] || '', date : f['Date'] ? String(f['Date']).split('T')[0] : '', hydNo : loc.startsWith('FH-') ? loc.split(' β€” ')[0].trim() : '', unit : loc.startsWith('PKG-') ? loc.split(' β€” ')[0].trim() : '', equipId : loc, location : loc, inspector : f['Inspector'] || '', hasIssue : f['Status'] === 'Not OK' || f['Status'] === 'Ada Isu', corrected : f['Status'] === 'Corrected', approved : f['Approval Status'] === 'Approved', approvedBy : f['Approved By'] || '', approvedAt : f['Approved At'] || '', caAction : f['Corrective Action'] || '', caBy : f['CA By'] || '', caDate : f['CA Date'] || '', comments : f['Comments'] || '', submittedAt: f['Submitted At'] || '', _detail : f['Detail Data'] || '', reading : (() => { if(loc.includes('Gardu PLN')) return parseFloat(detailObj['Total (kWh)']) || 0; const k = Object.keys(detailObj).find(x => x.toLowerCase().includes('reading')); return k ? parseFloat(detailObj[k]) || 0 : 0; })() }); }); setCloudStatus('ok'); renderView(); } catch(e) { console.error('Airtable load error:', e); setCloudStatus('error', e.message); showATDiagnostic(e.message); } } function setCloudStatus(status, msg) { cloudStatus = status; const el = document.getElementById('cloud-status'); if(!el) return; const map = { idle : ['☁️', '#6b778c', 'Terhubung ke Airtable'], saving : ['⏳', '#974f0c', 'Menyimpan ke Airtable...'], loading: ['πŸ”„', '#0052cc', 'Memuat data...'], ok : ['βœ…', '#006644', 'Tersimpan di Airtable'], error : ['❌', '#bf2600', 'Error: ' + (msg||'Gagal')] }; const [icon, color, label] = map[status] || map.idle; el.innerHTML = `${icon} ${label}`; } // Override submitForm to also save to Airtable submitForm = async function(formId) { const f = FORMS[formId]; // Auto-set location for single-point meters const meterSingleLoc = { meterWater:'Water Meter', meterFH:'FH Water Meter', meterSprinkler:'Sprinkler Meter', meterCT:'CT Water Meter', meterRain:'Rain Water', meterDaily:'Daily Supply' }; if(meterSingleLoc[formId]) formState.location = meterSingleLoc[formId]; // Validate header fields for(const field of f.header) { if(field.type !== 'multi-location') { const el = document.getElementById('hf-' + field.id); if(el && !el.value) { showToast('Lengkapi semua field informasi umum!', true); return; } if(el) formState[field.id] = el.value; } } if(!formState.inspector) { showToast('Pilih Petugas (PIC)!', true); return; } const unanswered = (f.items||[]).filter(item => formState[item.id] === null || formState[item.id] === undefined); if(unanswered.length > 0) { if(!confirm(unanswered.length + ' poin belum diisi. Lanjutkan?')) return; } const comments = document.getElementById('ff-comments'); if(comments) formState.comments = comments.value; (f.numbers||[]).forEach(n => { const el = document.getElementById('num-' + n.id); if(el) formState[n.id] = el.value; }); (f.measurements||[]).forEach(m => { const el = document.getElementById('meas-' + m.id); if(el) formState[m.id] = el.value; }); const hasIssue = (f.items||[]).some(item => formState[item.id] === 'no' || formState[item.id] === 'other') || (formState.comments && formState.comments.trim().length > 5); const record = { ...formState, hasIssue, submittedAt: new Date().toLocaleString('id-ID'), id: Date.now() }; STORE[formId].push(record); showToast('⏳ Menyimpan ke Airtable...'); const ok = await pushToAirtable(formId, record); showToast(ok ? 'βœ… Tersimpan di Airtable!' : '⚠️ Tersimpan lokal. Gagal ke Airtable.', !ok); updateNotOKBadge(); setTimeout(() => navigate('history', formId), 1000); }; // ============================================= // HYDRANT TRACKING FUNCTIONS // ============================================= function onHydrantSelect(hydId) { formState['hydNo'] = hydId; const h = HYDRANT_MASTER.find(x => x.id === hydId); if(h) { const locEl = document.getElementById('hf-location'); if(locEl) { locEl.value = h.location; formState['location'] = h.location; } const typeEl = document.getElementById('hf-hydType'); const isOutdoor = h.location.toLowerCase().includes('outdoor'); if(typeEl) typeEl.value = isOutdoor ? 'Outdoor' : 'Indoor'; formState['hydType'] = isOutdoor ? 'Outdoor' : 'Indoor'; } checkDupOnChange('fireHydrant'); } function getCheckedHydrantsThisMonth(month, year) { return STORE['fireHydrant'] .filter(r => r.month === month && r.year === year) .map(r => r.hydNo || r.location) .filter(Boolean); } function renderHydrantTracking() { const selMonth = document.getElementById('track-month')?.value || CUR_MONTH; const selYear = document.getElementById('track-year')?.value || CUR_YEAR; const checked = getCheckedHydrantsThisMonth(selMonth, selYear); const checkedSet = new Set(checked); const doneCount = HYDRANT_MASTER.filter(h => checkedSet.has(h.id)).length; const pendingCount = HYDRANT_MASTER.length - doneCount; const pct = Math.round(doneCount / HYDRANT_MASTER.length * 100); // Group by location const groups = {}; HYDRANT_MASTER.forEach(h => { if(!groups[h.location]) groups[h.location] = []; groups[h.location].push(h); }); const groupsHtml = Object.entries(groups).map(([loc, units]) => { const rows = units.map(h => { const isDone = checkedSet.has(h.id); const rec = STORE['fireHydrant'].find(r => r.hydNo === h.id && r.month === selMonth && r.year === selYear); return ` ${h.id} ${h.location} ${isDone ? rec?.inspector || '-' : '-'} ${isDone ? rec?.submittedAt || '-' : '-'} ${!isDone ? '⏳ Belum' : rec?.corrected ? 'βœ“ Corrected' : rec?.hasIssue ? 'βœ— Not OK' : 'βœ“ OK'} ${isDone ? '' : ``} `; }).join(''); const locDone = units.filter(h => checkedSet.has(h.id)).length; return ` πŸ“ ${loc}  ${locDone}/${units.length} unit selesai ${rows}`; }).join(''); return `
Total Unit
${HYDRANT_MASTER.length}
fire hydrant terdaftar
Sudah Dicek
${doneCount}
${selMonth} ${selYear}
Belum Dicek
${pendingCount}
perlu segera
Progress Bulan Ini
${doneCount} selesai ${pct}% ${pendingCount} belum
πŸš’ Daftar ${HYDRANT_MASTER.length} Fire Hydrant
${groupsHtml}
IDLokasiPICWaktuStatusAksi
`; } function renderHydrantRows() { const selMonth = document.getElementById('track-month')?.value || CUR_MONTH; const selYear = document.getElementById('track-year')?.value || CUR_YEAR; const checked = getCheckedHydrantsThisMonth(selMonth, selYear); const checkedSet = new Set(checked); const groups = {}; HYDRANT_MASTER.forEach(h => { if(!groups[h.location]) groups[h.location] = []; groups[h.location].push(h); }); return Object.entries(groups).map(([loc, units]) => { const rows = units.map(h => { const isDone = checkedSet.has(h.id); const rec = STORE['fireHydrant'].find(r => r.hydNo === h.id && r.month === selMonth && r.year === selYear); return ` ${h.id}${h.location} ${isDone ? rec?.inspector||'-' : '-'} ${isDone ? rec?.submittedAt||'-' : '-'} ${!isDone ? '⏳ Belum' : rec?.corrected ? 'βœ“ Corrected' : rec?.hasIssue ? 'βœ— Not OK' : 'βœ“ OK'} ${!isDone ? `` : rec?.hasIssue && !rec?.corrected ? `` : ''} `; }).join(''); const locDone = units.filter(h => checkedSet.has(h.id)).length; return `πŸ“ ${loc} ${locDone}/${units.length} selesai${rows}`; }).join(''); } // ============================================= // AC PACKAGE β€” SELECT & TRACKING // ============================================= function onPKGSelect(pkgId) { formState['unit'] = pkgId; const p = AC_PKG_MASTER.find(x => x.id === pkgId); if(p) { const locEl = document.getElementById('hf-location'); if(locEl) { locEl.value = p.location; formState['location'] = p.location; } } checkDupOnChange('acPackage'); } function getCheckedPKGThisMonth(month, year) { return STORE['acPackage'] .filter(r => r.month === month && r.year === year) .map(r => r.unit) .filter(Boolean); } function renderACTracking() { const selMonth = document.getElementById('ac-track-month')?.value || CUR_MONTH; const selYear = document.getElementById('ac-track-year')?.value || CUR_YEAR; const checked = new Set(getCheckedPKGThisMonth(selMonth, selYear)); const doneCount = AC_PKG_MASTER.filter(p => checked.has(p.id)).length; const pendingCount = AC_PKG_MASTER.length - doneCount; const pct = Math.round(doneCount / AC_PKG_MASTER.length * 100); // Group by location const groups = {}; AC_PKG_MASTER.forEach(p => { if(!groups[p.location]) groups[p.location] = []; groups[p.location].push(p); }); const groupsHtml = Object.entries(groups).map(([loc, units]) => { const rows = units.map(p => { const isDone = checked.has(p.id); const rec = STORE['acPackage'].find(r => r.unit === p.id && r.month === selMonth && r.year === selYear); const statusBadge = !isDone ? '⏳ Belum' : rec?.corrected ? 'βœ“ Corrected' : rec?.hasIssue ? 'βœ— Not OK' : 'βœ“ OK'; const actionBtn = !isDone ? `` : rec?.hasIssue && !rec?.corrected ? `` : ''; return ` ${p.id} ${p.location} ${isDone ? (rec?.inspector || 'β€”') : 'β€”'} ${isDone ? (rec?.submittedAt || 'β€”') : 'β€”'} ${statusBadge} ${actionBtn} `; }).join(''); const locDone = units.filter(p => checked.has(p.id)).length; return ` πŸ“ ${loc}  ${locDone}/${units.length} unit selesai ${rows}`; }).join(''); const pfClass = pct === 0 ? 'pf-gray' : pct < 50 ? 'pf-red' : pct < 100 ? 'pf-orange' : 'pf-green'; return `
Total Unit
${AC_PKG_MASTER.length}
AC Package terdaftar
Sudah Dicek
${doneCount}
${selMonth} ${selYear}
Belum Dicek
${pendingCount}
perlu segera
Progress Bulan Ini
${doneCount} selesai ${pct}% ${pendingCount} belum
❄️ Daftar ${AC_PKG_MASTER.length} Unit AC Package
${groupsHtml}
IDLokasiPICWaktuStatusAksi
`; } function renderACRows() { const selMonth = document.getElementById('ac-track-month')?.value || CUR_MONTH; const selYear = document.getElementById('ac-track-year')?.value || CUR_YEAR; const checked = new Set(getCheckedPKGThisMonth(selMonth, selYear)); const groups = {}; AC_PKG_MASTER.forEach(p => { if(!groups[p.location]) groups[p.location] = []; groups[p.location].push(p); }); return Object.entries(groups).map(([loc, units]) => { const rows = units.map(p => { const isDone = checked.has(p.id); const rec = STORE['acPackage'].find(r => r.unit === p.id && r.month === selMonth && r.year === selYear); const statusBadge = !isDone ? '⏳ Belum' : rec?.corrected ? 'βœ“ Corrected' : rec?.hasIssue ? 'βœ— Not OK' : 'βœ“ OK'; const actionBtn = !isDone ? `` : rec?.hasIssue && !rec?.corrected ? `` : ''; return ` ${p.id}${p.location} ${isDone ? (rec?.inspector||'β€”') : 'β€”'} ${isDone ? (rec?.submittedAt||'β€”') : 'β€”'} ${statusBadge}${actionBtn} `; }).join(''); const locDone = units.filter(p => checked.has(p.id)).length; return ` πŸ“ ${loc} ${locDone}/${units.length} selesai ${rows}`; }).join(''); } function prefillACAndNavigate(pkgId, location) { navigate('form', 'acPackage'); setTimeout(() => { const el = document.getElementById('hf-unit'); if(el) { el.value = pkgId; onPKGSelect(pkgId); } }, 300); } // ============================================= // METER TRACKING FUNCTIONS // ============================================= function getMeterRecords(meterId) { const m = METER_MASTER.find(x => x.id === meterId); if(!m) return []; return STORE[m.formId] .filter(r => m.formId === 'meterKwh' ? r.location === m.location : true) .map(r => { let reading = r.reading || 0; // Gardu PLN: pastikan baca dari Total (kWh) di Detail Data if(m.id === 'KWH-01' && r._detail) { try { const d = JSON.parse(r._detail); const total = parseFloat(d['Total (kWh)']); if(!isNaN(total) && total > 0) reading = total; } catch(e) {} } return { ...r, reading }; }) .sort((a,b) => (a.date||'') > (b.date||'') ? 1 : -1); } function getLatestReading(meterId) { const recs = getMeterRecords(meterId); return recs.length ? recs[recs.length-1] : null; } function getConsumption(meterId) { const recs = getMeterRecords(meterId); if(recs.length < 2) return null; const cur = recs[recs.length-1].reading; const prev = recs[recs.length-2].reading; const diff = cur - prev; const mult = METER_MULTIPLIER[meterId] || 1; return diff * mult; } function getAvgConsumption(meterId, n=6) { const recs = getMeterRecords(meterId); if(recs.length < 2) return null; const mult = METER_MULTIPLIER[meterId] || 1; const diffs = []; for(let i=1; ia+b,0) / diffs.length; } function isReadToday(meterId) { const today = new Date().toISOString().split('T')[0]; const m = METER_MASTER.find(x => x.id === meterId); if(!m) return false; return STORE[m.formId].some(r => r.date === today && (m.formId === 'meterKwh' ? r.location === m.location : true)); } function isReadThisMonth(meterId) { const m = METER_MASTER.find(x => x.id === meterId); if(!m) return false; return STORE[m.formId].some(r => { const d = new Date(r.date || r.submittedAt); return d.getMonth() === now.getMonth() && d.getFullYear() === now.getFullYear() && (m.formId === 'meterKwh' ? r.location === m.location : true); }); } function renderMeterTracking() { const today = new Date().toISOString().split('T')[0]; const todayLabel = new Date().toLocaleDateString('id-ID', {weekday:'long', year:'numeric', month:'long', day:'numeric'}); const dailyDone = METER_DAILY.filter(m => isReadToday(m.id)).length; const monthlyDone = METER_MONTHLY.filter(m => isReadThisMonth(m.id)).length; const totalAlerts = METER_MASTER.filter(m => { const cons = getConsumption(m.id), avg = getAvgConsumption(m.id); return cons !== null && avg !== null && avg > 0 && (cons > avg*1.3 || cons < avg*0.5); }).length; // Daily meters table const dailyRows = METER_DAILY.map(m => { const done = isReadToday(m.id); const rec = done ? getMeterRecords(m.id).find(r => r.date === today) : null; const cons = getConsumption(m.id); const avg = getAvgConsumption(m.id); let alert = ''; if(cons !== null && avg !== null && avg > 0) { if(cons > avg*1.3) alert = 'πŸ”΄ Tinggi'; else if(cons < avg*0.5) alert = '🟑 Rendah'; else alert = 'βœ“ Normal'; } return ` ${m.icon} ${m.name} ${done?'βœ“ Sudah':'⏳ Belum'} ${rec ? rec.reading.toLocaleString('id-ID') + ' ' + m.unit : 'β€”'} ${cons !== null ? cons.toLocaleString('id-ID') + ' ' + (m.id==='KWH-01'?'kWh':m.unit) : 'β€”'} ${alert} ${done ? '' : ``} `; }).join(''); // Monthly meters table const monthlyRows = METER_MONTHLY.map(m => { const done = isReadThisMonth(m.id); const rec = done ? getLatestReading(m.id) : null; const cons = getConsumption(m.id); return ` ${m.icon} ${m.name} ${done?'βœ“ Sudah':'⏳ Belum'} ${rec ? rec.reading.toLocaleString('id-ID') + ' ' + m.unit : 'β€”'} ${cons !== null ? cons.toLocaleString('id-ID') + ' ' + (m.id==='KWH-01'?'kWh':m.unit) : 'β€”'} ${done ? '' : ``} `; }).join(''); // Consumption summary cards const consCards = METER_MASTER.map(m => { const recs = getMeterRecords(m.id); const latest = recs.length ? recs[recs.length-1] : null; const cons = getConsumption(m.id); const avg = getAvgConsumption(m.id); const pct = (cons !== null && avg !== null && avg > 0) ? Math.round((cons/avg-1)*100) : null; const trend = pct === null ? '' : pct > 30 ? 'πŸ”΄' : pct < -50 ? '🟑' : '🟒'; // Extra info untuk Gardu PLN: tampilkan BP & LBP let extraInfo = ''; if(m.id === 'KWH-01' && latest && latest._detail) { try { const d = JSON.parse(latest._detail); const bp = parseFloat(d['BP β€” Beban Puncak (kWh)']) || 0; const lbp = parseFloat(d['LBP β€” Luar Beban Puncak (kWh)']) || 0; if(bp || lbp) extraInfo = '
BP: '+bp.toLocaleString('id-ID')+' Β· LBP: '+lbp.toLocaleString('id-ID')+'
'; } catch(e) {} } return `
${m.icon} ${m.name}
${latest ? latest.reading.toLocaleString('id-ID') : 'β€”'} ${m.id==='KWH-01'?'kWh Total':m.unit}
${extraInfo}
${m.id==='KWH-01'?'Pemakaian':'Konsumsi'}: ${cons !== null ? cons.toLocaleString('id-ID')+' '+(m.id==='KWH-01'?'kWh':m.unit) : 'β€”'} ${trend} ${pct !== null ? (pct>=0?'+':'')+pct+'% vs avg' : ''}
${recs.length} pembacaan Β· klik untuk grafik
`; }).join(''); return `
Total Meter
${METER_MASTER.length}
titik meter terdaftar
Daily β€” Hari Ini
${dailyDone}/${METER_DAILY.length}
${todayLabel}
Monthly β€” Bulan Ini
${monthlyDone}/${METER_MONTHLY.length}
${CUR_MONTH} ${CUR_YEAR}
Alert Konsumsi
${totalAlerts}
${totalAlerts?'perlu perhatian':'semua normal'}
βš‘πŸ’§ Daily Meters β€” Status Hari Ini${todayLabel}
${dailyRows}
MeterStatusPembacaan TerakhirKonsumsiKondisiAksi
πŸ“… Monthly Meters β€” Status Bulan IniDicatat setiap tgl 1
${monthlyRows}
MeterStatusPembacaan TerakhirKonsumsiAksi
πŸ“Š Ringkasan Konsumsi β€” Klik untuk Grafik
${consCards}
`; } let currentChartInstance = null; function showMeterChart(meterId) { const m = METER_MASTER.find(x => x.id === meterId); if(!m) return; const recs = getMeterRecords(meterId).slice(-12); // last 12 readings document.getElementById('chart-modal').style.display = 'flex'; document.getElementById('chart-title').textContent = m.icon + ' ' + m.name; const labels = recs.map(r => r.date || r.submittedAt?.split(',')[0] || ''); const values = recs.map(r => r.reading); const consumptions = recs.map((r,i) => i===0 ? 0 : r.reading - recs[i-1].reading); if(currentChartInstance) currentChartInstance.destroy(); const ctx = document.getElementById('meter-chart').getContext('2d'); currentChartInstance = new Chart(ctx, { type: 'line', data: { labels, datasets: [ { label: (m.id === 'KWH-01' ? 'Total BP+LBP' : 'Pembacaan') + ' (' + m.unit + ')', data: values, borderColor: '#0052cc', backgroundColor: 'rgba(0,82,204,.1)', tension: 0.3, fill: true, yAxisID: 'y' }, { label: m.id==='KWH-01' ? 'Pemakaian kWh (Γ—4000)' : 'Konsumsi (' + m.unit + ')', data: consumptions, borderColor: '#36b37e', backgroundColor: 'rgba(54,179,126,.1)', tension: 0.3, fill: false, type: 'bar', yAxisID: 'y1' } ] }, options: { responsive: true, interaction: { mode: 'index', intersect: false }, plugins: { legend: { position: 'top' } }, scales: { y: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Total Reading' }}, y1: { type: 'linear', display: true, position: 'right', title: { display: true, text: m.id==='KWH-01' ? 'Pemakaian (kWh)' : 'Consumption' }, grid: { drawOnChartArea: false }} } } }); // Stats const avg = getAvgConsumption(meterId); const cons = getConsumption(meterId); const latest = getLatestReading(meterId); const consUnit = m.id === 'KWH-01' ? 'kWh' : m.unit; const consLabel = m.id === 'KWH-01' ? 'Pemakaian kWh' : 'Konsumsi'; const formula = m.id === 'KWH-01' ? '
Formula: (Total hari ini βˆ’ Total kemarin) Γ— 4.000
' : ''; document.getElementById('chart-stats').innerHTML = `
Total Meter Terakhir
${latest ? latest.reading.toLocaleString('id-ID') : 'β€”'} ${m.unit}
${consLabel} Terakhir
${cons !== null ? cons.toLocaleString('id-ID')+' '+consUnit : 'β€”'}
${formula}
Rata-rata ${consLabel}
${avg !== null ? avg.toFixed(0)+' '+consUnit : 'β€”'}
`; } function closeChartModal() { document.getElementById('chart-modal').style.display = 'none'; if(currentChartInstance) { currentChartInstance.destroy(); currentChartInstance = null; } } // ============================================= // RUNNING HOURS LOG β€” Master Mesin // ============================================= const RH_MASTER = [ // Air Compressor {id:'AC-01', name:'Air Compressor 1', category:'Air Compressor', icon:'βš™οΈ'}, {id:'AC-02', name:'Air Compressor 2', category:'Air Compressor', icon:'βš™οΈ'}, // Air Dryer {id:'AD-01', name:'Air Dryer 1', category:'Air Dryer', icon:'πŸ’¨'}, {id:'AD-02', name:'Air Dryer 2', category:'Air Dryer', icon:'πŸ’¨'}, // Cooling Tower {id:'CT-1C', name:'Cooling Tower 1 Cell', category:'Cooling Tower', icon:'🌑️'}, {id:'CT-2C', name:'Cooling Tower 2 Cell', category:'Cooling Tower', icon:'🌑️'}, // Exhaust {id:'EX-01', name:'Exhaust 1', category:'Exhaust', icon:'πŸŒ€'}, {id:'EX-02', name:'Exhaust 2', category:'Exhaust', icon:'πŸŒ€'}, {id:'EX-03', name:'Exhaust 3', category:'Exhaust', icon:'πŸŒ€'}, {id:'EX-04', name:'Exhaust 4', category:'Exhaust', icon:'πŸŒ€'}, {id:'EX-05', name:'Exhaust 5', category:'Exhaust', icon:'πŸŒ€'}, // Adsorption Tower {id:'AT-01', name:'Adsorption Tower 1', category:'Adsorption Tower', icon:'🏭'}, {id:'AT-02', name:'Adsorption Tower 2', category:'Adsorption Tower', icon:'🏭'}, // Pompa Distribusi CT 2 Cell {id:'PD2C-01',name:'Pompa Distribusi CT 2 Cell 1', category:'Pompa Distribusi CT 2 Cell', icon:'πŸ’§'}, {id:'PD2C-02',name:'Pompa Distribusi CT 2 Cell 2', category:'Pompa Distribusi CT 2 Cell', icon:'πŸ’§'}, {id:'PD2C-03',name:'Pompa Distribusi CT 2 Cell 3', category:'Pompa Distribusi CT 2 Cell', icon:'πŸ’§'}, {id:'PD2C-04',name:'Pompa Distribusi CT 2 Cell 4', category:'Pompa Distribusi CT 2 Cell', icon:'πŸ’§'}, // Pompa Distribusi CT 1 Cell {id:'PD1C-01',name:'Pompa Distribusi CT 1 Cell 1', category:'Pompa Distribusi CT 1 Cell', icon:'πŸ’§'}, {id:'PD1C-02',name:'Pompa Distribusi CT 1 Cell 2', category:'Pompa Distribusi CT 1 Cell', icon:'πŸ’§'}, ]; // ============================================= // DUPLICATE CHECK β€” Cegah double submission // ============================================= const DAILY_FORMS = ['meterKwh','meterWater','meterCT','meterDaily']; const MONTHLY_FORMS = ['meterFH','meterSprinkler','meterRain']; // Lokasi otomatis untuk meter 1 titik var METER_SINGLE_LOC = { meterWater:'Water Meter', meterFH:'FH Water Meter', meterSprinkler:'Sprinkler Meter', meterCT:'CT Water Meter', meterRain:'Rain Water', meterDaily:'Daily Supply' }; // Semua form meter var ALL_METER_FORMS = ['meterKwh','meterWater','meterFH','meterSprinkler','meterCT','meterRain','meterDaily']; function getMeterMonth(dateStr) { if(!dateStr) return ''; var d = new Date(dateStr); return MONTHS[d.getMonth()] || ''; } function getMeterYear(dateStr) { if(!dateStr) return ''; return String(new Date(dateStr).getFullYear()); } function checkDuplicate(formId, data) { var recs = STORE[formId] || []; if(!recs.length) return null; // Fire Hydrant β€” per unit + bulan + tahun if(formId === 'fireHydrant' && data.hydNo) { return recs.find(function(r) { return r.hydNo === data.hydNo && r.month === data.month && r.year === data.year; }) || null; } // AC Package β€” per unit + bulan + tahun if(formId === 'acPackage' && data.unit) { return recs.find(function(r) { return r.unit === data.unit && r.month === data.month && r.year === data.year; }) || null; } // KWh Meter β€” per tanggal + lokasi (2 titik: Gardu PLN & Transformer Room) if(formId === 'meterKwh') { if(!data.date || !data.location) return null; return recs.find(function(r) { return r.date === data.date && r.location === data.location; }) || null; } // Daily meters (single-point) β€” cek per tanggal saja if(DAILY_FORMS.indexOf(formId) !== -1) { if(!data.date) return null; return recs.find(function(r) { return r.date === data.date; }) || null; } // Monthly meters β€” cek per bulan + tahun (derive dari date) if(MONTHLY_FORMS.indexOf(formId) !== -1) { var month = data.month || getMeterMonth(data.date); var year = data.year || getMeterYear(data.date); if(!month || !year) return null; return recs.find(function(r) { var rMonth = r.month || getMeterMonth(r.date); var rYear = r.year || getMeterYear(r.date); return rMonth === month && rYear === year; }) || null; } // Running Hours Log (specific equipment forms) if(formId === 'airCompressorLog' || formId === 'airDryerLog' || formId === 'acPkgLog') { if(!data.date || !data.equipId) return null; return recs.find(function(r) { var rEquip = r.equipId || r.location || ''; return r.date === data.date && rEquip === data.equipId; }) || null; } // Running Hours Log (generic) β€” per tanggal + mesin if(formId === 'runningHoursLog') { if(!data.date || !data.machineId) return null; return recs.find(function(r) { var rMachine = r.machineId || (r.location ? r.location.split('β€”')[0].trim() : ''); return r.date === data.date && rMachine === data.machineId; }) || null; } // Form bulanan lainnya β€” per bulan + tahun return recs.find(function(r) { return r.month === data.month && r.year === data.year; }) || null; } function getFormCurrentData(formId) { // Kumpulkan data header yang sudah diisi var data = {}; var f = FORMS[formId]; if(!f) return data; f.header.forEach(function(field) { var el = document.getElementById('hf-' + field.id); if(el) data[field.id] = el.value; }); // Merge formState Object.keys(formState).forEach(function(k) { if(!data[k]) data[k] = formState[k]; }); return data; } function showDupBanner(formId, rec) { var existing = document.getElementById('dup-banner-wrap'); if(!existing) return; if(!rec) { existing.innerHTML = ''; return; } var who = rec.inspector || 'β€”'; var when = rec.submittedAt || rec.date || 'β€”'; var stat = rec.hasIssue && !rec.corrected ? 'βœ— Not OK' : rec.corrected ? 'βœ“ Corrected' : 'βœ“ OK'; var statColor = rec.hasIssue && !rec.corrected ? '#bf2600' : '#006644'; existing.innerHTML = '
' + '
βœ…
' + '
Sudah diisi untuk periode ini
' + '
PIC: ' + who + '  Β·  Waktu: ' + when + '  Β·  Status: ' + stat + '
' + '
⚠️ Submit lagi akan membuat data duplikat. Pastikan ini memang perlu diisi ulang.
' + '
'; } function checkDupOnChange(formId) { var data = getFormCurrentData(formId); var rec = checkDuplicate(formId, data); showDupBanner(formId, rec); } // ============================================= // CORRECTIVE ACTION // ============================================= function showCAModal(formId, recId) { document.getElementById('ca-modal').style.display = 'flex'; document.getElementById('ca-form-id').value = formId; document.getElementById('ca-rec-id').value = recId; document.getElementById('ca-action').value = ''; document.getElementById('ca-by').value = ''; document.getElementById('ca-date').value = new Date().toISOString().split('T')[0]; } function showApproveModal(formId, recId) { // Cari record let rec = null, fId = formId; for(const k of Object.keys(STORE)) { const found = STORE[k].find(r => String(r.id) === String(recId) || r._atId === recId); if(found) { rec = found; fId = k; break; } } if(!rec) { showToast('Record tidak ditemukan', true); return; } // Hanya bisa approve jika OK atau Corrected if(rec.hasIssue && !rec.corrected) { showToast('Hanya checklist berstatus OK atau Corrected yang bisa di-approve', true); return; } if(rec.approved) { showToast('Checklist ini sudah di-approve sebelumnya', true); return; } const f = FORMS[fId]; const loc = rec.location||rec.hydNo||rec.unit||rec.panelName||rec.equipId||'β€”'; const stat = rec.corrected ? 'βœ“ Corrected' : 'βœ“ OK'; document.getElementById('approve-form-id').value = fId; document.getElementById('approve-rec-id').value = String(rec.id); document.getElementById('approve-info').innerHTML = `
Form ${f.icon} ${f.name}
Lokasi / ID ${loc}
Periode ${rec.month||rec.date||'β€”'} ${rec.year||''}
Inspector ${rec.inspector||'β€”'}
Status ${stat}
`; document.getElementById('approve-modal').style.display = 'flex'; } function closeApproveModal() { document.getElementById('approve-modal').style.display = 'none'; } async function submitApproval() { const formId = document.getElementById('approve-form-id').value; const recId = document.getElementById('approve-rec-id').value; // Cari record di STORE let rec = null; for(const k of Object.keys(STORE)) { const found = STORE[k].find(r => String(r.id) === String(recId) || r._atId === recId); if(found) { rec = found; break; } } if(!rec) { showToast('Record tidak ditemukan', true); return; } // Timestamp WIB const now = new Date(); const wibOffset = 7 * 60; // UTC+7 const wib = new Date(now.getTime() + (wibOffset - now.getTimezoneOffset()) * 60000); const approvedAt = wib.toLocaleString('id-ID', { day:'2-digit', month:'long', year:'numeric', hour:'2-digit', minute:'2-digit', hour12:false }) + ' WIB'; // Update local store rec.approved = true; rec.approvedBy = 'Surya Pratama'; rec.approvedAt = approvedAt; // PATCH ke Airtable const atId = rec._atId || rec.id; if(atId && String(atId).startsWith('rec')) { try { showToast('⏳ Menyimpan approval...'); const res = await fetch(AT_URL + '/' + atId, { method : 'PATCH', headers: AT_POST_HEADERS, mode : 'cors', body : JSON.stringify({ fields: { 'Approval Status': 'Approved', 'Approved By' : 'Surya Pratama', 'Approved At' : approvedAt }}) }); if(res.ok) showToast('βœ… Checklist berhasil di-approve!'); else showToast('⚠️ Tersimpan lokal, gagal update Airtable', true); } catch(e) { showToast('⚠️ Tersimpan lokal, gagal update Airtable', true); } } else { showToast('βœ… Checklist berhasil di-approve!'); } closeApproveModal(); renderView(); } function closeCAModal() { document.getElementById('ca-modal').style.display = 'none'; } async function submitCA() { const formId = document.getElementById('ca-form-id').value; const recId = document.getElementById('ca-rec-id').value; const action = document.getElementById('ca-action').value.trim(); const by = document.getElementById('ca-by').value.trim(); const date = document.getElementById('ca-date').value; if(!action||!by||!date) { showToast('Lengkapi semua field corrective action!', true); return; } // Find in local store β€” match by id or _atId let rec = null; for(const k of Object.keys(STORE)) { rec = STORE[k].find(r => String(r.id)===String(recId) || r._atId===recId); if(rec) break; } if(!rec) { showToast('Record tidak ditemukan', true); return; } rec.hasIssue = false; rec.corrected = true; rec.caAction = action; rec.caBy = by; rec.caDate = date; // PATCH to Airtable if(rec._atId) { try { showToast('⏳ Menyimpan corrective action...'); const url = AT_URL + '/' + rec._atId; const res = await fetch(url, { method : 'PATCH', headers: AT_POST_HEADERS, mode : 'cors', body : JSON.stringify({ fields: { 'Status' : 'Corrected', 'Corrective Action' : action, 'CA By' : by, 'CA Date' : date }}) }); if(res.ok) showToast('βœ… Corrective action tersimpan!'); else showToast('⚠️ Tersimpan lokal, gagal update Airtable', true); } catch(e) { showToast('⚠️ Tersimpan lokal, gagal update Airtable', true); } updateNotOKBadge(); } closeCAModal(); renderView(); } function calcKwhTotal() { var bp = parseFloat(document.getElementById('num-bp')?.value || 0); var lbp = parseFloat(document.getElementById('num-lbp')?.value || 0); var tot = bp + lbp; var el = document.getElementById('num-total'); if(el) { el.value = tot; formState['total'] = tot; } } function onRHSelect(machineId) { formState['machineId'] = machineId; formState['location'] = machineId; checkDupOnChange('runningHoursLog'); const m = RH_MASTER.find(x => x.id === machineId); if(m) { const locEl = document.getElementById('hf-location'); if(locEl) { locEl.value = m.category; formState['location'] = m.category; } formState['machineName'] = m.name; } } function onKwhLocationChange(loc) { formState['location'] = loc; checkDupOnChange('meterKwh'); var isGardu = loc === 'Gardu PLN'; ['bp','lbp','total'].forEach(function(id) { var el = document.getElementById('num-wrap-' + id); if(el) el.style.display = isGardu ? 'flex' : 'none'; }); var trafo = document.getElementById('num-wrap-reading'); if(trafo) trafo.style.display = isGardu ? 'none' : 'flex'; var h = document.getElementById('num-card')?.querySelector('.form-card-header'); if(h) h.textContent = isGardu ? 'πŸ”’ Pembacaan KWh β€” Gardu PLN' : 'πŸ”’ Pembacaan KWh β€” Transformer Room'; } function toggleSidebar(){ document.getElementById('sidebar').classList.toggle('open'); document.getElementById('sidebar-overlay').classList.toggle('open'); } function closeSidebar(){ document.getElementById('sidebar').classList.remove('open'); document.getElementById('sidebar-overlay').classList.remove('open'); } function setActiveBN(id){ document.querySelectorAll('.bn-item').forEach(function(b){b.classList.remove('active');}); var el=document.getElementById(id); if(el) el.classList.add('active'); } function showFormPicker(){ var cats=[ {label:'πŸ”΄ Fire Safety',forms:['fireExtinguisher','fireHydrant','emergencyDoor','smokeDetector','evacuationLamp','pampaPemadam']}, {label:'❄️ HVAC',forms:['acPackage','acSingleSplit']}, {label:'βš™οΈ Mekanikal',forms:['coolingTower']}, {label:'⚑ Panel',forms:['panelMonthly','panelQuarterly']}, {label:'πŸ“Š Meter',forms:['meterKwh','meterWater','meterFH','meterSprinkler','meterCT','meterRain','meterDaily']}, {label:'⏱️ Running Hours',forms:['airCompressorLog','airDryerLog','acPkgLog','runningHoursLog']}, ]; var html=''; cats.forEach(function(cat){ html+='
'+cat.label+'
'; cat.forms.forEach(function(fid){ var f=FORMS[fid]; if(!f) return; html+='
' +''+f.icon+'
'+f.name+'
'+f.freq+'
'; }); }); document.getElementById('fp-list').innerHTML=html; document.getElementById('form-picker').style.display='flex'; } function closeFPAndNav(e, fid){ document.getElementById('form-picker').style.display='none'; navigate('form', fid); } async function testATConnection() { var el = document.getElementById('at-test-result'); if(el) el.innerHTML = 'Testing...'; try { var res = await fetch(AT_URL + '?maxRecords=1', { method: 'GET', headers: AT_GET_HEADERS, mode: 'cors' }); var data = await res.json(); if(res.ok) { if(el) el.innerHTML = 'βœ… Koneksi OK! Klik Coba Lagi.'; setCloudStatus('ok'); setTimeout(loadFromAirtable, 800); } else { var msg = data && data.error ? data.error.message || ('HTTP '+res.status) : ('HTTP '+res.status); if(el) el.innerHTML = '❌ '+msg+''; } } catch(e) { if(el) el.innerHTML = '❌ '+e.message+''; } } function showATDiagnostic(errMsg) { var el = document.getElementById('content'); if(!el) return; var isHostErr = errMsg.indexOf('fetch') !== -1 || errMsg.indexOf('403') !== -1 || errMsg.indexOf('allowlist') !== -1; var h = '
'; // Main error box h += '
'; h += '
⚠️ Gagal Terhubung ke Airtable
'; h += '
Penyebab: Token Airtable memiliki pembatasan host sehingga diblokir dari Netlify.
'; h += '
'; // Solution box h += '
'; h += '
βœ… Solusi: Buat Token Baru
'; // Steps var steps = [ ['Buka halaman token Airtable', 'Klik link ini: airtable.com/create/tokens'], ['Klik "+ Create token"', 'Buat token baru dari awal'], ['Isi Name: PM App v2', ''], ['Scopes β€” centang keduanya:', 'βœ… data.records:read
βœ… data.records:write'], ['Access β€” pilih base:', 'βœ… BizLink PM System'], ['PENTING: Bagian "Allowed origins"', 'Biarkan KOSONG β€” jangan isi apapun'], ['Klik "Create token" β†’ Copy token', 'Kirim token ke Claude untuk diupdate ke aplikasi'], ]; steps.forEach(function(s, i) { h += '
'; h += '
' + (i+1) + '
'; h += '
' + s[0] + '
'; if(s[1]) h += '
' + s[1] + '
'; h += '
'; }); h += ''; h += '
'; // Test button h += '
'; h += '
Sudah punya token baru? Test dulu:
'; h += ''; h += '
'; h += '
'; el.innerHTML = h; } const _baseNavigate = navigate; navigate = function(view, formId) { closeSidebar(); _baseNavigate(view, formId); if(view==='dashboard') setActiveBN('bn-dashboard'); else if(view==='meter-tracking') setActiveBN('bn-meter'); else if(view==='history-all') setActiveBN('bn-history'); else if(view && view.includes('tracking')) setActiveBN('bn-tracking'); }; // ============================================= // INIT // ============================================= renderView(); loadFromAirtable(); setActiveBN('bn-dashboard');