xltw9521_XLTION · XLTION CMMS v2.0
Confirm Action
Are you sure?
Failure Locations
CodeNameDescriptionActions
Aspect Parameters
CodeAspectTypeWeightActions
Availability Targets
PlantYearUnitTarget (%)Actions
Reliability Data Log
Add Log
100){toast('Total exceeds 100%','e');return;} try{ await api('POST','criticality_settings',{},{cr_pA:nA,cr_pB:nB,cr_pC:nC,cr_pD:nD}); window._crSettings={pA:nA,pB:nB,pC:nC,pD:nD}; var ba=document.getElementById('cls-badge-a');if(ba)ba.textContent=nA; var bb=document.getElementById('cls-badge-b');if(bb)bb.textContent=nB; var bc=document.getElementById('cls-badge-c');if(bc)bc.textContent=nC; var bd=document.getElementById('cls-badge-d');if(bd)bd.textContent=nD; document.getElementById('mf-save').textContent='Save'; closeOverlay('ov-form'); toast('Classification saved','s'); }catch(e){toast(e.message,'e');} return; } if (type === 'fmea-master') { const rcmVals = window['_rcmVals_rcm-wizard-fmm']||{}; const body = { mi_type_code: fv('fm-mi-type'), iso14224_mi_code: fv('fm-mi-type'), failure_mode: fv('fm-mode'), failure_cause: fv('fm-cause'), failure_effect: fv('fm-effect'), is_hidden_function: rcmVals.hf||0, is_safety_env_consequence: rcmVals.se||0, is_operational_consequence: rcmVals.op||0, is_detectable: rcmVals.det||0, task_feasible: rcmVals.feasible===undefined?1:rcmVals.feasible, detection_method: fv('fm-detect'), task_description: fv('fm-task-desc'), interval_value: parseFloat(document.getElementById('ff-fm-interval-val')?.value)||null, interval_unit: fv('fm-interval-unit'), iso_reference: fv('fm-iso-ref'), task_type_override: rcmGetFinalTask('rcm-wizard-fmm'), created_by: S.user?.id||1, updated_by: S.user?.id||1 }; if (!body.mi_type_code || !body.failure_mode) { toast('Component Type and Failure Mode are required','e'); return; } const res = id ? await api('PUT','fmea_master',{id},body) : await api('POST','fmea_master',{},body); const ttype = rcmGetFinalTask('rcm-wizard-fmm'); toast((id?'Updated':'Added')+' — Task: '+ttype,'s'); closeOverlay('ov-form'); loadFmeaMaster(); return; } if (type === 'fmea-entry') { const masterId = fiv('fe-master')||null; const body = { asset_id: S._fmeaEntryAssetId, asset_type: S._fmeaEntryAssetType||'component', fmea_master_id: masterId, is_hidden_function: (window['_rcmVals_rcm-wizard-fme']||{}).hf||0, is_safety_env_consequence: (window['_rcmVals_rcm-wizard-fme']||{}).se||0, is_operational_consequence: (window['_rcmVals_rcm-wizard-fme']||{}).op||0, is_detectable: (window['_rcmVals_rcm-wizard-fme']||{}).det||0, task_feasible: (window['_rcmVals_rcm-wizard-fme']||{}).feasible===undefined?1:(window['_rcmVals_rcm-wizard-fme']||{}).feasible, equipment_id: S._fmeaEntryEqId||null, failure_mode: fv('fe-mode'), failure_cause: fv('fe-cause'), failure_effect: fv('fe-effect'), hfs: fiv('fe-hfs'), coc: fv('fe-coc'), detection_method: fv('fe-detect'), task_type: rcmGetFinalTask('rcm-wizard-fme'), task_description: fv('fe-task-desc'), interval_value: parseFloat(document.getElementById('ff-fe-iv')?.value)||null, interval_unit: fv('fe-iu'), pm_master_id: fiv('fe-pm')||null, is_from_master: masterId ? 1 : 0, notes: fv('fe-notes'), created_by: S.user?.id||1, updated_by: S.user?.id||1 }; if (!body.failure_mode) { toast('Failure Mode is required','e'); return; } if (id) await api('PUT','fmea_entries',{id},body); else await api('POST','fmea_entries',{},body); toast(id?'Updated':'Added','s'); closeOverlay('ov-form'); if (typeof loadFmeaForAsset === 'function') loadFmeaForAsset(S._fmeaEntryAssetId); return; } if (type === 'cr-finalize') { const year = S.formId; const notes = document.getElementById('ff-fin-notes')?.value?.trim(); if (!notes) { toast('Notes are required for finalization','e'); return; } try { const res = await api('POST','criticality_years',{action:'finalize'},{ year, notes, finalized_by:S.user?.id||1 }); toast(`Year ${year} finalized. ${year+1} baseline created.`,'s'); closeOverlay('ov-form'); // Reload year selector const yrSel = document.getElementById('f-cr-year'); if (yrSel) { yrSel.innerHTML = ''; } loadCriticalityRanking(); } catch(e) { toast(e.message,'e'); } return; } if (type === 'cr-scr') { const sysId=S.formId; const params=S._scrParams||DEFAULT_CRIT_PARAMS.filter(p=>parseFloat(p.weight)>0); for(const p of params){ const val=parseFloat(document.getElementById('ff-scr-'+p.code)?.value)||0; if(val>0) await api('POST','criticality_scores',{},{entity_type:'system',entity_id:parseInt(sysId),param_code:p.code,score:val,updated_by:S.user?.id||1}); } toast('SCR saved','s'); closeOverlay('ov-form'); loadCriticalityRanking(); return; } if (type === 'cascade-delete') { const notes = document.getElementById('ff-cascade-notes')?.value?.trim(); if (!notes) { toast('Notes are required','e'); return; } const ctx = S._cascadeCtx||{}; try { await api('DELETE', ctx.apiModule, {id:ctx.id}, {force:true, notes, updated_by:S.user?.id||1}); document.getElementById('mf-save').style.background=''; document.getElementById('mf-save').style.color=''; toast('Deleted (record retained in audit trail)','s'); closeOverlay('ov-form'); if(ctx.onSuccess) ctx.onSuccess(); } catch(e){ toast(e.message,'e'); } return; } if (type === 'link-asset-item') { const assetId = S.formId; const unit = document.getElementById('ff-link-unit')?.value || 'pcs'; const critical = parseInt(document.getElementById('ff-link-critical')?.value)||0; try { const res = await api('POST','physical_assets',{id:assetId,action:'link_item'},{unit,is_critical:critical,created_by:S.user?.id||1}); toast('Item created and linked: '+res.item_id,'s'); closeOverlay('ov-form'); loadPhysicalAssets(); } catch(e) { toast(e.message,'e'); } return; } if (type === 'criticality-score') { const ctx = S._scoreCtx||{}; const val = parseFloat(document.getElementById('ff-score-val')?.value)||0; await api('POST','criticality_scores',{},{equipment_id:ctx.eqId,param_code:ctx.paramCode,score:val,updated_by:S.user?.id||1}); toast('Score saved','s'); closeOverlay('ov-form'); loadCriticalityRanking(); return; } if (type === 'criticality-score-all') { const eqId = S.formId; const params = S._scoreParams||[]; for (const p of params) { const val = parseFloat(document.getElementById('ff-score-'+p.code)?.value)||0; await api('POST','criticality_scores',{},{equipment_id:eqId,param_code:p.code,score:val,updated_by:S.user?.id||1}); } toast('All scores saved','s'); closeOverlay('ov-form'); loadCriticalityRanking(); return; } if (type === 'sys-scr-score') { const sysId = S.formId; const sysScores = S._sysScores||{}; for (const [code, val] of Object.entries(sysScores)) { await api('POST','criticality_scores',{},{entity_type:'system',entity_id:sysId,param_code:code,score:val,updated_by:S.user?.id||1}); } toast('SCR scores saved','s'); closeOverlay('ov-form'); loadCriticalityRanking(); return; } if (type === 'eq-acr-afpf-score') { const eqId = S.formId; const params = S._eqParams||[]; const scores = S._eqScores||{}; for (const p of params) { const val = S._eqIsHILP && p.component==='AFPF' ? 10 : (scores[p.code]||0); await api('POST','criticality_scores',{},{entity_type:'equipment',entity_id:eqId,param_code:p.code,score:val,updated_by:S.user?.id||1}); } toast('ACR/AFPF scores saved','s'); closeOverlay('ov-form'); loadCriticalityRanking(); return; } if (type === 'risk-loc') { const body = {plant_id:parseInt(document.getElementById('ff-rloc-plant')?.value)||null,level1:document.getElementById('ff-rloc-l1')?.value||'',level2:document.getElementById('ff-rloc-l2')?.value||'',level3:document.getElementById('ff-rloc-l3')?.value||null}; if (!body.plant_id||!body.level1||!body.level2) { toast('Plant, Level 1 and Level 2 are required','e'); return; } if (id) await api('PUT','risk_failure_locations',{id},body); else await api('POST','risk_failure_locations',{},body); toast(id?'Updated':'Added','s'); closeOverlay('ov-form'); loadRiskLoc(); return; } if (type === 'risk-aspect') { const body = {aspect_type:document.getElementById('ff-asp-type')?.value,unit:document.getElementById('ff-asp-unit')?.value,equipment_id:parseInt(document.getElementById('ff-asp-equip')?.value)||null,parameter_name:document.getElementById('ff-asp-name')?.value||'',reference_value:document.getElementById('ff-asp-ref')?.value||null,unit_measure:document.getElementById('ff-asp-unit-meas')?.value||null,plant_id:S.user?.company_id||1,is_active:1,created_by:S.user?.id||1}; if (!body.parameter_name||!body.equipment_id) { toast('Equipment and Parameter Name required','e'); return; } if (id) await api('PUT','risk_aspect_params',{id},body); else await api('POST','risk_aspect_params',{},body); toast(id?'Updated':'Added','s'); closeOverlay('ov-form'); loadRiskAspect(); return; } if (type === 'risk-avail-target') { const body = {plant_id:parseInt(document.getElementById('ff-tgt-plant')?.value)||null,unit:document.getElementById('ff-tgt-unit')?.value,year:parseInt(document.getElementById('ff-tgt-year')?.value)||new Date().getFullYear(),month:parseInt(document.getElementById('ff-tgt-month')?.value)||1,target_pct:parseFloat(document.getElementById('ff-tgt-pct')?.value)||90,updated_by:S.user?.id||1}; if (!body.plant_id||!body.month) { toast('Plant and Month required','e'); return; } if (id) await api('PUT','risk_avail_targets',{id},body); else await api('POST','risk_avail_targets',{},body); toast(id?'Updated':'Added','s'); closeOverlay('ov-form'); loadRiskAvailTargets(); return; } if (type === 'fmea') { const comp_id = document.getElementById('ff-fmea-comp')?.value||''; if (!comp_id) { toast('Component required','e'); return; } const body = { component_id: parseInt(comp_id), failure_code: document.getElementById('ff-fmea-code')?.value||'', failure_name: document.getElementById('ff-fmea-name')?.value||'', failure_cause: document.getElementById('ff-fmea-cause')?.value||null, failure_effect: document.getElementById('ff-fmea-effect')?.value||null, detection_method: document.getElementById('ff-fmea-detect-method')?.value||null, recommended_action: document.getElementById('ff-fmea-rec-action')?.value||null, is_hidden_function: document.getElementById('ff-fmea-hf')?.checked ? 1 : 0, has_safety_consequence: document.getElementById('ff-fmea-sc')?.checked ? 1 : 0, has_operational_consequence: document.getElementById('ff-fmea-oc')?.checked ? 1 : 0, is_detectable: document.getElementById('ff-fmea-det')?.checked ? 1 : 0, task_type: document.getElementById('ff-fmea-task-type')?.value||'RTF', task_description: document.getElementById('ff-fmea-task-desc')?.value||null, task_interval: document.getElementById('ff-fmea-interval')?.value||null, pm_master_id: parseInt(document.getElementById('ff-fmea-pm-master')?.value||0)||null, created_by: S.user?.id||1, updated_by: S.user?.id||1 }; if (id) await api('PUT','failure_modes',{id},body); else await api('POST','failure_modes',{},body); toast(id?'FMEA entry updated':'FMEA entry added','s'); closeOverlay('ov-form'); loadFM(fmPage); return; } if (type === 'fmea-pm-link') { const pmId = parseInt(document.getElementById('ff-pm-link-sel')?.value||0)||null; // Fetch current record then update only pm_master_id try { const cur = await api('GET','failure_modes',{id}); if (cur) { await api('PUT','failure_modes',{id},{ failure_code: cur.failure_code, failure_name: cur.failure_name, failure_cause: cur.failure_cause, failure_effect: cur.failure_effect, detection_method: cur.detection_method, recommended_action: cur.recommended_action, is_hidden_function: cur.is_hidden_function, has_safety_consequence: cur.has_safety_consequence, has_operational_consequence: cur.has_operational_consequence, is_detectable: cur.is_detectable, task_type: cur.task_type, task_description: cur.task_description, task_interval: cur.task_interval, pm_master_id: pmId }); } } catch(e) { // Fallback: simple update with just pm_master_id — server handles missing fields gracefully await api('PUT','failure_modes',{id},{pm_master_id: pmId, failure_code:'', failure_name:'', task_type:'PM'}); } toast(pmId ? 'PM Master linked' : 'PM Master unlinked','s'); closeOverlay('ov-form'); loadFM(fmPage); return; } if (type === 'inv-cls') { const body = {item_number:document.getElementById('ff-cls-itemno')?.value||'',description:document.getElementById('ff-cls-desc')?.value||'',cleansed_description:document.getElementById('ff-cls-cdesc')?.value||null,uom:document.getElementById('ff-cls-uom')?.value||null,master_uom:document.getElementById('ff-cls-muom')?.value||null,category:document.getElementById('ff-cls-cat')?.value||'Uncategorized',section:document.getElementById('ff-cls-section')?.value||null,rack:document.getElementById('ff-cls-rack')?.value||null,criticality:document.getElementById('ff-cls-crit')?.value||'-',pr_po_grn_status:document.getElementById('ff-cls-status')?.value||null,updated_by:S.user?.id||1,created_by:S.user?.id||1}; if(id) await api('PUT','inv_classification',{id},body); else await api('POST','inv_classification',{},body); toast(id?'Updated':'Added','s'); closeOverlay('ov-form'); loadInvClassItems(); return; } if (type === 'inv-rate') { const body = {currency:(document.getElementById('ff-rate-cur')?.value||'').toUpperCase(),year:parseInt(document.getElementById('ff-rate-year')?.value)||new Date().getFullYear(),rate_to_idr:parseFloat(document.getElementById('ff-rate-val')?.value)||0,notes:document.getElementById('ff-rate-notes')?.value||null,updated_by:S.user?.id||1}; await api('POST','inv_exchange_rates',{},body); toast('Saved','s'); closeOverlay('ov-form'); loadInvExRates(); return; } if (type === 'inv-mov') { const body = {item_number:document.getElementById('ff-mov-item')?.value||'',storeroom_id:parseInt(document.getElementById('ff-mov-store')?.value)||null,movement_date:document.getElementById('ff-mov-date')?.value||null,qty:parseFloat(document.getElementById('ff-mov-qty')?.value)||0,uom:document.getElementById('ff-mov-uom')?.value||null,wo_id:parseInt(document.getElementById('ff-mov-wo')?.value)||null,equipment_id:parseInt(document.getElementById('ff-mov-equip')?.value)||null,notes:document.getElementById('ff-mov-notes')?.value||null,requested_by:S.user?.id||1,created_by:S.user?.id||1}; await api('POST','inv_movements',{},body); toast('Movement recorded','s'); closeOverlay('ov-form'); loadInvMovements(); return; } if (type==='sr') { const body = { equipment_id:fiv('sr-equipment'), component_id:fiv('sr-component'), title:fv('sr-title'), description:fv('sr-desc'), priority:fv('sr-priority'), reported_by:S.user?.id||1 }; if (id) await api('PUT','service_requests',{id},body); else await api('POST','service_requests',{},body); toast(id?'SR updated':'SR created','s'); closeOverlay('ov-form'); loadSR(); return; } if (type==='wo') { const woType = fv('wo-type'); const body = { wo_type:woType, equipment_id:fiv('wo-equipment'), component_id:fiv('wo-component'), job_plan_id:fiv('wo-jobplan'), title:fv('wo-title'), description:fv('wo-desc'), priority:fv('wo-priority'), planned_start:fv('wo-start'), planned_end:fv('wo-end'), estimated_hours:fi('wo-hours'), budget_code:fv('wo-budget-code'), downtime_start: woType==='CM_BD' ? (document.getElementById('ff-wo-dt-start')?.value||null) : null, downtime_finish: woType==='CM_BD' ? (document.getElementById('ff-wo-dt-finish')?.value||null) : null, created_by:S.user?.id||1 }; if (id) await api('PUT','work_orders',{id},body); else await api('POST','work_orders',{},body); toast(id?'WO updated':'WO created','s'); closeOverlay('ov-form'); loadWO(); loadBadgeCounts(); return; } if (type==='wo-child') { const body = { wo_type:fv('wo-type'), equipment_id:fiv('wo-equipment'), component_id:fiv('wo-component'), job_plan_id:fiv('wo-jobplan'), title:fv('wo-title'), description:fv('wo-desc'), priority:fv('wo-priority'), planned_start:fv('wo-start'), planned_end:fv('wo-end'), estimated_hours:fi('wo-hours'), parent_wo_id:id, created_by:S.user?.id||1 }; await api('POST','work_orders',{},body); toast('Child WO created','s'); closeOverlay('ov-form'); loadWO(); return; } if (type==='wo-assign') { await api('PUT','work_orders',{id,action:'assign'},{assigned_to:fiv('assign-user'),assigned_by:S.user?.id||1}); toast('WO assigned','s'); closeOverlay('ov-form'); loadWO(); return; } if (type==='wo-close') { await api('PUT','work_orders',{id,action:'close'},{ actual_start:fv('cl-start'), actual_end:fv('cl-end'), actual_hours:fi('cl-hours'), downtime_hours:fi('cl-downtime'), failure_description:fv('cl-fail-desc'), failure_cause:fv('cl-fail-cause'), root_cause:fv('cl-root'), remarks:fv('cl-remarks'), closed_by:S.user?.id||1 }); toast('WO closed','s'); closeOverlay('ov-form'); loadWO(); return; } if (type==='wo-labor') { await api('POST','wo_labor',{},{wo_id:id, user_id:fiv('lab-user'), work_date:fv('lab-date'), hours:fi('lab-hours'), labor_cost:fi('lab-cost'), craft:fv('lab-craft'), notes:fv('lab-notes')}); toast('Labor recorded','s'); closeOverlay('ov-form'); return; } if (type==='wo-parts') { const itemEl = document.getElementById('ff-part-item'); const selItem = itemEl?.options[itemEl.selectedIndex]; const itemName = selItem?.value ? selItem.textContent : fv('part-name'); await api('POST','wo_parts',{},{wo_id:id, inventory_item_id:fiv('part-item')||null, item_name:itemName, quantity_requested:fi('part-qty'), unit:fv('part-unit'), storeroom_id:fiv('part-store')||null, requested_by:S.user?.id||1}); toast('Parts requested','s'); closeOverlay('ov-form'); return; } if (type==='pm') { const body = { equipment_id:fiv('pm-equipment'), job_plan_id:fiv('pm-jobplan'), title:fv('pm-title'), description:fv('pm-desc'), trigger_type:fv('pm-trigger'), interval_value:parseInt(fv('pm-interval-val'))||null, interval_unit:fv('pm-interval-unit')||null, hours_interval:fi('pm-hours-interval')||null, next_due_date:fv('pm-next-due'), auto_generate:fiv('pm-auto'), created_by:S.user?.id||1 }; if (id) await api('PUT','pm_masters',{id},body); else await api('POST','pm_masters',{},body); toast(id?'PM updated':'PM created','s'); closeOverlay('ov-form'); loadPM(); return; } if (type==='jp') { const body = { code:fv('jp-code'), name:fv('jp-name'), craft:fv('jp-craft')||null, estimated_hours:fi('jp-hours'), description:fv('jp-desc'), is_active:1, created_by:S.user?.id||1 }; if (id) await api('PUT','job_plans',{id},body); else await api('POST','job_plans',{},body); toast(id?'JP updated':'JP created','s'); closeOverlay('ov-form'); loadJP(); S.cache.job_plans = await safeFetch('job_plans',{limit:500}); return; } if (type==='preng') { const body = { equipment_id:fiv('preng-equipment'), title:fv('preng-title'), type:fv('preng-type'), priority:fv('preng-priority'), estimated_cost:fi('preng-cost'), budget_code:fv('preng-budget-code'), description:fv('preng-desc'), scope:fv('preng-scope'), requested_by:S.user?.id||1 }; if (id) await api('PUT','project_requests',{id},body); else await api('POST','project_requests',{},body); toast(id?'Request updated':'Request created','s'); closeOverlay('ov-form'); loadPREng(); return; } if (type==='inv') { const body = { name:fv('inv-name'), category_id:fiv('inv-cat'), unit:fv('inv-unit'), manufacturer:fv('inv-mfr'), model:fv('inv-model'), part_number:fv('inv-pn'), alternative_pn:fv('inv-alt-pn'), serial_number:fv('inv-serial'), is_critical:fiv('inv-critical'), notes:fv('inv-notes'), is_active:1, created_by:S.user?.id||1 }; if (id) await api('PUT','inventory_items',{id},body); else await api('POST','inventory_items',{},body); toast(id?'Item updated':'Item created','s'); closeOverlay('ov-form'); _maybeRefreshChangeLog(); loadInv(); S.cache.inventory_items = await safeFetch('inventory_items',{limit:1000}); return; } if (type==='store') { const body = { code:fv('store-code'), name:fv('store-name'), type:fv('store-type'), plant_id:fiv('store-plant'), location:fv('store-location'), description:fv('store-desc'), keeper_id:fiv('store-keeper'), is_active:1 }; if (id) await api('PUT','storerooms',{id},body); else await api('POST','storerooms',{},body); toast(id?'Updated':'Created','s'); closeOverlay('ov-form'); loadStorerooms(); S.cache.storerooms = await safeFetch('storerooms'); return; } if (type==='adj') { await api('POST','stock_transactions',{action:'adjust'},{ inventory_item_id:fiv('adj-item'), storeroom_id:fiv('adj-store'), qty:parseFloat(document.getElementById('ff-adj-qty')?.value||0), unit_cost:fi('adj-cost'), notes:fv('adj-notes'), created_by:S.user?.id||1 }); toast('Adjustment recorded','s'); closeOverlay('ov-form'); loadTxn(); return; } if (type==='trf') { await api('POST','stock_transactions',{action:'transfer'},{ inventory_item_id:fiv('trf-item'), from_storeroom_id:fiv('trf-from'), to_storeroom_id:fiv('trf-to'), qty:fi('trf-qty'), unit_cost:fi('trf-cost'), notes:fv('trf-notes'), requested_by:S.user?.id||1 }); toast('Transfer requested — pending approval','s'); closeOverlay('ov-form'); loadTxn(); return; } if (type==='wu') { await api('POST','item_where_used',{},{ inventory_item_id:fiv('wu-item'), equipment_id:fiv('wu-eq'), component_id:fiv('wu-comp'), notes:fv('wu-notes'), created_by:S.user?.id||1 }); toast('Where used added','s'); closeOverlay('ov-form'); loadWhereUsed(); return; } if (type==='opn') { await api('POST','stock_opnames',{},{ storeroom_id:fiv('opn-store'), opname_date:fv('opn-date'), conducted_by:S.user?.id||1 }); toast('Opname created','s'); closeOverlay('ov-form'); loadOpname(); return; } // ── Planning & Scheduling ────────────────────────────── if (type==='plancfg') { const body = { year:parseInt(fv('pc-year')), workgroup:fv('pc-workgroup'), plant_id:fiv('pc-plant'), technician_count:parseInt(fv('pc-tech'))||5, hours_per_technician:parseFloat(fv('pc-hrs'))||6, working_days_per_week:parseInt(fv('pc-days'))||5, is_active:1, created_by:S.user?.id||1 }; if (id) await api('PUT','plan_configs',{id},body); else await api('POST','plan_configs',{},body); toast(id?'Config updated':'Config created','s'); closeOverlay('ov-form'); loadPlanConfigs(); const cfgs = await safeFetch('plan_configs'); S.cache.plan_configs = cfgs; const cfgOpts = cfgs.map(c=>``).join(''); ['sel-52w-config','sel-4w-config'].forEach(elId=>{ const el=document.getElementById(elId); if(el) el.innerHTML=``+cfgOpts; }); return; } if (type==='p52entry') { const body = { plan_config_id:parseInt(S._p52cfgId)||0, equipment_id:fiv('p52-eq'), pm_master_id:fiv('p52-pm')||null, pm_number:fv('p52-pmno'), pm_description:fv('p52-desc'), maintenance_plan_no:fv('p52-maintno'), freq_weeks:parseInt(fv('p52-freq'))||1, starting_week:parseInt(fv('p52-start'))||1, num_people:parseInt(fv('p52-people'))||1, duration_hours:parseFloat(fv('p52-dur'))||1, duration_unit:fv('p52-unit')||'HR', craft:fv('p52-craft'), equipment_type:fv('p52-eqtype'), is_active:1, created_by:S.user?.id||1 }; if (id) await api('PUT','plan_52w',{id},body); else await api('POST','plan_52w',{},body); toast(id?'Entry updated':'Entry added','s'); closeOverlay('ov-form'); load52W(); return; } if (type==='sched4w') { const res = await api('POST','schedule_4w',{},{ plan_config_id:parseInt(S._s4wCfgId)||0, current_week:parseInt(fv('s4w-week'))||getWeekNumber(new Date()), year:parseInt(fv('s4w-year'))||new Date().getFullYear(), technician_count:parseInt(fv('s4w-tech'))||5, hours_per_technician:parseFloat(fv('s4w-hrs'))||6, supervisor_count:parseInt(fv('s4w-spv'))||1, notes:fv('s4w-notes'), created_by:S.user?.id||1 }); toast(`Schedule W${res.t1}→W${res.t4} created`,'s'); closeOverlay('ov-form'); await load4WSessions(); const sel=document.getElementById('sel-4w-session'); if(sel){sel.value=res.id; load4WDetail();} return; } if (type==='sched4w-cm') { await api('POST','schedule_4w',{action:'add_item'},{ schedule_id:id, source:fv('cm-src'), equipment_id:fiv('cm-eq'), wo_number:fv('cm-wono')||null, description:fv('cm-desc'), num_people:parseInt(fv('cm-people'))||1, duration_hours:parseFloat(fv('cm-dur'))||1, priority:fv('cm-prio')||'medium', scheduled_week:parseInt(fv('cm-week'))||1, created_by:S.user?.id||1 }); toast('Item added to schedule','s'); closeOverlay('ov-form'); load4WDetail(); return; } // ── Availability data ────────────────────────────────── if (type==='avail') { const plantId=parseInt(document.getElementById('av-plant')?.value)||0; const body={plant_id:plantId,unit:document.getElementById('ff-av-unit')?.value||'unit1', equipment_id:parseInt(document.getElementById('ff-av-eq')?.value)||0, start_date:document.getElementById('ff-av-start')?.value, end_date:document.getElementById('ff-av-end')?.value, loss_type:document.getElementById('ff-av-loss-type')?.value||'FO', loss_mw:parseFloat(document.getElementById('ff-av-loss-mw')?.value)||0, level1:document.getElementById('ff-av-l1')?.value||null, level2:document.getElementById('ff-av-l2')?.value||null, level3:document.getElementById('ff-av-l3')?.value||null, description:document.getElementById('ff-av-desc')?.value||null, created_by:S.user?.id||1}; if(!body.equipment_id||!body.start_date||!body.end_date){toast('Equipment, start and end date required','e');return;} if(id) await api('PUT','risk_availability',{id},body); else await api('POST','risk_availability',{},body); toast(id?'Updated':'Saved','s'); closeOverlay('ov-form'); loadRiskAvailability(); return; } // ── Risk Input Detail ──────────────────────────────────── if (type==='risk-detail') { const plantId=S._rdPlantId||0; const cfg=RD_CONFIGS[RK.rdTab]||{}; const body={plant_id:plantId,detail_type:RK.rdTab, unit:document.getElementById('ff-rd-unit')?.value||'unit1', equipment_id:parseInt(document.getElementById('ff-rd-eq')?.value)||0, year:parseInt(document.getElementById('ff-rd-year')?.value)||new Date().getFullYear(), month:document.getElementById('ff-rd-month')?.value||'01', operational_impact:document.getElementById('ff-rd-impact')?.value||'no-impact', notes:document.getElementById('ff-rd-notes')?.value||null, created_by:S.user?.id||1}; if(!body.equipment_id){toast('Equipment required','e');return;} if(cfg.type==='parameter'){ body.week=parseInt(document.getElementById('ff-rd-week')?.value)||1; body.parameter_id=parseInt(document.getElementById('ff-rd-param')?.value)||null; const av=document.getElementById('ff-rd-actual')?.value; body.actual_value=av!==''&&av!==undefined?parseFloat(av):null; } else if(cfg.type==='task'){ body.task_description=document.getElementById('ff-rd-task')?.value||null; body.planned_quantity=parseFloat(document.getElementById('ff-rd-planned')?.value)||0; body.actual_quantity=parseFloat(document.getElementById('ff-rd-actual-qty')?.value)||0; } else { body.status_value=document.getElementById('ff-rd-status')?.value||null; } await api('POST','risk_detail_entries',{},body); toast('Saved','s'); closeOverlay('ov-form'); loadRiskDetails(); return; } // ── Risk Failure Location ──────────────────────────────── if (type==='risk-loc') { const plantId=S._rdbPlantId||0; const body={plant_id:plantId, level1:document.getElementById('ff-loc-l1')?.value||'', level2:document.getElementById('ff-loc-l2')?.value||'', level3:document.getElementById('ff-loc-l3')?.value||null}; if(!body.level1||!body.level2){toast('Level 1 and Level 2 required','e');return;} await api('POST','risk_failure_locations',{},body); toast('Saved','s'); closeOverlay('ov-form'); loadLocations(plantId); return; } // ── Risk Aspect Parameter ──────────────────────────────── if (type==='risk-asp-param') { const plantId=S._rdbPlantId||0; const body={plant_id:plantId, aspect_type:document.getElementById('ff-ap-type')?.value, unit:document.getElementById('ff-ap-unit')?.value||'unit1', equipment_id:parseInt(document.getElementById('ff-ap-eq')?.value)||0, parameter_name:document.getElementById('ff-ap-name')?.value||'', reference_value:document.getElementById('ff-ap-ref')?.value||null, unit_measure:document.getElementById('ff-ap-uom')?.value||null, created_by:S.user?.id||1}; if(!body.equipment_id||!body.parameter_name){toast('Equipment and parameter name required','e');return;} await api('POST','risk_aspect_params',{},body); toast('Saved','s'); closeOverlay('ov-form'); loadAspectParams(plantId); return; } // ── Risk Availability Target ───────────────────────────── if (type==='risk-target') { const plantId=S._rdbPlantId||0; const body={plant_id:plantId, unit:document.getElementById('ff-tgt-unit')?.value||'unit1', year:parseInt(document.getElementById('ff-tgt-year')?.value)||new Date().getFullYear(), month:parseInt(document.getElementById('ff-tgt-month')?.value)||1, target_pct:parseFloat(document.getElementById('ff-tgt-pct')?.value)||90, updated_by:S.user?.id||1}; await api('POST','risk_avail_targets',{},body); toast('Saved','s'); closeOverlay('ov-form'); loadAvailTargets(plantId); return; } // ── Block Builder Diagram ──────────────────────────────── if (type==='physical_asset') { var body={ asset_tag:gv('ff-pa-tag'),name:gv('ff-pa-name'),asset_type:gv('ff-pa-type'), equipment_type_code:gv('ff-pa-eq-type')||null,mi_id:gv('ff-pa-mi')||null, status:gv('ff-pa-status'),condition_grade:gv('ff-pa-cond')||null, manufacturer:gv('ff-pa-mfr'),model:gv('ff-pa-mdl'),serial_number:gv('ff-pa-sn'), manufacture_year:gv('ff-pa-yr')||null,purchase_date:gv('ff-pa-pd')||null, purchase_cost:parseFloat(gv('ff-pa-pc'))||0,current_value:parseFloat(gv('ff-pa-cv'))||0, runtime_hours:parseFloat(gv('ff-pa-rh'))||0, technical_specs:gv('ff-pa-specs')||null,notes:gv('ff-pa-notes'),created_by:S.user?.id||1 }; if(!body.asset_tag||!body.name){toast('Asset Tag and Name required','e');return;} try{ if(S.formId) await api('PUT','physical_assets',{id:S.formId},body); else await api('POST','physical_assets',{},body); toast(S.formId?'Asset updated':'Asset added'); closeOverlay('ov-form'); loadPhysicalAssets(); }catch(e){toast(e.message,'e');} return; } if (type==='assign_asset') { var assetId = parseInt(gv('ff-asgn-asset')); var locRaw = gv('ff-asgn-loc'); if (!assetId || !locRaw) { toast('Select asset and location','e'); return; } var parts = locRaw.split(':'); var locType = parts[0]; var locId = parseInt(parts[1]); try{ await api('POST','asset_assignments',{},{ asset_id:assetId, location_type:locType, location_id:locId, assigned_by:S.user?.id||1, notes:gv('ff-asgn-notes') }); toast('Asset assigned successfully'); document.getElementById('mf-save').textContent = 'Save'; closeOverlay('ov-form'); loadPhysicalAssets(); if(document.getElementById('tree-panel')) loadHierarchy(); }catch(e){toast(e.message,'e');} return; } if (type==='move_asset') { var reason = gv('ff-mv-reason'); if (!reason) { toast('Please provide a reason','e'); return; } var mvType = gv('ff-mv-type'); var locRaw = mvType==='transfer' ? gv('ff-mv-loc') : ''; var locType = null; var locId = null; if (locRaw) { var parts=locRaw.split(':'); locType=parts[0]; locId=parseInt(parts[1]); } try{ await api('POST','asset_movements',{},{ asset_id:S.formId, to_location_type:locType, to_location_id:locId, to_status:gv('ff-mv-status'), reason:reason, move_type:mvType, requested_by:S.user?.id||1 }); toast('Movement request submitted — awaiting approval'); document.getElementById('mf-save').textContent = 'Save'; closeOverlay('ov-form'); loadPhysicalAssets(); }catch(e){toast(e.message,'e');} return; } if (type==='bbd') { const eqId=parseInt(document.getElementById('ff-bbd-eq')?.value)||0; if(!eqId){toast('Select equipment','e');return;} const body={equipment_id:eqId, diagram_name:document.getElementById('ff-bbd-name')?.value||'API Availability Diagram', diagram_data:'{"blocks":[],"connections":[]}', notes:document.getElementById('ff-bbd-notes')?.value||null, created_by:S.user?.id||1}; const res=await api('POST','block_builder',{},body); toast('Diagram created','s'); closeOverlay('ov-form'); loadBlockBuilder(); setTimeout(function(){openBBDEditor(res.id);},300); return; } // ── Regulation Category ────────────────────────────────── if (type==='reg-cat') { await api('POST','regulation_categories',{},{ company_id:1, name:document.getElementById('ff-rc-name')?.value||'', icon:document.getElementById('ff-rc-icon')?.value||'📋', sort_order:parseInt(document.getElementById('ff-rc-order')?.value)||0}); toast('Category saved','s'); closeOverlay('ov-form'); REG.regCats=[]; loadRegCats(); return; } // ── Ministry ───────────────────────────────────────────── if (type==='ministry') { const cid=parseInt(document.getElementById('regm-company')?.value)||1; await api('POST','reg_ministries',{},{ company_id:cid, name:document.getElementById('ff-min-name')?.value||'', abbreviation:document.getElementById('ff-min-abbr')?.value||null}); toast('Ministry saved','s'); closeOverlay('ov-form'); loadMinistries(); return; } // ── Regulation Permit ──────────────────────────────────── if (type==='permit') { const body={ plant_id:parseInt(document.getElementById('ff-p-plant')?.value)||0, category_id:parseInt(document.getElementById('ff-p-cat')?.value)||0, permit_name:document.getElementById('ff-p-name')?.value||'', issuing_body:document.getElementById('ff-p-issuer')?.value||null, ministry_id:parseInt(document.getElementById('ff-p-ministry')?.value)||null, certificate_no:document.getElementById('ff-p-certno')?.value||null, issue_date:document.getElementById('ff-p-issued')?.value||null, expiry_date:document.getElementById('ff-p-expiry')?.value||null, pic_user_id:parseInt(document.getElementById('ff-p-pic')?.value)||null, pic_dept:document.getElementById('ff-p-dept')?.value||null, notes:document.getElementById('ff-p-notes')?.value||null, created_by:S.user?.id||1}; if(!body.permit_name||!body.category_id||!body.expiry_date){toast('Name, category and expiry date required','e');return;} if(id) await api('PUT','regulation_permits',{id},body); else await api('POST','regulation_permits',{},body); toast(id?'Updated':'Saved','s'); closeOverlay('ov-form'); loadRegWatchlist(); updateNavBadges(); loadNotifCount(); return; } // ── Oplog New Package ──────────────────────────────────── if (type==='oplog-pkg-new') { const plantId=parseInt(document.getElementById('ff-pkg-plant')?.value)||0; const areaId=parseInt(document.getElementById('ff-pkg-area')?.value)||0; if(!plantId||!areaId){toast('Plant and area required','e');return;} const body={plant_id:plantId,area_id:areaId, unit:document.getElementById('ff-pkg-unit')?.value||null, log_date:document.getElementById('ff-pkg-date')?.value||new Date().toISOString().slice(0,10), operator_id:S.user?.id||1}; const res=await api('POST','oplog_packages',{},body); closeOverlay('ov-form'); OPL.pkgId=res.id; openExistingPackage(res.id); toast('Package created – fill in readings','s'); return; } // ── Oplog Parameter ────────────────────────────────────── if (type==='oplog-param') { const compId=parseInt(document.getElementById('ff-prm-comp')?.value)||0; if(!compId){toast('Component required','e');return;} const lv=document.getElementById('ff-prm-low')?.value; const hv=document.getElementById('ff-prm-high')?.value; const body={component_id:compId, param_name:document.getElementById('ff-prm-name')?.value||'', tag_number:document.getElementById('ff-prm-tag')?.value||null, unit_measure:document.getElementById('ff-prm-unit')?.value||null, threshold_low:lv!==''&&lv!==undefined?parseFloat(lv):null, threshold_high:hv!==''&&hv!==undefined?parseFloat(hv):null, normal_range:document.getElementById('ff-prm-range')?.value||null, created_by:S.user?.id||1}; if(id) await api('PUT','oplog_parameters',{id},body); else await api('POST','oplog_parameters',{},body); toast(id?'Updated':'Saved','s'); closeOverlay('ov-form'); loadOplogMaster(); return; } // Fall through to original asset handler await _submitFormAsset(); } catch(e) { toast(e.message,'e'); } } // ───────────────────────────────────────────── // HELPER: confirm2 (promise-based) // ───────────────────────────────────────────── // confirm2 — merged version defined above in COMMON UTILITIES // ───────────────────────────────────────────── // HELPER: fmtDate, fmtNum // ───────────────────────────────────────────── // fmtDate — defined above in FORMAT HELPERS // fmtNum — alias for fmtCur, defined above // ───────────────────────────────────────────── // SECTION → PAGE MAPPING // ───────────────────────────────────────────── var SECTION_MAP = { overview: { default: 'dashboard', pages: ['dashboard'] }, assets: { default: 'asset-overview', pages: ['asset-overview','assets','hierarchy','block-builder','criticality-ranking','locations','categories','maintainable-items','criticality-params'] }, risk: { default: 'risk-overview', pages: ['risk-overview','risk-operation','risk-details','risk-loc','risk-aspect','risk-avail-targets'] }, reliability: { default: 'rel-dashboard', pages: ['rel-dashboard','failure-modes','rel-wo-database','rel-rcfa','risk-availability','kpi','fmea-master'] }, maintenance: { default: 'service-requests', pages: ['service-requests','engineering-requests','work-orders','pm-masters','job-plans','project-requests','plan-configs','plan-52w','schedule-4w'] }, 'operation-data':{ default: 'oplog-summary', pages: ['oplog-summary','oplog-database','oplog-packages','oplog-master'] }, inventory: { default: 'inventory', pages: ['inventory','storerooms','stock-transactions','where-used','stock-opname','inv-transactions','inv-analysis','inv-classification-db','inv-movements','inv-exchange-rates'] }, ehsia: { default: 'ehsia-dashboard', pages: ['ehsia-dashboard','ehsia-production','ehsia-safety','ehsia-enviro','ehsia-health'] }, regulation: { default: 'reg-dashboard', pages: ['reg-dashboard','reg-watchlist','reg-database'] }, 'change-log': { default: 'change-log', pages: ['change-log'] }, users: { default: 'users', pages: ['users'] } }; // Reverse map: page → section var PAGE_SECTION = {}; Object.keys(SECTION_MAP).forEach(function(sec) { SECTION_MAP[sec].pages.forEach(function(pg) { PAGE_SECTION[pg] = sec; }); }); // Which page was last visited per section var SECTION_LAST = {}; function navigateSection(section) { // Update sidebar active document.querySelectorAll('#sidebar .nav-item[data-section]').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.section === section); }); // Hide all section tab rows document.querySelectorAll('.sec-tab-row').forEach(function(el) { el.style.display = 'none'; }); // Show the right tab row var tabRow = document.getElementById('sec-tabs-' + section); if (tabRow) tabRow.style.display = ''; // Navigate to last visited page in section, or default var targetPage = SECTION_LAST[section] || SECTION_MAP[section]?.default || 'dashboard'; navigate(targetPage); S.section = section; } function navigateTab(page) { var section = PAGE_SECTION[page] || 'overview'; // Always keep sidebar button highlighted for current section document.querySelectorAll('#sidebar .nav-item[data-section]').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.section === section); }); // Show correct section tab row document.querySelectorAll('.sec-tab-row').forEach(function(el){ el.style.display = 'none'; }); var tabRow = document.getElementById('sec-tabs-' + section); if (tabRow) { tabRow.style.display = ''; tabRow.querySelectorAll('.sec-tab').forEach(function(btn) { btn.classList.toggle('active', btn.getAttribute('onclick') === "navigateTab('" + page + "')"); }); } SECTION_LAST[section] = page; S.section = section; navigate(page); } // Override navigate() to also update section tabs var _origNavigate = null; function _initSectionNav() { // Patch navigate to also sync section state var origNav = window.navigate; window.navigate = function(pg) { origNav(pg); var sec = PAGE_SECTION[pg]; if (sec && sec !== S.section) { // Update sidebar without recursion document.querySelectorAll('#sidebar .nav-item[data-section]').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.section === sec); }); document.querySelectorAll('.sec-tab-row').forEach(function(el) { el.style.display='none'; }); var tabRow = document.getElementById('sec-tabs-' + sec); if (tabRow) tabRow.style.display = ''; S.section = sec; } // Sync tab active state var sec2 = PAGE_SECTION[pg] || S.section; var tabRow2 = document.getElementById('sec-tabs-' + sec2); if (tabRow2) { tabRow2.querySelectorAll('.sec-tab').forEach(function(btn) { btn.classList.toggle('active', btn.getAttribute('onclick') === "navigateTab('" + pg + "')"); }); } if (sec) SECTION_LAST[sec] = pg; }; } // ───────────────────────────────────────────── // SMART SEARCH // ───────────────────────────────────────────── var _searchPill = 'all'; var _searchTimer = null; var _searchCache = {}; // Full menu index: all pages across sections var MENU_INDEX = [ // Overview { icon:'⊞', title:'Main Dashboard', sub:'Operation · Maintenance · Reliability', page:'dashboard', section:'overview' }, // Assets { icon:'⬡', title:'Asset Registry', sub:'Equipment, components, asset catalog', page:'assets', section:'assets' }, { icon:'⋮⋮',title:'Location Hierarchy', sub:'Location tree with assigned assets', page:'hierarchy', section:'assets' }, { icon:'⚠', title:'FMEA', sub:'Failure Mode & Effects Analysis – RCM decision tree', page:'failure-modes', section:'reliability' }, { icon:'◈', title:'KPI & Reliability', sub:'MTBF, MTTR, availability snapshots', page:'kpi', section:'assets' }, // Risk { icon:'📊', title:'Availability Monitoring', sub:'Unit availability data & targets', page:'risk-availability', section:'reliability' }, { icon:'🏭', title:'Equipment Risk Mapping', sub:'Equipment wellness board', page:'risk-operation', section:'risk' }, { icon:'📋', title:'Risk Details Input', sub:'Detail entries per aspect type', page:'risk-details', section:'risk' }, { icon:'📍', title:'Failure Locations', sub:'Plant failure location classification', page:'risk-loc', section:'risk' }, { icon:'📋', title:'Aspect Parameters', sub:'Risk aspect parameter configuration', page:'risk-aspect', section:'risk' }, { icon:'🎯', title:'Availability Targets', sub:'Monthly availability targets per unit', page:'risk-avail-targets', section:'risk' }, { icon:'🏗', title:'Block Builder', sub:'RBD diagram & availability calc', page:'block-builder', section:'assets' }, // Maintenance { icon:'📋', title:'Service Requests', sub:'SR workflow – submit, approve, convert', page:'service-requests', section:'maintenance' }, { icon:'🔨', title:'Work Orders', sub:'WO management – all types', page:'work-orders', section:'maintenance' }, { icon:'📅', title:'PM Masters', sub:'Preventive maintenance schedule', page:'pm-masters', section:'maintenance' }, { icon:'📝', title:'Job Plans', sub:'Task templates for work orders', page:'job-plans', section:'maintenance' }, { icon:'🏗', title:'Project Requests', sub:'Engineering modification requests', page:'project-requests', section:'maintenance' }, { icon:'📋', title:'Plan Configs', sub:'52-week plan configuration', page:'plan-configs', section:'maintenance' }, { icon:'📅', title:'52W Plan', sub:'Annual maintenance planning', page:'plan-52w', section:'maintenance' }, { icon:'🗓', title:'4W Schedule', sub:'Short-term 4-week scheduling', page:'schedule-4w', section:'maintenance' }, // Reliability { icon:'📊', title:'Evaluation Dashboard', sub:'MTBF/MTTR/availability evaluation', page:'rel-dashboard', section:'reliability' }, { icon:'🗃', title:'WO Database (Reliability)', sub:'Work order history for reliability', page:'rel-wo-database', section:'reliability' }, { icon:'🔍', title:'RCFA', sub:'Root cause failure analysis', page:'rel-rcfa', section:'reliability' }, // Management { icon:'📦', title:'Spare Parts Catalog', sub:'Inventory items master', page:'inventory', section:'inventory' }, { icon:'🏪', title:'Storerooms', sub:'Storeroom locations & keepers', page:'storerooms', section:'inventory' }, { icon:'↔', title:'Stock Transactions', sub:'Issue, receipt, transfer, adjustment', page:'stock-transactions', section:'inventory' }, { icon:'🔗', title:'Where Used', sub:'Item usage by equipment/component', page:'where-used', section:'inventory' }, { icon:'✅', title:'Stock Opname', sub:'Physical stock count & reconciliation', page:'stock-opname', section:'inventory' }, { icon:'🧾', title:'Procurement Transactions', sub:'PR/PO/GRN purchase history', page:'inv-transactions', section:'inventory' }, { icon:'📊', title:'Inventory Analysis', sub:'ABC · Stock Analysis · Strategy', page:'inv-analysis', section:'inventory' }, { icon:'🏷', title:'Classification DB', sub:'Item master, policy matrix, params', page:'inv-classification-db', section:'inventory' }, { icon:'🚚', title:'Warehouse Movements', sub:'Items issued to maintenance', page:'inv-movements', section:'inventory' }, { icon:'💱', title:'Exchange Rates', sub:'Currency rates per year (IDR base)', page:'inv-exchange-rates', section:'inventory' }, { icon:'📊', title:'Compliance Dashboard', sub:'Permits expiry & compliance overview', page:'reg-dashboard', section:'regulation' }, { icon:'📋', title:'Regulation Watchlist', sub:'Active permits & certificates', page:'reg-watchlist', section:'regulation' }, { icon:'🗃', title:'Regulation Database', sub:'Categories, ministries & permit DB', page:'reg-database', section:'regulation' }, // Operation Data { icon:'📈', title:'Log Summary', sub:'Parameter trend & abnormal readings', page:'oplog-summary', section:'operation-data' }, { icon:'🗃', title:'Log Database', sub:'Daily parameter readings by area', page:'oplog-database', section:'operation-data' }, { icon:'📦', title:'Log Packages', sub:'Operator log packages – approve/reject', page:'oplog-packages', section:'operation-data' }, { icon:'⚙', title:'Master Parameters', sub:'Parameter config & QR codes', page:'oplog-master', section:'operation-data' }, // Master Data { icon:'📍', title:'Locations & Systems', sub:'Plants, areas, systems hierarchy', page:'locations', section:'assets' }, { icon:'🔧', title:'Component Types', sub:'MI types – seals, bearings, shafts', page:'maintainable-items', section:'assets' }, { icon:'📂', title:'Asset Categories', sub:'Equipment category classification', page:'categories', section:'assets' }, // EHSIA { icon:'📊', title:'EHSIA Dashboard', sub:'Production · Safety · Enviro · Health overview', page:'ehsia-dashboard', section:'ehsia' }, { icon:'🏭', title:'Production & CEMS', sub:'Production, coal, CEMS (SOx/NOx/PM/Hg)', page:'ehsia-production', section:'ehsia' }, { icon:'🛡', title:'Safety Indicator', sub:'Safety performance and manhour tracking', page:'ehsia-safety', section:'ehsia' }, { icon:'🌿', title:'Environment Indicator', sub:'Waste, effluent, environmental monitoring', page:'ehsia-enviro', section:'ehsia' }, { icon:'🩺', title:'Health Indicator', sub:'Leading and lagging health indicators', page:'ehsia-health', section:'ehsia' }, { icon:'👤', title:'Users', sub:'User accounts & role management', page:'users', section:'users' }, ]; // Table columns index: what searchable tables exist per page var TABLE_INDEX = [ { page:'assets', title:'Assets table', hint:'Filter by asset number, name, category' }, { page:'failure-modes', title:'FMEA table', hint:'Filter by component, failure mode, cause, task type' }, { page:'work-orders', title:'Work Orders table', hint:'Filter by WO number, type, status' }, { page:'service-requests', title:'Service Requests table', hint:'Filter by SR number, equipment' }, { page:'pm-masters', title:'PM Masters table', hint:'Filter by PM number, equipment, due date' }, { page:'job-plans', title:'Job Plans table', hint:'Filter by code, name, craft' }, { page:'inventory', title:'Spare Parts table', hint:'Filter by item number, name, category' }, { page:'stock-transactions', title:'Stock Transactions table', hint:'Filter by transaction number, type' }, { page:'inv-transactions', title:'Procurement Transactions', hint:'Filter by PR, PO, item number' }, { page:'inv-classification-db', title:'Classification DB', hint:'Filter by item number, criticality' }, { page:'inv-movements', title:'Warehouse Movements', hint:'Filter by item, WO number, date' }, { page:'reg-watchlist', title:'Regulation Watchlist', hint:'Filter by permit name, category, status' }, { page:'oplog-database', title:'Operation Log Database', hint:'Filter by area, date, parameter' }, { page:'oplog-packages', title:'Log Packages table', hint:'Filter by status, date, area' }, { page:'rel-wo-database', title:'Reliability WO Database', hint:'Filter by equipment, WO type' }, { page:'users', title:'Users table', hint:'Filter by name, username, role', section:'users' }, { page:'storerooms', title:'Storerooms table', hint:'Filter by storeroom name, code' }, { page:'inv-exchange-rates', title:'Exchange Rates table', hint:'Filter by currency, year' }, ]; function setSearchPill(btn, pill) { _searchPill = pill; document.querySelectorAll('.search-pill').forEach(function(b){ b.classList.remove('active'); }); btn.classList.add('active'); smartSearch(document.getElementById('global-q').value); } function smartSearch(q) { var dropdown = document.getElementById('search-dropdown'); var results = document.getElementById('search-results'); if (!dropdown || !results) return; if (!q || q.trim().length < 1) { dropdown.classList.remove('open'); return; } dropdown.classList.add('open'); clearTimeout(_searchTimer); _searchTimer = setTimeout(function() { _runSmartSearch(q.trim().toLowerCase()); }, 120); } async function _runSmartSearch(q) { var results = document.getElementById('search-results'); if (!results) return; var html = ''; var pill = _searchPill; // ── Menu results ───────────────────────── if (pill === 'all' || pill === 'menu') { var menuHits = MENU_INDEX.filter(function(m) { return m.title.toLowerCase().includes(q) || m.sub.toLowerCase().includes(q); }).slice(0, pill === 'menu' ? 12 : 5); if (menuHits.length) { if (pill === 'all') html += '
MENU
'; menuHits.forEach(function(m) { html += '
' + '
' + m.icon + '
' + '
' + '
' + _highlight(m.title, q) + '
' + '
' + m.sub + '
' + '
' + '
' + m.section.replace('-',' ').replace('masterdata','Master Data').replace('operation-data','Op. Data') + '
' + '
'; }); } } // ── Table results ──────────────────────── if (pill === 'all' || pill === 'table') { var tableHits = TABLE_INDEX.filter(function(t) { return t.title.toLowerCase().includes(q) || t.hint.toLowerCase().includes(q) || t.page.includes(q); }).slice(0, pill === 'table' ? 10 : 3); if (tableHits.length) { if (pill === 'all') html += '
TABLE
'; tableHits.forEach(function(t) { html += '
' + '
📋
' + '
' + '
' + _highlight(t.title, q) + '
' + '
' + t.hint + '
' + '
' + '
Table
' + '
'; }); } } // ── Data results (API search) ──────────────────── if (pill === 'all' || pill === 'data') { try { var dataHtml = ''; var label = '
DATA
'; // Equipment var eqRes = await api('GET','equipment',{q:q,limit:4}); (eqRes.items||[]).forEach(function(r) { dataHtml += `
${_highlight(r.asset_no,q)} – ${r.name}
Equipment · ${r.status||''}
Asset
`; }); // Work Orders var woRes = await api('GET','work_orders',{q:q,limit:3}); (woRes.items||[]).forEach(function(r) { dataHtml += `
🔨
${_highlight(r.wo_number,q)} – ${r.title}
Work Order · ${r.status||''} · ${r.wo_type||''}
WO
`; }); // Inventory items var invRes = await api('GET','inventory_items',{q:q,limit:3}); (invRes.items||[]).forEach(function(r) { dataHtml += `
📦
${_highlight(r.item_number,q)} – ${r.name}
Inventory · ${r.unit||''}
Item
`; }); if (dataHtml) { if (pill === 'all') html += label; html += dataHtml; } } catch(e) {} } if (!html) html = '
No results for "' + q + '"
'; results.innerHTML = html; } function _highlight(text, q) { if (!q) return text; var re = new RegExp('(' + q.replace(/[.*+?^${}()|[\]\\]/g,'\\$&') + ')', 'gi'); return text.replace(re, '$1'); } function searchGoMenu(page, section) { closeSearch(); navigateSection(section); setTimeout(function(){ navigate(page); }, 10); } function searchGoTable(page) { closeSearch(); var section = PAGE_SECTION[page] || 'overview'; navigateSection(section); setTimeout(function(){ navigate(page); // Focus search field of target table if it has one setTimeout(function(){ var tq = document.getElementById('q-' + page.replace(/-/g,'')); if (!tq) tq = document.querySelector('#page-' + page + ' .f-input'); if (tq) { tq.focus(); tq.select(); } }, 300); }, 10); } function searchGoData(page, searchVal) { closeSearch(); var section = PAGE_SECTION[page] || 'overview'; navigateSection(section); setTimeout(function(){ navigate(page); setTimeout(function(){ // Set search value in that page's filter input var tq = document.querySelector('#page-' + page + ' .f-input[id^="q-"]'); if (!tq) tq = document.querySelector('#page-' + page + ' .f-input'); if (tq) { tq.value = searchVal; tq.dispatchEvent(new Event('input')); } }, 300); }, 10); } function closeSearch() { var dd = document.getElementById('search-dropdown'); if (dd) dd.classList.remove('open'); var inp = document.getElementById('global-q'); if (inp) inp.value = ''; } // Close dropdown on outside click document.addEventListener('click', function(e) { var wrap = document.querySelector('.search-wrap'); if (wrap && !wrap.contains(e.target)) { var dd = document.getElementById('search-dropdown'); if (dd) dd.classList.remove('open'); } }); // ───────────────────────────────────────────── // SIDEBAR TOGGLE // ───────────────────────────────────────────── function toggleSidebar() { var sb = document.getElementById('sidebar'); if (sb) sb.classList.toggle('collapsed'); // Tooltip on collapsed icons if (sb && sb.classList.contains('collapsed')) { sb.querySelectorAll('.nav-item').forEach(function(btn){ var lbl = btn.querySelector('.nav-lbl'); if (lbl) btn.setAttribute('title', lbl.textContent); }); } else { sb.querySelectorAll('.nav-item').forEach(function(btn){ btn.removeAttribute('title'); }); } } // ───────────────────────────────────────────── // DASHBOARD TAB SWITCH // ───────────────────────────────────────────── var _dashTab = 'op'; var _dashCharts = { opAvail: null, dmWo: null, dmType: null, drAvail: null }; function switchDashTab(btn, tab) { document.querySelectorAll('.dash-tab').forEach(function(b){ b.classList.remove('active'); }); btn.classList.add('active'); _dashTab = tab; document.getElementById('dash-op').style.display = tab==='op' ? '' : 'none'; document.getElementById('dash-maint').style.display = tab==='maint' ? '' : 'none'; document.getElementById('dash-rel').style.display = tab==='rel' ? '' : 'none'; if (tab === 'op') loadOpDashboard(); if (tab === 'maint') loadMaintDashboard(); if (tab === 'rel') loadRelDashboardTab(); } // ───────────────────────────────────────────── // OPERATION DASHBOARD // ───────────────────────────────────────────── async function loadOpDashboard() { // Populate plant select var plantSel = document.getElementById('op-plant-sel'); if (plantSel && plantSel.options.length < 2) { S.cache.plants.forEach(function(p){ plantSel.innerHTML += ''; }); } // Populate year filter var bufYear = document.getElementById('op-buf-year'); if (bufYear && bufYear.options.length < 2) { var curY = new Date().getFullYear(); for (var y = curY; y >= curY-3; y--) { bufYear.innerHTML += ''; } } // Load KPIs try { var d = await api('GET','dashboard',{role: S.user?.role||'technician', user_id: S.user?.id||0}); var el = function(id){ return document.getElementById(id); }; if(el('kv-active')) el('kv-active').textContent = d.asset_counts?.active||'–'; if(el('kv-crit')) el('kv-crit').textContent = d.asset_counts?.critical||'–'; if(el('dash-alerts')) _renderAlerts(d.alerts||[]); } catch(e){} // Permits expiring try { var regSum = await api('GET','regulation_permits',{action:'summary'}); var permEl = document.getElementById('kv-permits'); if(permEl) permEl.textContent = (regSum.expiring||0) + (regSum.expired||0); } catch(e){} // Pending log approvals try { var pkgRes = await api('GET','oplog_packages',{status:'submitted',limit:1}); var pkgEl = document.getElementById('kv-log-pending'); if(pkgEl) pkgEl.textContent = pkgRes.total||0; } catch(e){} loadOpDashAvail(); loadOpDashBuffer(); loadOpDashChart(); } function _renderAlerts(alerts) { var el = document.getElementById('dash-alerts'); if(!el) return; if(!alerts.length){ el.innerHTML='
No active alerts
'; return; } el.innerHTML = alerts.map(function(a){ var color = a.type==='danger'?'var(--red)':a.type==='warning'?'#f59e0b':'var(--blue)'; return '
'+a.msg+'
'; }).join(''); } // ── AVAILABILITY PROGRESSION ────────────────────────────────────── async function loadOpDashAvail() { var unit = document.getElementById('op-unit-sel')?.value || 'unit1'; var plantId= parseInt(document.getElementById('op-plant-sel')?.value)||0; var el = document.getElementById('op-avail-progression'); if(!el) return; var riskEl = document.getElementById('op-finance-risk'); if(!riskEl) return; var now = new Date(); var year = now.getFullYear(); var month = now.getMonth() + 1; var days = new Date(year, month, 0).getDate(); var dayElapsed = now.getDate(); var hoursElapsed = (dayElapsed - 1) * 24 + now.getHours(); var totalHours = days * 24; // Fetch availability data from risk_availability_data for current month try { var params = { date_from: year+'-'+String(month).padStart(2,'0')+'-01', date_to: year+'-'+String(month).padStart(2,'0')+'-'+String(days).padStart(2,'0'), limit: 500 }; if(plantId) params.plant_id = plantId; var res = await api('GET','risk_availability', params); var records = Array.isArray(res) ? res : (res.items||[]); // Get targets var targets = {}; try { var tRes = await api('GET','risk_avail_targets', {year:year, month:month}); var tArr = Array.isArray(tRes) ? tRes : (tRes.items||[]); tArr.forEach(function(t){ targets[t.unit] = t.target_pct; }); } catch(e){} var units = unit === 'both' ? ['unit1','unit2'] : [unit]; var rows = ''; var financeRows = ''; units.forEach(function(u) { var label = u === 'unit1' ? 'Unit 1' : 'Unit 2'; var target = targets[u] || 85; // Calculate downtime hours in this month for this unit var downHrs = 0; records.filter(function(r){ return r.unit===u || r.unit==='common'; }).forEach(function(r){ var rs = new Date(r.start_date).getTime(); var re = new Date(r.end_date).getTime(); var ms = new Date(year, month-1, 1).getTime(); var me = new Date(year, month, 0, 23, 59, 59).getTime(); var ov = Math.max(0, (Math.min(re,me) - Math.max(rs,ms)) / 3600000); downHrs += ov; }); var availHrs = hoursElapsed - downHrs; var currentAF = hoursElapsed > 0 ? Math.max(0, Math.min(100, (availHrs/hoursElapsed)*100)) : 100; // Projected end-of-month var projectedAF = totalHours > 0 ? Math.max(0, Math.min(100, ((totalHours-downHrs)/totalHours)*100)) : 100; var color = projectedAF >= target ? '#22c55e' : projectedAF >= target*0.95 ? '#f59e0b' : '#ef4444'; rows += '
' +'
'+label+'
' +'
' +'
'+currentAF.toFixed(1)+'%
' +'
Target: '+target+'%
' +'
'; // Finance risk var plantCap = 100; // MW default — will use capacity from config if available var lostMWh = downHrs * plantCap; var revPerMWh = 50; // default USD/MWh var riskUSD = lostMWh * revPerMWh; financeRows += '
' +''+label+'' +'
' +'
Downtime: '+downHrs.toFixed(1)+'h
' +'
Est. Loss: ~'+fmtCur(riskUSD)+' USD
' +'
'; }); var projNote = '
Day '+dayElapsed+'/'+days+' · '+hoursElapsed+'h elapsed of '+totalHours+'h
'; el.innerHTML = rows ? rows + projNote : '
No availability data for this month
'; riskEl.innerHTML = financeRows || '
No downtime recorded this month
'; } catch(e) { el.innerHTML = '
'+e.message+'
'; } } // ── BUFFER TIME CARDS ───────────────────────────────────────────── async function loadOpDashBuffer() { var container = document.getElementById('op-buffer-display'); if(!container) return; var yearFilter = document.getElementById('op-buf-year')?.value || 'all'; var plantId = parseInt(document.getElementById('op-plant-sel')?.value)||0; var now = new Date(); var curY = now.getFullYear(); var curM = now.getMonth() + 1; // Build month list var months = []; if(yearFilter === 'all') { for(var i = -11; i <= 0; i++) { var d = new Date(curY, now.getMonth()+i, 1); months.push({year:d.getFullYear(), month:d.getMonth()+1, occurred:true}); } } else { var sy = parseInt(yearFilter); for(var m = 1; m <= 12; m++) { months.push({year:sy, month:m, occurred: sy 0 ? Math.max(0,Math.min(100,((totalHrs-downHrs)/totalHrs)*100)) : 100; } var html = ''; months.forEach(function(mObj) { var key = mObj.year+'-'+String(mObj.month).padStart(2,'0'); var tgt = targets[key] || {unit1:85,unit2:85}; var days = new Date(mObj.year, mObj.month, 0).getDate(); var totalHrs = days * 24; var avgTarget = (tgt.unit1 + tgt.unit2) / 2; var planBufferHrs = totalHrs * (1 - avgTarget/100); var isCurrent = mObj.year===curY && mObj.month===curM; var monthLabel = new Date(mObj.year, mObj.month-1, 1).toLocaleDateString('en-US',{month:'short',year:'2-digit'}); if(!mObj.occurred) { html += '
'+monthLabel+'
Future
'; return; } var af1 = calcMonthAF(mObj.year, mObj.month, 'unit1'); var af2 = calcMonthAF(mObj.year, mObj.month, 'unit2'); var avgAF = (af1+af2)/2; var usedHrs = totalHrs * (1 - avgAF/100); var remainHrs = planBufferHrs - usedHrs; var status = remainHrs < 0 ? 'exceeded' : usedHrs > planBufferHrs*0.8 ? 'warning' : 'safe'; var cls = isCurrent ? 'current' : status; var valStr = isCurrent ? avgAF.toFixed(1)+'%' : (remainHrs>=0?'+':'')+remainHrs.toFixed(0)+'h'; var lblStr = isCurrent ? 'Live AF' : (status==='exceeded'?'Exceeded':status==='warning'?'Warning':'Buffer OK'); html += '
' +'
'+monthLabel+'
' +'
'+valStr+'
' +'
'+lblStr+'
' +'
'; }); container.innerHTML = html; container.style.gridTemplateColumns = 'repeat('+months.length+',1fr)'; } // ── MONTHLY AVAILABILITY CHART ──────────────────────────────────── async function loadOpDashChart() { var plantId = parseInt(document.getElementById('op-plant-sel')?.value)||0; var now = new Date(); var curY = now.getFullYear(); var months = []; for(var i=-11;i<=0;i++){ var d=new Date(curY, now.getMonth()+i, 1); months.push({year:d.getFullYear(),month:d.getMonth()+1,label:d.toLocaleDateString('en-US',{month:'short',year:'2-digit'})}); } var dateFrom = months[0].year+'-'+String(months[0].month).padStart(2,'0')+'-01'; var dateTo = curY+'-'+String(now.getMonth()+1).padStart(2,'0')+'-28'; var records=[], targets={}; try { var rp={date_from:dateFrom,date_to:dateTo,limit:1000}; if(plantId) rp.plant_id=plantId; var rRes=await api('GET','risk_availability',rp); records=Array.isArray(rRes)?rRes:(rRes.items||[]); } catch(e){} try { for(var i=0;i0?Math.max(0,Math.min(100,((total-down)/total)*100)):100; } var labels=[],af1=[],af2=[],tgt1=[],tgt2=[]; months.forEach(function(mo){ var key=mo.year+'-'+String(mo.month).padStart(2,'0'); var t=targets[key]||{unit1:85,unit2:85}; labels.push(mo.label); af1.push(parseFloat(calcAF(mo.year,mo.month,'unit1').toFixed(2))); af2.push(parseFloat(calcAF(mo.year,mo.month,'unit2').toFixed(2))); tgt1.push(t.unit1); tgt2.push(t.unit2); }); if(_dashCharts.opAvail){ _dashCharts.opAvail.destroy(); } var ctx=document.getElementById('op-avail-chart'); if(!ctx) return; _dashCharts.opAvail = new Chart(ctx, { type:'bar', data:{labels:labels,datasets:[ {label:'Unit 1 AFa',data:af1,backgroundColor:'rgba(23,125,144,.7)',borderRadius:3,order:2}, {label:'Unit 2 AFa',data:af2,backgroundColor:'rgba(106,154,117,.7)',borderRadius:3,order:2}, {type:'line',label:'Unit 1 AFpm',data:tgt1,borderColor:'#f59e0b',borderWidth:2,fill:false,tension:0.3,pointRadius:3,order:1}, {type:'line',label:'Unit 2 AFpm',data:tgt2,borderColor:'#3b82f6',borderWidth:2,fill:false,tension:0.3,pointRadius:3,order:1} ]}, options:{responsive:true,maintainAspectRatio:false, plugins:{legend:{position:'top'},tooltip:{mode:'index',intersect:false,callbacks:{label:function(c){return c.dataset.label+': '+(c.raw!==null?c.raw.toFixed(2)+'%':'–');}}}}, scales:{y:{beginAtZero:false,min:50,max:100,ticks:{callback:function(v){return v+'%';}},title:{display:true,text:'Availability (%)'}},x:{grid:{display:false}}} } }); } // ── MAINTENANCE DASHBOARD ───────────────────────────────────────── async function loadMaintDashboard() { try { var d = await api('GET','dashboard',{role:S.user?.role||'technician',user_id:S.user?.id||0}); var el=function(id){return document.getElementById(id);}; if(el('dm-wo-open')) el('dm-wo-open').textContent = d.wo_summary?.open||0; if(el('dm-wo-prog')) el('dm-wo-prog').textContent = d.wo_summary?.in_progress||0; if(el('dm-wo-over')) el('dm-wo-over').textContent = d.wo_summary?.overdue||0; if(el('dm-sr-open')) el('dm-sr-open').textContent = d.sr_summary?.open||0; if(el('dm-pm-comp')) el('dm-pm-comp').textContent = (d.pm_compliance||0)+'%'; // PM Due table if(el('dash-pm-due')) { var pmRows=(d.pm_due||[]); el('dash-pm-due').innerHTML = pmRows.length ? pmRows.map(function(r){return ''+r.pm_number+''+r.equipment_name+''+fmtDate(r.next_due_date)+''+(r.craft||'–')+'';}).join('') : 'No PM due in 30 days'; } // Failure modes if(el('dash-rpn')) { var fmRows=(d.top_failures||[]); el('dash-rpn').innerHTML = fmRows.length ? fmRows.map(function(r){return ''+(r.asset_no||'–')+''+r.failure_mode+''+r.severity+''+r.occurrence+''+r.detectability+''+r.rpn+'';}).join('') : emptyRow(6,'No failure mode data'); } // Recently added assets if(el('dash-recent')) { var asRows=(d.recent_assets||[]); el('dash-recent').innerHTML = asRows.length ? asRows.map(function(r){return ''+r.asset_no+''+r.name+''+(r.category_name||'–')+''+(r.system_name||'–')+''+statusBadge(r.status)+'';}).join('') : emptyRow(5,'No assets found'); } // WO status donut if(_dashCharts.dmWo){ _dashCharts.dmWo.destroy(); } var ctx1=document.getElementById('dm-wo-chart'); if(ctx1 && d.wo_summary) { var ws=d.wo_summary; _dashCharts.dmWo=new Chart(ctx1,{type:'doughnut',data:{ labels:['Open','In Progress','Completed','Overdue'], datasets:[{data:[ws.open||0,ws.in_progress||0,ws.completed||0,ws.overdue||0], backgroundColor:['#f59e0b','#3b82f6','#22c55e','#ef4444'],borderWidth:2}] },options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom'}}}}); } // WO type bar if(_dashCharts.dmType){ _dashCharts.dmType.destroy(); } var ctx2=document.getElementById('dm-type-chart'); if(ctx2 && d.wo_by_type) { _dashCharts.dmType=new Chart(ctx2,{type:'bar',data:{ labels:d.wo_by_type.map(function(t){return t.wo_type;}), datasets:[{label:'WOs',data:d.wo_by_type.map(function(t){return t.count;}), backgroundColor:'rgba(23,125,144,.7)',borderRadius:4}] },options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true}}}}); } } catch(e){ toast(e.message,'e'); } } // ── RELIABILITY DASHBOARD TAB ───────────────────────────────────── async function loadRelDashboardTab() { try { var el=function(id){return document.getElementById(id);}; var d = await api('GET','dashboard',{role:S.user?.role||'technician',user_id:S.user?.id||0}); if(el('dm-wo-over')) el('dm-wo-over').textContent = d.wo_summary?.overdue||0; // KPI averages var kpiRes = await api('GET','asset_kpi',{limit:200}); var kpiRows = Array.isArray(kpiRes)?kpiRes:(kpiRes.items||[]); if(kpiRows.length) { var avgMTBF = kpiRows.reduce(function(s,r){return s+(parseFloat(r.mtbf_hours)||0);},0)/kpiRows.length; var avgMTTR = kpiRows.reduce(function(s,r){return s+(parseFloat(r.mttr_hours)||0);},0)/kpiRows.length; var avgAV = kpiRows.reduce(function(s,r){return s+(parseFloat(r.availability_pct)||0);},0)/kpiRows.length; if(el('dr-mtbf')) el('dr-mtbf').textContent=avgMTBF.toFixed(0)+'h'; if(el('dr-mttr')) el('dr-mttr').textContent=avgMTTR.toFixed(1)+'h'; if(el('dr-avail'))el('dr-avail').textContent=avgAV.toFixed(1)+'%'; // Per-asset bars if(el('dash-avail')) { var top = kpiRows.slice(0,8); el('dash-avail').innerHTML = top.map(function(k){ var pct=parseFloat(k.availability_pct)||0; var color=pct>=95?'var(--green)':pct>=85?'#f59e0b':'var(--red)'; return '
'+(k.asset_no||'–')+''+pct.toFixed(1)+'%
'; }).join(''); } // By category if(el('dash-by-cat') && d.asset_counts?.by_category) { el('dash-by-cat').innerHTML = d.asset_counts.by_category.map(function(c){ return '
'+c.name+''+c.count+'
'; }).join(''); } } // Availability trend chart if(_dashCharts.drAvail){ _dashCharts.drAvail.destroy(); } var ctx3=document.getElementById('dr-avail-chart'); if(ctx3 && kpiRows.length) { var byMonth={}; kpiRows.forEach(function(k){ if(!k.period_date) return; var key=k.period_date.slice(0,7); if(!byMonth[key]){byMonth[key]=[];} byMonth[key].push(parseFloat(k.availability_pct)||0); }); var mkeys=Object.keys(byMonth).sort().slice(-12); _dashCharts.drAvail=new Chart(ctx3,{type:'line',data:{ labels:mkeys, datasets:[{label:'Avg Availability (%)',data:mkeys.map(function(k){var a=byMonth[k];return parseFloat((a.reduce(function(s,v){return s+v;},0)/a.length).toFixed(2));}), borderColor:'var(--accent)',backgroundColor:'rgba(23,125,144,.1)',fill:true,tension:0.3,borderWidth:2,pointRadius:3}] },options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:false,min:50,max:100,ticks:{callback:function(v){return v+'%';}}},x:{ticks:{maxTicksLimit:12}}}}}); } } catch(e){ toast(e.message,'e'); } } // ───────────────────────────────────────────── // INVENTORY ANALYSIS v8 // ───────────────────────────────────────────── var INVA = { rawData: null, year: null, excludeOutage: false, tab: 'summary-raw', calcData: null }; var INVCLS = { tab: 'items', policyEdits: {} }; // ── Procurement Transactions ────────────────── async function loadInvTxn() { try { var year = document.getElementById('f-invt-year')?.value || ''; var excl = document.getElementById('invt-excl-outage')?.checked; var params = { page: S.pages['invt']||1, limit: 50 }; if (S.search['invt']) params.q = S.search['invt']; if (year) params.year = year; if (excl) params.exclude_outage = 1; var res = await api('GET','inv_transactions', params); var rows = (res.items||[]); var tbody = document.getElementById('tbl-invt'); if (!tbody) return; tbody.innerHTML = rows.length ? rows.map(function(r) { var outageBadge = r.is_outage_oh==1||r.is_outage_oh==2 ? 'OH' : ''; return ''+r.txn_number+'' +''+r.pr_number+'' +''+(r.pr_desc||'–')+'' +''+r.item_number+'' +''+r.pr_line_desc+'' +''+( r.section||'–')+'' +''+(r.uom||'–')+'' +''+fmtNum(r.qty)+'' +''+fmtNum(r.price_original)+'' +''+r.currency+'' +''+fmtNum(r.price_idr)+'' +''+fmtCur(r.total_idr)+'' +''+(r.po_number||'–')+'' +''+(r.pr_date?r.pr_date.slice(0,10):'–')+'' +''+(r.grn_date?r.grn_date.slice(0,10):'–')+'' +''+(r.int_lead_time_days!==null?r.int_lead_time_days+'d':'–')+'' +''+(r.ext_lead_time_days!==null?r.ext_lead_time_days+'d':'–')+'' +''+outageBadge+'' +'' +''; }).join('') : 'No transactions found'; renderPag('pag-invt', res, S.pages['invt']||1, function(p){ S.pages['invt']=p; loadInvTxn(); }); // Populate year filter var yearSel = document.getElementById('f-invt-year'); if (yearSel && yearSel.options.length < 2) { api('GET','inv_analysis',{action:'available_years'}).then(function(yrs){ (yrs||[]).forEach(function(y){ yearSel.innerHTML += ''; }); }).catch(function(){}); } } catch(e) { toast(e.message,'e'); } } function invtBatchUpload() { document.getElementById('invt-file-input')?.click(); } async function invtProcessExcel(input) { try { var file = input.files[0]; if (!file) return; toast('Reading Excel...','i'); var data = await new Promise(function(resolve, reject) { var reader = new FileReader(); reader.onload = function(e) { resolve(new Uint8Array(e.target.result)); }; reader.onerror = reject; reader.readAsArrayBuffer(file); }); var wb = XLSX.read(data, {type:'array'}); var ws = wb.Sheets[wb.SheetNames[0]]; var json = XLSX.utils.sheet_to_json(ws, {header:1, defval:'', raw:true}); if (json.length < 2) { toast('File empty or no data rows','e'); return; } var rows = json.slice(1); var getVal = function(v){ return (v===undefined||v===null)?'':String(v).trim(); }; var getNum = function(v){ return parseFloat(v)||0; }; var getDate = function(v){ if (typeof v === 'number') { var d = new Date(Math.round((v-25569)*86400*1000)); return isNaN(d)?'':d.toISOString().split('T')[0]; } return getVal(v); }; var prCounters = {}; var processed = rows.map(function(row){ if (!getVal(row[0])) return null; var prNum = getVal(row[0]); if (!prCounters[prNum]) prCounters[prNum] = 10; else prCounters[prNum] += 10; return { pr_number: prNum, pr_desc: getVal(row[1]), pr_date: getDate(row[2]), item_number: getVal(row[3]), pr_line_desc: getVal(row[4]), pr_line_item_no: String(prCounters[prNum]), section: getVal(row[5]), uom: getVal(row[6]), qty: getNum(row[7]), price_original: getNum(row[8]), currency: getVal(row[9])||'IDR', po_number: getVal(row[10]), po_desc: getVal(row[11]), po_date: getDate(row[12]), grn_date: getDate(row[13]) }; }).filter(function(r){ return r!==null; }); if (!processed.length) { toast('No valid rows found','e'); return; } var mode = await confirm2('Replace existing data? Cancel = Append instead.'); if (mode === null) { input.value=''; return; } if (mode) { // Replace: clear first await api('DELETE','inv_transactions',{action:'clear_all'},{}); } var res = await api('POST','inv_transactions',{action:'batch'},{rows:processed, created_by:S.user?.id||1}); toast('Uploaded: '+res.inserted+' rows'+(res.skipped?' ('+res.skipped+' skipped)':''),'s'); INVA.rawData = null; INVA.calcData = null; // invalidate analysis cache loadInvTxn(); } catch(e) { toast(e.message,'e'); } finally { input.value=''; } } async function deleteInvTxn(id) { if (!await confirm2('Delete this transaction?')) return; try { await api('DELETE','inv_transactions',{id:id},{}); toast('Deleted','s'); loadInvTxn(); } catch(e){ toast(e.message,'e'); } } // ── Inv Analysis ────────────────────────────── function setInvaTab(btn, tab) { document.querySelectorAll('#inva-tabs .tab-btn').forEach(function(b){ b.classList.remove('active'); }); btn.classList.add('active'); INVA.tab = tab; renderInvaTab(); } async function loadInvAnalysis() { var yearSel = document.getElementById('inva-year'); // Populate year selector if (yearSel && yearSel.options.length <= 1) { try { var yrs = await api('GET','inv_analysis',{action:'available_years'}); yearSel.innerHTML = ''; (yrs||[]).forEach(function(y){ yearSel.innerHTML += ''; }); if (yrs && yrs.length) yearSel.value = yrs[0]; } catch(e){} } INVA.rawData = null; INVA.calcData = null; renderInvaTab(); } async function _invaLoadRaw() { var year = document.getElementById('inva-year')?.value || new Date().getFullYear(); var excl = document.getElementById('inva-excl-outage')?.checked; var params = {action:'raw_data', year:year}; if (excl) params.exclude_outage = 1; var data = await api('GET','inv_analysis', params); INVA.rawData = data; INVA.year = parseInt(year); INVA.excludeOutage = !!excl; return data; } async function renderInvaTab() { var el = document.getElementById('inva-content'); if (!el) return; el.innerHTML = '
Loading...
'; try { if (!INVA.rawData) await _invaLoadRaw(); if (INVA.tab === 'summary-raw') renderInvaSummary(false); else if (INVA.tab === 'cleansing') renderInvaCleansing(); else if (INVA.tab === 'summary-clean') renderInvaSummary(true); else if (INVA.tab === 'abc') renderInvaABC(); else if (INVA.tab === 'stock') renderInvaStock(); else if (INVA.tab === 'strategy') renderInvaStrategy(); } catch(e) { el.innerHTML = '
'+e.message+'
'; } } function _invaAggregate(transactions, classMap, uomMap, useCleansed) { var aggregated = {}; transactions.forEach(function(row) { var itemNo = row.item_number; if (!itemNo) return; var cls = classMap[itemNo]; if (!aggregated[itemNo]) { aggregated[itemNo] = { itemNumber: itemNo, description: useCleansed && cls && cls.description ? cls.description : row.pr_line_desc, category: cls && cls.category && cls.category !== '-' ? cls.category : 'Uncategorized', section: cls && cls.section ? cls.section : (row.section || 'Unassigned'), criticality: cls ? cls.criticality : '-', usageValue: 0, totalLeadTimeSum: 0, txCount: 0, leadTimes: [], quantities: [], currentBalance: 0, price: 0 }; } var q = parseFloat(row.qty) || 0; var uomSrc = (row.uom || 'Unknown').trim(); var uomConv = uomMap[itemNo]; if (uomConv && uomConv[uomSrc]) q = q * parseFloat(uomConv[uomSrc].factor); aggregated[itemNo].usageValue += parseFloat(row.total_idr) || 0; aggregated[itemNo].quantities.push(q); aggregated[itemNo].currentBalance += q; if (row.ext_lead_time_days !== null && row.ext_lead_time_days !== undefined) { var lt = Math.max(0, parseInt(row.ext_lead_time_days) || 0); aggregated[itemNo].totalLeadTimeSum += lt; aggregated[itemNo].leadTimes.push(lt); aggregated[itemNo].txCount++; } }); return Object.values(aggregated).sort(function(a,b){ return b.usageValue - a.usageValue; }); } function _invaCalcABC(items, availParams, usageParams, policyMap) { var grandTotal = items.reduce(function(s,i){ return s+i.usageValue; }, 0); var running = 0; var getStdDev = function(arr) { if (!arr||arr.length<2) return 0; var mean = arr.reduce(function(a,b){return a+b;},0)/arr.length; return Math.sqrt(arr.reduce(function(sq,n){return sq+Math.pow(n-mean,2);},0)/(arr.length-1)); }; var checkRange = function(v,min,max){ return v>=(min||0) && v<=(max===null?Infinity:max); }; return items.map(function(item) { running += item.usageValue; var cumPct = grandTotal>0 ? (running/grandTotal)*100 : 0; var avgLT = item.txCount>0 ? (item.totalLeadTimeSum/item.txCount) : 0; var totalQty = item.quantities.reduce(function(a,b){return a+b;},0); var avgPrice = totalQty>0 ? (item.usageValue/totalQty) : 0; // Availability char (based on avg lead time) var availChar = '-'; if (checkRange(avgLT, availParams.a.min, availParams.a.max)) availChar='A'; else if (checkRange(avgLT, availParams.b.min, availParams.b.max)) availChar='B'; else if (checkRange(avgLT, availParams.c.min, availParams.c.max)) availChar='C'; // Usage char (cumulative %) var usageChar = 'C'; if (checkRange(cumPct, usageParams.a.min, usageParams.a.max)) usageChar='A'; else if (checkRange(cumPct, usageParams.b.min, usageParams.b.max)) usageChar='B'; // Criticality char var critChar = '-'; var cVal = String(item.criticality).toUpperCase(); if (cVal==='HIGH'||cVal==='A') critChar='A'; else if (cVal==='MEDIUM'||cVal==='MED'||cVal==='B') critChar='B'; else if (cVal==='LOW'||cVal==='C') critChar='C'; item.abcCode = critChar+availChar+usageChar; item.cumulativePercent = cumPct; item.avgLeadTime = avgLT; item.avgPrice = avgPrice; item.totalQty = totalQty; // ROP / EOQ / SS var policy = policyMap[item.abcCode]; var Z = policy ? parseFloat(policy.z_score) : 1.04; var K = policy ? parseFloat(policy.order_cost_k) : 1500000; var iPct = policy ? parseFloat(policy.holding_cost_i) : 15; var D = totalQty; var H = avgPrice * (iPct/100); item.eoq = (K>0&&H>0&&D>0) ? Math.sqrt((2*D*K)/H) : null; var avgD = D/365; var avgOrderQty = item.txCount>0 ? totalQty/item.txCount : 0; var sigmaLT = getStdDev(item.leadTimes); var sigmaD = getStdDev(item.quantities); var variance = (avgLT * Math.pow(sigmaD,2)) + (Math.pow(avgD,2) * Math.pow(sigmaLT,2)); if (item.abcCode.includes('-')) { item.ss=null; item.rop=null; } else { item.ss = Z * Math.sqrt(variance); item.rop = (avgD * avgLT) + item.ss; } return item; }); } async function _invaGetCalcData() { if (INVA.calcData) return INVA.calcData; if (!INVA.rawData) await _invaLoadRaw(); var d = INVA.rawData; var classMap = d.classification || {}; var uomMap = d.uom_map || {}; // Load configs var configs = await api('GET','inv_analysis_config',{}); var availParams = (configs.avail_params) || {a:{min:91,max:null},b:{min:31,max:90},c:{min:0,max:30}}; var usageParams = (configs.usage_params) || {a:{min:0,max:80},b:{min:80.01,max:95},c:{min:95.01,max:100}}; var plantId = 1; var policyRows = await api('GET','inv_policy',{plant_id:plantId}); var policyMap = {}; (policyRows||[]).forEach(function(p){ policyMap[p.criteria]=p; }); var items = _invaAggregate(d.transactions, classMap, uomMap, true); INVA.calcData = _invaCalcABC(items, availParams, usageParams, policyMap); return INVA.calcData; } // ── Item Summary ───────────────────────────── function renderInvaSummary(cleaned) { var el = document.getElementById('inva-content'); var d = INVA.rawData; var classMap = d.classification || {}; var uomMap = d.uom_map || {}; var items = _invaAggregate(d.transactions, classMap, uomMap, cleaned); var html = '
' +''+items.length+' unique items · ' +d.transactions.length+' transactions
'; html += '
' +'' +'' +''; items.forEach(function(item, i) { var totalQty = item.quantities.reduce(function(a,b){return a+b;},0); var avgLT = item.txCount>0 ? (item.totalLeadTimeSum/item.txCount).toFixed(1) : '–'; html += '' +'' +'' +'' +'' +'' +'' +'' +'' +'' +''; }); html += '
#Item NumberDescriptionCategorySectionTx CountTotal QtyUsage Value (IDR)Avg Lead Time
'+( i+1)+''+item.itemNumber+''+item.description+''+item.category+''+item.section+''+item.txCount+''+fmtNum(totalQty)+''+fmtCur(item.usageValue)+''+(avgLT!=='–'?avgLT+'d':avgLT)+'
'; el.innerHTML = html; } // ── Cleansing ───────────────────────────────── async function renderInvaCleansing() { var el = document.getElementById('inva-content'); var d = INVA.rawData; // Get all unique items from transactions var allItems = {}; d.transactions.forEach(function(r){ if (!allItems[r.item_number]) allItems[r.item_number] = r.pr_line_desc; }); var classMap = d.classification || {}; var html = '
' +(Object.keys(allItems).length)+' unique items from transactions
' +'
' +'' +'' +''; Object.keys(allItems).forEach(function(itemNo){ var cls = classMap[itemNo]; var inDb = !!cls; html += '' +'' +'' +'' +'' +'' +'' +'' +''; }); html += '
Item NumberOriginal DescriptionCleansed DescriptionCategorySectionCriticalityIn Class DB?
'+itemNo+''+allItems[itemNo]+''+(cls&&cls.description||'')+''+(cls&&cls.category||'–')+''+(cls&&cls.section||'–')+''+(cls&&cls.criticality&&cls.criticality!=='-'?''+cls.criticality+'':'')+''+(inDb?'✓ Yes':'✗ No')+'
'; // Missing items button var missing = Object.keys(allItems).filter(function(k){ return !classMap[k]; }); if (missing.length) { html += '
' +'' +'
'; } html += '
'; el.innerHTML = html; } async function insertMissingToClassDB() { try { var d = INVA.rawData; if (!d) return; var classMap = d.classification || {}; var allItems = {}; d.transactions.forEach(function(r){ if (!allItems[r.item_number]) allItems[r.item_number]=r.pr_line_desc; }); var missing = Object.keys(allItems).filter(function(k){ return !classMap[k]; }); if (!missing.length) { toast('No missing items','i'); return; } var items = missing.map(function(k){ return {item_number:k, description:allItems[k], category:'Uncategorized', criticality:'-'}; }); var res = await api('POST','inv_classification',{action:'bulk_insert'},{items:items, created_by:S.user?.id||1}); toast('Inserted '+res.inserted+' items to Classification DB','s'); INVA.rawData = null; INVA.calcData = null; renderInvaTab(); } catch(e){ toast(e.message,'e'); } } // ── ABC Analysis ────────────────────────────── async function renderInvaABC() { var el = document.getElementById('inva-content'); el.innerHTML = '
Calculating ABC...
'; var calcData = await _invaGetCalcData(); var html = '
' +'' +'' +'' +'
' +'
' +'' +'' +'' +''; calcData.forEach(function(item, i) { var critColor = item.criticality==='High'?'b-red':item.criticality==='Medium'?'b-yellow':item.criticality==='Low'?'b-green':''; var abcFirst = item.abcCode?item.abcCode.charAt(0):'?'; var abcColor = abcFirst==='A'?'var(--red)':abcFirst==='B'?'#f59e0b':'var(--green)'; var fmtCalc = function(v){ return v===null||v===undefined?'Invalid':Math.ceil(v).toLocaleString(); }; html += '' +'' +'' +'' +'' +'' +'' +'' +'' +'' +'' +'' +'' +''; }); html += '
#Item No.DescriptionCategoryCriticalityAvailUsage ValueCum%ABCROPEOQSS
'+( i+1)+''+item.itemNumber+''+item.description+''+item.category+''+(item.criticality&&item.criticality!=='-'?''+item.criticality+'':'')+''+(item.avgLeadTime?item.avgLeadTime.toFixed(0)+'d':'–')+''+fmtCur(item.usageValue)+''+(item.cumulativePercent?item.cumulativePercent.toFixed(1)+'%':'–')+''+item.abcCode+''+fmtCalc(item.rop)+''+fmtCalc(item.eoq)+''+fmtCalc(item.ss)+'
'; el.innerHTML = html; } function filterInvaABC(q) { var tbody = document.getElementById('tbody-inva-abc'); if (!tbody) return; var validOnly = document.getElementById('inva-abc-valid')?.value === 'valid'; var ql = (q||'').toLowerCase(); Array.from(tbody.rows).forEach(function(row) { var text = row.textContent.toLowerCase(); var abcCell = row.cells[8]?.textContent||''; var isValid = !abcCell.includes('?') && !abcCell.includes('-'); var show = (!ql || text.includes(ql)) && (!validOnly || isValid); row.style.display = show ? '' : 'none'; }); } function exportInvaABC() { if (!INVA.calcData) { toast('No data to export','e'); return; } var rows = INVA.calcData.map(function(item, i) { return { 'No': i+1, 'Item Number': item.itemNumber, 'Description': item.description, 'Category': item.category, 'Criticality': item.criticality, 'Avg Lead Time (d)': item.avgLeadTime?item.avgLeadTime.toFixed(1):'', 'Usage Value (IDR)': Math.round(item.usageValue), 'Cumulative %': item.cumulativePercent?item.cumulativePercent.toFixed(2):'', 'ABC Code': item.abcCode, 'ROP': item.rop!==null&&item.rop!==undefined?Math.ceil(item.rop):'Invalid', 'EOQ': item.eoq!==null&&item.eoq!==undefined?Math.ceil(item.eoq):'Invalid', 'SS': item.ss!==null&&item.ss!==undefined?Math.ceil(item.ss):'Invalid' }; }); var wb = XLSX.utils.book_new(); var ws = XLSX.utils.json_to_sheet(rows); XLSX.utils.book_append_sheet(wb, ws, 'ABC Analysis'); XLSX.writeFile(wb, 'XLTION_ABC_Analysis_'+(INVA.year||'All')+'.xlsx'); } // ── Stock Analysis ──────────────────────────── async function renderInvaStock() { var el = document.getElementById('inva-content'); el.innerHTML = '
Calculating stock positions...
'; var calcData = await _invaGetCalcData(); var d = INVA.rawData; // Compare current balance vs ROP / SS var basis = 'rop'; // default var validItems = calcData.filter(function(i){ return i.rop!==null && i.currentBalance!==undefined; }); var stats = {overstock:0, precise:0, understock:0}; var stockData = validItems.map(function(item) { var threshold = basis==='rop' ? item.rop : item.ss; var bal = item.currentBalance || 0; var gap = bal - (threshold||0); var status = gap > 0 ? 'Overstock' : gap < 0 ? 'Understock' : 'Precise'; stats[status.toLowerCase()]++; return Object.assign({}, item, {stockStatus:status, gap:gap, threshold:threshold||0}); }); var html = '
' +'
Understock
'+stats.understock+'
' +'
Precise
'+stats.precise+'
' +'
Overstock
'+stats.overstock+'
' +'
' +'
' +'' +'
' +'
' +'' +'' +''; stockData.forEach(function(item) { var statusColor = item.stockStatus==='Understock'?'b-red':item.stockStatus==='Precise'?'b-green':'b-yellow'; html += '' +'' +'' +'' +'' +'' +'' +'' +'' +'' +''; }); html += '
Item No.DescriptionABCCategoryCurrent BalanceThreshold ('+basis.toUpperCase()+')GapAvg Price (IDR)Status
'+item.itemNumber+''+item.description+''+item.abcCode+''+item.category+''+Math.ceil(item.currentBalance).toLocaleString()+''+Math.ceil(item.threshold).toLocaleString()+''+( item.gap>=0?'+':'')+Math.ceil(item.gap).toLocaleString()+''+fmtCur(item.avgPrice)+''+item.stockStatus+'
'; el.innerHTML = html; // Restore basis var bs = document.getElementById('inva-stock-basis'); if (bs) bs.value = basis; } function exportInvaStock() { if (!INVA.calcData) { toast('No data','e'); return; } var rows = INVA.calcData.filter(function(i){return i.rop!==null;}).map(function(item) { var threshold = item.rop; var gap = (item.currentBalance||0) - (threshold||0); return {'Item Number':item.itemNumber,'Description':item.description,'ABC':item.abcCode, 'Category':item.category,'Current Balance':Math.ceil(item.currentBalance||0), 'ROP':Math.ceil(threshold||0),'SS':item.ss!==null?Math.ceil(item.ss):'-', 'Gap':Math.ceil(gap),'Avg Price IDR':Math.round(item.avgPrice||0), 'Status':gap>0?'Overstock':gap<0?'Understock':'Precise'}; }); var wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, XLSX.utils.json_to_sheet(rows), 'Stock Analysis'); XLSX.writeFile(wb, 'XLTION_Stock_Analysis_'+(INVA.year||'All')+'.xlsx'); } // ── Strategy Analysis (placeholder — deferred to next build) ────── function renderInvaStrategy() { var el = document.getElementById('inva-content'); el.innerHTML = '
' +'
📉
' +'
Strategy Analysis
' +'
Sawtooth (Routine), JIC (Critical Spare), JIT (Outage/Overhaul)
' +'Coming in next build — requires ABC analysis data above.
'; } // ── Classification DB ───────────────────────── function setInvClsTab(btn, tab) { document.querySelectorAll('#invcls-tabs .tab-btn').forEach(function(b){ b.classList.remove('active'); }); btn.classList.add('active'); INVCLS.tab = tab; ['items','policy','avail','usage','std'].forEach(function(t){ var el = document.getElementById('invcls-'+t+'-content'); if (el) el.style.display = t===tab ? '' : 'none'; }); if (tab==='items') loadInvClassItems(); else if (tab==='policy') loadInvPolicy(); else if (tab==='avail') loadInvAvailParams(); else if (tab==='usage') loadInvUsageParams(); else if (tab==='std') loadInvStdConfig(); } async function loadInvClassDB() { // Default to items tab loadInvClassItems(); } async function loadInvClassItems() { try { var params = {page: S.pages['invcls']||1, limit:50}; if (S.search['invcls']) params.q = S.search['invcls']; var crit = document.getElementById('f-invcls-crit')?.value; if (crit) params.criticality = crit; var res = await api('GET','inv_classification',params); var tbody = document.getElementById('tbl-invcls'); if (!tbody) return; tbody.innerHTML = (res.items||[]).length ? (res.items||[]).map(function(r){ var critBadge = r.criticality&&r.criticality!=='-' ? ''+r.criticality+'' : ''; return '' +''+r.item_number+'' +''+r.description+'' +''+(r.cleansed_description||'')+'' +''+(r.uom||'–')+'' +''+(r.master_uom||'–')+'' +''+(r.category||'–')+'' +''+(r.section||'–')+'' +''+(r.rack||'–')+'' +''+critBadge+'' +''+(r.pr_po_grn_status||'–')+'' +'' +' ' +'' +''; }).join('') : 'No items in Classification DB'; renderPag('pag-invcls', res, S.pages['invcls']||1, function(p){ S.pages['invcls']=p; loadInvClassItems(); }); } catch(e){ toast(e.message,'e'); } } function openInvClsForm(r) { document.getElementById('mf-title').textContent = r && r.id ? 'Edit Classification' : 'Add Classification Item'; document.getElementById('mf-body').innerHTML = '
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
'; S.formType='inv-cls'; S.formId=r&&r.id?r.id:null; document.getElementById('mf-save').style.display=''; openOverlay('ov-form'); } async function deleteInvCls(id) { if (!await confirm2('Remove this item from Classification DB?')) return; try { await api('DELETE','inv_classification',{id:id},{}); toast('Removed','s'); loadInvClassItems(); } catch(e){ toast(e.message,'e'); } } async function loadInvPolicy() { try { var plantId = parseInt(document.getElementById('f-policy-plant')?.value)||1; var rows = await api('GET','inv_policy',{plant_id:plantId}); var tbody = document.getElementById('tbl-policy'); if (!tbody) return; INVCLS.policyEdits = {}; rows.forEach(function(r){ INVCLS.policyEdits[r.criteria]=Object.assign({},r); }); tbody.innerHTML = rows.map(function(r) { return ` ${r.criteria} % ${parseFloat(r.z_score).toFixed(4)} % `; }).join('') || 'No policy data'; } catch(e){ toast(e.message,'e'); } } function invPolicyEdit(criteria, field, value) { if (!INVCLS.policyEdits[criteria]) return; INVCLS.policyEdits[criteria][field] = parseFloat(value); // Auto-calc Z from availability level if (field === 'avail_level') { var p = parseFloat(value) / 100; var z = invNormInv(p); INVCLS.policyEdits[criteria].z_score = parseFloat(z.toFixed(4)); var zEl = document.getElementById('z-'+criteria); if (zEl) zEl.textContent = z.toFixed(4); } } function invNormInv(p) { if (p<=0||p>=1) return 0; var a=[-3.969683028665376e+01,2.209460984245205e+02,-2.759285104469687e+02,1.383577518672690e+02,-3.066479806614716e+01,2.506628277459239e+00]; var b=[-5.447609879822406e+01,1.615858368580409e+02,-1.556989798598866e+02,6.680131188771972e+01,-1.328068155288572e+01]; var c=[-7.784894002430293e-03,-3.223964580411365e-01,-2.400758277161838e+00,-2.549732539343734e+00,4.374664141464968e+00,2.938163982698783e+00]; var d=[7.784695709041462e-03,3.224671290700398e-01,2.445134137142996e+00,3.754408661907416e+00]; var p_low=0.02425, p_high=1-p_low, q, r; if (pBased on average lead time (days). Determines the "Availability" character in ABC code.

' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +''; } catch(e){ el.innerHTML='
Error loading
'; } } async function saveInvAvailParams() { try { var p = { a:{min:parseFloat(document.getElementById('avp-a-min').value)||91, max:document.getElementById('avp-a-max').value?parseFloat(document.getElementById('avp-a-max').value):null}, b:{min:parseFloat(document.getElementById('avp-b-min').value)||31, max:parseFloat(document.getElementById('avp-b-max').value)||90}, c:{min:parseFloat(document.getElementById('avp-c-min').value)||0, max:parseFloat(document.getElementById('avp-c-max').value)||30} }; await api('POST','inv_analysis_config',{},{config_key:'avail_params',config_value:p,updated_by:S.user?.id||1}); toast('Availability params saved','s'); INVA.calcData=null; } catch(e){ toast(e.message,'e'); } } async function loadInvUsageParams() { var el = document.getElementById('invcls-usage-form'); if (!el) return; try { var cfg = await api('GET','inv_analysis_config',{action:'usage_params'}); var p = cfg || {a:{min:0,max:80},b:{min:80.01,max:95},c:{min:95.01,max:100}}; el.innerHTML = '

Usage Value Parameter Thresholds

' +'

Based on cumulative usage value %. Class A = top 80% of total spend.

' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +''; } catch(e){ el.innerHTML='
Error loading
'; } } async function saveInvUsageParams() { try { var p = { a:{min:parseFloat(document.getElementById('uvp-a-min').value)||0, max:parseFloat(document.getElementById('uvp-a-max').value)||80}, b:{min:parseFloat(document.getElementById('uvp-b-min').value)||80.01, max:parseFloat(document.getElementById('uvp-b-max').value)||95}, c:{min:parseFloat(document.getElementById('uvp-c-min').value)||95.01, max:parseFloat(document.getElementById('uvp-c-max').value)||100} }; await api('POST','inv_analysis_config',{},{config_key:'usage_params',config_value:p,updated_by:S.user?.id||1}); toast('Usage params saved','s'); INVA.calcData=null; } catch(e){ toast(e.message,'e'); } } async function loadInvStdConfig() { var el = document.getElementById('invcls-std-form'); if (!el) return; try { var cfg = await api('GET','inv_analysis_config',{action:'std_params'}); var p = cfg || {demand:75,isDemandCapEnabled:true,leadTime:40,isLTCapEnabled:true}; el.innerHTML = '

STD Config (Safety Stock Cap)

' +'

Caps the CV (coefficient of variation) to prevent extreme safety stock values.

' +'
' +'
' +'
' +'
' +'
' +'
' +''; } catch(e){ el.innerHTML='
Error loading
'; } } async function saveInvStdConfig() { try { var p = { demand: parseFloat(document.getElementById('std-demand').value)||75, isDemandCapEnabled: document.getElementById('std-demand-en').checked, leadTime: parseFloat(document.getElementById('std-lt').value)||40, isLTCapEnabled: document.getElementById('std-lt-en').checked }; await api('POST','inv_analysis_config',{},{config_key:'std_params',config_value:p,updated_by:S.user?.id||1}); toast('STD Config saved','s'); INVA.calcData=null; } catch(e){ toast(e.message,'e'); } } // ── Warehouse Movements ─────────────────────── async function loadInvMovements() { try { var params = {page:S.pages['invmov']||1, limit:50}; if (S.search['invmov']) params.q = S.search['invmov']; var from = document.getElementById('f-invmov-from')?.value; var to = document.getElementById('f-invmov-to')?.value; if (from) params.date_from = from; if (to) params.date_to = to; var res = await api('GET','inv_movements', params); var tbody = document.getElementById('tbl-invmov'); if (!tbody) return; tbody.innerHTML = (res.items||[]).length ? (res.items||[]).map(function(r){ return '' +''+r.movement_number+'' +''+r.item_number+'' +''+(r.movement_date?r.movement_date.slice(0,10):'–')+'' +''+fmtNum(r.qty)+'' +''+(r.uom||'–')+'' +''+(r.wo_number||'–')+'' +''+(r.equipment_name||'–')+'' +''+(r.requested_by_name||'–')+'' +''+(r.notes||'–')+'' +'' +''; }).join('') : 'No movements found'; renderPag('pag-invmov', res, S.pages['invmov']||1, function(p){ S.pages['invmov']=p; loadInvMovements(); }); } catch(e){ toast(e.message,'e'); } } async function deleteInvMov(id) { if (!await confirm2('Delete this movement record?')) return; try { await api('DELETE','inv_movements',{id:id},{}); toast('Deleted','s'); loadInvMovements(); } catch(e){ toast(e.message,'e'); } } // ── Exchange Rates ──────────────────────────── async function loadInvExRates() { try { var year = document.getElementById('f-rate-year')?.value||''; var params = {}; if (year) params.year = year; var rows = await api('GET','inv_exchange_rates', params); var tbody = document.getElementById('tbl-invrate'); if (!tbody) return; // Populate year selector var yearSel = document.getElementById('f-rate-year'); if (yearSel && yearSel.options.length<2) { var yrs = [...new Set((rows||[]).map(function(r){return r.year;}))].sort().reverse(); yrs.forEach(function(y){ yearSel.innerHTML += ''; }); } tbody.innerHTML = (rows||[]).length ? (rows||[]).map(function(r){ return '' +''+r.currency+'' +''+r.year+'' +''+parseFloat(r.rate_to_idr).toLocaleString(undefined,{maximumFractionDigits:2})+'' +''+(r.notes||'–')+'' +' ' +'' +''; }).join('') : 'No exchange rates'; } catch(e){ toast(e.message,'e'); } } function openInvRateForm(r) { document.getElementById('mf-title').textContent = r&&r.id?'Edit Exchange Rate':'Add Exchange Rate'; document.getElementById('mf-body').innerHTML = '
' +'
' +'
' +'
' +'
' +'
'; S.formType='inv-rate'; S.formId=r&&r.id?r.id:null; document.getElementById('mf-save').style.display=''; openOverlay('ov-form'); } async function deleteInvRate(id) { if (!await confirm2('Delete this exchange rate?')) return; try { await api('DELETE','inv_exchange_rates',{id:id},{}); toast('Deleted','s'); loadInvExRates(); } catch(e){ toast(e.message,'e'); } } // ── Submit form handlers for new types ─────────────────────────── // These are appended to existing submitForm function via type checks // (added below in the submitForm extension) function openInvMovForm() { var storeOpts = (S.cache.storerooms||[]).map(function(s){ return ''; }).join(''); var eqOpts = (S.cache.equipment||[]).map(function(e){ return ''; }).join(''); document.getElementById('mf-title').textContent = 'New Warehouse Movement'; document.getElementById('mf-body').innerHTML = '
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
' +'
'; S.formType='inv-mov'; S.formId=null; document.getElementById('mf-save').style.display=''; openOverlay('ov-form'); } // ───────────────────────────────────────────── // HELPER: Oplog date navigation // ───────────────────────────────────────────── function opldbNavDay(delta) { var el = document.getElementById('opldb-date'); if (!el) return; var d = new Date(el.value || new Date().toISOString().slice(0,10)); d.setDate(d.getDate() + delta); el.value = d.toISOString().slice(0,10); loadOplogSheet(); } function setOplogRange(range) { var now = new Date(); var from, to = now.toISOString().slice(0,10); if (range === 'today') { from = to; } else if (range === 'week') { var d = new Date(now); d.setDate(d.getDate() - d.getDay()); from = d.toISOString().slice(0,10); } else if (range === 'month') { from = now.getFullYear()+'-'+String(now.getMonth()+1).padStart(2,'0')+'-01'; } else { var d30 = new Date(now); d30.setDate(d30.getDate()-29); from = d30.toISOString().slice(0,10); } var fe = document.getElementById('opls-from'); var te = document.getElementById('opls-to'); if (fe) fe.value = from; if (te) te.value = to; loadOplogSummary(); } // ───────────────────────────────────────────── // HELPER: Delete all notifications // ───────────────────────────────────────────── async function deleteAllNotif() { if (!S.user) return; if (!await confirm2('Delete all notifications?')) return; try { var res = await api('GET','notifications',{user_id:S.user.id,limit:100}); var items = res.items || []; for (var n of items) await api('DELETE','notifications',{id:n.id}).catch(function(){}); loadNotifCount(); loadNotifPanel(); toast('All notifications deleted','s'); } catch(e) { toast(e.message,'e'); } } // ───────────────────────────────────────────── // HELPER: Empty state standard message // ───────────────────────────────────────────── // emptyRow already defined above — ensure consistent format // Standard: emptyRow(cols, msg) — 'No [entity] found. Click + Add [entity] to get started.' // ───────────────────────────────────────────── // HELPER: debounce (extends existing) // ───────────────────────────────────────────── function debounce(key, val) { clearTimeout(S.debounceTimers[key]); S.debounceTimers[key] = setTimeout(()=>{ switch(key) { case 'sr': loadSR(); break; case 'wo': loadWO(); break; case 'pm': loadPM(); break; case 'jp': loadJP(); break; case 'preng': loadPREng(); break; case 'inv': loadInv(); break; case '52w': load52W(); break; case 'relwo': loadRelWODatabase(); break; case 'avfail': loadAvailData(); break; case 'regw': loadRegWatchlist(); break; case 'equipment': loadEquipment(1); break; case 'components': loadComponents(1);break; case 'mi': loadMI(1); break; case 'fm': loadFM(1); break; case 'users': loadUsers(1); break; } }, 350); } // ─── Block Builder State ──────────────────────────────────────── var BBD={ diagramId:null, blocks:[], connections:[], nextId:1, connMode:false, connFrom:null, scale:1, offsetX:0, offsetY:0, dragging:null, dragOX:0, dragOY:0, activeObsStart:null, activeObsEnd:null, // Block types: single | group | junction }; function bbdTogglePane(){var p=document.getElementById('bb-pane');if(p)p.style.display=p.style.display==='none'?'':'none';} function bbdFilterEq(q){ var items=document.querySelectorAll('#bb-eq-list .bb-eq-item'); items.forEach(function(el){el.style.display=el.textContent.toLowerCase().includes(q.toLowerCase())?'':'none';}); } function _bbdPopulateEqPane(){ var list=document.getElementById('bb-eq-list'); if(!list)return; list.innerHTML=S.cache.equipment.map(function(e){ return '
' +'
'+e.asset_no+'
' +'
'+e.name+'
' +(e.is_hilp?'HILP':'') +'
'; }).join(''); } function bbdAddSingle(){BBD.connMode=false;BBD.connFrom=null;_bbdShowInfo('Click canvas to place Single equipment block');document.getElementById('bb-blocks-layer').dataset.mode='add-single';} function bbdAddGroup(){BBD.connMode=false;var k=prompt('k-out-of-n: how many required (k)?','1');var n=prompt('Total members in group (n)?','2');if(!k||!n)return;BBD.pendingGroup={k:parseInt(k),n:parseInt(n)};_bbdShowInfo('Click canvas to place Group block ('+k+'/'+n+')');document.getElementById('bb-blocks-layer').dataset.mode='add-group';} function bbdAddJunction(){BBD.connMode=false;_bbdShowInfo('Click canvas to place Junction node');document.getElementById('bb-blocks-layer').dataset.mode='add-junction';} function bbdStartConnect(){BBD.connMode=true;BBD.connFrom=null;document.getElementById('bb-blocks-layer').dataset.mode='connect';_bbdShowInfo('Click source block, then target block to connect');} function _bbdShowInfo(msg){var el=document.getElementById('bb-info-msg');if(el){el.textContent=msg;el.style.display='';setTimeout(function(){el.style.display='none';},3000);}} // Canvas click handler document.addEventListener('click',function(e){ var layer=document.getElementById('bb-blocks-layer'); if(!layer)return; var mode=layer.dataset.mode||''; if(!['add-single','add-group','add-junction'].includes(mode))return; if(e.target.closest('.bb-block'))return; // clicked a block var rect=layer.getBoundingClientRect(); var x=e.clientX-rect.left-60; var y=e.clientY-rect.top-20; var label=mode==='add-junction'?'Junction':(prompt('Block label:','Equipment')||''); if(!label&&mode!=='add-junction')return; var blk={id:BBD.nextId++,label:label,x:Math.max(0,x),y:Math.max(0,y), type:mode==='add-single'?'single':mode==='add-group'?'group':'junction', failed:false,dbId:null, k:BBD.pendingGroup?BBD.pendingGroup.k:1, n:BBD.pendingGroup?BBD.pendingGroup.n:1, members:[],capacity:100, availability:1.0,reliability:1.0}; BBD.pendingGroup=null; BBD.blocks.push(blk); layer.dataset.mode=''; bbdRender(); bbdCalc(); }); // Drop equipment from pane document.addEventListener('dragover',function(e){if(e.target.closest('#bb-blocks-layer')||e.target.id==='bb-blocks-layer')e.preventDefault();}); document.addEventListener('drop',function(e){ var layer=document.getElementById('bb-blocks-layer'); if(!layer)return; var eqId=e.dataTransfer.getData('bbEqId'); if(!eqId)return; e.preventDefault(); var rect=layer.getBoundingClientRect(); var x=e.clientX-rect.left-60; var y=e.clientY-rect.top-20; var eq=S.cache.equipment.find(function(e){return String(e.id)===String(eqId);}); if(!eq)return; var blk={id:BBD.nextId++,label:eq.asset_no,x:Math.max(0,x),y:Math.max(0,y), type:eq.is_hilp?'single':'single', failed:false,dbId:eq.id,isHILP:!!eq.is_hilp, k:1,n:1,members:[],capacity:100, availability:1.0,reliability:1.0}; BBD.blocks.push(blk); bbdRender(); bbdCalc(); }); document.addEventListener('mousemove',function(e){ if(!BBD.dragging)return; var layer=document.getElementById('bb-blocks-layer'); if(!layer)return; var rect=layer.getBoundingClientRect(); var blk=BBD.blocks.find(function(b){return b.id===BBD.dragging;}); if(!blk)return; blk.x=Math.max(0,e.clientX-rect.left-BBD.dragOX); blk.y=Math.max(0,e.clientY-rect.top-BBD.dragOY); bbdRender(); }); document.addEventListener('mouseup',function(){BBD.dragging=null;}); function bbdToggleFail(id){ var blk=BBD.blocks.find(function(b){return b.id===id;}); if(blk){blk.failed=!blk.failed;bbdCalc();} } async function bbdCalc(){ try { var startEl=document.getElementById('bb-obs-start'); var endEl=document.getElementById('bb-obs-end'); BBD.activeObsStart=startEl?.value||null; BBD.activeObsEnd=endEl?.value||null; if(!BBD.activeObsStart||!BBD.activeObsEnd)return; var tStart=new Date(BBD.activeObsStart).getTime(); var tEnd=new Date(BBD.activeObsEnd).getTime(); var windowHrs=Math.max(0,(tEnd-tStart)/3600000); if(windowHrs<=0)return; // Step 1: Calculate intrinsic metrics for each block from WO data for(var i=0;itStart; }); var downHrs=0; wos.forEach(function(w){ var ds=new Date(w.downtime_start).getTime(); var de=new Date(w.downtime_finish).getTime(); var ov=Math.max(0,(Math.min(de,tEnd)-Math.max(ds,tStart))/3600000); downHrs+=ov; }); // Failures within window var failures=wos.filter(function(w){ var ds=new Date(w.downtime_start).getTime(); return ds>=tStart&&ds<=tEnd; }).length; var operatingHrs=Math.max(0,windowHrs-downHrs); var failureRate=operatingHrs>0?failures/operatingHrs:0; var mtbf=failures>0?operatingHrs/failures:operatingHrs; var mttr=failures>0?downHrs/failures:0; var availability=windowHrs>0?Math.max(0,Math.min(1,(windowHrs-downHrs)/windowHrs)):1; // Reliability: R(t) = e^(-λ·t_eff) — time since last recond or start of window var tEff=windowHrs; var reliability=Math.exp(-failureRate*tEff); return {availability:availability,reliability:reliability,failureRate:failureRate,mtbf:mtbf,mttr:mttr}; }catch(e){return {availability:1,reliability:1,failureRate:0,mtbf:0,mttr:0};} } function _bbdPropagateGraph(){ // Build adjacency var graph={}; BBD.blocks.forEach(function(b){graph[b.id]={block:b,inputs:[],outputs:[]};}); BBD.connections.forEach(function(c){ if(graph[c.from])graph[c.from].outputs.push(c.to); if(graph[c.to])graph[c.to].inputs.push(c.from); }); var memo={}; function getMetrics(nodeId){ if(memo[nodeId]!==undefined)return memo[nodeId]; memo[nodeId]={availability:0,reliability:0}; var node=graph[nodeId]; if(!node)return memo[nodeId]; var blk=node.block; // Intrinsic var intrinsicA=blk.failed?0:(blk.availability||1); var intrinsicR=blk.failed?0:(blk.reliability||1); if(node.inputs.length===0){ memo[nodeId]={availability:intrinsicA,reliability:intrinsicR}; return memo[nodeId]; } // Combine inputs var seriesA=[],parallelA=[],seriesR=1,parallelUnR=1; node.inputs.forEach(function(inputId){ var m=getMetrics(inputId); var inputBlk=graph[inputId]?graph[inputId].block:null; if(inputBlk&&inputBlk.isHILP){ seriesA.push(m.availability); seriesR*=m.reliability; } else { parallelA.push(m.availability); parallelUnR*=(1-m.reliability); } }); var finSerA=seriesA.length>0?Math.min.apply(null,seriesA):1; var sumParA=parallelA.reduce(function(s,v){return s+v;},0); var finParA=parallelA.length>0?Math.min(sumParA,1):1; var combA=Math.min(finSerA,finParA); var finParR=parallelA.length>0?(1-parallelUnR):1; var combR=seriesR*finParR; var finalA=Math.min(intrinsicA,combA); var finalR=intrinsicR*combR; memo[nodeId]={availability:finalA,reliability:finalR}; return memo[nodeId]; } // Find terminal nodes (no outputs) var terminals=BBD.blocks.filter(function(b){return (graph[b.id]?.outputs.length||0)===0;}); var results={}; terminals.forEach(function(b){results[b.id]=getMetrics(b.id);}); // Update block display values BBD.blocks.forEach(function(b){ if(memo[b.id]){b.availability=memo[b.id].availability;b.reliability=memo[b.id].reliability;} }); return results; } function _bbdUpdateOutputs(terminalResults){ var el=document.getElementById('bb-outputs'); if(!el)return; var keys=Object.keys(terminalResults); if(!keys.length){el.innerHTML='No terminal outputs';return;} el.innerHTML=keys.map(function(id){ var blk=BBD.blocks.find(function(b){return b.id===parseInt(id);}); var m=terminalResults[id]; var av=(m.availability*100).toFixed(2); var rl=(m.reliability*100).toFixed(2); var color=m.availability>=0.95?'var(--green)':m.availability>=0.8?'#f59e0b':'var(--red)'; return '
' +'
'+(blk?blk.label:'Output')+'
' +'
AF: '+av+'%
' +'
R: '+rl+'%
' +'
'; }).join(''); } // ───────────────────────────────────────────── // RELIABILITY DASHBOARD // ───────────────────────────────────────────── // ───────────────────────────────────────────── // WO DATABASE (Reliability view) // ───────────────────────────────────────────── // ───────────────────────────────────────────── // RCFA // ───────────────────────────────────────────── // ════════════════════════════════════════════════════════════════ // SECTION: MAINTENANCE MANAGEMENT // Service Requests · Work Orders · PM Masters // Job Plans · Project Requests · Safety Permits // ════════════════════════════════════════════════════════════════ // ───────────────────────────────────────────── // BADGE COUNTS // ───────────────────────────────────────────── // ───────────────────────────────────────────── // WO TYPE BADGE // ───────────────────────────────────────────── // ───────────────────────────────────────────── // SERVICE REQUESTS // ───────────────────────────────────────────── // ───────────────────────────────────────────── // WORK ORDERS // ───────────────────────────────────────────── // ───────────────────────────────────────────── // PM MASTERS // ───────────────────────────────────────────── // ───────────────────────────────────────────── // JOB PLANS // ───────────────────────────────────────────── // ───────────────────────────────────────────── // PROJECT REQUESTS (ENG) // ───────────────────────────────────────────── // ── The full blockBuilder object from RISK.html (adapted) ───────── window.app = window.app || {}; app.blockBuilder = { // CONFIG & STATE initialState: null, elementMap: new Map(), // <-- TAMBAHKAN BARIS INI hoveredConnection: null, groupSelectionIds: new Set(), // <-- BARU: Untuk melacak ID equipment yang di-checkbox // Fungsi baru untuk menangani pemilihan grup toggleGroupSelection(equipmentId, isChecked) { if (isChecked) { this.groupSelectionIds.add(equipmentId); } else { this.groupSelectionIds.delete(equipmentId); } this.updateGroupingUI(); // REVISI: Panggil fungsi update untuk memperbarui tooltip pada checkbox yang diklik. const equipmentToUpdate = this.equipments.find(eq => eq.id === equipmentId); if (equipmentToUpdate) { this.updateEquipmentElement(equipmentToUpdate); } }, // Fungsi baru untuk menampilkan/menyembunyikan tombol "Group" updateGroupingUI() { const actionsPanel = document.querySelector('#selectionActions'); const groupBtn = document.getElementById('groupBtn'); const ungroupBtn = document.getElementById('ungroupBtn'); // Cek kondisi untuk menampilkan tombol yang sesuai const singleSelectedId = this.selectedEquipmentIds.size === 1 ? [...this.selectedEquipmentIds][0] : null; const selectedItem = singleSelectedId ? this.equipments.find(e => e.id === singleSelectedId) : null; if (this.groupSelectionIds.size > 1) { actionsPanel.style.display = 'flex'; groupBtn.style.display = 'block'; ungroupBtn.style.display = 'none'; } else if (selectedItem && selectedItem.mode === 'group') { actionsPanel.style.display = 'flex'; groupBtn.style.display = 'none'; ungroupBtn.style.display = 'block'; } else { actionsPanel.style.display = 'none'; } }, ungroupSelectedEquipment() { const selectedGroupId = [...this.selectedEquipmentIds][0]; const group = this.equipments.find(eq => eq.id === selectedGroupId); if (!group || group.mode !== 'group') return; const members = this.equipments.filter(eq => eq.groupId === selectedGroupId); const oldIdToNewIdMap = new Map(); // --- BLOK REVISI DIMULAI DI SINI --- // 1. Buat array baru untuk equipment individual yang akan di-ungroup const newIndividualEquipments = []; members.forEach((member, index) => { const originalEq = member.originalData; const newId = 'eq-' + this.equipmentCounter++; oldIdToNewIdMap.set(originalEq.id, newId); const newEqData = { ...originalEq }; newEqData.id = newId; newEqData.x = group.x + (index % 3) * (originalEq.width + 20); newEqData.y = group.y + Math.floor(index / 3) * (originalEq.height + 20); newIndividualEquipments.push(newEqData); this.createEquipmentElement(newEqData); }); // 2. Bangun ulang array 'equipments' dengan mengganti grup dengan anggota-anggotanya const newEquipmentsArray = []; this.equipments.forEach(eq => { // Jika equipment saat ini adalah grup yang di-ungroup... if (eq.id === selectedGroupId) { // ...ganti dengan semua equipment individual yang baru dibuat. newEquipmentsArray.push(...newIndividualEquipments); } // Jangan tambahkan grup atau anggota-anggota lamanya. else if (eq.groupId !== selectedGroupId) { newEquipmentsArray.push(eq); } }); this.equipments = newEquipmentsArray; // --- AKHIR BLOK REVISI --- // 3. Buat ulang koneksi (logika ini tetap sama) if (group.originalConnections) { group.originalConnections.forEach(conn => { const newFrom = oldIdToNewIdMap.get(conn.from) || conn.from; const newTo = oldIdToNewIdMap.get(conn.to) || conn.to; if (this.equipments.some(e => e.id === newFrom) && this.equipments.some(e => e.id === newTo)) { this.connections.push({ from: newFrom, to: newTo }); } }); } // 4. Hapus elemen grup dari DOM (logika ini tetap sama) document.querySelector(`#${selectedGroupId}`)?.remove(); this.elementMap.delete(selectedGroupId); this.deselectAll(); this.updateGroupingUI(); this.runFullAnalysis(); this.renderEquipmentPane(); // <-- BARIS INI DITAMBAHKAN/DIPASTIKAN ADA if(typeof xlBBSave==="function") xlBBSave(); }, // Fungsi baru untuk melakukan proses grouping groupSelectedEquipment() { if (this.groupSelectionIds.size < 2) { alert("Please select at least two equipments to group."); return; } // --- REVISI BARU DIMULAI DI SINI --- // Cek apakah semua equipment yang dipilih berada di unit yang sama. const selectedUnits = new Set(); for (const id of this.groupSelectionIds) { const eq = this.equipments.find(e => e.id === id); if (eq) { selectedUnits.add(eq.unit); } } if (selectedUnits.size > 1) { alert("Grouping must be done within the same unit. Please select equipment from only one unit."); // Reset seleksi untuk menghindari kebingungan this.groupSelectionIds.clear(); document.querySelectorAll('.equipment-select-checkbox').forEach(cb => cb.checked = false); this.updateGroupingUI(); return; // Hentikan eksekusi fungsi } // --- AKHIR REVISI BARU --- // Cek apakah ada equipment yang sudah di-group di dalam seleksi const includesGroup = [...this.groupSelectionIds].some(id => { const equipment = this.equipments.find(eq => eq.id === id); return equipment && equipment.mode === 'group'; }); if (includesGroup) { alert("Cannot group items that already include a group. Please ungroup the grouped equipment first."); // Reset seleksi untuk menghindari kebingungan this.groupSelectionIds.clear(); document.querySelectorAll('.equipment-select-checkbox').forEach(cb => cb.checked = false); this.updateGroupingUI(); return; // Hentikan eksekusi fungsi } const groupName = prompt("Enter a base name for the new group (e.g., BFP):"); if (!groupName) return; let prefixType = prompt("Choose prefix type: Enter 'A' for Alphabet or 'N' for Number.", "A"); if (!prefixType) return; // Cancel if user pressed cancel prefixType = prefixType.trim().toUpperCase(); let prefixes = []; if (prefixType === 'A') { let startLetter = prompt("Enter the starting letter for the prefix (A-Z):", "A"); if (!startLetter || startLetter.length !== 1 || !/^[A-Z]$/i.test(startLetter)) { alert("Invalid letter. Please enter a single letter from A to Z."); return; } let currentPrefix = startLetter.toUpperCase(); for (let i = 0; i < this.groupSelectionIds.size; i++) { prefixes.push(currentPrefix); currentPrefix = String.fromCharCode(currentPrefix.charCodeAt(0) + 1); } } else if (prefixType === 'N') { let startNumber = prompt("Enter the starting number for the prefix (1-99):", "1"); startNumber = parseInt(startNumber, 10); if (isNaN(startNumber) || startNumber < 1 || startNumber > 99) { alert("Invalid number. Please enter a number between 1 and 99."); return; } for (let i = 0; i < this.groupSelectionIds.size; i++) { prefixes.push(String(startNumber + i)); } } else { alert("Invalid prefix type. Please enter 'A' or 'N'."); return; } const memberEquipments = []; let sumX = 0, sumY = 0, commonUnit = null; let memberIndex = 0; for (const id of this.groupSelectionIds) { const eq = this.equipments.find(e => e.id === id); if (eq) { eq.prefix = prefixes[memberIndex++]; memberEquipments.push(eq); sumX += eq.x + eq.width / 2; sumY += eq.y + eq.height / 2; if (commonUnit === null) commonUnit = eq.unit; else if (commonUnit !== eq.unit) commonUnit = 'common'; } } const capacities = memberEquipments.map(eq => (xlBBGetEquipment(eq.dbId)?.capacity || 100)).sort((a, b) => b - a); let capacitySum = 0; let k = 0; for (const cap of capacities) { if (capacitySum >= 100) break; capacitySum += cap; k++; } const n = memberEquipments.length; memberEquipments.forEach(eq => document.querySelector(`#${eq.id}`)?.remove()); const newGroupId = 'eq-' + this.equipmentCounter++; const groupWidth = this.calculateEquipmentWidth(groupName); const originalConnections = this.connections.filter(c => this.groupSelectionIds.has(c.from) || this.groupSelectionIds.has(c.to)); const groupData = { id: newGroupId, name: `${groupName} (${k}/${n})`, dbId: groupName, isHILP: memberEquipments.some(eq => eq.isHILP), x: this.snapToGrid(sumX / n - groupWidth / 2), y: this.snapToGrid(sumY / n), width: groupWidth, height: 0, mode: 'group', unit: commonUnit, requiredCount: k, totalCount: n, isFinalOutput: false, displayAvailability: 1.0, intrinsicAvailability: 1.0, failureState: 'OK', originalConnections: originalConnections }; // --- BLOK REVISI DIMULAI DI SINI --- // 1. Buat objek anggota baru (tanpa langsung mengubah this.equipments) const newMemberObjects = []; memberEquipments.forEach((oldEq, index) => { const dbItem = xlBBGetEquipment(oldEq.dbId); const currentCapacity = dbItem ? dbItem.capacity : 100; const member = { id: 'member-' + newGroupId + '-' + index, name: oldEq.name, prefix: oldEq.prefix, dbId: oldEq.dbId, mode: 'member', groupId: newGroupId, failed: false, capacity: currentCapacity, originalData: oldEq }; newMemberObjects.push(member); }); // 2. Bangun ulang array 'equipments' dengan menempatkan grup di posisi yang benar const newEquipmentsArray = []; let groupInserted = false; this.equipments.forEach(eq => { if (this.groupSelectionIds.has(eq.id)) { // Jika ini adalah anggota grup pertama yang ditemui... if (!groupInserted) { // ...sisipkan objek grup baru di posisinya. newEquipmentsArray.push(groupData); groupInserted = true; } // Jangan tambahkan anggota lama itu sendiri. } else { // Jika bukan bagian dari grup, pertahankan item tersebut. newEquipmentsArray.push(eq); } }); // 3. Tambahkan objek anggota baru ke akhir array. newEquipmentsArray.push(...newMemberObjects); // 4. Ganti array equipment lama dengan yang baru. this.equipments = newEquipmentsArray; // --- AKHIR BLOK REVISI --- this.connections = this.connections.filter(c => !this.groupSelectionIds.has(c.from) && !this.groupSelectionIds.has(c.to)); this.createEquipmentElement(groupData); this.groupSelectionIds.clear(); this.updateGroupingUI(); this.runFullAnalysis(); this.renderEquipmentPane(); // Panggilan ini sekarang akan menggunakan array yang sudah diurutkan if(typeof xlBBSave==="function") xlBBSave(); // REVISI: Tambahkan panggilan viewEquipment setelah grup dibuat. this.viewEquipment(groupName); }, // Letakkan fungsi ini di dalam object app.blockBuilder = { ... }; getBlockBuilderStateObject() { return { name: 'RBD State', // Name not lagi diambil dari input field timestamp: new Date().toISOString(), equipments: this.equipments, connections: this.connections, pages: [], // Fungsi halaman dihapus unitCapacities: this.unitCapacities, equipmentCounter: this.equipmentCounter, pageCounter: 1, // Fungsi halaman dihapus viewport: this.viewport }; }, GRID_SIZE: 40, MIN_ZOOM: 0.2, MAX_ZOOM: 3.0, CONNECTION_SELECT_THRESHOLD: 5, UNIT_COLORS: { unit1: { fill: 'rgba(52, 152, 219, 0.1)', stroke: 'rgba(52, 152, 219, 0.4)' }, unit2: { fill: 'rgba(26, 188, 156, 0.1)', stroke: 'rgba(26, 188, 156, 0.4)' }, common: { fill: 'rgba(158, 158, 158, 0.1)', stroke: 'rgba(158, 158, 158, 0.4)' }, }, UNIT_BOUNDARY_PADDING: 20, MM_TO_UNITS: 3.78, PAGE_SIZES: { a4: { width: 297 * 3.78, height: 210 * 3.78 }, a3: { width: 420 * 3.78, height: 297 * 3.78 } }, PAGE_ASPECT_RATIOS: {}, RESIZE_HANDLE_SIZE: 8, PAGE_LAYOUT_PADDING: 80, equipmentCounter: 1, pageCounter: 1, equipments: [], connections: [], pages: [], unitCapacities: { unit1: 100, unit2: 100 }, isDragging: false, draggedEquipment: null, dragStartOffset: { x: 0, y: 0 }, isConnecting: false, connectingFrom: null, tempConnectionEndPos: { x: 0, y: 0 }, selectedEquipmentIds: new Set(), selectedConnections: [], selectedPageId: null, isDraggingPage: false, isResizingPage: false, activeResizeHandle: null, canvas: null, ctx: null, workspace: null, canvasContainer: null, viewport: { x: 0, y: 0, scale: 1.0, isPanning: false, lastPan: { x: 0, y: 0 } }, editModal: null, editMemberModal: null, initialized: false, // BOUND EVENT LISTENERS boundResizeCanvas: null, boundHandleZoom: null, boundHandleMouseDownGeneral: null, boundHandleMouseMoveGeneral: null, boundHandleMouseUpGeneral: null, boundHandleWorkspaceClick: null, boundHandleKeyDown: null, // INITIALIZATION init() { if (this.initialized) return; this.workspace = document.querySelector('#workspace'); this.canvasContainer = document.querySelector('#canvas-container'); this.editModal = document.querySelector('#editModal'); this.editMemberModal = document.querySelector('#editMemberModal'); this.initCanvas(); this.setupWorkspace(); this.initZoomControls(); this.initScrollbars(); this.syncCapacitiesAndRecalculate(); // REVISI: Ganti populateEquipmentSelector dengan fungsi pane yang baru this.renderEquipmentPane(); // REVISI: Tambahkan event listener untuk fitur-fitur baru this.initPaneControls(); // --- NEW: Initialize Global Observation Dates (Default values) --- const defaultStart = '2020-01-01T00:00'; const now = new Date(); const defaultEnd = new Date(now.getTime() - (now.getTimezoneOffset() * 60000)).toISOString().slice(0, 16); const globalStartInput = document.getElementById('globalObsStartDate'); const globalEndInput = document.getElementById('globalObsEndDate'); // Set default value ke DOM jika kosong if (globalStartInput && !globalStartInput.value) globalStartInput.value = defaultStart; if (globalEndInput && !globalEndInput.value) globalEndInput.value = defaultEnd; // REVISI: Save nilai "Active" di memori. Ini yang akan dipakai kalkulasi. // Input datepicker hanya visual sampai tombol Calc ditekan. this.activeObsStart = globalStartInput ? globalStartInput.value : defaultStart; this.activeObsEnd = globalEndInput ? globalEndInput.value : defaultEnd; // ------------------------------------------------ this.initialized = true; if (this.initialState) { console.log("Initializing Block Builder with stored state."); this.loadConfigurationFromData(this.initialState); } else { this.runFullAnalysis(); } // --- REVISI KUNCI DI SINI --- // Panggil optimisasi secara otomatis setelah semuanya siap. // Diberi sedikit delay untuk memastikan semua elemen sudah dirender oleh browser. setTimeout(() => this.viewAllEquipment(), 100); }, destroy() { if (!this.initialized) return; window.removeEventListener('resize', this.boundResizeCanvas); this.workspace.removeEventListener('wheel', this.boundHandleZoom); this.workspace.removeEventListener('mousedown', this.boundHandleMouseDownGeneral); this.workspace.removeEventListener('mousemove', this.boundHandleMouseMoveGeneral); this.workspace.removeEventListener('mouseup', this.boundHandleMouseUpGeneral); this.workspace.removeEventListener('mouseleave', this.boundHandleMouseUpGeneral); this.workspace.removeEventListener('click', this.boundHandleWorkspaceClick); window.removeEventListener('keydown', this.boundHandleKeyDown); this.initialized = false; }, setupWorkspace() { this.boundResizeCanvas = () => { if (!this.workspace || !this.canvas) return; this.canvas.width = this.workspace.clientWidth; this.canvas.height = this.workspace.clientHeight; if (this.equipments.length === 0 && this.viewport.x === 0 && this.viewport.y === 0) { this.viewport.x = this.canvas.width / 2; this.viewport.y = this.canvas.height / 2; } this.redraw(); this.updateUIComponents(); }; this.boundResizeCanvas(); // Bind event listeners this.boundHandleZoom = this.handleZoom.bind(this); this.boundHandleMouseDownGeneral = this.handleMouseDownGeneral.bind(this); this.boundHandleMouseMoveGeneral = this.handleMouseMoveGeneral.bind(this); this.boundHandleMouseUpGeneral = this.handleMouseUpGeneral.bind(this); this.boundHandleWorkspaceClick = this.handleWorkspaceClick.bind(this); this.boundHandleKeyDown = this.handleKeyDown.bind(this); // Add event listeners window.addEventListener('resize', this.boundResizeCanvas); this.workspace.addEventListener('wheel', this.boundHandleZoom); this.workspace.addEventListener('mousedown', this.boundHandleMouseDownGeneral); this.workspace.addEventListener('mousemove', this.boundHandleMouseMoveGeneral); this.workspace.addEventListener('mouseup', this.boundHandleMouseUpGeneral); this.workspace.addEventListener('mouseleave', this.boundHandleMouseUpGeneral); this.workspace.addEventListener('click', this.boundHandleWorkspaceClick); window.addEventListener('keydown', this.boundHandleKeyDown); this.editModal.addEventListener('click', (e) => { if (e.target === this.editModal) this.closeEditModal(); }); this.editMemberModal.addEventListener('click', (e) => { if (e.target === this.editMemberModal) this.closeMemberEditModal(); }); }, initCanvas() { this.canvas = document.querySelector('#workspace-canvas'); if (!this.canvas) { console.error("Fatal Error: Canvas element not found."); return; } this.ctx = this.canvas.getContext('2d'); }, screenToWorld(x, y) { return { x: (x - this.viewport.x) / this.viewport.scale, y: (y - this.viewport.y) / this.viewport.scale }; }, worldToScreen(x, y) { return { x: (x * this.viewport.scale) + this.viewport.x, y: (y * this.viewport.scale) + this.viewport.y }; }, snapToGrid(value) { return Math.round(value / this.GRID_SIZE) * this.GRID_SIZE; }, redraw() { if (!this.ctx) return; this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); this.ctx.save(); this.ctx.translate(this.viewport.x, this.viewport.y); this.ctx.scale(this.viewport.scale, this.viewport.scale); this.drawGrid(); // Panggilan ke drawPages() dihapus this.drawUnitBoundaries(); this.drawConnections(); this.drawTemporaryConnection(); this.ctx.restore(); this.updateEquipmentElementsPosition(); }, drawGrid() { this.ctx.beginPath(); this.ctx.strokeStyle = '#e9ecef'; this.ctx.lineWidth = 1 / this.viewport.scale; const worldTopLeft = this.screenToWorld(0, 0); const worldBottomRight = this.screenToWorld(this.canvas.width, this.canvas.height); const startX = Math.floor(worldTopLeft.x / this.GRID_SIZE) * this.GRID_SIZE; const endX = Math.ceil(worldBottomRight.x / this.GRID_SIZE) * this.GRID_SIZE; const startY = Math.floor(worldTopLeft.y / this.GRID_SIZE) * this.GRID_SIZE; const endY = Math.ceil(worldBottomRight.y / this.GRID_SIZE) * this.GRID_SIZE; for (let x = startX; x <= endX; x += this.GRID_SIZE) { this.ctx.moveTo(x, startY); this.ctx.lineTo(x, endY); } for (let y = startY; y <= endY; y += this.GRID_SIZE) { this.ctx.moveTo(startX, y); this.ctx.lineTo(endX, y); } this.ctx.stroke(); }, drawUnitBoundaries() { const unitsToDraw = Object.keys(this.UNIT_COLORS); const visibleEquipments = this.equipments.filter(eq => eq.mode !== 'member'); unitsToDraw.forEach(unitId => { const unitEquipments = visibleEquipments.filter(eq => eq.unit === unitId); if (unitEquipments.length === 0) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; unitEquipments.forEach(eq => { minX = Math.min(minX, eq.x); minY = Math.min(minY, eq.y); maxX = Math.max(maxX, eq.x + eq.width); maxY = Math.max(maxY, eq.y + eq.height); }); if (minX === Infinity) return; const x = minX - this.UNIT_BOUNDARY_PADDING; const y = minY - this.UNIT_BOUNDARY_PADDING; const width = (maxX - minX) + (2 * this.UNIT_BOUNDARY_PADDING); const height = (maxY - minY) + (2 * this.UNIT_BOUNDARY_PADDING); const cornerRadius = 10; this.ctx.fillStyle = this.UNIT_COLORS[unitId].fill; this.ctx.strokeStyle = this.UNIT_COLORS[unitId].stroke; this.ctx.lineWidth = 2 / this.viewport.scale; this.ctx.beginPath(); this.ctx.moveTo(x + cornerRadius, y); this.ctx.lineTo(x + width - cornerRadius, y); this.ctx.quadraticCurveTo(x + width, y, x + width, y + cornerRadius); this.ctx.lineTo(x + width, y + height - cornerRadius); this.ctx.quadraticCurveTo(x + width, y + height, x + width - cornerRadius, y + height); this.ctx.lineTo(x + cornerRadius, y + height); this.ctx.quadraticCurveTo(x, y + height, x, y + height - cornerRadius); this.ctx.lineTo(x, y + cornerRadius); this.ctx.quadraticCurveTo(x, y, x + cornerRadius, y); this.ctx.closePath(); this.ctx.fill(); this.ctx.stroke(); }); }, connectionColors: [ // DITAMBAHKAN: Palet 100 warna untuk garis koneksi '#FF1A1A', '#FF5733', '#FFC300', '#DAF7A6', '#33FF57', '#33FFBD', '#33D4FF', '#3361FF', '#8333FF', '#C70039', '#E60000', '#FF7F50', '#FFD700', '#ADFF2F', '#00FF00', '#00FA9A', '#00CED1', '#1E90FF', '#9932CC', '#FF1493', '#FF4500', '#FFA500', '#FFFF00', '#7FFF00', '#32CD32', '#66CDAA', '#20B2AA', '#4169E1', '#8A2BE2', '#C71585', '#FF6347', '#FF8C00', '#FFEB3B', '#CDDC39', '#8BC34A', '#4CAF50', '#009688', '#00BCD4', '#03A9F4', '#2196F3', '#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#007BFF', '#00ADB5', '#393E46', '#FFC107', '#FF5722', '#FF005E', '#D9006C', '#B30074', '#8D007B', '#67007B', '#41007B', '#7B0041', '#7B0022', '#B32D00', '#D95800', '#FF8300', '#FFAD00', '#D4FF00', '#A2FF00', '#6FFF00', '#3BFF00', '#08FF00', '#00FF2A', '#00FF5E', '#00FF83', '#00FFAD', '#00FFD4', '#00EFFF', '#00C2FF', '#0095FF', '#0062FF', '#0037FF', '#0008FF', '#2A00FF', '#5E00FF', '#9500FF', '#C200FF', '#EF00FF', '#FF00D4', '#FF00A2', '#FF006F', '#FF003B', '#FF2200', '#FF5500', '#FF8800', '#FFBB00', '#FFEE00', '#CCFF00', '#99FF00', '#66FF00', '#33FF00', '#00FF33', '#00FF66', '#FF69B4' ], // ...properti lain yang sudah ada... // DIUBAH: Fungsi drawConnections diganti total dengan logika baru // riskApp.blockBuilder -> (FUNGSI HELPER BARU DITAMBAHKAN) _segmentsIntersect(p1, p2, p3, p4) { // Ignore if segments share the same endpoint, as that is not a visual crossing. if ((p1.x === p3.x && p1.y === p3.y) || (p1.x === p4.x && p1.y === p4.y) || (p2.x === p3.x && p2.y === p3.y) || (p2.x === p4.x && p2.y === p4.y)) { return false; } // --- REVISI UTAMA: Menggunakan perbandingan inklusif (>= dan <=) --- // Kasus 1: Segmen pertama (p1-p2) horizontal, segmen kedua (p3-p4) vertikal if (p1.y === p2.y && p3.x === p4.x) { const h_minX = Math.min(p1.x, p2.x); const h_maxX = Math.max(p1.x, p2.x); const v_minY = Math.min(p3.y, p4.y); const v_maxY = Math.max(p3.y, p4.y); // Cek apakah titik potong (p3.x, p1.y) berada di dalam rentang kedua segmen. return p3.x >= h_minX && p3.x <= h_maxX && p1.y >= v_minY && p1.y <= v_maxY; } // Kasus 2: Segmen pertama vertikal, segmen kedua horizontal else if (p1.x === p2.x && p3.y === p4.y) { const v_minY = Math.min(p1.y, p2.y); const v_maxY = Math.max(p1.y, p2.y); const h_minX = Math.min(p3.x, p4.x); const h_maxX = Math.max(p3.x, p4.x); // Cek apakah titik potong (p1.x, p3.y) berada di dalam rentang kedua segmen. return p1.x >= h_minX && p1.x <= h_maxX && p3.y >= v_minY && p3.y <= v_maxY; } // --- AKHIR REVISI --- // Kasus lain (misal: paralel) not dianggap bersilangan dalam sistem ortogonal ini return false; }, // riskApp.blockBuilder -> (FUNGSI HELPER BARU DITAMBAHKAN) _pathsIntersect(pathA, pathB) { for (let i = 0; i < pathA.length - 1; i++) { for (let j = 0; j < pathB.length - 1; j++) { if (this._segmentsIntersect(pathA[i], pathA[i+1], pathB[j], pathB[j+1])) { return true; // Ditemukan persilangan, langsung kembalikan true } } } return false; // Tidak ada persilangan setelah memeriksa semua segmen }, // riskApp.blockBuilder -> drawConnections (After) drawConnections() { const getPointDetails = (equipment, type) => { if (!equipment) return null; switch (type) { case 'input': return { x: equipment.x, y: equipment.y + equipment.height / 2, dir: 'left' }; case 'output': return { x: equipment.x + equipment.width, y: equipment.y + equipment.height / 2, dir: 'right' }; case 'top': return { x: equipment.x + equipment.width / 2, y: equipment.y, dir: 'up' }; case 'bottom': return { x: equipment.x + equipment.width / 2, y: equipment.y + equipment.height, dir: 'down' }; default: return { x: equipment.x + equipment.width, y: equipment.y + equipment.height / 2, dir: 'right' }; } }; const sourcePointGroups = new Map(); this.connections.forEach(conn => { const key = `${conn.from}-${conn.fromPoint || 'output'}`; if (!sourcePointGroups.has(key)) { sourcePointGroups.set(key, []); } sourcePointGroups.get(key).push(conn); }); const segmentGroups = new Map(); const offsetStep = 6 / this.viewport.scale; const allVisibleEquipment = this.equipments.filter(eq => eq.mode !== 'member'); const allPaths = this.connections.map(conn => { const fromEq = this.equipments.find(eq => eq.id === conn.from); const toEq = this.equipments.find(eq => eq.id === conn.to); if (!fromEq || !toEq) return []; const sourceKey = `${conn.from}-${conn.fromPoint || 'output'}`; const group = sourcePointGroups.get(sourceKey) || []; let startOffset = 0; if (group.length > 1) { const lineIndex = group.findIndex(c => c.to === conn.to && c.toPoint === conn.toPoint); startOffset = (lineIndex - (group.length - 1) / 2) * offsetStep; } return this.getOrthogonalPath( getPointDetails(fromEq, conn.fromPoint || 'output'), getPointDetails(toEq, conn.toPoint || 'input'), startOffset ); }); // Kalkulasi persilangan sekarang akan lebih akurat karena _segmentsIntersect sudah diperbaiki. const intersectingConnectionIndices = new Set(); for (let i = 0; i < allPaths.length; i++) { for (let j = i + 1; j < allPaths.length; j++) { if (this._pathsIntersect(allPaths[i], allPaths[j])) { intersectingConnectionIndices.add(i); intersectingConnectionIndices.add(j); } } } this.connections.forEach((conn, index) => { const fromEq = this.equipments.find(eq => eq.id === conn.from); const toEq = this.equipments.find(eq => eq.id === conn.to); if (!fromEq || !toEq) return; const path = allPaths[index]; let pathIntersectsEquipment = false; for (let i = 0; i < path.length - 1; i++) { const p1 = path[i], p2 = path[i+1]; if (!pathIntersectsEquipment) { for (const eq of allVisibleEquipment) { if (eq.id === conn.from || eq.id === conn.to) continue; const segMinX = Math.min(p1.x, p2.x), segMaxX = Math.max(p1.x, p2.x); const segMinY = Math.min(p1.y, p2.y), segMaxY = Math.max(p1.y, p2.y); if (segMaxX > eq.x && segMinX < (eq.x + eq.width) && segMaxY > eq.y && segMinY < (eq.y + eq.height)) { pathIntersectsEquipment = true; break; } } } if (p1.y === p2.y) { const key = `h-${p1.y}`; if (!segmentGroups.has(key)) segmentGroups.set(key, []); segmentGroups.get(key).push({ connIndex: index, start: Math.min(p1.x, p2.x), end: Math.max(p1.x, p2.x) }); } else { const key = `v-${p1.x}`; if (!segmentGroups.has(key)) segmentGroups.set(key, []); segmentGroups.get(key).push({ connIndex: index, start: Math.min(p1.y, p2.y), end: Math.max(p1.y, p2.y) }); } } conn.intersectsEquipment = pathIntersectsEquipment; }); this.connections.forEach((conn, index) => { const fromEq = this.equipments.find(eq => eq.id === conn.from); const toEq = this.equipments.find(eq => eq.id === conn.to); if (!fromEq || !toEq) return; const path = allPaths[index]; let adjustedPath = path.map(p => ({ ...p })); for (let i = 0; i < adjustedPath.length - 1; i++) { const p1 = adjustedPath[i], p2 = adjustedPath[i + 1]; const isHorizontal = path[i].y === path[i+1].y; const key = isHorizontal ? `h-${path[i].y}` : `v-${path[i].x}`; const group = segmentGroups.get(key) || []; const segmentStart = isHorizontal ? Math.min(path[i].x, path[i+1].x) : Math.min(path[i].y, path[i+1].y); const segmentEnd = isHorizontal ? Math.max(path[i].x, path[i+1].x) : Math.max(path[i].y, path[i+1].y); const overlappingIndices = group .filter(seg => seg.connIndex !== index && Math.max(segmentStart, seg.start) < Math.min(segmentEnd, seg.end)) .map(seg => seg.connIndex); if (overlappingIndices.length > 0) { const allInvolvedIndices = [...new Set([index, ...overlappingIndices])].sort((a, b) => a - b); const lineIndex = allInvolvedIndices.indexOf(index); const offset = (lineIndex - (allInvolvedIndices.length - 1) / 2) * offsetStep; if (isHorizontal) { p1.y += offset; p2.y += offset; } else { p1.x += offset; p2.x += offset; } } } const isSelected = this.selectedConnections.some(sc => sc.from === conn.from && sc.to === conn.to); const isHovered = this.hoveredConnection && this.hoveredConnection.from === conn.from && this.hoveredConnection.to === conn.to; const lineIntersects = intersectingConnectionIndices.has(index); const baseColor = (conn.intersectsEquipment || lineIntersects) ? this.connectionColors[index % this.connectionColors.length] : '#000000'; this.ctx.lineWidth = (isSelected ? 4 : 2.5) / this.viewport.scale; this.ctx.lineCap = 'butt'; this.ctx.shadowBlur = isHovered && !isSelected ? 10 / this.viewport.scale : 0; this.ctx.shadowColor = '#3498db'; for (let i = 0; i < adjustedPath.length - 1; i++) { const p1 = adjustedPath[i], p2 = adjustedPath[i+1]; const originalP1 = path[i], originalP2 = path[i+1]; const isHorizontal = originalP1.y === originalP2.y; const key = isHorizontal ? `h-${originalP1.y}` : `v-${originalP1.x}`; const group = segmentGroups.get(key) || []; let segmentColor = isSelected ? '#e74c3c' : (isHovered ? '#3498db' : baseColor); const segmentStart = isHorizontal ? Math.min(path[i].x, path[i+1].x) : Math.min(path[i].y, path[i+1].y); const segmentEnd = isHorizontal ? Math.max(path[i].x, path[i+1].x) : Math.max(path[i].y, path[i+1].y); const overlappingSegs = group.filter(seg => Math.max(segmentStart, seg.start) < Math.min(segmentEnd, seg.end)); if (overlappingSegs.length > 1 && !conn.intersectsEquipment && !lineIntersects) { segmentColor = '#000000'; } this.ctx.strokeStyle = segmentColor; this.ctx.beginPath(); this.ctx.moveTo(p1.x, p1.y); this.ctx.lineTo(p2.x, p2.y); this.ctx.stroke(); } const final_p1 = adjustedPath[adjustedPath.length - 2]; const final_p2 = adjustedPath[adjustedPath.length - 1]; let finalColor = isSelected ? '#e74c3c' : (isHovered ? '#3498db' : baseColor); const finalIsHorizontal = path[path.length - 2].y === path[path.length - 1].y; const finalKey = finalIsHorizontal ? `h-${path[path.length - 2].y}` : `v-${path[path.length - 2].x}`; const finalGroup = segmentGroups.get(finalKey) || []; if (finalGroup.filter(seg => Math.max(finalIsHorizontal ? Math.min(path[path.length-2].x, path[path.length-1].x) : Math.min(path[path.length-2].y, path[path.length-1].y), seg.start) < Math.min(finalIsHorizontal ? Math.max(path[path.length-2].x, path[path.length-1].x) : Math.max(path[path.length-2].y, path[path.length-1].y), seg.end)).length > 1 && !conn.intersectsEquipment && !lineIntersects) { finalColor = '#000000'; } this.ctx.shadowBlur = 0; this.ctx.fillStyle = finalColor; const arrowLength = 10 / this.viewport.scale; const arrowWidth = 5 / this.viewport.scale; const angle = Math.atan2(final_p2.y - final_p1.y, final_p2.x - final_p1.x); this.ctx.save(); this.ctx.translate(final_p2.x, final_p2.y); this.ctx.rotate(angle); this.ctx.beginPath(); this.ctx.moveTo(0, 0); this.ctx.lineTo(-arrowLength, -arrowWidth); this.ctx.lineTo(-arrowLength, arrowWidth); this.ctx.closePath(); this.ctx.fill(); this.ctx.restore(); }); }, // Fungsi helper baru yang ditambahkan di dalam app.blockBuilder // riskApp.blockBuilder -> getOrthogonalPath (After) getOrthogonalPath(start, end, startOffset = 0) { // Menerima parameter startOffset const offset = this.GRID_SIZE / 2; const path = [start]; // p1 adalah titik awal setelah offset keluar dari equipment sumber const p1 = { x: start.x, y: start.y }; // --- REVISI: Terapkan offset awal di sini --- // Segmen pertama (dari 'start' ke 'p1') akan tetap lurus, // tapi posisi 'p1' disesuaikan untuk menciptakan efek menyebar (fan out). if (start.dir === 'left' || start.dir === 'right') { // Jika keluar secara horizontal p1.x += (start.dir === 'left' ? -offset : offset); p1.y += startOffset; // Sesuaikan posisi vertikalnya } else { // Jika keluar secara vertikal p1.y += (start.dir === 'up' ? -offset : offset); p1.x += startOffset; // Sesuaikan posisi horizontalnya } path.push(p1); // --- AKHIR REVISI --- // p_final adalah titik akhir sebelum offset masuk ke equipment tujuan const p_final = { x: end.x, y: end.y }; if (end.dir === 'left') p_final.x -= offset; if (end.dir === 'right') p_final.x += offset; if (end.dir === 'up') p_final.y -= offset; if (end.dir === 'down') p_final.y += offset; const isStartHorizontal = start.dir === 'right' || start.dir === 'left'; const isEndHorizontal = end.dir === 'right' || end.dir === 'left'; // Case 1: Perpendicular direction (e.g. horizontal to vertical). Forms a single "L" bend. if (isStartHorizontal !== isEndHorizontal) { if (isStartHorizontal) { // Dari horizontal (p1) ke vertikal (p_final) // Titik sudutnya akan memiliki x dari p_final dan y dari p1 path.push({ x: p_final.x, y: p1.y }); } else { // Dari vertikal (p1) ke horizontal (p_final) // Titik sudutnya akan memiliki x dari p1 dan y dari p_final path.push({ x: p1.x, y: p_final.y }); } } // Case 2: Parallel direction (e.g. horizontal to horizontal). Forms two "C" or "S" bends. else { if (isStartHorizontal) { // Dari horizontal ke horizontal, perlu dua belokan vertikal const midY = (p1.y + p_final.y) / 2; path.push({ x: p1.x, y: midY }); path.push({ x: p_final.x, y: midY }); } else { // Dari vertikal ke vertikal, perlu dua belokan horizontal const midX = (p1.x + p_final.x) / 2; path.push({ x: midX, y: p1.y }); path.push({ x: midX, y: p_final.y }); } } path.push(p_final); path.push(end); return path; }, drawTemporaryConnection() { if (!this.isConnecting || !this.connectingFrom) return; const fromEq = this.equipments.find(eq => eq.id === this.connectingFrom.equipmentId); if (!fromEq) return; let fromX, fromY; if (this.connectingFrom.type === 'output') { fromX = fromEq.x + fromEq.width; fromY = fromEq.y + fromEq.height / 2; } else { fromX = fromEq.x; fromY = fromEq.y + fromEq.height / 2; } this.ctx.beginPath(); this.ctx.moveTo(fromX, fromY); this.ctx.lineTo(this.tempConnectionEndPos.x, this.tempConnectionEndPos.y); this.ctx.strokeStyle = '#e74c3c'; this.ctx.lineWidth = 2 / this.viewport.scale; this.ctx.setLineDash([5, 5]); this.ctx.stroke(); this.ctx.setLineDash([]); }, updateEquipmentElementsPosition() { this.equipments.forEach(eq => { const el = this.elementMap.get(eq.id); if (el) { const screenPos = this.worldToScreen(eq.x, eq.y); // Tetap gunakan left/top untuk posisi awal yang benar el.style.left = `${screenPos.x}px`; el.style.top = `${screenPos.y}px`; // Terapkan skala dan trik akselerasi GPU secara bersamaan el.style.transform = `scale(${this.viewport.scale}) translateZ(0)`; } }); }, handleZoom(e) { e.preventDefault(); const mousePos = { x: e.offsetX, y: e.offsetY }; const worldPosBefore = this.screenToWorld(mousePos.x, mousePos.y); const zoomAmount = e.deltaY < 0 ? 1.1 : 1 / 1.1; const newScale = this.viewport.scale * zoomAmount; if (newScale >= this.MIN_ZOOM && newScale <= this.MAX_ZOOM) { this.viewport.scale = newScale; const worldPosAfter = this.screenToWorld(mousePos.x, mousePos.y); this.viewport.x += (worldPosAfter.x - worldPosBefore.x) * this.viewport.scale; this.viewport.y += (worldPosAfter.y - worldPosBefore.y) * this.viewport.scale; this.redraw(); this.updateUIComponents(); if(typeof xlBBSave==="function") xlBBSave(); } }, handleMouseDownGeneral(e) { if (e.button !== 0) return; // HAPUS: Semua logika yang berhubungan dengan halaman (pages) const targetEl = e.target.closest('.equipment-box'); if (targetEl || e.target.closest('.connection-point') || e.target.classList.contains('thumb')) { return; } e.preventDefault(); this.viewport.isPanning = true; this.viewport.lastPan = { x: e.clientX, y: e.clientY }; this.workspace.style.cursor = 'grabbing'; }, handleMouseMoveGeneral(e) { // Use clientX for consistent coords regardless of which child element is under cursor const _rect = this.workspace.getBoundingClientRect(); const worldPos = this.screenToWorld(e.clientX - _rect.left, e.clientY - _rect.top); if (this.viewport.isPanning) { e.preventDefault(); const dx = e.clientX - this.viewport.lastPan.x; const dy = e.clientY - this.viewport.lastPan.y; this.viewport.x += dx; this.viewport.y += dy; this.viewport.lastPan = { x: e.clientX, y: e.clientY }; this.redraw(); this.updateUIComponents(); return; } else { let foundConnection = null; for (const conn of this.connections) { if (this.isPointOnConnection(worldPos, conn)) { foundConnection = conn; break; } } const hoverChanged = (this.hoveredConnection?.from !== foundConnection?.from || this.hoveredConnection?.to !== foundConnection?.to); if (hoverChanged) { this.hoveredConnection = foundConnection; this.redraw(); } if (this.hoveredConnection) { this.workspace.style.cursor = 'pointer'; } else { this.workspace.style.cursor = 'grab'; } } if (this.isConnecting) { this.tempConnectionEndPos.x = worldPos.x; this.tempConnectionEndPos.y = worldPos.y; this.redraw(); } }, handleMouseUpGeneral() { // HAPUS: Semua logika yang berhubungan dengan halaman (pages) if (this.viewport.isPanning) { this.viewport.isPanning = false; this.workspace.style.cursor = 'grab'; if(typeof xlBBSave==="function") xlBBSave(); // <-- Baris ini sudah ada dan benar } }, handleEquipmentMouseDown(e) { const self = this; if (e.button !== 0) return; if (e.target.classList.contains('canvas-toggle') || e.target.classList.contains('final-output-toggle')) { e.stopPropagation(); return; } e.stopPropagation(); self.isDragging = true; self.draggedEquipment = e.currentTarget; // Disable CSS transition during drag for immediate response self.draggedEquipment.style.transition = 'none'; const equipmentData = self.equipments.find(eq => eq.id === self.draggedEquipment.id); if (!equipmentData) return; const mouseWorldPos = self.screenToWorld(e.clientX - self.workspace.getBoundingClientRect().left, e.clientY - self.workspace.getBoundingClientRect().top); self.dragStartOffset.x = mouseWorldPos.x - equipmentData.x; self.dragStartOffset.y = mouseWorldPos.y - equipmentData.y; const onMouseMove = (moveEvent) => { if (!self.isDragging) return; const mouseWorld = self.screenToWorld(moveEvent.clientX - self.workspace.getBoundingClientRect().left, moveEvent.clientY - self.workspace.getBoundingClientRect().top); equipmentData.x = mouseWorld.x - self.dragStartOffset.x; equipmentData.y = mouseWorld.y - self.dragStartOffset.y; self.redraw(); self.updateUIComponents(); }; const onMouseUp = () => { if (!self.isDragging) return; self.isDragging = false; equipmentData.x = self.snapToGrid(equipmentData.x); equipmentData.y = self.snapToGrid(equipmentData.y); // Restore CSS transition after drag if (self.draggedEquipment) self.draggedEquipment.style.transition = ''; self.draggedEquipment = null; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); self.checkAndRemoveEmptyPages(); if(typeof xlBBSave==="function") xlBBSave(); // <-- BARIS INI DITAMBAHKAN self.runFullAnalysis(); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }, handleEquipmentClick(e) { e.stopPropagation(); // Jangan lakukan apapun jika yang diklik adalah salah satu checkbox atau toggle if (this.isDragging || e.target.classList.contains('canvas-toggle') || e.target.classList.contains('final-output-toggle') || e.target.classList.contains('equipment-select-checkbox')) return; // Hapus seleksi grup jika box biasa diklik this.groupSelectionIds.clear(); document.querySelectorAll('.equipment-select-checkbox').forEach(cb => cb.checked = false); this.toggleSelection(e.currentTarget.id, 'equipment', e.ctrlKey); // Perbarui UI setelah seleksi biasa this.updateGroupingUI(); }, handleWorkspaceClick(e) { if (e.target.id === 'workspace' || e.target.id === 'workspace-canvas' || e.target.closest && !e.target.closest('.equipment-box') && !e.target.closest('.connection-point')) { const _r = this.workspace.getBoundingClientRect(); const worldPos = this.screenToWorld(e.clientX - _r.left, e.clientY - _r.top); // HAPUS: Pengecekan this.getPageAt(worldPos) for (const conn of this.connections) { if (this.isPointOnConnection(worldPos, conn)) { this.toggleSelection(conn, 'connection', e.ctrlKey); return; } } if (!e.ctrlKey) this.deselectAll(); } }, handleKeyDown(e) { if (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'SELECT') return; if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); this.deleteSelection(); } }, runFullAnalysis() { // --- BLOK SINKRONISASI BARU --- const affectedGroupIds = new Set(); if (this.equipments && app.state.equipmentDatabase) { // ... (kode sinkronisasi yang sudah ada) ... } // --- AKHIR BLOK SINKRONISASI --- const graph = this.buildGraph(); // Membangun graph koneksi // --- BLOK BARU: Update nama semua junction secara dinamis --- this.equipments.forEach(eq => { if (eq.mode === 'junction') { const node = graph[eq.id]; if (node && node.inputs.length > 0) { const inputNames = node.inputs.map(inputId => { const inputEq = this.equipments.find(e => e.id === inputId); return inputEq ? (inputEq.dbId || inputEq.name) : 'Unknown'; }).sort(); // Urutkan nama agar konsisten let newName; if (inputNames.length === 1) { newName = `Junction from ${inputNames[0]}`; } else if (inputNames.length === 2) { newName = `Junction from ${inputNames.join(' & ')}`; } else { const last = inputNames.pop(); newName = `Junction from ${inputNames.join(', ')}, & ${last}`; } eq.name = newName; } else { eq.name = 'Junction (No Inputs)'; } } }); // --- AKHIR BLOK BARU --- this.calculateAndDisplayAllAvailability(); // --- REVISI: Update visual (Warna Kotak & Posisi Toggle) --- // Pastikan updateEquipmentElement dipanggil setelah kalkulasi selesai // agar status 'failed' yang baru bisa diterapkan ke CSS. this.equipments.forEach(eq => { if (eq.mode !== 'member') { this.updateEquipmentElement(eq); } }); // --- AKHIR REVISI --- this.redraw(); this.updateUIComponents(); }, calculateEquipmentWidth(id) { const BASE_WIDTH_UNITS = 6; const baseWidth = BASE_WIDTH_UNITS * this.GRID_SIZE; const estimatedTextWidth = (id.length * 7) + 40; if (estimatedTextWidth <= baseWidth) return baseWidth; const requiredWidth = Math.ceil(estimatedTextWidth / this.GRID_SIZE) * this.GRID_SIZE; return Math.max(baseWidth, requiredWidth); }, calculateEquipmentHeight(equipment) { // Selalu kembalikan tinggi standar 60px (1.5 * 40px) untuk SEMUA tipe equipment. // Konten (termasuk toggle anggota grup) akan di-render di dalam kotak dengan tinggi tetap ini. return 1.5 * this.GRID_SIZE; }, addEquipment(selectedEquipmentId = null) { // REVISI: Tambahkan parameter // REVISI: Logika pengambilan ID diubah if (!selectedEquipmentId) { alert('Equipment ID not provided.'); return; } const dbEquipment = xlBBGetEquipment(selectedEquipmentId); if (!dbEquipment) { alert('Selected equipment not found in the database.'); return; } // Cek apakah equipment sudah ada di canvas if (this.equipments.some(eq => eq.dbId === selectedEquipmentId)) { this.showMessage('This equipment is already on the canvas.', 2000); this.viewEquipment(selectedEquipmentId); return; } const name = dbEquipment.equipmentName; const eqWidth = this.calculateEquipmentWidth(dbEquipment.equipmentId); const viewCenter = this.screenToWorld(this.canvas.width / 2, this.canvas.height / 2); const initialX = this.snapToGrid(viewCenter.x - eqWidth / 2); const eqHeight = 1.5 * this.GRID_SIZE; const initialY = this.snapToGrid(viewCenter.y - eqHeight / 2); const equipmentData = { id: 'eq-' + this.equipmentCounter++, name: name, dbId: selectedEquipmentId, isHILP: dbEquipment.isHILP, x: initialX, y: initialY, width: eqWidth, height: eqHeight, mode: 'single', unit: dbEquipment.unit, failed: false, isFinalOutput: false, displayAvailability: 1.0, intrinsicAvailability: 1.0, displayReliability: 1.0, intrinsicReliability: 1.0, hasRedundancyWarning: false, failureState: 'OK' }; this.equipments.push(equipmentData); // Ensure canvas dimensions are correct before creating element if (this.canvas && (this.canvas.width === 0 || this.canvas.height === 0)) { if (this.boundResizeCanvas) this.boundResizeCanvas(); } this.createEquipmentElement(equipmentData); if(typeof xlBBSave==="function") xlBBSave(); this.runFullAnalysis(); this.renderEquipmentPane(); // Auto-viewEquipment so positions are correct immediately (no lag on drag) this.viewEquipment(selectedEquipmentId); }, async deleteSelection() { if (this.selectedEquipmentIds.size === 0 && this.selectedConnections.length === 0) return; const idsToDelete = new Set(this.selectedEquipmentIds); // Expand groups: include all member ids this.equipments.forEach(eq => { if (eq.mode === 'group' && idsToDelete.has(eq.id)) { this.equipments.filter(m => m.groupId === eq.id).forEach(m => idsToDelete.add(m.id)); } }); // ── Collect dbIds of deleted equipment for SQL log deletion ── const dbIdsToDelete = []; idsToDelete.forEach(id => { const eq = this.equipments.find(e => e.id === id); if (eq && eq.dbId && eq.mode !== 'junction') dbIdsToDelete.push(eq.dbId); }); // ── Own page's diagram_id (only delete logs that belong to THIS page) ── const _ownTab = (typeof bbTabs !== 'undefined') ? bbTabs.tabs[bbTabs.activeIdx] : null; const _ownDiagramId = _ownTab ? String(_ownTab.id) : null; // ── Count OWN-page logs for warning (NOT source page logs) ─── const logsToDelete = (app.state.detailsData['reliability-logs'] || []) .filter(l => dbIdsToDelete.includes(l.equipmentId) && (_ownDiagramId === null || String(l.diagramId) === _ownDiagramId)); const hasConnections = this.selectedConnections.length > 0; const hasEquipment = dbIdsToDelete.length > 0; // Build confirmation message let msg = ''; if (hasEquipment && hasConnections) { msg = `Delete ${dbIdsToDelete.length} equipment and ${this.selectedConnections.length} connection(s)?`; } else if (hasEquipment) { msg = `Delete ${dbIdsToDelete.length} equipment?`; } else { msg = `Delete ${this.selectedConnections.length} connection(s)?`; } if (logsToDelete.length > 0) { msg += `\n\n⚠️ WARNING: This will also delete ${logsToDelete.length} log entry(s) belonging to this page. Logs from other pages are NOT affected.`; } if (!confirm(msg)) return; // Remove from canvas this.equipments = this.equipments.filter(eq => !idsToDelete.has(eq.id)); idsToDelete.forEach(id => { const el = document.querySelector(`#${id}`); if (el) el.remove(); this.elementMap.delete(id); }); this.connections = this.connections.filter(c => !idsToDelete.has(c.from) && !idsToDelete.has(c.to)); const selectedConnSet = new Set(this.selectedConnections.map(c => `${c.from}->${c.to}`)); this.connections = this.connections.filter(c => !selectedConnSet.has(`${c.from}->${c.to}`)); // ── Remove from local state: only own page's logs ───────────── if (dbIdsToDelete.length > 0) { app.state.detailsData['reliability-logs'] = (app.state.detailsData['reliability-logs'] || []) .filter(l => !(dbIdsToDelete.includes(l.equipmentId) && (_ownDiagramId === null || String(l.diagramId) === _ownDiagramId))); } this.deselectAll(); if (typeof xlBBSave === 'function') xlBBSave(); this.runFullAnalysis(); this.renderEquipmentPane(); // ── Delete from SQL: ONLY this page's logs (scoped by diagram_id) ── if (dbIdsToDelete.length > 0 && _ownDiagramId) { try { await Promise.all(dbIdsToDelete.map(dbId => api('DELETE', 'bb_reliability_logs', { equipment_id: dbId, diagram_id: _ownDiagramId }) .catch(e => console.warn('[BB] R-log delete for', dbId, 'failed:', e)) )); } catch(e) { console.warn('[BB] bulk R-log delete error:', e); } } }, viewAllEquipment() { if (this.equipments.length === 0) { this.showMessage('No equipment on the canvas to optimize.', 2000); return; } let optimizedCount = 0; // Iterasi melalui semua equipment yang ada di data this.equipments.forEach(eq => { // Ambil elemen DOM dari Map const el = this.elementMap.get(eq.id); if (el) { // --- REVISI KUNCI UTAMA DIMULAI DI SINI --- // 1. Terapkan efek glowing merah, sama seperti saat "view" satu equipment. el.style.transition = 'box-shadow 0.3s ease-in-out'; el.style.boxShadow = '0 0 25px rgba(231, 76, 60, 0.8)'; // 2. Setel timer untuk menghilangkan efek glowing setelah beberapa saat. // Ini memberikan umpan balik visual tanpa sorotan permanen. setTimeout(() => { // Hapus box-shadow untuk menghilangkan sorotan, // tetapi JANGAN hapus el.style.transition agar animasi pudar terlihat mulus. el.style.boxShadow = ''; }, 1500); // Effect fades after 1.5 seconds // --- AKHIR REVISI --- optimizedCount++; } }); // Panggil fungsi `updateEquipmentElementsPosition` sekali saja. // Fungsi ini sudah mengandung trik `translateZ(0)` yang akan memastikan // akselerasi GPU permanen untuk semua equipment. this.updateEquipmentElementsPosition(); // Pesan diubah agar lebih sesuai dengan proses otomatis if (optimizedCount > 0) { this.showMessage(`⚡ Canvas optimized for smooth panning.`, 2000); } }, createEquipmentElement(equipment) { const box = document.createElement('div'); box.className = 'equipment-box'; box.id = equipment.id; this.elementMap.set(equipment.id, box); box.style.width = `${equipment.width}px`; box.style.height = `${equipment.height}px`; if (equipment.mode === 'junction') { box.style.borderRadius = '50%'; box.style.justifyContent = 'center'; box.style.backgroundColor = '#95a5a6'; box.style.borderColor = '#34495e'; box.title = `Junction: ${equipment.name}`; box.innerHTML = `
R-Log
i
A: 100.0% Req: 100.0% Rpr: 100.0%
${equipment.name}
`; } else { let mainName = equipment.dbId; let subName = equipment.name; if (equipment.mode === 'group' && equipment.name.includes('(')) { const parts = equipment.name.split(' ('); subName = parts[0]; } // REVISI LABEL: R -> Rpr (subscript) & ADD Req let displayText = 'A: 100.0% Req: 100.0% Rpr: 100.0%'; const hilpIndicator = equipment.isHILP ? 'HILP' : 'REG'; const unitDisplayName = xlBBUnitName(equipment.unit); const outputButtonText = `${unitDisplayName} Output`; box.innerHTML = `
R-Log
i
${equipment.mode !== 'group' ? `
` : ''}
${hilpIndicator}
${displayText}
${mainName}
`; } box.addEventListener('mousedown', this.handleEquipmentMouseDown.bind(this)); box.addEventListener('click', this.handleEquipmentClick.bind(this)); box.addEventListener('dblclick', (e) => { e.stopPropagation(); if(equipment.mode !== 'junction') this.openEditModal(equipment.id); }); const finalOutputBtn = box.querySelector('.final-output-toggle'); if(finalOutputBtn) { finalOutputBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleFinalOutput(equipment.id); }); } const checkbox = box.querySelector('.equipment-select-checkbox'); if (checkbox) { checkbox.addEventListener('click', (e) => { e.stopPropagation(); this.toggleGroupSelection(equipment.id, e.target.checked); }); } box.querySelectorAll('.connection-point').forEach(point => { point.addEventListener('mousedown', (e) => { e.stopPropagation(); this.startConnection(e); }); point.addEventListener('mouseup', (e) => { e.stopPropagation(); this.endConnection(e); }); }); this.canvasContainer.appendChild(box); this.updateEquipmentElementsPosition(); this.updateEquipmentElement(equipment); }, calculateLogReliability(equipmentId) { const logs = (app.state.detailsData['reliability-logs'] || []).filter(l => l.equipmentId === equipmentId); // REVISI: Tetap lanjutkan perhitungan meskipun not ada log, // karena kita butuh nilai 1.0 (100%) yang valid berdasarkan interval waktu untuk konsistensi tampilan. if (logs.length === 0) return 1.0; // Ambil interval waktu dari global state (Top Bar) yang diset oleh tombol Calc // Default fallback jika belum pernah di-set const defaultEnd = new Date(); const defaultStart = new Date(defaultEnd.getFullYear(), 0, 1); // 1 Jan tahun ini const obsStartVal = this.activeObsStart || defaultStart.toISOString().slice(0, 16); const obsEndVal = this.activeObsEnd || defaultEnd.toISOString().slice(0, 16); const t_start = new Date(obsStartVal).getTime(); const t_end = new Date(obsEndVal).getTime(); const windowDurationHrs = Math.max(0, (t_end - t_start) / 3600000); if (windowDurationHrs <= 0) return 1.0; // 1. Hitung Failure Rate (Lambda) dalam jendela observasi let downHrs = 0; let failuresCount = 0; const failureLogs = logs.filter(l => l.state === 'DOWN' || l.state === 'RECOND_BREAKDOWN' || l.state === 'RECONDITIONED'); failureLogs.forEach(log => { const dStart = new Date(log.startDate).getTime(); const dEnd = new Date(log.endDate).getTime(); const metricOverlapStart = Math.max(dStart, t_start); const metricOverlapEnd = Math.min(dEnd, t_end); if (metricOverlapEnd > metricOverlapStart) { downHrs += (metricOverlapEnd - metricOverlapStart) / (1000 * 60 * 60); } if (dStart >= t_start && dStart <= t_end) { failuresCount++; } }); const operatingTime = windowDurationHrs - downHrs; const failureRate = operatingTime > 0 ? (failuresCount / operatingTime) : 0; // 2. Cari Waktu Reset Terakhir (Reconditioned) sebelum t_end let cutoffTime = t_start; const recondLogs = logs.filter(l => l.state === 'RECOND_BREAKDOWN' || l.state === 'RECOND_OVERHAUL' || l.state === 'RECONDITIONED'); recondLogs.sort((a, b) => new Date(b.endDate) - new Date(a.endDate)); for (const rLog of recondLogs) { const rTime = new Date(rLog.endDate).getTime(); if (rTime < t_end) { // Reset point valid jika berada dalam range ATAU sebelum range (mengambil efek historis) // Namun untuk perhitungan Req windowed, kita biasanya memotong di t_start jika reset terjadi sebelumnya. if (rTime > t_start) cutoffTime = rTime; break; } } // 3. Kalkulasi Reliability Menggunakan Exponential Decay: R(t) = e^(-λ * t_eff) const timeSinceResetHrs = (t_end - cutoffTime) / (1000 * 60 * 60); return Math.exp(-failureRate * timeSinceResetHrs); }, calculateGroupReliability(members, k, n) { // Ambil reliability masing-masing member (memperhitungkan toggle manual fail) // R_i = calculateLogReliability (Observed Availability) // REVISI: Reliability member selalu dihitung dari log, mengabaikan status toggle manual (m.failed) const memberReliabilities = members.map(m => this.calculateLogReliability(m.dbId)); // --- IEC 61078: Parallel Configuration (Active Redundancy) --- // Cek apakah setiap equipment memiliki kapasitas >= 100% const isPureParallel = members.every(m => (m.capacity || 0) >= 100); if (isPureParallel) { // Formula: R_sys = 1 - Product(Unreliability_i) // Q_sys = (1-R1) * (1-R2) * ... * (1-Rn) const systemUnreliability = memberReliabilities.reduce((acc, r) => acc * (1.0 - r), 1.0); return 1.0 - systemUnreliability; } // --- IEC 61078: k-out-of-n Configuration (State Enumeration Method) --- // Digunakan karena Reliability (R) setiap member bisa berbeda (Non-identical components). // Rumus: Sum of Probabilities of all successful states. let successProb = 0.0; const totalCombinations = 1 << n; // 2^n kemungkinan state for (let i = 0; i < totalCombinations; i++) { let activeCount = 0; let combinationProb = 1.0; for (let j = 0; j < n; j++) { // Cek bit ke-j (Status equipment: 1=UP, 0=DOWN) const isUp = (i >> j) & 1; if (isUp) { activeCount++; combinationProb *= memberReliabilities[j]; // P(Up) } else { combinationProb *= (1.0 - memberReliabilities[j]); // P(Down) } } // IEC 61078: State dianggap sukses jika unit aktif >= k if (activeCount >= k) { successProb += combinationProb; } } return Math.min(1.0, Math.max(0.0, successProb)); }, // Tambahkan fungsi ini ke dalam app.blockBuilder = { ... } triggerGlobalCalculation() { const btn = document.getElementById('btnCalcGlobal'); const eqId = this.currentModalEquipmentId; // Ambil nilai dari input box const inputStart = document.getElementById('globalObsStartDate').value; const inputEnd = document.getElementById('globalObsEndDate').value; // Validasi sederhana if (new Date(inputEnd) <= new Date(inputStart)) { this.showMessage('End Date must be after Start Date.', 'danger'); return; } // REVISI: Commit perubahan! Save nilai input ke variabel state aktif this.activeObsStart = inputStart; this.activeObsEnd = inputEnd; // Visual feedback: Ubah tombol menjadi loading state const originalText = btn.innerHTML; btn.innerHTML = '⏳'; btn.style.opacity = '0.7'; btn.disabled = true; // Gunakan setTimeout kecil agar browser sempat merender perubahan UI tombol sebelum proses berat setTimeout(() => { // 1. Update Engine Utama (Canvas) // This will trigger calculateAndDisplayAllAvailability -> calculateLogReliability // sehingga Req dan Rpr pada Box Equipment akan terupdate otomatis. this.runFullAnalysis(); // 2. Update Modal R-Log yang sedang terbuka (jika ada) if (eqId) { this.renderReliabilityTimeline(eqId); this.renderReliabilityLogTable(eqId); } btn.innerHTML = originalText; btn.style.opacity = '1'; btn.disabled = false; // REVISI PESAN: Memberitahu user bahwa Req dan Rpr di canvas sudah berubah this.showMessage('Req & Rpr updated based on new interval!', 'success'); }, 100); }, openReliabilityLogModal(equipmentId) { // Pastikan array penyimpanan ada di global state detailsData if (!app.state.detailsData['reliability-logs']) { app.state.detailsData['reliability-logs'] = []; } this.currentModalEquipmentId = equipmentId; const equipment = xlBBGetEquipment(equipmentId) || this.equipments.find(e => e.id === equipmentId); const eqName = equipment ? equipment.equipmentName : equipmentId; const eqIdDisplay = equipment ? equipment.equipmentId : equipmentId; // --- LOGIKA HEADER: Penentuan Tipe Sistem & Formula (Updated to ISO/TR 12489) --- const blockObj = this.equipments.find(e => e.id === equipmentId || e.dbId === equipmentId); let headerInfoHtml = `Equipment: ${eqName} (${eqIdDisplay})`; if (blockObj) { if (blockObj.mode === 'group') { const members = this.equipments.filter(m => m.groupId === blockObj.id); const isPureParallel = members.every(m => (m.capacity || 0) >= 100); const memberIds = members.map(m => m.dbId).join(', '); if (isPureParallel) { const n = members.length; headerInfoHtml = ` System Type: Parallel / Active Redundancy (1oo${n})
Members: ${memberIds}
RSYS = 1 - (1 - R)${n}
*ISO/TR 12489: Redundansi Penuh
`; } else { const n = blockObj.totalCount; const k = blockObj.requiredCount; headerInfoHtml = ` System Type: k-out-of-n (${k}oo${n})
Members: ${memberIds}
Rkoon =
${n} i=${k}
Binomial(n,i)
*ISO/TR 12489: Redundansi Parsial
`; } } else if (blockObj.mode === 'single') { headerInfoHtml = ` System Type: Single Equipment
ID: ${eqIdDisplay} - ${eqName}
Formula: R(t) = e-λt
*ISO/TR 12489: Single Component
`; } else if (blockObj.mode === 'junction') { headerInfoHtml = ` System Type: Junction Node
Name: ${blockObj.name}
Formula: RSYS = RIN 1 × RIN 2 ...
`; } } // --- PRE-CALCULATE HTML CONTENT (Synchronous) --- const generatedContent = this.renderReliabilityTimeline(equipmentId, true); // --- TATA LETAK GRID 3 KOLOM --- const topRowLayout = `
${headerInfoHtml}
${generatedContent.statsHtml}
${generatedContent.metricsHtml}
`; // Wadah untuk Timeline & Inputs (Baris Bawah) const timelineContainer = `
${generatedContent.timelineHtml}
`; // Template Modal Lengkap const modalContent = ` ${topRowLayout} ${timelineContainer}
📋 Log History
Equipment State Start Time End Time Duration (Hrs) Description Action
`; bbOpenModal( `Reliability Data Log - ${eqIdDisplay}`, modalContent, null, // no Save button — this modal is view-only; saving done via Add/Edit forms 'extra-wide' ); // Render tabel log terpisah karena strukturnya lebih sederhana dan jarang menyebabkan layout shift setTimeout(() => { this.renderReliabilityLogTable(eqIdDisplay); }, 0); // Immediate execution }, renderReliabilityTimeline(equipmentId, returnHtmlOnly = false) { function findBBEl(id) { var dual = document.getElementById('bbDualContainer'); if (dual && dual.classList.contains('open')) { var dMain = document.getElementById('bbDualMainBody'); if (dMain) { var el = dMain.querySelector('#'+id); if (el) return el; } } var solo = document.getElementById('bbDynBody'); if (solo) { var el2 = solo.querySelector('#'+id); if (el2) return el2; } return document.getElementById(id); } const statsBox = returnHtmlOnly ? null : findBBEl('statsDisplayBox'); const failureRateBox = returnHtmlOnly ? null : findBBEl('failureRateDisplayBox'); const timelineBox = returnHtmlOnly ? null : findBBEl('timelineDisplayBox'); if (!returnHtmlOnly && (!statsBox || !timelineBox || !failureRateBox)) return; // 1. Tentukan Member Group atau Single const blockObj = this.equipments.find(e => e.id === equipmentId || e.dbId === equipmentId); let involvedEquipmentIds = []; if (blockObj) { if (blockObj.mode === 'group') { const members = this.equipments.filter(m => m.groupId === blockObj.id); involvedEquipmentIds = members.map(m => m.dbId); } else { involvedEquipmentIds = [equipmentId]; } } else { involvedEquipmentIds = [equipmentId]; } // 2. Ambil Input Waktu (SEKARANG DARI GLOBAL INPUT DI TOP BAR) const obsStartVal = this.activeObsStart || '2020-01-01T00:00'; const obsEndVal = this.activeObsEnd || new Date().toISOString().slice(0, 16); const t_start = new Date(obsStartVal).getTime(); const t_end = new Date(obsEndVal).getTime(); // --- NEW: Time Context Logic --- const nowTime = new Date().getTime(); const isFuture = t_end > nowTime; const endDateStr = new Date(t_end).toLocaleString('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }); let timeContextHtml = ''; if (isFuture) { timeContextHtml = `
⚠️ PROJECTED RELIABILITY
Target: ${endDateStr}
Estimates using current Failure Rate (λ) & Exponential Decay.
`; } else { timeContextHtml = `
✅ HISTORICAL DATA
As of: ${endDateStr}
Calculated from actual operation logs.
`; } // 3. Kalkulasi Statistik let windowDurationHrs = (t_end - t_start) / (1000 * 60 * 60); if (windowDurationHrs < 0) windowDurationHrs = 0; let metricsHtml = ''; // --- REVISI: LOOP UNTUK METRIK PER EQUIPMENT --- const memberReliabilities = involvedEquipmentIds.map(id => { const logs = (app.state.detailsData['reliability-logs'] || []).filter(l => l.equipmentId === id); // --- 3a. HITUNG METRIK (FAILURE RATE, MTBF, MTTR) --- let downHrsForMetrics = 0; let failuresCountForMetrics = 0; const metricFailureLogs = logs.filter(l => l.state === 'DOWN' || l.state === 'RECOND_BREAKDOWN' || l.state === 'RECONDITIONED'); metricFailureLogs.forEach(dLog => { const dStart = new Date(dLog.startDate).getTime(); const dEnd = new Date(dLog.endDate).getTime(); const metricOverlapStart = Math.max(dStart, t_start); const metricOverlapEnd = Math.min(dEnd, t_end); if (metricOverlapEnd > metricOverlapStart) { downHrsForMetrics += (metricOverlapEnd - metricOverlapStart) / (1000 * 60 * 60); } if (dStart >= t_start && dStart <= t_end) { failuresCountForMetrics++; } }); const operatingTimeForMetrics = windowDurationHrs - downHrsForMetrics; const failureRate = operatingTimeForMetrics > 0 ? (failuresCountForMetrics / operatingTimeForMetrics) : 0; const mtbf = failuresCountForMetrics > 0 ? (operatingTimeForMetrics / failuresCountForMetrics) : operatingTimeForMetrics; const mttr = failuresCountForMetrics > 0 ? (downHrsForMetrics / failuresCountForMetrics) : 0; // --- 3b. KALKULASI RELIABILITY --- let cutoffTime = t_start; let isResetted = false; const recondLogs = logs.filter(l => l.state === 'RECONDITIONED' || l.state === 'RECOND_BREAKDOWN' || l.state === 'RECOND_OVERHAUL'); recondLogs.sort((a, b) => new Date(b.endDate) - new Date(a.endDate)); for (const rLog of recondLogs) { const rTime = new Date(rLog.endDate).getTime(); if (rTime < t_end) { if (rTime > t_start) { cutoffTime = rTime; isResetted = true; } break; } } const timeSinceResetHrs = (t_end - cutoffTime) / (1000 * 60 * 60); let r_i = Math.exp(-failureRate * timeSinceResetHrs); // Format tampilan const failRateDisplay = failureRate.toExponential(2); const mtbfDisplay = mtbf.toLocaleString('en-US', {maximumFractionDigits: 1}); const mttrDisplay = mttr.toLocaleString('en-US', {maximumFractionDigits: 2}); const resetIcon = isResetted ? `🔄` : ''; const reliabilityDisplay = `${(r_i * 100).toFixed(2)}% ${resetIcon}`; const dbEq = xlBBGetEquipment(id); const nameDisplay = dbEq ? dbEq.equipmentId : id; metricsHtml += `
${nameDisplay} (${failuresCountForMetrics} fails)
Reliability:
${reliabilityDisplay}
Rate:
${failRateDisplay}
MTBF:
${mtbfDisplay}h
MTTR:
${mttrDisplay}h
`; return r_i; }); // 2. Hitung Reliability System let systemReliability = 0; let formulaDisplay = ""; const n = memberReliabilities.length; let k = 1; let isGroup = false; if (blockObj && blockObj.mode === 'group') { isGroup = true; const members = this.equipments.filter(m => m.groupId === blockObj.id); const isPureParallel = members.every(m => (m.capacity || 0) >= 100); k = isPureParallel ? 1 : (blockObj.requiredCount || 1); } if (!isGroup) { systemReliability = memberReliabilities[0]; formulaDisplay = `R(t) = e-λt`; } else { const totalCombinations = 1 << n; let successProbSum = 0; for (let i = 0; i < totalCombinations; i++) { let activeCount = 0; let stateProb = 1.0; for (let j = 0; j < n; j++) { const isUp = (i >> j) & 1; if (isUp) { activeCount++; stateProb *= memberReliabilities[j]; } else { stateProb *= (1.0 - memberReliabilities[j]); } } if (activeCount >= k) { successProbSum += stateProb; } } systemReliability = successProbSum; if (isGroup && k === 1 && n === memberReliabilities.length) { formulaDisplay = `ISO/TR 12489: 1 - (1 - R)^n`; } else { formulaDisplay = `ISO/TR 12489: Sum(Binomial)`; } } const reliabilityPct = (systemReliability * 100).toFixed(2); const reliabilityColor = systemReliability < 0.9 ? '#c0392b' : '#27ae60'; let memberDetailsHtml = ''; if (isGroup) { memberDetailsHtml = `
Member R(t): `; memberDetailsHtml += memberReliabilities.map((r, idx) => `M${idx+1}: ${(r*100).toFixed(1)}%`).join(', '); memberDetailsHtml += `
`; } // 4. Render Stats Box const statsHtml = `

📊 Calculated Reliability Statistics

Observation Window (t): ${windowDurationHrs.toLocaleString('en-US', {maximumFractionDigits: 2})} hrs
${new Date(t_start).toLocaleDateString()} - ${new Date(t_end).toLocaleDateString()}
${memberDetailsHtml}
${formulaDisplay}
${reliabilityPct}%
System R${isGroup ? ` (${k}-oo-${n})` : ''}
${timeContextHtml}
`; // 5. Render Failure Rate Box const containerHtml = `

⚠️ Failure Metrics (Per Item)

${metricsHtml}
`; // 6. Render Timeline HTML let timelineHtml = `

📅 Reliability Timeline

UP DOWN STANDBY RECOND (BRK) RECOND (OVH)
`; const VISUAL_START = new Date('2020-01-01T00:00:00').getTime(); const VISUAL_END = new Date('2045-01-01T00:00:00').getTime(); const VISUAL_SPAN = VISUAL_END - VISUAL_START; involvedEquipmentIds.forEach(id => { const eqLogs = (app.state.detailsData['reliability-logs'] || []).filter(l => l.equipmentId === id); const dbEq = xlBBGetEquipment(id); const name = dbEq ? dbEq.equipmentName : id; timelineHtml += `
${name} (${id})
`; eqLogs.forEach(log => { const start = new Date(log.startDate).getTime(); const end = new Date(log.endDate).getTime(); const validStart = Math.max(VISUAL_START, start); const validEnd = Math.min(VISUAL_END, end); if (validEnd > validStart) { const left = ((validStart - VISUAL_START) / VISUAL_SPAN) * 100; const width = ((validEnd - validStart) / VISUAL_SPAN) * 100; let color = '#22c55e'; // UP let zIndex = 1; if (log.state === 'DOWN') { color = '#ef4444'; zIndex = 2; } else if (log.state === 'STANDBY') { color = '#ffffff'; zIndex = 2; } else if (log.state === 'RECOND_BREAKDOWN' || log.state === 'RECONDITIONED') { color = '#f1c40f'; zIndex = 3; } else if (log.state === 'RECOND_OVERHAUL') { color = '#3498db'; zIndex = 3; } const borderStyle = log.state === 'STANDBY' ? 'border:1px solid #ccc; box-sizing:border-box;' : ''; timelineHtml += `
`; } }); timelineHtml += `
`; }); timelineHtml += `
2020 2025 2030 2035 2040 2045
`; // --- RETURN OR UPDATE --- if (returnHtmlOnly) { return { statsHtml, metricsHtml: containerHtml, timelineHtml }; } statsBox.innerHTML = statsHtml; failureRateBox.innerHTML = containerHtml; timelineBox.innerHTML = timelineHtml; }, openAddLogEntryModal(targetEquipmentId, mainParentId) { const equipment = xlBBGetEquipment(targetEquipmentId); const eqName = equipment ? equipment.equipmentName : targetEquipmentId; const modalContent = `
Adding log entry for: ${eqName} (${targetEquipmentId})
`; // Gunakan openSecondaryModal agar modal utama (Timeline) tetap terbuka di belakangnya bbOpenSecondaryModal(`Add Log: ${targetEquipmentId}`, modalContent); }, async addReliabilityLogEntry(targetEquipmentId, mainParentId) { const startDate = document.getElementById('newLogStartDate').value; const endDate = document.getElementById('newLogEndDate').value; const state = document.getElementById('newLogState').value; const description = document.getElementById('newLogDescription').value; if (!startDate || !endDate) { alert('Please select both Start and End dates.'); return; } const start = new Date(startDate), end = new Date(endDate); if (end <= start) { alert('End date must be after Start date.'); return; } const durationHours = (end - start) / 3600000; try { // Always save new logs to OWN page's diagram_id (not the referenced source page) var _diagId = null; if (typeof bbTabs !== 'undefined') { var _ownTab = bbTabs.tabs[bbTabs.activeIdx]; if (_ownTab && _ownTab.id) _diagId = _ownTab.id; } const res = await api('POST', 'bb_reliability_logs', {}, { equipment_id: targetEquipmentId, diagram_id: _diagId, start_date: startDate.replace('T',' ') + ':00', end_date: endDate.replace('T',' ') + ':00', state, duration_hours: durationHours, description: description||null }); app.state.detailsData['reliability-logs'].push({ id: res && res.id ? String(res.id) : String(Date.now()), equipmentId: targetEquipmentId, startDate, endDate, state, duration: durationHours, description, createdAt: new Date().toISOString() }); } catch(e) { console.warn('[BB] R-log POST failed:', e); app.state.detailsData['reliability-logs'].push({ id: String(Date.now()), equipmentId: targetEquipmentId, startDate, endDate, state, duration: durationHours, description, createdAt: new Date().toISOString() }); } if (typeof xlBBSave === 'function') xlBBSave(); const refreshTargetId = mainParentId || this.currentModalEquipmentId; if (refreshTargetId) { this.renderReliabilityTimeline(refreshTargetId); this.renderReliabilityLogTable(refreshTargetId); } this.runFullAnalysis(); app.ui.closeSecondaryModal(); setTimeout(() => this.runFullAnalysis(), 60); }, async deleteReliabilityLogEntry(parentEquipmentId, logId) { if (!confirm('Delete this log entry?')) return; const isDbId = /^\d+$/.test(String(logId)) && parseInt(logId) < 2000000000000; if (isDbId) { try { await api('DELETE', 'bb_reliability_logs', { id: logId }); } catch(e) { console.warn('[BB] R-log DELETE failed:', e); } } app.state.detailsData['reliability-logs'] = app.state.detailsData['reliability-logs'].filter(log => log.id !== logId); if (typeof xlBBSave === 'function') xlBBSave(); this.renderReliabilityTimeline(parentEquipmentId); this.renderReliabilityLogTable(parentEquipmentId); this.runFullAnalysis(); setTimeout(() => this.runFullAnalysis(), 60); }, renderReliabilityLogTable(equipmentId) { function findTbody() { var dual = document.getElementById('bbDualContainer'); if (dual && dual.classList.contains('open')) { var dMain = document.getElementById('bbDualMainBody'); if (dMain) { var el = dMain.querySelector('#reliabilityLogTableBody'); if (el) return el; } } var solo = document.getElementById('bbDynBody'); if (solo) { var el2 = solo.querySelector('#reliabilityLogTableBody'); if (el2) return el2; } return document.getElementById('reliabilityLogTableBody'); } const tbody = findTbody(); if (!tbody) return; // --- REVISI: Logika Pengambilan Data Log (Support Group) --- let logs = []; const blockObj = this.equipments.find(e => e.id === equipmentId || e.dbId === equipmentId); if (blockObj && blockObj.mode === 'group') { const members = this.equipments.filter(m => m.groupId === blockObj.id); const memberIds = members.map(m => m.dbId); logs = (app.state.detailsData['reliability-logs'] || []) .filter(log => memberIds.includes(log.equipmentId)); } else { logs = (app.state.detailsData['reliability-logs'] || []) .filter(log => log.equipmentId === equipmentId); } logs.sort((a, b) => new Date(b.startDate) - new Date(a.startDate)); // Terbaru di atas if (logs.length === 0) { tbody.innerHTML = 'No log data found.'; return; } const formatDt = (isoStr) => { const d = new Date(isoStr); return d.toLocaleDateString() + ' ' + d.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}); }; // ── Page context for badges ────────────────────────────────── const _tabs = (typeof bbTabs !== 'undefined') ? bbTabs.tabs : []; const _activeTab = _tabs[typeof bbTabs !== 'undefined' ? bbTabs.activeIdx : 0] || null; const _ownId = _activeTab ? String(_activeTab.id) : null; // Build tab name lookup: id → name const _tabNames = {}; _tabs.forEach(function(t){ if(t.id) _tabNames[String(t.id)] = t.name; }); // Which tabs use THIS tab as their log source? const _usedByNames = _tabs .filter(function(t){ return t.state && String(t.state.logSource) === _ownId; }) .map(function(t){ return t.name; }); // Are we currently viewing logs from a source page? const _srcId = (typeof bbLogSource !== 'undefined') ? bbLogSource.value : ''; const _viewingSource = _srcId && _srcId !== ''; tbody.innerHTML = logs.map(log => { const logDiagId = log.diagramId ? String(log.diagramId) : null; const isOwnLog = !logDiagId || logDiagId === _ownId; // belongs to own page const fromTabName = !isOwnLog && logDiagId ? _tabNames[logDiagId] || ('Page ?') : null; // State badge let badgeStyle = 'background-color: #22c55e; color: white;'; let label = log.state; if (log.state === 'DOWN') { badgeStyle = 'background-color: #ef4444; color: white;'; } else if (log.state === 'STANDBY') { badgeStyle = 'background-color: #ffffff; color: #333; border: 1px solid #ccc;'; } else if (log.state === 'RECONDITIONED' || log.state === 'RECOND_BREAKDOWN') { badgeStyle = 'background-color: #f1c40f; color: white;'; label = 'RECOND (BRK)'; } else if (log.state === 'RECOND_OVERHAUL') { badgeStyle = 'background-color: #3498db; color: white;'; label = 'RECOND (OVH)'; } // Row identifier badges let identBadge = ''; if (!isOwnLog && fromTabName) { // This log came from a source page → show "From: Page X" badge, disable actions identBadge = `📎 From: ${fromTabName}`; } else if (isOwnLog && _usedByNames.length > 0) { // Own log, and other pages reference this page as source → show "Used by" badge identBadge = `👁 Used by: ${_usedByNames.join(', ')}`; } // Action buttons: disable edit/delete for logs from source pages const actionsHtml = isOwnLog ? ` ` : `read-only`; return ` ${log.equipmentId} ${label} ${identBadge} ${formatDt(log.startDate)} ${formatDt(log.endDate)} ${log.duration.toFixed(2)} ${log.description || '-'} ${actionsHtml} `; }).join(''); }, // --- FUNGSI BARU: Membuka Modal Edit Log --- openEditLogEntryModal(parentEquipmentId, logId) { const log = app.state.detailsData['reliability-logs'].find(l => l.id === logId); if (!log) { alert("Log entry not found."); return; } const equipment = xlBBGetEquipment(log.equipmentId); const eqName = equipment ? equipment.equipmentName : log.equipmentId; // Gunakan nilai yang ada untuk pre-fill form const modalContent = `
Editing log for: ${eqName}
`; bbOpenSecondaryModal(`Edit Log Entry`, modalContent); }, // --- FUNGSI BARU: Menyimpan Perubahan Log --- async updateReliabilityLogEntry(parentEquipmentId, logId) { const log = app.state.detailsData['reliability-logs'].find(l => l.id === logId); if (!log) return; const startDate = document.getElementById('editLogStartDate').value; const endDate = document.getElementById('editLogEndDate').value; const state = document.getElementById('editLogState').value; const description = document.getElementById('editLogDescription').value; if (!startDate || !endDate) { alert('Please select both Start and End dates.'); return; } const start = new Date(startDate), end = new Date(endDate); if (end <= start) { alert('End date must be after Start date.'); return; } const durationHours = (end - start) / 3600000; const isDbId = /^\d+$/.test(String(logId)) && parseInt(logId) < 2000000000000; if (isDbId) { try { await api('PUT', 'bb_reliability_logs', { id: logId }, { start_date: startDate.replace('T',' ') + ':00', end_date: endDate.replace('T',' ') + ':00', state, duration_hours: durationHours, description: description||null }); } catch(e) { console.warn('[BB] R-log PUT failed:', e); } } log.startDate=startDate; log.endDate=endDate; log.state=state; log.description=description; log.duration=durationHours; log.updatedAt=new Date().toISOString(); if (typeof xlBBSave === 'function') xlBBSave(); this.renderReliabilityTimeline(parentEquipmentId); this.renderReliabilityLogTable(parentEquipmentId); this.runFullAnalysis(); app.ui.closeSecondaryModal(); setTimeout(() => this.runFullAnalysis(), 60); }, showCalculationProof(equipmentId) { // 1. Ambil Data Dasar const blockObj = this.equipments.find(e => e.id === equipmentId || e.dbId === equipmentId); let involvedEquipmentIds = []; if (blockObj) { if (blockObj.mode === 'group') { const members = this.equipments.filter(m => m.groupId === blockObj.id); involvedEquipmentIds = members.map(m => m.dbId); } else { involvedEquipmentIds = [equipmentId]; } } else { involvedEquipmentIds = [equipmentId]; } const globalStartInput = document.getElementById('globalObsStartDate'); const globalEndInput = document.getElementById('globalObsEndDate'); const obsStartVal = globalStartInput ? globalStartInput.value : '2020-01-01T00:00'; const obsEndVal = globalEndInput ? globalEndInput.value : new Date().toISOString().slice(0, 16); const t_start = new Date(obsStartVal).getTime(); const t_end = new Date(obsEndVal).getTime(); let windowDurationHrs = (t_end - t_start) / (1000 * 60 * 60); if (windowDurationHrs < 0) windowDurationHrs = 0; const eqName = blockObj ? (blockObj.dbId || blockObj.name) : equipmentId; // --- STEP 1: Analisis Per Member --- let memberAnalysisHtml = ''; const memberReliabilities = involvedEquipmentIds.map(id => { const logs = (app.state.detailsData['reliability-logs'] || []).filter(l => l.equipmentId === id); // A. Cari Cutoff (Reset Time) let cutoffTime = t_start; let lastRecondDate = 'Start of Window'; const recondLogs = logs.filter(l => l.state === 'RECONDITIONED' || l.state === 'RECOND_BREAKDOWN' || l.state === 'RECOND_OVERHAUL'); recondLogs.sort((a, b) => new Date(b.endDate) - new Date(a.endDate)); for (const rLog of recondLogs) { const rTime = new Date(rLog.endDate).getTime(); if (rTime < t_end) { if (rTime > t_start) { cutoffTime = rTime; lastRecondDate = new Date(rLog.endDate).toLocaleString('en-GB'); } break; } } // B. Hitung Failure Rate (Lambda) let downHrsForMetrics = 0; let failuresCount = 0; const failureLogs = logs.filter(l => l.state === 'DOWN' || l.state === 'RECOND_BREAKDOWN' || l.state === 'RECONDITIONED'); failureLogs.forEach(dLog => { const dStart = new Date(dLog.startDate).getTime(); const dEnd = new Date(dLog.endDate).getTime(); const metricOverlapStart = Math.max(dStart, t_start); const metricOverlapEnd = Math.min(dEnd, t_end); if (metricOverlapEnd > metricOverlapStart) { downHrsForMetrics += (metricOverlapEnd - metricOverlapStart) / (1000 * 60 * 60); } if (dStart >= t_start && dStart <= t_end) { failuresCount++; } }); const operatingTimeForMetrics = windowDurationHrs - downHrsForMetrics; const failureRate = operatingTimeForMetrics > 0 ? (failuresCount / operatingTimeForMetrics) : 0; // C. Hitung Reliability (R) // R(t) = e^(-lambda * t_effective) // PERBAIKAN: Menggunakan variabel 'failuresCount' yang benar (bukan failuresCountForMetrics) const mtbf = failuresCount > 0 ? (operatingTimeForMetrics / failuresCount) : operatingTimeForMetrics; const timeSinceResetHrs = (t_end - cutoffTime) / (1000 * 60 * 60); const r_val = Math.exp(-failureRate * timeSinceResetHrs); // Build HTML Row const dbEq = xlBBGetEquipment(id); const name = dbEq ? dbEq.equipmentId : id; // REVISI: Tata letak diubah menjadi GRID (3 kolom horizontal) memberAnalysisHtml += ` ${name} ${failuresCount} ${failureRate.toExponential(4)} ${lastRecondDate} ${timeSinceResetHrs.toFixed(2)} hrs ${(r_val * 100).toFixed(4)}%
1. MTBF: (Window - Downtime) / Count
  = ${operatingTimeForMetrics.toFixed(2)} / ${failuresCount} = ${mtbf.toLocaleString('en-US', {maximumFractionDigits: 2})} hrs
2. Failure Rate (λ): 1 / MTBF
  = 1 / ${mtbf.toLocaleString('en-US', {maximumFractionDigits: 2})} = ${failureRate.toExponential(4)}
3. Reliability R(t): e-(λ × t_eff)
  = e-(${failureRate.toExponential(2)} × ${timeSinceResetHrs.toFixed(2)}) = ${r_val.toFixed(6)}
`; return r_val; }); // --- STEP 2: Analisis Sistem --- let systemAnalysisHtml = ''; let finalResult = 0; // Group Logic Config let k = 1; let n = memberReliabilities.length; let isGroup = (blockObj && blockObj.mode === 'group'); if (isGroup) { const members = this.equipments.filter(m => m.groupId === blockObj.id); const isPureParallel = members.every(m => (m.capacity || 0) >= 100); k = isPureParallel ? 1 : (blockObj.requiredCount || 1); } if (!isGroup) { // Single finalResult = memberReliabilities[0]; systemAnalysisHtml = `
Single Component Logic:
RSYS = RComponent
RSYS = ${(finalResult * 100).toFixed(4)}%
`; } else { // Group (KooN) - Enumeration Detail const totalCombinations = 1 << n; let successProbSum = 0; let combinationsHtml = ''; for (let i = 0; i < totalCombinations; i++) { let activeCount = 0; let stateProb = 1.0; let stateDesc = []; for (let j = 0; j < n; j++) { const isUp = (i >> j) & 1; const r = memberReliabilities[j]; if (isUp) { activeCount++; stateProb *= r; stateDesc.push(`UP`); } else { stateProb *= (1.0 - r); stateDesc.push(`DOWN`); } } const isSuccess = activeCount >= k; if (isSuccess) { successProbSum += stateProb; // REVISI: Membuat dua string: satu untuk Formula simbolik, satu untuk Angka let formulaParts = []; let calcParts = []; for (let j = 0; j < n; j++) { const isUp = (i >> j) & 1; const r = memberReliabilities[j]; // Index j dimulai dari 0, jadi member adalah j+1 if (isUp) { formulaParts.push(`R${j+1}`); calcParts.push(`${r.toFixed(4)}`); } else { formulaParts.push(`(1-R${j+1})`); calcParts.push(`(1-${r.toFixed(4)})`); } } const formulaString = formulaParts.join(' × '); const calcString = calcParts.join(' × '); combinationsHtml += ` ${stateDesc.join(' | ')} ${activeCount} (Req: ${k})
${formulaString}
= ${calcString}
${(stateProb * 100).toFixed(4)}% ✅ OK `; } } finalResult = successProbSum; // PERUBAHAN: Menambahkan kolom 'Calculation' pada header tabel systemAnalysisHtml = `
System Logic: ${k}-out-of-${n} (ISO/TR 12489 State Enumeration)
Summing all probabilities where at least ${k} equipment are functional.
${combinationsHtml}
Equipment State Combination Active Count Calculation Breakdown Probability Result
TOTAL SYSTEM RELIABILITY (∑ Success States): ${(finalResult * 100).toFixed(4)}%
`; } // --- RENDER MODAL CONTENT --- const content = `

1. Parameters

  • Observation Start: ${new Date(t_start).toLocaleString('en-GB')}
  • Observation End (Target): ${new Date(t_end).toLocaleString('en-GB')}
  • Total Window (t): ${windowDurationHrs.toLocaleString('en-US')} hours
  • Current Time (Now): ${new Date().toLocaleString('en-GB')}

2. Equipment Analysis

Reliability Formula: R(t) = e-λt
λ (Failure Rate): Total Failures / Operating Time in window.
t (Effective Time): Time elapsed since the last "As Good As New" (Recondition) event until Target Date.

${memberAnalysisHtml}
Equipment Fails Rate (λ) Last Reset Eff. Time (t) Reliability (R)

3. System Calculation

${systemAnalysisHtml}
`; bbOpenSecondaryModal(`Calculation Proof - ${eqName}`, content); }, showConnectionInfoPopup(equipmentId) { const mainEquipment = this.equipments.find(eq => eq.id === equipmentId); if (!mainEquipment) { this.showMessage('Equipment not found.', 'danger'); return; } const beforeEquipment = []; const afterEquipment = []; // Cari equipment "Before" (input ke equipment saat ini) this.connections.forEach(conn => { if (conn.to === equipmentId) { const beforeEq = this.equipments.find(eq => eq.id === conn.from); if (beforeEq) { beforeEquipment.push(beforeEq); } } }); // Cari equipment "After" (output dari equipment saat ini) this.connections.forEach(conn => { if (conn.from === equipmentId) { const afterEq = this.equipments.find(eq => eq.id === conn.to); if (afterEq) { afterEquipment.push(afterEq); } } }); const renderList = (list) => { if (list.length === 0) { return '
None
'; } return `
    ${list.map(eq => `
  • ${eq.dbId || eq.name}
  • `).join('')}
`; }; const modalContent = `
Connection details for: ${mainEquipment.dbId || mainEquipment.name}

Equipment Before (Inputs)

${renderList(beforeEquipment)}

Equipment After (Outputs)

${renderList(afterEquipment)}
`; bbOpenModal( 'Connection Information', modalContent, null, // Tidak perlu tombol Save 'wide' // Menggunakan class modal yang lebih lebar ); }, updateEquipmentElement(equipment) { const el = document.querySelector(`#${equipment.id}`); if (!el || equipment.mode === 'member') return; if (equipment.mode === 'junction') { equipment.width = this.GRID_SIZE * 0.75; equipment.height = this.GRID_SIZE * 0.75; el.style.width = `${equipment.width}px`; el.style.height = `${equipment.height}px`; el.title = equipment.name; const displayEl = el.querySelector('.equipment-availability-display'); if (displayEl) { const availabilityPercent = (equipment.displayAvailability * 100).toFixed(1); // REVISI LABEL: R -> Rpr (subscript) displayEl.innerHTML = `A: ${availabilityPercent}% Rpr: NA%`; displayEl.title = `Combined Availability: ${availabilityPercent}%`; } const nameLabel = el.querySelector('.name-label-sub'); if (nameLabel) { nameLabel.textContent = equipment.name; } return; } let mainTitle = ''; equipment.width = this.calculateEquipmentWidth(equipment.dbId || equipment.name); equipment.height = this.calculateEquipmentHeight(equipment); el.style.width = `${equipment.width}px`; el.style.height = `${equipment.height}px`; const selectCheckbox = el.querySelector('.equipment-select-checkbox'); if (selectCheckbox) { selectCheckbox.checked = this.groupSelectionIds.has(equipment.id); selectCheckbox.title = selectCheckbox.checked ? 'Click to unselect' : 'Click to select'; } const mainNameEl = el.querySelector('.name-label-main'); if (mainNameEl) { let mainName = equipment.dbId || equipment.name; mainNameEl.textContent = mainName; } el.classList.remove('failed', 'cascade-failed', 'degraded-source', 'degraded-cascade'); if (equipment.failureState === 'MANUAL_FAIL') { el.classList.add('failed'); } else if (equipment.failureState === 'CASCADE_FAIL') { el.classList.add('cascade-failed'); } else if (equipment.failureState === 'DEGRADED_SOURCE') { el.classList.add('degraded-source'); } else if (equipment.failureState === 'DEGRADED_CASCADE') { el.classList.add('degraded-cascade'); } const finalOutputBtn = el.querySelector('.final-output-toggle'); const unitDisplayName = xlBBUnitName(equipment.unit); const outputButtonText = `${unitDisplayName} Output`; if (equipment.isFinalOutput) { el.classList.add('final-output'); if (finalOutputBtn) { finalOutputBtn.innerHTML = `${outputButtonText} ✓`; finalOutputBtn.classList.remove('btn-action'); finalOutputBtn.classList.add('btn-success'); finalOutputBtn.title = `Click to unselect as ${unitDisplayName} Output`; } } else { el.classList.remove('final-output'); if (finalOutputBtn) { finalOutputBtn.innerHTML = outputButtonText; finalOutputBtn.classList.remove('btn-success'); finalOutputBtn.classList.add('btn-action'); finalOutputBtn.title = `Click to select as ${unitDisplayName} Output`; } } const displayEl = el.querySelector('.equipment-availability-display'); if (displayEl) { const availabilityPercent = (equipment.displayAvailability * 100).toFixed(1); const reliabilityPercent = (equipment.displayReliability * 100).toFixed(1); // REVISI: Hitung Req (Intrinsic Reliability) const intrinsicReliabilityPercent = (equipment.intrinsicReliability * 100).toFixed(1); const warningIconHtml = equipment.hasRedundancyWarning ? `
⚠️
` : ''; // REVISI LABEL: ADD Req (subscript) displayEl.innerHTML = `A: ${availabilityPercent}% Req: ${intrinsicReliabilityPercent}% Rpr: ${reliabilityPercent}% ${warningIconHtml}`; } const togglesContainer = el.querySelector(`#toggles-container-${equipment.id}`); if (togglesContainer) { togglesContainer.innerHTML = ''; if (equipment.mode === 'single') { const dbEq = xlBBGetEquipment(equipment.dbId); mainTitle = dbEq ? `Name: ${dbEq.equipmentId}\nID: ${dbEq.equipmentName}\nDesign Config: ${dbEq.capacity || 100}%` : ''; const memberContainer = document.createElement('div'); memberContainer.style.cssText = 'display: flex; align-items: center; gap: 5px; background: rgba(0,0,0,0.35); padding: 3px 6px; border-radius: 10px;'; const toggle = document.createElement('div'); toggle.className = `canvas-toggle ${equipment.failed ? 'is-down' : 'is-run'}`; toggle.title = `Toggle status for ${equipment.name}`; toggle.onclick = (e) => { e.stopPropagation(); this.toggleFailure(equipment.id); }; const capacityLabel = document.createElement('span'); capacityLabel.style.cssText = 'font-size: 10px; color: white; font-weight: bold; user-select: none;'; capacityLabel.textContent = `(${dbEq ? dbEq.capacity || 100 : 'N/A'}%)`; memberContainer.appendChild(toggle); memberContainer.appendChild(capacityLabel); togglesContainer.appendChild(memberContainer); } else if (equipment.mode === 'group') { const members = this.equipments.filter(m => m.groupId === equipment.id); members.sort((a, b) => (a.prefix || '').localeCompare(b.prefix || '')); const groupTooltipLines = members.map(member => { const dbItem = xlBBGetEquipment(member.dbId); if (!dbItem) return `${member.dbId}: (Not found)`; return `Name: ${dbItem.equipmentId}\nID: ${dbItem.equipmentName}\nDesign Config: ${member.capacity || 0}%`; }); mainTitle = groupTooltipLines.join('\n\n'); members.forEach(member => { const memberContainer = document.createElement('div'); memberContainer.style.cssText = 'display: flex; align-items: center; gap: 5px; background: rgba(0,0,0,0.35); padding: 3px 6px; border-radius: 10px;'; const toggle = document.createElement('div'); toggle.className = `canvas-toggle ${member.failed ? 'is-down' : 'is-run'}`; let memberTooltip = `Double-click to edit member.`; const dbItem = xlBBGetEquipment(member.dbId); if(dbItem) { memberTooltip = `Name: ${dbItem.equipmentId}\nID: ${dbItem.equipmentName}\nDesign Config: ${member.capacity || 0}%`; } toggle.title = memberTooltip; toggle.onclick = (e) => { e.stopPropagation(); this.toggleFailure(member.id); }; toggle.ondblclick = (e) => { e.stopPropagation(); this.openMemberEditModal(member.id); }; const label = document.createElement('span'); label.style.cssText = 'font-size: 10px; color: white; font-weight: bold; user-select: none;'; const labelText = member.prefix ? `${member.prefix} (${member.capacity || 0}%)` : `(${member.capacity || 0}%)`; label.textContent = labelText; memberContainer.appendChild(toggle); memberContainer.appendChild(label); togglesContainer.appendChild(memberContainer); }); } } el.title = mainTitle; const mainNameElForTitle = el.querySelector('.name-label-main'); if (mainNameElForTitle) { mainNameElForTitle.title = mainTitle; } const displayElForTitle = el.querySelector('.equipment-availability-display'); if (displayElForTitle) { displayElForTitle.title = mainTitle; } }, initPaneControls() { const pane = document.getElementById('bbEquipmentPane'); const resizer = document.getElementById('bbPaneResizer'); const container = document.querySelector('#page-block-builder .main-container'); const searchInput = document.getElementById('bbEquipmentSearch'); const toggleBtn = document.getElementById('bbTogglePaneBtn'); // Search if (searchInput) searchInput.addEventListener('input', () => { this.renderEquipmentPane(searchInput.value.trim().toLowerCase()); }); // Toggle Show/Hide - handled by onclick attribute on the button // Resizer let isResizing = false; if (resizer) resizer.addEventListener('mousedown', (e) => { isResizing = true; container.style.cursor = 'col-resize'; document.body.style.userSelect = 'none'; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isResizing) return; const newWidth = e.clientX - container.getBoundingClientRect().left; if (newWidth > 150 && newWidth < 500) { // Batasi min/max width // REVISI: Mengubah 'width' dari elemen pane, bukan 'gridTemplateColumns' dari container pane.style.width = `${newWidth}px`; } }); document.addEventListener('mouseup', () => { if (isResizing) { isResizing = false; container.style.cursor = ''; document.body.style.userSelect = ''; } }); }, renderEquipmentPane(searchTerm = '') { const listEl = document.getElementById('bbEquipmentList'); if (!listEl) return; // 1. Buat beberapa Set untuk pengecekan status yang efisien. const canvasSingleItemDbIds = new Set(this.equipments.filter(e => e.mode === 'single').map(e => e.dbId)); const groupedMemberDbIds = new Set(this.equipments.filter(e => e.mode === 'member').map(e => e.dbId)); const renderedGroupDbIds = new Set(); // Untuk mencegah grup dirender berkali-kali. // 2. Terapkan filter pencarian pada database utama. let filteredDatabase = app.state.equipmentDatabase; if (searchTerm) { filteredDatabase = filteredDatabase.filter(dbEq => dbEq.equipmentId.toLowerCase().includes(searchTerm) || dbEq.equipmentName.toLowerCase().includes(searchTerm) ); } if (filteredDatabase.length === 0 && this.equipments.every(e => e.mode !== 'group')) { listEl.innerHTML = '
No equipment found.
'; return; } // 3. Bangun HTML dengan mengiterasi database sebagai sumber urutan utama. const listHtml = filteredDatabase.map(dbEq => { const dbId = dbEq.equipmentId; // Kasus 1: Equipment ini adalah anggota dari sebuah grup. if (groupedMemberDbIds.has(dbId)) { const memberInfo = this.equipments.find(e => e.dbId === dbId && e.mode === 'member'); if (!memberInfo) return ''; // Seharusnya not terjadi const group = this.equipments.find(e => e.id === memberInfo.groupId); if (!group || renderedGroupDbIds.has(group.dbId)) { return ''; // Grup not ditemukan atau sudah dirender. } // Render item grup. renderedGroupDbIds.add(group.dbId); return `
${group.name} Grouped Equipment
`; } // Kasus 2: Equipment ini ada di canvas sebagai item tunggal. else if (canvasSingleItemDbIds.has(dbId)) { return `
${dbEq.equipmentId} ${dbEq.equipmentName}
`; } // Kasus 3: Equipment ini belum ada di canvas. else { return `
${dbEq.equipmentId} ${dbEq.equipmentName}
`; } }).join(''); listEl.innerHTML = listHtml; }, viewEquipment(idToFind) { let equipmentOnCanvas = this.equipments.find(eq => eq.dbId === idToFind); if (!equipmentOnCanvas) equipmentOnCanvas = this.equipments.find(eq => eq.id === idToFind); if (!equipmentOnCanvas) { this.showMessage('Equipment not found on canvas.', 2000); return; } const eq = equipmentOnCanvas; const eqCenterX = eq.x + eq.width / 2; const eqCenterY = eq.y + eq.height / 2; // Target viewport: equipment centered, scale = 1.0 const targetScale = 1.0; const targetX = (this.canvas.width / 2) - (eqCenterX * targetScale); const targetY = (this.canvas.height / 2) - (eqCenterY * targetScale); // ── Smooth animated pan ─────────────────────────────────────── const startX = this.viewport.x; const startY = this.viewport.y; const startScale = this.viewport.scale; const DURATION = 420; // ms const startTime = performance.now(); const self = this; // Ease-out cubic function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); } function animate(now) { const elapsed = now - startTime; const t = Math.min(elapsed / DURATION, 1); const e = easeOutCubic(t); self.viewport.x = startX + (targetX - startX) * e; self.viewport.y = startY + (targetY - startY) * e; self.viewport.scale = startScale + (targetScale - startScale) * e; self.redraw(); self.updateUIComponents(); if (t < 1) { requestAnimationFrame(animate); } else { // ── Pulse highlight on arrival ──────────────────────── const el = document.getElementById(eq.id); if (el) { el.style.transition = 'box-shadow 0.25s ease-in-out'; el.style.boxShadow = '0 0 30px 6px rgba(52, 152, 219, 0.9)'; setTimeout(() => { el.style.transition = 'box-shadow 0.6s ease-out'; el.style.boxShadow = ''; }, 500); } } } requestAnimationFrame(animate); }, toggleFinalOutput(equipmentId) { const newOutputEquipment = this.equipments.find(eq => eq.id === equipmentId); if (!newOutputEquipment) return; // --- REVISI: Logika diubah untuk fokus pada output per unit --- // Cari equipment lain yang saat ini menjadi output UNTUK UNIT YANG SAMA. const currentOutputInUnit = this.equipments.find(eq => eq.isFinalOutput && eq.unit === newOutputEquipment.unit && eq.id !== newOutputEquipment.id ); // Kasus 1: Equipment yang diklik sudah menjadi output. Maka, batalkan. if (newOutputEquipment.isFinalOutput) { newOutputEquipment.isFinalOutput = false; // Kasus 2: Belum ada output lain di unit ini. Langsung jadikan equipment ini sebagai output. } else if (!currentOutputInUnit) { newOutputEquipment.isFinalOutput = true; // Kasus 3: Ada output lain di unit yang sama. Minta konfirmasi untuk menggantinya. } else { const unitDisplayName = xlBBUnitName(newOutputEquipment.unit); const confirmationMessage = `Anda ingin mengubah ${unitDisplayName} Output dari "${currentOutputInUnit.name}" menjadi "${newOutputEquipment.name}"?\n\nSetiap unit hanya boleh memiliki satu output.`; if (confirm(confirmationMessage)) { currentOutputInUnit.isFinalOutput = false; // Hapus status output dari yang lama newOutputEquipment.isFinalOutput = true; // Atur status output pada yang baru } else { return; // Batalkan jika pengguna menekan "Cancel" } } // Perbarui UI dan kalkulasi setelah perubahan if(typeof xlBBSave==="function") xlBBSave(); this.runFullAnalysis(); }, startConnection(e) { this.isConnecting = true; const targetBox = e.target.closest('.equipment-box'); const type = e.target.dataset.type; this.connectingFrom = { equipmentId: targetBox.id, type: type }; const worldPos = this.screenToWorld(e.clientX - this.workspace.getBoundingClientRect().left, e.clientY - this.workspace.getBoundingClientRect().top); this.tempConnectionEndPos.x = worldPos.x; this.tempConnectionEndPos.y = worldPos.y; document.addEventListener('mouseup', this.cancelConnection.bind(this), { once: true }); }, endConnection(e) { if (!this.isConnecting || !this.connectingFrom) return; const endTargetBox = e.target.closest('.equipment-box'); if (!endTargetBox) { this.resetConnectionState(); return; } const endEquipmentId = endTargetBox.id; const endPointType = e.target.dataset.type; // Hapus logika 'isSourceDrag'. Arah koneksi ditentukan murni oleh aksi pengguna. const fromId = this.connectingFrom.equipmentId; const fromPoint = this.connectingFrom.type; const toId = endEquipmentId; const toPoint = endPointType; if (fromId !== toId) { // Pemeriksaan duplikasi yang lebih baik: koneksi dianggap sama terlepas dari arahnya. if (this.connections.some(c => (c.from === fromId && c.to === toId) || (c.from === toId && c.to === fromId))) { this.showMessage("Connection already exists between these two items.", 2000); this.resetConnectionState(); return; } // Save koneksi persis seperti yang dibuat oleh pengguna, menghormati titik awal dan akhir. this.connections.push({ from: fromId, to: toId, fromPoint: fromPoint, toPoint: toPoint }); if(typeof xlBBSave==="function") xlBBSave(); this.runFullAnalysis(); } document.removeEventListener('mouseup', this.cancelConnection.bind(this)); this.resetConnectionState(); }, cancelConnection(e) { // Fungsi ini dipanggil saat mouse dilepas di mana saja JIKA sebuah koneksi sedang ditarik. // Pertama, periksa apakah mouse dilepas di titik koneksi yang valid. Jika ya, biarkan fungsi endConnection yang menanganinya. if (e.target.classList.contains('connection-point')) { return; } // Jika sedang dalam mode menyambung, periksa apakah mouse berada di atas garis koneksi yang sudah ada. if (this.isConnecting) { const clickPos = { x: e.clientX - this.workspace.getBoundingClientRect().left, y: e.clientY - this.workspace.getBoundingClientRect().top }; const worldPos = this.screenToWorld(clickPos.x, clickPos.y); // Iterasi semua koneksi untuk memeriksa apakah kita melepaskannya di atas salah satunya. for (const conn of this.connections) { if (this.isPointOnConnection(worldPos, conn)) { // Jika koneksi ditemukan, buat sebuah simpang/junction di sana. this.createJunctionOnConnection(conn, this.connectingFrom.equipmentId, worldPos); // Atur ulang status koneksi dan hentikan proses lebih lanjut. this.resetConnectionState(); // Cegah resetConnectionState() standar di akhir fungsi agar не dijalankan. return; } } } // Jika not dilepaskan di titik koneksi atau di garis koneksi lain, batalkan saja. this.resetConnectionState(); }, createJunctionOnConnection(existingConn, newSourceId, position) { // --- REVISI: Name awal sekarang dibuat dari kedua input pertama --- const source1 = this.equipments.find(eq => eq.id === existingConn.from); const source2 = this.equipments.find(eq => eq.id === newSourceId); const name1 = source1 ? (source1.dbId || source1.name) : 'Source 1'; const name2 = source2 ? (source2.dbId || source2.name) : 'Source 2'; const initialJunctionName = `Junction from ${name1} & ${name2}`; // --- AKHIR REVISI --- const junctionId = 'eq-' + this.equipmentCounter++; const junctionData = { id: junctionId, name: initialJunctionName, // Menggunakan nama awal yang dinamis dbId: null, x: this.snapToGrid(position.x), y: this.snapToGrid(position.y), width: this.GRID_SIZE * 0.75, height: this.GRID_SIZE * 0.75, mode: 'junction', calculationMode: 'weighted', // <-- PROPERTI BARU DITAMBAHKAN DENGAN NILAI DEFAULT unit: 'common', isFinalOutput: false, displayAvailability: 1.0, intrinsicAvailability: 1.0, failureState: 'OK' }; this.equipments.push(junctionData); this.connections = this.connections.filter(c => !(c.from === existingConn.from && c.to === existingConn.to)); this.connections.push({ from: existingConn.from, to: junctionId }); this.connections.push({ from: newSourceId, to: junctionId }); this.connections.push({ from: junctionId, to: existingConn.to }); this.createEquipmentElement(junctionData); if(typeof xlBBSave==="function") xlBBSave(); this.runFullAnalysis(); // REVISI: Tambahkan panggilan viewEquipment setelah junction dibuat. this.viewEquipment(junctionId); }, resetConnectionState() { this.isConnecting = false; this.connectingFrom = null; this.redraw(); }, toggleSelection(item, type, isMultiSelect) { if (!isMultiSelect) { this.deselectAll(); } if (type === 'equipment') { if (this.selectedEquipmentIds.has(item)) { this.selectedEquipmentIds.delete(item); } else { this.selectedEquipmentIds.add(item); } } else if (type === 'connection') { const index = this.selectedConnections.findIndex(c => c.from === item.from && c.to === item.to); if (index > -1) { this.selectedConnections.splice(index, 1); } else { this.selectedConnections.push(item); } } this.updateSelectionVisuals(); }, deselectAll(keepPageSelected = false) { this.selectedEquipmentIds.clear(); this.selectedConnections = []; if (!keepPageSelected) { this.selectedPageId = null; } this.updateSelectionVisuals(); }, updateSelectionVisuals() { document.querySelectorAll('#page-block-builder .equipment-box.selected').forEach(el => el.classList.remove('selected')); this.selectedEquipmentIds.forEach(id => { const el = document.querySelector(`#${id}`); if(el) el.classList.add('selected'); }); this.redraw(); }, isPointOnConnection(point, conn) { const fromEq = this.equipments.find(eq => eq.id === conn.from); const toEq = this.equipments.find(eq => eq.id === conn.to); if (!fromEq || !toEq) return false; // Fungsi helper baru untuk mendapatkan detail titik const getPointDetails = (equipment, type) => { if (!equipment) return null; switch (type) { case 'input': return { x: equipment.x, y: equipment.y + equipment.height / 2, dir: 'left' }; case 'output': return { x: equipment.x + equipment.width, y: equipment.y + equipment.height / 2, dir: 'right' }; case 'top': return { x: equipment.x + equipment.width / 2, y: equipment.y, dir: 'up' }; case 'bottom': return { x: equipment.x + equipment.width / 2, y: equipment.y + equipment.height, dir: 'down' }; default: return { x: equipment.x + equipment.width, y: equipment.y + equipment.height / 2, dir: 'right' }; } }; const start = getPointDetails(fromEq, conn.fromPoint || 'output'); const end = getPointDetails(toEq, conn.toPoint || 'input'); const path = this.getOrthogonalPath(start, end); const threshold = this.CONNECTION_SELECT_THRESHOLD / this.viewport.scale; for (let i = 0; i < path.length - 1; i++) { const p1 = path[i]; const p2 = path[i + 1]; // Hitung jarak titik ke segmen garis const dx = p2.x - p1.x; const dy = p2.y - p1.y; const lenSq = dx * dx + dy * dy; if (lenSq === 0) { if (Math.hypot(point.x - p1.x, point.y - p1.y) < threshold) return true; } else { let t = ((point.x - p1.x) * dx + (point.y - p1.y) * dy) / lenSq; t = Math.max(0, Math.min(1, t)); const closestX = p1.x + t * dx; const closestY = p1.y + t * dy; if (Math.hypot(point.x - closestX, point.y - closestY) < threshold) return true; } } return false; }, _silentClear() { this.equipments = []; this.connections = []; this.pages = []; this.elementMap.clear(); // <-- TAMBAHKAN BARIS INI this.equipmentCounter = 1; this.pageCounter = 1; this.deselectAll(); if (this.canvasContainer) { Array.from(this.canvasContainer.querySelectorAll('.equipment-box')).forEach(el => el.remove()); if (!this.canvasContainer.querySelector('#workspace-canvas')) { const cvs = document.createElement('canvas'); cvs.id = 'workspace-canvas'; cvs.style.cssText = 'position:absolute;top:0;left:0;pointer-events:none'; this.canvasContainer.insertBefore(cvs, this.canvasContainer.firstChild); } } this.initCanvas(); }, clearAll() { if (!confirm("Clear entire workspace? This cannot be undone.")) return; this._silentClear(); if(typeof xlBBSave==="function") xlBBSave(); // <-- DITAMBAHKAN DI SINI this.runFullAnalysis(); }, showMessage(message, duration = 3000) { const infoMessageEl = document.querySelector('#infoMessage'); infoMessageEl.textContent = message; infoMessageEl.style.display = 'block'; infoMessageEl.style.opacity = 1; clearTimeout(infoMessageEl._timer); infoMessageEl._timer = setTimeout(() => { infoMessageEl.style.opacity = 0; setTimeout(() => infoMessageEl.style.display = 'none', 500); }, duration); }, toggleAddFormEquipmentMode() { const mode = document.querySelector('#equipmentMode').value; const groupFieldsContainer = document.querySelector('#groupEquipmentFields'); groupFieldsContainer.style.display = (mode === 'group') ? 'flex' : 'none'; if (mode === 'group') { groupFieldsContainer.style.gap = '10px'; groupFieldsContainer.style.alignItems = 'flex-end'; } }, toggleFailure(equipmentId) { const equipment = this.equipments.find(eq => eq.id === equipmentId); if (!equipment) return; equipment.failed = !equipment.failed; event.stopPropagation(); if(typeof xlBBSave==="function") xlBBSave(); // <-- DITAMBAHKAN DI SINI this.runFullAnalysis(); }, openEditModal(id) { const equipment = this.equipments.find(eq => eq.id === id); if (!equipment) return; this.toggleSelection(id, 'equipment', false); document.querySelector('#bb_editEquipmentId').value = id; const baseName = equipment.mode === 'group' ? equipment.name.split(' (')[0] : equipment.name; document.querySelector('#bb_editEquipmentName').value = baseName; this.editModal.style.display = 'flex'; }, closeEditModal() { this.editModal.style.display = 'none'; this.deselectAll(); }, saveFromEditModal() { const id = document.querySelector('#bb_editEquipmentId').value; const equipment = this.equipments.find(eq => eq.id === id); if (!equipment) return; const baseName = document.querySelector('#bb_editEquipmentName').value.trim(); if (equipment.mode === 'single') { equipment.name = baseName; } else if (equipment.mode === 'group') { // REVISI: Perbarui juga properti 'dbId' yang menjadi nama dasar grup. equipment.dbId = baseName; equipment.name = `${baseName} (${equipment.requiredCount}/${equipment.totalCount})`; } if(typeof xlBBSave==="function") xlBBSave(); this.runFullAnalysis(); this.closeEditModal(); }, openMemberEditModal(id) { const member = this.equipments.find(eq => eq.id === id); if (!member || member.mode !== 'member') return; document.querySelector('#editMemberId').value = id; document.querySelector('#editMemberName').value = member.name; document.querySelector('#editMemberCapacity').value = member.capacity; this.editMemberModal.style.display = 'flex'; }, closeMemberEditModal() { this.editMemberModal.style.display = 'none'; }, saveFromMemberEditModal() { const id = document.querySelector('#editMemberId').value; const member = this.equipments.find(eq => eq.id === id); if (!member) return; member.name = document.querySelector('#editMemberName').value.trim(); member.capacity = parseFloat(document.querySelector('#editMemberCapacity').value); if(typeof xlBBSave==="function") xlBBSave(); // --- LOGIKA TAMBAHAN UNTUK KONSISTENSI --- const parentGroup = this.equipments.find(eq => eq.id === member.groupId); if (parentGroup) { const members = this.equipments.filter(eq => eq.groupId === parentGroup.id); const memberCapacities = members.map(m => m.capacity).sort((a, b) => b - a); let capacitySum = 0; let k = 0; for (const cap of memberCapacities) { if (capacitySum >= 100) break; capacitySum += cap; k++; } parentGroup.requiredCount = k; parentGroup.name = `${parentGroup.dbId} (${k}/${parentGroup.totalCount})`; } // --- AKHIR LOGIKA TAMBAHAN --- this.runFullAnalysis(); this.closeMemberEditModal(); }, syncCapacitiesAndRecalculate() { // 1. Baca langsung dari state konfigurasi sistem global const u1Cap = (app.state.config||{}).unit1Capacity||100; const u2Cap = (app.state.config||{}).unit2Capacity||100; // 2. Perbarui state internal block builder this.unitCapacities.unit1 = isNaN(u1Cap) ? 0 : u1Cap; this.unitCapacities.unit2 = isNaN(u2Cap) ? 0 : u2Cap; // 3. Perbarui field input read-only di UI untuk mencerminkan config const u1Input = document.querySelector('#unit1Capacity') || {value: '100'}; const u2Input = document.querySelector('#unit2Capacity') || {value: '100'}; if (u1Input) u1Input.value = this.unitCapacities.unit1; if (u2Input) u2Input.value = this.unitCapacities.unit2; // 4. Jalankan analisis penuh dengan nilai baru this.runFullAnalysis(); }, buildGraph() { const graph = {}; this.equipments.filter(eq => eq.mode !== 'member').forEach(eq => { graph[eq.id] = { equipment: eq, inputs: [], outputs: [] }; }); this.connections.forEach(conn => { if (graph[conn.from] && graph[conn.to]) { graph[conn.from].outputs.push(conn.to); graph[conn.to].inputs.push(conn.from); } }); return graph; }, calculateAndDisplayAllAvailability() { const graph = this.buildGraph(); if (Object.keys(graph).length === 0) { this.updateNoOutputsMessage("No equipment in system.", true); return; } if (Object.keys(graph).some(id => this.isCyclic(graph, id, new Set(), new Set()))) { this.equipments.forEach(eq => this.updateEquipmentElement(eq)); this.updateNoOutputsMessage("Cyclic dependency detected!", true); return; } // 1. CALCULATE INTRINSIC METRICS (Availability & Reliability) this.equipments.forEach(eq => { if (eq.mode === 'single') { eq.intrinsicAvailability = eq.failed ? 0.0 : 1.0; // Reliability from R-Logs via ISO formula (real-time sync) // REVISI: Reliability not lagi dipengaruhi oleh toggle (eq.failed) eq.intrinsicReliability = this.calculateLogReliability(eq.dbId); } else if (eq.mode === 'group') { const members = this.equipments.filter(m => m.groupId === eq.id); if (members.length === 0) { eq.intrinsicAvailability = 1.0; eq.intrinsicReliability = 1.0; return; } // Availability: Capacity Sum Logic const workingMembers = members.filter(m => !m.failed); const totalCapacitySum = workingMembers.reduce((sum, member) => sum + (member.capacity || 0), 0); eq.intrinsicAvailability = Math.min(totalCapacitySum / 100, 1.0); // Reliability: K-out-of-N Logic eq.intrinsicReliability = this.calculateGroupReliability(members, eq.requiredCount, eq.totalCount); } else if (eq.mode === 'junction') { eq.intrinsicAvailability = 1.0; eq.intrinsicReliability = 1.0; } }); const memo = {}; const getMetrics = (nodeId) => { if (memo[nodeId] !== undefined) return memo[nodeId]; memo[nodeId] = { availability: 0, reliability: 0 }; const node = graph[nodeId]; if (!node) return { availability: 0, reliability: 0 }; const { equipment, inputs } = node; equipment.hasRedundancyWarning = false; let combinedInputAvailability = 1.0; let combinedInputReliability = 1.0; if (inputs.length > 0) { let isConvergingSeries = (equipment.mode !== 'junction' && inputs.length > 1); if (isConvergingSeries) { for (const inputId of inputs) { if (graph[inputId] && graph[inputId].outputs.length > 1) { isConvergingSeries = false; break; } } } if (isConvergingSeries) { const inputMetrics = inputs.map(inputId => getMetrics(inputId)); const availabilities = inputMetrics.map(m => m.availability); combinedInputAvailability = availabilities.length > 0 ? Math.min(...availabilities) : 1.0; combinedInputReliability = inputMetrics.reduce((acc, m) => acc * m.reliability, 1.0); } else { if (equipment.mode === 'junction') { let weightedAvailSum = 0; let reliabilityProduct = 1.0; inputs.forEach(inputId => { const inputNode = graph[inputId]; const metrics = getMetrics(inputId); if (inputNode) { if (inputNode.equipment.mode === 'group') { weightedAvailSum += metrics.availability; } else { const dbId = inputNode.equipment.dbId; const dbEquipment = xlBBGetEquipment(dbId); const capacity = (dbEquipment && typeof dbEquipment.capacity === 'number') ? dbEquipment.capacity : 0; weightedAvailSum += metrics.availability * (capacity / 100.0); } } reliabilityProduct *= metrics.reliability; }); combinedInputAvailability = Math.min(weightedAvailSum, 1.0); combinedInputReliability = reliabilityProduct; } else { const seriesAvail = []; const parallelAvail = []; let seriesReliability = 1.0; let parallelReliabilityUnavailabilityProduct = 1.0; const parallelInputIds = []; inputs.forEach(inputId => { const inputNode = graph[inputId]; if (inputNode) { const metrics = getMetrics(inputId); if (!inputNode.equipment.isHILP) { parallelAvail.push(metrics.availability); parallelInputIds.push(inputId); parallelReliabilityUnavailabilityProduct *= (1.0 - metrics.reliability); } else { seriesAvail.push(metrics.availability); seriesReliability *= metrics.reliability; } } }); const finalSeriesAvail = seriesAvail.length > 0 ? Math.min(...seriesAvail) : 1.0; const sumParallelAvail = parallelAvail.reduce((sum, val) => sum + val, 0); if (parallelInputIds.length > 1) { const siblingIds = new Set(); parallelInputIds.forEach(inputId => { if (graph[inputId]) graph[inputId].outputs.forEach(outputId => siblingIds.add(outputId)); }); let totalDemand = 0; siblingIds.forEach(siblingId => { const siblingEq = this.equipments.find(e => e.id === siblingId); if (siblingEq && !siblingEq.isHILP) totalDemand += siblingEq.intrinsicAvailability; }); if (sumParallelAvail < totalDemand && equipment.intrinsicAvailability > 0.0) { equipment.hasRedundancyWarning = true; } } const finalParallelAvail = parallelAvail.length > 0 ? Math.min(sumParallelAvail, 1.0) : 1.0; combinedInputAvailability = Math.min(finalSeriesAvail, finalParallelAvail); const finalParallelReliability = parallelAvail.length > 0 ? (1.0 - parallelReliabilityUnavailabilityProduct) : 1.0; combinedInputReliability = seriesReliability * finalParallelReliability; } } } const finalAvailability = Math.min(equipment.intrinsicAvailability, combinedInputAvailability); const finalReliability = equipment.intrinsicReliability * combinedInputReliability; const result = { availability: finalAvailability, reliability: finalReliability }; memo[nodeId] = result; return result; }; const visualEquipments = this.equipments.filter(eq => eq.mode !== 'member'); visualEquipments.forEach(eq => getMetrics(eq.id)); visualEquipments.forEach(eq => { const metrics = memo[eq.id] || { availability: 0, reliability: 0 }; eq.displayAvailability = metrics.availability; eq.displayReliability = metrics.reliability; if (eq.intrinsicAvailability === 0) { eq.failureState = 'MANUAL_FAIL'; } else if (eq.displayAvailability === 0) { eq.failureState = 'CASCADE_FAIL'; } else if (eq.intrinsicAvailability < 1.0) { if (Math.abs(eq.displayAvailability - eq.intrinsicAvailability) < 0.0001) eq.failureState = 'DEGRADED_SOURCE'; else eq.failureState = 'DEGRADED_CASCADE'; } else if (eq.displayAvailability < 1.0) { eq.failureState = 'DEGRADED_CASCADE'; } else { eq.failureState = 'OK'; } const el = document.querySelector(`#${eq.id}`); if (el) { const displayEl = el.querySelector('.equipment-availability-display'); if (displayEl) { const availPct = (eq.displayAvailability * 100).toFixed(1); const relPct = (eq.displayReliability * 100).toFixed(1); // REVISI: Ambil Req dan masukkan ke HTML const reqPct = (eq.intrinsicReliability * 100).toFixed(1); const warningIconHtml = eq.hasRedundancyWarning ? `
⚠️
` : ''; // REVISI LABEL: ADD Req displayEl.innerHTML = `A: ${availPct}% Req: ${reqPct}% Rpr: ${relPct}% ${warningIconHtml}`; } } }); const outputsPanel = document.querySelector("#outputsPanel"); outputsPanel.innerHTML = ''; const unitMetrics = { unit1: { a: 1.0, r: 1.0 }, unit2: { a: 1.0, r: 1.0 }, common: { a: 1.0, r: 1.0 } }; const units = ['unit1', 'unit2', 'common']; let hasAnyOutput = false; units.forEach(unitId => { const unitFinalOutputs = visualEquipments.filter(eq => eq.isFinalOutput && eq.unit === unitId); if (unitFinalOutputs.length > 0) { hasAnyOutput = true; unitMetrics[unitId].a = Math.min(...unitFinalOutputs.map(eq => eq.displayAvailability)); unitMetrics[unitId].r = unitFinalOutputs.reduce((acc, eq) => acc * eq.displayReliability, 1.0); } const unitName = xlBBUnitName(unitId); let displayClass = unitFinalOutputs.length === 0 ? "" : (unitMetrics[unitId].a < 0.001 ? "failed" : (unitMetrics[unitId].a < 1.0 ? "degraded-output" : "high")); const capacityMW = unitId in this.unitCapacities ? unitMetrics[unitId].a * this.unitCapacities[unitId] : null; this.addUnitAvailabilityToPanel(unitName, unitId, unitMetrics[unitId].a, displayClass, unitFinalOutputs.length, capacityMW, unitMetrics[unitId].r); }); const overallPlantAvailability = (unitMetrics.unit1.a + unitMetrics.unit2.a) / 2; const overallPlantMW = (unitMetrics.unit1.a * this.unitCapacities.unit1) + (unitMetrics.unit2.a * this.unitCapacities.unit2); const overallPlantReliability = unitMetrics.unit1.r * unitMetrics.unit2.r; let overallDisplayClass = "high"; if (overallPlantAvailability < 1.0) overallDisplayClass = "degraded-output"; if (overallPlantAvailability < 0.001) overallDisplayClass = "failed"; this.addUnitAvailabilityToPanel("Overall Plant", "overall", overallPlantAvailability, overallDisplayClass, hasAnyOutput ? 1 : 0, overallPlantMW, overallPlantReliability); }, isCyclic(graph, nodeId, visited, recursionStack) { if (recursionStack.has(nodeId)) return true; if (visited.has(nodeId)) return false; visited.add(nodeId); recursionStack.add(nodeId); const node = graph[nodeId]; if (node) { for (const neighborId of node.outputs) { if (this.isCyclic(graph, neighborId, visited, recursionStack)) return true; } } recursionStack.delete(nodeId); return false; }, updateNoOutputsMessage(message, clearFirst = false) { const outputsPanel = document.querySelector("#outputsPanel"); if (clearFirst) outputsPanel.innerHTML = ''; outputsPanel.innerHTML += `
--.-%
${message}
`; }, addUnitAvailabilityToPanel(name, unitId, availability, displayClass, outputCount, capacityMW = null, reliability = 0) { const outputsPanel = document.querySelector("#outputsPanel"); const percentage = (availability * 100).toFixed(1); const relPercentage = (reliability * 100).toFixed(1); const displayWrapper = document.createElement("div"); displayWrapper.className = "availability-display-wrapper"; const titleDiv = document.createElement("div"); titleDiv.className = "availability-unit-title"; titleDiv.innerHTML = `${name}`; const unitEquipments = (unitId === 'overall') ? this.equipments : this.equipments.filter(eq => eq.unit === unitId); if (this.getEquipmentBoundingBox(unitEquipments)) { const fitBtn = document.createElement('button'); fitBtn.className = 'btn btn-action btn-sm'; fitBtn.style.padding = '2px 5px'; fitBtn.innerHTML = '▢'; fitBtn.title = `Fit view to ${name}`; fitBtn.onclick = () => this.fitViewToUnit(unitId); titleDiv.appendChild(fitBtn); } displayWrapper.appendChild(titleDiv); const statsContainer = document.createElement("div"); statsContainer.style.cssText = "display: flex; gap: 0; align-items: stretch; border: 1px solid #bdc3c7; border-radius: 6px; overflow: hidden;"; // A. Availability const availBox = document.createElement("div"); availBox.className = `availability-display ${displayClass}`; availBox.style.cssText = "flex: 1; min-width: 80px; border-radius: 0; padding: 5px 2px;"; if (outputCount === 0) { const message = (name === 'Overall Plant') ? 'No Out' : 'No Out'; availBox.innerHTML = `
${message}
`; } else { let content = `
A: ${percentage}%
`; if (capacityMW !== null) { content += `
${capacityMW.toFixed(1)} MW
`; } availBox.innerHTML = content; } statsContainer.appendChild(availBox); const divider = document.createElement("div"); divider.style.cssText = "width: 2px; background-color: rgba(255,255,255,0.8); z-index: 2;"; statsContainer.appendChild(divider); // C. Reliability (Updated) const relBox = document.createElement("div"); relBox.className = "availability-display"; // Determine color based on Reliability threshold (e.g. < 90% is bad) const relColor = reliability < 0.9 ? "#e67e22" : "#27ae60"; const relBg = reliability < 0.9 ? "linear-gradient(45deg, #f39c12, #d35400)" : "linear-gradient(45deg, #2ecc71, #27ae60)"; relBox.style.cssText = `background: ${outputCount === 0 ? 'linear-gradient(45deg, #7f8c8d, #95a5a6)' : relBg}; flex: 1; min-width: 80px; border-radius: 0; padding: 5px 2px;`; if (outputCount === 0) { relBox.innerHTML = `
N/A
`; } else { relBox.innerHTML = `
R: ${relPercentage}%
Reliability
`; } statsContainer.appendChild(relBox); displayWrapper.appendChild(statsContainer); outputsPanel.appendChild(displayWrapper); }, // GANTI SELURUH FUNGSI INI di dalam app.blockBuilder = { ... }; loadConfigurationFromData(config) { console.log("Starting to load configuration from data...", config); this._silentClear(); console.log("Workspace cleared."); this.equipments = config.equipments || []; this.connections = config.connections || []; this.pages = []; // Fungsi halaman dihapus this.unitCapacities = config.unitCapacities || { unit1: 100, unit2: 100 }; this.equipmentCounter = config.equipmentCounter || 1; this.pageCounter = 1; // Fungsi halaman dihapus if (config.viewport) { this.viewport = JSON.parse(JSON.stringify(config.viewport)); console.log("Viewport loaded:", this.viewport); } else { this.viewport = { x: this.canvas.width / 2, y: this.canvas.height / 2, scale: 1.0, isPanning: false, lastPan: { x: 0, y: 0 } }; console.log("No viewport in config, using default:", this.viewport); } if(document.querySelector('#unit1Capacity')) document.querySelector('#unit1Capacity').value = this.unitCapacities.unit1; if(document.querySelector('#unit2Capacity')) document.querySelector('#unit2Capacity').value = this.unitCapacities.unit2; console.log("UI controls updated."); this.equipments.forEach(eq => { if (eq.isFinalOutput === undefined) eq.isFinalOutput = false; if (eq.unit === undefined) eq.unit = 'common'; if (eq.displayAvailability === undefined) eq.displayAvailability = 1.0; if (eq.displayReliability === undefined) eq.displayReliability = 1.0; if (eq.intrinsicAvailability === undefined) eq.intrinsicAvailability = 1.0; if (eq.intrinsicReliability === undefined) eq.intrinsicReliability = 1.0; if (eq.hasRedundancyWarning === undefined) eq.hasRedundancyWarning = false; if (eq.failureState === undefined) eq.failureState = 'OK'; eq.width = this.calculateEquipmentWidth(eq.name); eq.height = this.calculateEquipmentHeight(eq); }); console.log("Equipment data validated."); this.canvas.width = this.workspace.clientWidth; this.canvas.height = this.workspace.clientHeight; console.log(`Canvas resized to ${this.canvas.width}x${this.canvas.height}`); // --- REVISI UTAMA DI SINI --- // Logika diubah untuk merender SEMUA equipment yang terlihat (bukan 'member'). // Ini secara otomatis akan menyertakan 'single', 'group', dan 'junction'. this.equipments.filter(eq => eq.mode !== 'member').forEach(eq => { this.createEquipmentElement(eq); }); console.log(`${this.equipments.filter(eq => eq.mode !== 'member').length} visible equipment elements created (including junctions).`); // --- AKHIR REVISI UTAMA --- this.runFullAnalysis(); this.renderEquipmentPane(); // --- REVISI KUNCI DI SINI --- // Panggil optimisasi juga saat data selesai dimuat ulang. // Ini memastikan equipment dari local storage juga menjadi responsif. setTimeout(() => this.viewAllEquipment(), 100); console.log("Full analysis and redraw complete. Configuration load finished."); }, updateUIComponents() { this.updateZoomSlider(); this.updateScrollbars(); }, togglePane() { const pane = document.getElementById('bbEquipmentPane'); if (!pane) return; const isHidden = pane.style.width === '0px'; if (isHidden) { pane.style.width = '260px'; pane.style.minWidth = ''; pane.style.borderRight = ''; } else { pane.style.width = '0px'; pane.style.minWidth = '0'; pane.style.borderRight = 'none'; } // Resize canvas after pane width changes so equipment boxes stay correct setTimeout(() => { if (this.boundResizeCanvas) this.boundResizeCanvas(); this.updateEquipmentElementsPosition(); }, 220); // wait for transition (0.2s) to finish }, initZoomControls() { const slider = document.querySelector('#zoomSlider'); const valueInput = document.querySelector('#zoomValue'); const applyZoom = (newScale) => { if (Math.abs(newScale - this.viewport.scale) < 0.001) return; const canvasCenter = { x: this.canvas.width / 2, y: this.canvas.height / 2 }; const worldPosBefore = this.screenToWorld(canvasCenter.x, canvasCenter.y); this.viewport.scale = newScale; const worldPosAfter = this.screenToWorld(canvasCenter.x, canvasCenter.y); this.viewport.x += (worldPosAfter.x - worldPosBefore.x) * this.viewport.scale; this.viewport.y += (worldPosAfter.y - worldPosBefore.y) * this.viewport.scale; this.redraw(); this.updateUIComponents(); }; // Event listener untuk slider (not berubah) slider.addEventListener('input', () => { const newScale = parseFloat(slider.value) / 100; applyZoom(newScale); }); // --- BLOK BARU: Event listeners untuk input teks --- const handleValueInput = () => { let percentValue = parseFloat(valueInput.value); if (!isNaN(percentValue)) { // Batasi nilai zoom sesuai dengan MIN dan MAX const newScale = Math.max(this.MIN_ZOOM, Math.min(this.MAX_ZOOM, percentValue / 100)); applyZoom(newScale); } // Update tampilan input setelah divalidasi this.updateZoomSlider(); }; // Terapkan zoom saat menekan 'Enter' valueInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') { handleValueInput(); e.target.blur(); // Hilangkan fokus dari input } }); // Terapkan zoom saat input kehilangan fokus (misal: klik di tempat lain) valueInput.addEventListener('blur', handleValueInput); // --- AKHIR BLOK BARU --- }, updateZoomSlider() { const slider = document.querySelector('#zoomSlider'); const valueDisplay = document.querySelector('#zoomValue'); // Sekarang ini adalah const percent = Math.round(this.viewport.scale * 100); // Update posisi slider jika not sedang digeser if (document.activeElement !== slider) { slider.value = percent; } // REVISI: Update nilai , bukan textContent. // Tambahkan juga pengecekan agar not mengganggu saat pengguna sedang mengetik. if (document.activeElement !== valueDisplay) { valueDisplay.value = percent; } }, getEquipmentBoundingBox(equipmentList) { if (!equipmentList || equipmentList.length === 0) return null; const visibleEquipments = equipmentList.filter(eq => eq.mode !== 'member'); if (visibleEquipments.length === 0) return null; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; visibleEquipments.forEach(eq => { minX = Math.min(minX, eq.x); minY = Math.min(minY, eq.y); maxX = Math.max(maxX, eq.x + eq.width); maxY = Math.max(maxY, eq.y + eq.height); }); return { minX, minY, maxX, maxY }; }, fitViewToUnit(unitId) { let equipmentsToFit; if (unitId === 'overall') { equipmentsToFit = this.equipments; } else { equipmentsToFit = this.equipments.filter(eq => eq.unit === unitId); } const bounds = this.getEquipmentBoundingBox(equipmentsToFit); if (!bounds) { this.showMessage(`No equipment found for ${unitId} to fit view.`, 2000); return; } const PADDING = 80; const boxWidth = (bounds.maxX - bounds.minX) + PADDING * 2; const boxHeight = (bounds.maxY - bounds.minY) + PADDING * 2; if (boxWidth <= 0 || boxHeight <= 0) return; const scaleX = this.canvas.width / boxWidth; const scaleY = this.canvas.height / boxHeight; const newScale = Math.min(scaleX, scaleY, this.MAX_ZOOM); this.viewport.scale = Math.max(newScale, this.MIN_ZOOM); const boxCenterX = bounds.minX + (bounds.maxX - bounds.minX) / 2; const boxCenterY = bounds.minY + (bounds.maxY - bounds.minY) / 2; this.viewport.x = (this.canvas.width / 2) - (boxCenterX * this.viewport.scale); this.viewport.y = (this.canvas.height / 2) - (boxCenterY * this.viewport.scale); this.redraw(); this.updateUIComponents(); }, initScrollbars() { const hThumb = document.querySelector('#h-thumb'); const vThumb = document.querySelector('#v-thumb'); const startDrag = (e, isVertical) => { e.preventDefault(); e.stopPropagation(); const startMouse = isVertical ? e.clientY : e.clientX; const startViewport = isVertical ? this.viewport.y : this.viewport.x; const onMove = (moveEvent) => { const currentMouse = isVertical ? moveEvent.clientY : moveEvent.clientX; const mouseDelta = currentMouse - startMouse; const bounds = this.getEquipmentBoundingBox(this.equipments); if (!bounds) return; const PADDING = 40; if (isVertical) { const trackHeight = vThumb.parentElement.clientHeight; const thumbHeight = vThumb.offsetHeight; const scrollableTrack = trackHeight - thumbHeight; if (scrollableTrack <= 0) return; const worldHeight = (bounds.maxY - bounds.minY) + PADDING * 2; const viewportHeight = this.canvas.height; const scrollableWorld = worldHeight * this.viewport.scale - viewportHeight; if (scrollableWorld <= 0) return; const viewportDelta = (mouseDelta / scrollableTrack) * scrollableWorld; this.viewport.y = startViewport - viewportDelta; } else { const trackWidth = hThumb.parentElement.clientWidth; const thumbWidth = hThumb.offsetWidth; const scrollableTrack = trackWidth - thumbWidth; if (scrollableTrack <= 0) return; const worldWidth = (bounds.maxX - bounds.minX) + PADDING * 2; const viewportWidth = this.canvas.width; const scrollableWorld = worldWidth * this.viewport.scale - viewportWidth; if (scrollableWorld <= 0) return; const viewportDelta = (mouseDelta / scrollableTrack) * scrollableWorld; this.viewport.x = startViewport - viewportDelta; } this.redraw(); this.updateScrollbars(); }; const onEnd = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onEnd); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onEnd); }; hThumb.addEventListener('mousedown', (e) => startDrag(e, false)); vThumb.addEventListener('mousedown', (e) => startDrag(e, true)); }, updateScrollbars() { const hScrollbar = document.querySelector('#h-scrollbar'); const vScrollbar = document.querySelector('#v-scrollbar'); const hThumb = document.querySelector('#h-thumb'); const vThumb = document.querySelector('#v-thumb'); const PADDING = 40; const bounds = this.getEquipmentBoundingBox(this.equipments); if (!bounds) { hScrollbar.style.display = 'none'; vScrollbar.style.display = 'none'; return; } const worldWidth = (bounds.maxX - bounds.minX) + PADDING * 2; const worldHeight = (bounds.maxY - bounds.minY) + PADDING * 2; const worldLeft = bounds.minX - PADDING; const worldTop = bounds.minY - PADDING; const viewableWorldWidth = this.canvas.width / this.viewport.scale; const viewableWorldHeight = this.canvas.height / this.viewport.scale; const viewportWorldPos = this.screenToWorld(0, 0); if (viewableWorldWidth >= worldWidth) { hScrollbar.style.display = 'none'; } else { hScrollbar.style.display = 'block'; const trackWidth = hScrollbar.clientWidth; const thumbWidth = Math.max(trackWidth * (viewableWorldWidth / worldWidth), 20); hThumb.style.width = `${thumbWidth}px`; const scrollableDist = worldWidth - viewableWorldWidth; const scrolledDist = viewportWorldPos.x - worldLeft; const scrollRatio = scrolledDist / scrollableDist; const thumbLeft = scrollRatio * (trackWidth - thumbWidth); hThumb.style.left = `${Math.max(0, Math.min(thumbLeft, trackWidth - thumbWidth))}px`; } if (viewableWorldHeight >= worldHeight) { vScrollbar.style.display = 'none'; } else { vScrollbar.style.display = 'block'; const trackHeight = vScrollbar.clientHeight; const thumbHeight = Math.max(trackHeight * (viewableWorldHeight / worldHeight), 20); vThumb.style.height = `${thumbHeight}px`; const scrollableDist = worldHeight - viewableWorldHeight; const scrolledDist = viewportWorldPos.y - worldTop; const scrollRatio = scrolledDist / scrollableDist; const thumbTop = scrollRatio * (trackHeight - thumbHeight); vThumb.style.top = `${Math.max(0, Math.min(thumbTop, trackHeight - thumbHeight))}px`; } } };