PageSourceSearch

https://napoparte-cmms.hf.space/assets/maint_settings_tables.js?m=1789806719.0

js napoparte-cmms.hf.space collected 2026-10-03 17:10:03 UTC 95,439 bytes, 1,829 lines download raw bytes

1/**
2 * maint_settings_tables.js
3 * Handles Area, Details, Break Time tables in Maintenance Settings modal.
4 * - Pagination (100 rows/page) thay vì virtual scroll — đơn giản, không bug scroll
5 * - Pure JS DOM manipulation (no Dash callbacks for add/edit/delete)
6 * - Dirty tracking → warn before close
7 * - Progress/status feedback on save
8 * - All code inside IIFE (no global scope leaks)
9 */
10(function () {
11  'use strict';
12
13  var PAGE_SIZE = 100;
14
15  // ── i18n ─────────────────────────────────────────────────────────────────
16  // Reads translations from the window._maintI18n object, which is set by
17  // a clientside callback (see cb_clientside.py) whenever lang-store changes.
18  // Fallback defaults are Vietnamese so the UI is never blank.
19  var _i18nDefaults = {
20    maint_js_del_tooltip:       'Xóa dòng',
21    maint_js_loading:           '⏳ Đang tải dữ liệu...',
22    maint_js_pag_info:          '{s}–{e} / {total} (tr.{page}/{pages})',
23    maint_js_prev_page:         '◀',
24    maint_js_next_page:         '▶',
25    maint_js_first_page:        'Trang đầu',
26    maint_js_last_page:         'Trang cuối',
27    maint_js_goto_page:         'Nhập số trang rồi Enter để đến trang đó',
28    maint_settings_unsaved_title: 'Thay đổi chưa được lưu',
29    maint_settings_unsaved_body:  'Bạn có thay đổi chưa được lưu. Thoát mà không lưu?',
30    maint_settings_confirm_back:  'Quay lại',
31    maint_settings_confirm_exit:  'Thoát không lưu',
32    maint_js_no_data:             'Chưa có dữ liệu. Nhấn "Thêm dòng" hoặc Upload File.',
33    maint_js_saving_db:           'Đang lưu vào database...',
34    maint_js_no_shift_employees:  'Không có nhân viên đang hoạt động trong Shift Schedule.',
35    maint_js_area_empty:          'Chưa có cấu hình. Nhấn "Thêm System" để bắt đầu.',
36    maint_js_component_select:    '-- Chọn Component --',
37    maint_js_combo_search_ph:     'Tìm...',
38    maint_js_combo_no_match:      'Không tìm thấy',
39    // (FIX <4>) 4 dòng "trống bảng" sau đây trước đây hard-code tiếng Việt
40    // thẳng trong JS (không qua _t()) nên không bao giờ đổi theo ngôn ngữ —
41    // nay đưa vào i18n giống các key maint_js_* khác.
42    maint_js_bt_empty:            'Chưa có khung giờ. Nhấn "Thêm khung giờ" để bắt đầu.',
43    maint_js_type_empty:          'Chưa có loại. Nhấn "Thêm loại" để bắt đầu.',
44    maint_js_status_empty:        'Chưa có trạng thái. Nhấn "Thêm trạng thái" để bắt đầu.',
45    maint_js_period_empty:        'Chưa có loại kỳ. Nhấn "Thêm loại kỳ" để bắt đầu.',
46  };
47
48  // (FIX) Trước đây _t() chỉ nhận 1 tham số nên các chỗ gọi _t(key, defaultText)
49  // (VD: dropdown Component) bị bỏ qua defaultText, và khi key đó chưa có trong
50  // window._maintI18n / _i18nDefaults thì trả về nguyên literal "key" lên UI
51  // (VD: chữ "maint_js_component_select" hiện trong dropdown).
52  function _t(key, fallback) {
53    var store = window._maintI18n || {};
54    return store[key] || _i18nDefaults[key] || fallback || key;
55  }
56
57  function _fmt(tpl, vars) {
58    return tpl.replace(/\{(\w+)\}/g, function(_, k) {
59      return vars[k] !== undefined ? vars[k] : '{' + k + '}';
60    });
61  }
62
63  // ── State ────────────────────────────────────────────────────────────────
64  var _areaData   = [];  var _areaPage   = 0;  var _areaLocations = [];  var _areaCurrentLocationId = '';
65  // (2026-08 FIX <filter Area theo hệ máy sidebar>) Mặc định lọc BẬT — chỉ
66  // hiện dòng thuộc Location đang chọn ở sidebar (đồng bộ hành vi với tab
67  // "Tần suất PM"). _areaData luôn giữ FULL dữ liệu (mọi hệ máy) để Save
68  // không làm mất dữ liệu của hệ máy khác — chỉ phần HIỂN THỊ/phân trang bị
69  // lọc qua _areaFilteredIndices().
70  var _areaFilterByLocation = true;
71
72  function _areaFilteredIndices() {
73    if (!_areaFilterByLocation || !_areaCurrentLocationId) {
74      var all = []; for (var k = 0; k < _areaData.length; k++) all.push(k);
75      return all;
76    }
77    var out = [];
78    _areaData.forEach(function(row, idx) {
79      var locId = row.location_id || _areaCurrentLocationId;
80      if (locId === _areaCurrentLocationId) out.push(idx);
81    });
82    return out;
83  }
84  var _detData    = [];  var _detPage    = 0;
85  var _btData     = [];  var _btPage     = 0;
86  var _staffData  = [];
87  var _typeData   = [];
88  var _statusData = [];
89  var _periodData = [];
90
91  // ── Helpers ───────────────────────────────────────────────────────────────
92  function esc(s) {
93    return String(s == null ? '' : s)
94      .replace(/&/g,'&amp;').replace(/</g,'&lt;')
95      .replace(/>/g,'&gt;').replace(/"/g,'&quot;');
96  }
97
98  function syncHidden(id, data) {
99    var el = document.getElementById(id);
100    if (!el) return;
101    var json = JSON.stringify(data);
102    if (el.value === json) return;
103    el.value = json;
104    el.dispatchEvent(new Event('input', { bubbles: true }));
105  }
106
107  function isDark() {
108    return document.documentElement.getAttribute('data-theme') === 'dark';
109  }
110
111  function isMobile() {
112    return document.body.classList.contains('is-mobile-device');
113  }
114
115  function textColor()   { return isDark() ? '#e2e8f0' : '#1e293b'; }
116  function inputBg()     { return isDark() ? '#1e293b' : '#ffffff'; }
117  function inputBorder() { return isDark() ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.15)'; }
118  function borderColor() { return isDark() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)'; }
119  function mutedColor()  { return isDark() ? '#94a3b8' : '#64748b'; }
120
121  function applyInputStyle(el, isTextarea) {
122    el.className = (el.className ? el.className + ' ' : '') +
123      (isTextarea ? 'maint-settings-textarea' : 'maint-settings-input');
124    if (isMobile()) {
125      el.style.fontSize = '0.60rem';
126      if (!isTextarea) el.style.height = '22px';
127      else el.style.minHeight = '32px';
128    }
129  }
130
131  function cellStyle(td, extraCss) {
132    td.className = (td.className ? td.className + ' ' : '') + 'maint-settings-cell';
133    if (isMobile()) td.style.padding = '2px 4px';
134    if (extraCss) {
135      extraCss.split(';').forEach(function(part) {
136        part = part.trim(); if (!part) return;
137        var colon = part.indexOf(':'); if (colon < 0) return;
138        var prop = part.slice(0, colon).trim();
139        var val  = part.slice(colon + 1).trim();
140        var jsProp = prop.replace(/-([a-z])/g, function(_, c) { return c.toUpperCase(); });
141        td.style[jsProp] = val;
142      });
143    }
144  }
145
146  // ── Per-tab dirty tracking (FIX 2026-08) ────────────────────────────────
147  // Trước đây dùng 1 cờ "_dirty" DÙNG CHUNG cho cả 7 tab, set = true VÔ ĐIỀU
148  // KIỆN mỗi lần gõ (không so sánh với baseline) → 2 bug:
149  //  <1> Không có dòng thông báo "Có thay đổi chưa lưu" (chỉ ẩn/hiện 2 nút).
150  //  <2> Sửa xong rồi tự sửa lại về đúng giá trị cũ, 2 nút vẫn không ẩn (vì
151  //      _dirty chỉ set true, không bao giờ tự tắt khi khớp lại baseline).
152  //  <3> Upload file chỉ đổi data-init (nạp dữ liệu mới vào bảng) nhưng
153  //      KHÔNG hề gọi markDirty() ở đâu cả → 2 nút Save/Cancel + badge không
154  //      xuất hiện dù dữ liệu đã đổi và chưa được lưu vào DB.
155  //
156  // Fix (giống kiến trúc asm_settings_tables.js — 1 baseline độc lập mỗi
157  // tab, so sánh JSON để tự bật/tắt, không phụ thuộc cờ boolean thủ công):
158  //  - Mỗi tab có 1 baseline riêng "_original[key]" — snapshot ĐÃ LƯU gần
159  //    nhất, lấy từ attribute "data-original" (Python set, KHÔNG đổi khi
160  //    Upload — chỉ đổi khi Save thành công) — xem ui_maintenance.py.
161  //  - markDirty(key): so sánh dữ liệu hiện tại của tab "key" với baseline
162  //    → tự bật/tắt Save/Cancel + badge, không set cờ vô điều kiện.
163  //  - Upload chỉ đổi "data-init" (không đổi "data-original") → dữ liệu lệch
164  //    baseline → dirty tự bật, không cần gọi markDirty() thủ công ở upload.
165  //  - Cancel/Lưu-thành-công đều làm dữ liệu khớp lại "data-original" →
166  //    dirty tự tắt.
167  var _KEYS = ['area', 'details', 'bt', 'staff', 'type', 'status', 'period'];
168
169  var _TBODY_ID = {
170    area: 'maint-area-js-tbody', details: 'maint-details-js-tbody', bt: 'maint-bt-js-tbody',
171    staff: 'maint-staff-js-tbody', type: 'maint-type-js-tbody', status: 'maint-status-js-tbody',
172    period: 'maint-period-js-tbody',
173  };
174  var _KEY_BY_TBODY = {};
175  Object.keys(_TBODY_ID).forEach(function (k) { _KEY_BY_TBODY[_TBODY_ID[k]] = k; });
176
177  var _BTN_IDS = {
178    area: ['maint-area-save-btn', 'maint-area-cancel-btn'],
179    details: ['maint-details-save-btn', 'maint-details-cancel-btn'],
180    bt: ['maint-bt-save-btn', 'maint-bt-cancel-btn'],
181    staff: ['maint-staff-save-btn', 'maint-staff-cancel-btn'],
182    type: ['maint-type-save-btn', 'maint-type-cancel-btn'],
183    status: ['maint-status-save-btn', 'maint-status-cancel-btn'],
184    period: ['maint-period-save-btn', 'maint-period-cancel-btn'],
185  };
186  var _BADGE_ID = {
187    area: 'maint-area-unsaved-badge', details: 'maint-details-unsaved-badge', bt: 'maint-bt-unsaved-badge',
188    staff: 'maint-staff-unsaved-badge', type: 'maint-type-unsaved-badge', status: 'maint-status-unsaved-badge',
189    period: 'maint-period-unsaved-badge',
190  };
191
192  var _original = {}; // key -> JSON string baseline (dữ liệu đã lưu gần nhất)
193
194  function _dataFor(key) {
195    switch (key) {
196      case 'area':    return _areaData;
197      case 'details': return _detData;
198      case 'bt':      return _btData;
199      case 'staff':   return _staffData;
200      case 'type':    return _typeData;
201      case 'status':  return _statusData;
202      case 'period':  return _periodData;
203    }
204    return [];
205  }
206
207  function _setKeyDirtyUI(key, dirty) {
208    (_BTN_IDS[key] || []).forEach(function (id) {
209      var btn = document.getElementById(id);
210      if (btn) btn.style.display = dirty ? '' : 'none';
211    });
212    var badge = document.getElementById(_BADGE_ID[key]);
213    if (badge) badge.style.display = dirty ? '' : 'none';
214  }
215
216  // markDirty(key) — gọi SAU KHI đã sửa mảng dữ liệu của tab "key" (VD sau
217  // khi gõ input / xóa dòng / thêm dòng) — so sánh với baseline để tự bật
218  // hoặc tắt Save/Cancel + badge, KHÔNG set dirty=true vô điều kiện.
219  function markDirty(key) {
220    if (!key) return;
221    _refreshDirtyUI(key);
222  }
223
224  function _refreshDirtyUI(key) {
225    var same = JSON.stringify(_dataFor(key)) === (_original[key] !== undefined ? _original[key] : '[]');
226    _setKeyDirtyUI(key, !same);
227  }
228
229  function _refreshAllDirtyUI() { _KEYS.forEach(_refreshDirtyUI); }
230
231  // _captureBaseline(key) — nạp lại baseline từ attribute "data-original"
232  // của tbody (fallback "data-init" nếu chưa có, VD lần đầu deploy trước khi
233  // Python được cập nhật để set data-original).
234  function _captureBaseline(key) {
235    var tb = document.getElementById(_TBODY_ID[key]);
236    if (!tb) return;
237    var raw = tb.getAttribute('data-original');
238    if (raw === null || raw === undefined) raw = tb.getAttribute('data-init') || '[]';
239    try { _original[key] = JSON.stringify(JSON.parse(raw)); }
240    catch (e) { _original[key] = '[]'; }
241  }
242
243  // clearDirty(key?) — coi dữ liệu hiện tại của tab "key" là baseline mới
244  // (dùng khi bấm Save — ẩn Save/Cancel/badge NGAY tại thời điểm click, thay
245  // vì đợi Dash round-trip xong. data-original thật sự do Python set lại
246  // sau đó sẽ được _captureBaseline() nạp lại — kết quả giống nhau).
247  // Không truyền key → clear tất cả (dùng khi đóng modal không lưu).
248  function clearDirty(key) {
249    if (!key) { _KEYS.forEach(function (k) { clearDirty(k); }); return; }
250    _original[key] = JSON.stringify(_dataFor(key));
251    _setKeyDirtyUI(key, false);
252  }
253
254  function _anyDirty() {
255    var tableDirty = _KEYS.some(function (k) {
256      return JSON.stringify(_dataFor(k)) !== (_original[k] !== undefined ? _original[k] : '[]');
257    });
258    if (tableDirty) return true;
259    // (Fix 2 — 2026-08) Tab "Tự động PM" (auto) là 1 form đơn giản, dirty-
260    // check của nó được tính hoàn toàn phía Dash/Python (xem
261    // cb_auto_planning.py::_auto_planning_dirty_check) — không dùng hệ
262    // thống _dataFor/_TBODY_ID (chỉ dành cho các tab dạng bảng). Ở đây chỉ
263    // cần đọc lại kết quả (badge đang hiện hay ẩn) để gộp vào _anyDirty()
264    // chung, cho phép nút "Đóng" ở footer modal cũng chặn + hỏi xác nhận
265    // khi tab Auto đang có thay đổi chưa lưu — y hệt các tab khác.
266    var autoBadge = document.getElementById('maint-auto-unsaved-badge');
267    if (autoBadge && autoBadge.style.display !== 'none') return true;
268    // (Feature 2026-08 <Tần suất PM dirty-check>) Tab "🕐 Tần suất PM" cũng
269    // tính dirty hoàn toàn phía Dash/Python (xem cb_pm_frequency.py) — gộp
270    // vào _anyDirty() chung y hệt tab "auto" ở trên.
271    var pmfreqBadge = document.getElementById('maint-pmfreq-unsaved-badge');
272    return !!(pmfreqBadge && pmfreqBadge.style.display !== 'none');
273  }
274
275  // ── Pagination bar — rendered into inline div next to Save/Cancel ─────────
276  // Div IDs: maint-area-pag, maint-det-pag, maint-bt-pag
277  // These are inside modal-body but the observer explicitly ignores changes to them.
278  var _PAG_IDS = ['maint-area-pag', 'maint-det-pag', 'maint-bt-pag'];
279  var _pagStates = {};  // pagId → { currentPage, totalRows, onPageChange }
280
281  function renderPagBar(pagId, currentPage, totalRows, onPageChange) {
282    var c = document.getElementById(pagId);
283    if (!c) return;
284
285    _pagStates[pagId] = { currentPage: currentPage, totalRows: totalRows, onPageChange: onPageChange };
286
287    var totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
288    if (totalPages <= 1) { c.innerHTML = ''; return; }
289
290    var start = currentPage * PAGE_SIZE + 1;
291    var end   = Math.min((currentPage + 1) * PAGE_SIZE, totalRows);
292
293    // Reuse existing buttons — only update text/disabled, no add/remove DOM nodes
294    var first = c.querySelector('._pag-first');
295    var prev  = c.querySelector('._pag-prev');
296    var pageInput = c.querySelector('._pag-page-input');
297    var pageTotal = c.querySelector('._pag-page-total');
298    var next  = c.querySelector('._pag-next');
299    var last  = c.querySelector('._pag-last');
300    var info  = c.querySelector('._pag-info');
301
302    // FIX (2026-08) <First/Last + nhập số trang>: thêm 2 nút First/Last và 1
303    // ô input cho phép gõ thẳng số trang muốn đến, thay vì chỉ Prev/Next đi
304    // từng trang một — hữu ích khi dữ liệu nhiều trang (VD Area 1450 dòng /
305    // 100 dòng-trang = 15 trang, phải bấm Next 14 lần mới tới trang cuối).
306    // Cùng nguyên tắc "build 1 lần, chỉ update giá trị" như prev/info/next
307    // sẵn có — tránh tạo lại DOM node mỗi lần render (giữ focus/con trỏ ô
308    // input nếu đang gõ dở, không bị mất khi renderPagBar() gọi lại).
309    function goToPage(pageInput1Based) {
310      var st = _pagStates[pagId];
311      if (!st) return;
312      var tp = Math.max(1, Math.ceil(st.totalRows / PAGE_SIZE));
313      var p = parseInt(pageInput1Based, 10);
314      if (isNaN(p)) p = st.currentPage + 1;
315      p = Math.max(1, Math.min(tp, p));
316      st.onPageChange(p - 1);
317    }
318
319    if (!first) {
320      // Build once
321      first = document.createElement('button');
322      first.className = 'btn btn-sm btn-outline-secondary _pag-first';
323      first.textContent = '⏮';
324      first.title = _t('maint_js_first_page', 'Trang đầu');
325      first.addEventListener('click', function(e) {
326        e.preventDefault(); e.stopPropagation();
327        var st = _pagStates[pagId];
328        if (st && st.currentPage > 0) st.onPageChange(0);
329      });
330      c.appendChild(first);
331
332      prev = document.createElement('button');
333      prev.className = 'btn btn-sm btn-outline-secondary _pag-prev';
334      prev.textContent = '◀';
335      prev.title = _t('maint_js_prev_page');
336      prev.addEventListener('click', function(e) {
337        e.preventDefault(); e.stopPropagation();
338        var st = _pagStates[pagId];
339        if (st && st.currentPage > 0) st.onPageChange(st.currentPage - 1);
340      });
341      c.appendChild(prev);
342
343      var pageWrap = document.createElement('span');
344      pageWrap.className = '_pag-page-wrap';
345      pageWrap.style.cssText = 'display:inline-flex;align-items:center;gap:3px;margin:0 4px;';
346
347      pageInput = document.createElement('input');
348      pageInput.type = 'number'; pageInput.min = '1';
349      pageInput.className = 'form-control form-control-sm _pag-page-input';
350      pageInput.style.cssText = 'width:52px;padding:2px 4px;text-align:center;font-size:0.78rem;';
351      pageInput.title = _t('maint_js_goto_page', 'Nhập số trang rồi Enter để đến trang đó');
352      // Enter → nhảy trang; blur → snap lại về trang hiện tại nếu bỏ dở/không hợp lệ
353      pageInput.addEventListener('keydown', function(e) {
354        e.stopPropagation();
355        if (e.key === 'Enter') { e.preventDefault(); goToPage(pageInput.value); pageInput.blur(); }
356      });
357      pageInput.addEventListener('click', function(e) { e.stopPropagation(); });
358      pageInput.addEventListener('blur', function() {
359        var st = _pagStates[pagId];
360        if (st) pageInput.value = st.currentPage + 1;
361      });
362      pageWrap.appendChild(pageInput);
363
364      pageTotal = document.createElement('span');
365      pageTotal.className = '_pag-page-total';
366      pageTotal.style.cssText = 'font-size:0.78rem;color:#6c757d;white-space:nowrap;';
367      pageWrap.appendChild(pageTotal);
368
369      c.appendChild(pageWrap);
370
371      next = document.createElement('button');
372      next.className = 'btn btn-sm btn-outline-secondary _pag-next';
373      next.textContent = '▶';
374      next.title = _t('maint_js_next_page');
375      next.addEventListener('click', function(e) {
376        e.preventDefault(); e.stopPropagation();
377        var st = _pagStates[pagId];
378        if (st && st.currentPage < Math.ceil(st.totalRows / PAGE_SIZE) - 1) st.onPageChange(st.currentPage + 1);
379      });
380      c.appendChild(next);
381
382      last = document.createElement('button');
383      last.className = 'btn btn-sm btn-outline-secondary _pag-last';
384      last.textContent = '⏭';
385      last.title = _t('maint_js_last_page', 'Trang cuối');
386      last.addEventListener('click', function(e) {
387        e.preventDefault(); e.stopPropagation();
388        var st = _pagStates[pagId];
389        if (!st) return;
390        var tp = Math.max(1, Math.ceil(st.totalRows / PAGE_SIZE));
391        if (st.currentPage < tp - 1) st.onPageChange(tp - 1);
392      });
393      c.appendChild(last);
394
395      info = document.createElement('span');
396      info.className = '_pag-info';
397      info.style.cssText = 'font-size:0.78rem;color:#6c757d;white-space:nowrap;font-weight:500;margin-left:6px;';
398      c.appendChild(info);
399    }
400
401    // Update values only — no DOM structure change
402    first.disabled = currentPage === 0;
403    prev.disabled  = currentPage === 0;
404    next.disabled  = currentPage >= totalPages - 1;
405    last.disabled  = currentPage >= totalPages - 1;
406    pageInput.max = totalPages;
407    // Không ghi đè giá trị ô input nếu user đang gõ dở (đang focus) — tránh
408    // giật con trỏ/xoá mất số đang nhập khi renderPagBar() bị gọi lại do
409    // thao tác khác (VD sửa 1 ô input trên bảng cũng trigger markDirty →
410    // không re-render pag, nhưng để an toàn vẫn check document.activeElement).
411    if (document.activeElement !== pageInput) { pageInput.value = currentPage + 1; }
412    pageTotal.textContent = '/ ' + totalPages;
413    info.textContent = _fmt(_t('maint_js_pag_info'), {s: start, e: end, total: totalRows, page: currentPage+1, pages: totalPages});
414  }
415
416  // (Component dropdown) Danh sách tên Component duy nhất, lấy từ dữ liệu tab
417  // Details (_detData) — dùng làm option cho dropdown Component ở tab Area.
418  function _componentNameOptions() {
419    var seen = {}, out = [];
420    (_detData || []).forEach(function(r) {
421      var name = (r && r.component_name) ? String(r.component_name).trim() : '';
422      if (name && !seen[name]) { seen[name] = true; out.push(name); }
423    });
424    out.sort(function(a, b) { return a.localeCompare(b); });
425    return out;
426  }
427
428  // ── Custom Component Combo ──────────────────────────────────────────────
429  // Panel nổi dùng CHUNG cho mọi dòng (thay vì tạo 1 <select> riêng mỗi
430  // dòng) — nhẹ hơn, và cho phép style hoàn toàn bằng CSS (height, font,
431  // dark mode), có ô tìm kiếm để dễ chọn khi danh sách Component dài.
432  var _comboPanel = null, _comboSearchInput = null, _comboList = null;
433  var _comboOnSelect = null, _comboTrigger = null, _comboOptions = [], _comboCurVal = '';
434
435  function _ensureComboPanel() {
436    if (_comboPanel) return;
437    _comboPanel = document.createElement('div');
438    _comboPanel.className = 'maint-combo-panel';
439    _comboPanel.style.display = 'none';
440
441    var searchWrap = document.createElement('div');
442    searchWrap.className = 'maint-combo-search-wrap';
443    _comboSearchInput = document.createElement('input');
444    _comboSearchInput.type = 'text';
445    _comboSearchInput.className = 'maint-combo-search';
446    searchWrap.appendChild(_comboSearchInput);
447
448    _comboList = document.createElement('div');
449    _comboList.className = 'maint-combo-list';
450
451    _comboPanel.appendChild(searchWrap);
452    _comboPanel.appendChild(_comboList);
453    document.body.appendChild(_comboPanel);
454
455    _comboSearchInput.addEventListener('input', function () {
456      _renderComboItems(_comboSearchInput.value);
457    });
458    _comboSearchInput.addEventListener('keydown', function (e) {
459      if (e.key === 'Escape') { _closeCombo(); }
460    });
461    document.addEventListener('mousedown', function (e) {
462      if (!_comboPanel || _comboPanel.style.display === 'none') return;
463      if (_comboPanel.contains(e.target) || (_comboTrigger && _comboTrigger.contains(e.target))) return;
464      _closeCombo();
465    });
466    // Đóng panel khi cuộn (bảng, modal, trang...) để tránh panel bị lệch vị
467    // trí so với trigger (panel dùng position:fixed, tính 1 lần lúc mở).
468    document.addEventListener('scroll', function (e) {
469      if (!_comboPanel || _comboPanel.style.display === 'none') return;
470      if (_comboPanel.contains(e.target)) return;
471      _closeCombo();
472    }, true);
473    window.addEventListener('resize', function () { _closeCombo(); });
474  }
475
476  function _renderComboItems(filterText) {
477    var ft = (filterText || '').trim().toLowerCase();
478    _comboList.innerHTML = '';
479
480    var blank = document.createElement('div');
481    blank.className = 'maint-combo-item maint-combo-item-blank';
482    blank.textContent = _t('maint_js_component_select', '-- Chọn Component --');
483    if (!ft) {
484      if (!_comboCurVal) blank.classList.add('active');
485      blank.addEventListener('click', function () { _selectCombo(''); });
486      _comboList.appendChild(blank);
487    }
488
489    var any = false;
490    _comboOptions.forEach(function (name) {
491      if (ft && name.toLowerCase().indexOf(ft) === -1) return;
492      any = true;
493      var it = document.createElement('div');
494      it.className = 'maint-combo-item';
495      it.textContent = name;
496      if (name === _comboCurVal) it.classList.add('active');
497      it.addEventListener('click', function () { _selectCombo(name); });
498      _comboList.appendChild(it);
499    });
500    if (!any && ft) {
501      var empty = document.createElement('div');
502      empty.className = 'maint-combo-empty';
503      empty.textContent = _t('maint_js_combo_no_match', 'Không tìm thấy');
504      _comboList.appendChild(empty);
505    }
506  }
507
508  function _selectCombo(val) {
509    var cb = _comboOnSelect;
510    _closeCombo();
511    if (cb) cb(val);
512  }
513
514  function _positionCombo() {
515    if (!_comboTrigger) return;
516    var r = _comboTrigger.getBoundingClientRect();
517    var panelMaxH = 240;
518    var spaceBelow = window.innerHeight - r.bottom;
519    var openUp = spaceBelow < panelMaxH && r.top > spaceBelow;
520    _comboPanel.style.left = Math.max(4, r.left) + 'px';
521    _comboPanel.style.width = Math.max(r.width, 200) + 'px';
522    if (openUp) {
523      var h = Math.min(panelMaxH, r.top - 8);
524      _comboPanel.style.maxHeight = h + 'px';
525      _comboPanel.style.top = (r.top - h) + 'px';
526    } else {
527      var h2 = Math.min(panelMaxH, spaceBelow - 8);
528      _comboPanel.style.maxHeight = Math.max(h2, 120) + 'px';
529      _comboPanel.style.top = r.bottom + 'px';
530    }
531  }
532
533  function _closeCombo() {
534    if (!_comboPanel) return;
535    _comboPanel.style.display = 'none';
536    _comboTrigger = null;
537    _comboOnSelect = null;
538  }
539
540  function _openCombo(triggerEl, options, curVal, onSelect) {
541    _ensureComboPanel();
542    if (_comboTrigger === triggerEl && _comboPanel.style.display !== 'none') {
543      _closeCombo();
544      return;
545    }
546    _comboTrigger = triggerEl;
547    _comboOptions = options;
548    _comboCurVal = curVal;
549    _comboOnSelect = onSelect;
550    _comboSearchInput.value = '';
551    _comboSearchInput.placeholder = _t('maint_js_combo_search_ph', 'Tìm...');
552    _renderComboItems('');
553    _comboPanel.style.display = 'flex';
554    _positionCombo();
555    setTimeout(function () { _comboSearchInput.focus(); }, 0);
556  }
557
558  // ── AREA TABLE ────────────────────────────────────────────────────────────
559  function renderAreaTable() {
560    _closeCombo();
561    var tbody = document.getElementById('maint-area-js-tbody');
562    if (!tbody) return;
563    var ro = tbody.getAttribute('data-readonly') === 'true';
564    tbody.innerHTML = '';
565
566    var visibleIdx = _areaFilteredIndices();
567
568    if (!_areaData.length) {
569      var tr = document.createElement('tr');
570      var td = document.createElement('td');
571      td.colSpan = 6; td.className = 'maint-settings-empty-cell';
572      td.textContent = _t('maint_js_area_empty');
573      tr.appendChild(td); tbody.appendChild(tr);
574      syncHidden('maint-area-store-hidden', []);
575      renderPagBar('maint-area-pag', 0, 0, function(){});
576      return;
577    }
578
579    if (!visibleIdx.length) {
580      // Có dữ liệu nhưng hệ máy đang chọn không có dòng nào — khác thông
581      // báo "chưa có cấu hình gì" để không gây hiểu lầm là mất dữ liệu.
582      var tr2 = document.createElement('tr');
583      var td2 = document.createElement('td');
584      td2.colSpan = 6; td2.className = 'maint-settings-empty-cell';
585      td2.textContent = _t('maint_js_area_empty_filtered', 'Hệ máy đang chọn chưa có cấu hình Area. Bỏ tick "Chỉ hiện hệ máy đang chọn" để xem tất cả.');
586      tr2.appendChild(td2); tbody.appendChild(tr2);
587      renderPagBar('maint-area-pag', 0, 0, function(){});
588      syncHidden('maint-area-store-hidden', _areaData);
589      return;
590    }
591
592    var frag = document.createDocumentFragment();
593    var start = _areaPage * PAGE_SIZE;
594    visibleIdx.slice(start, start + PAGE_SIZE).forEach(function(i) {
595      var row = _areaData[i];
596      var tr = document.createElement('tr');
597      tr.className = 'maint-settings-row';
598
599      var fields = ['system_name','equipment_name','area_name','component_id'];
600      fields.forEach(function(f) {
601        var td = document.createElement('td');
602        cellStyle(td);
603        if (ro) {
604          td.textContent = row[f] || '';
605        } else if (f === 'component_id') {
606          // (Component dropdown) value lấy từ danh sách component_name của
607          // tab Details, thay vì cho gõ tự do — tránh sai chính tả/không
608          // khớp với Details khi tính checklist bảo trì.
609          // (FIX) Trước đây dùng <select> gốc của trình duyệt → popup xấu,
610          // không thể chỉnh height/font, không theo dark mode. Nay thay bằng
611          // custom combo (trigger + panel nổi dùng chung, xem _openCombo()).
612          var curVal = row[f] || '';
613          var trigger = document.createElement('button');
614          trigger.type = 'button';
615          trigger.className = 'maint-combo-trigger';
616          var lbl = document.createElement('span');
617          lbl.className = 'maint-combo-trigger-label';
618          var phText = _t('maint_js_component_select', '-- Chọn Component --');
619          lbl.textContent = curVal || phText;
620          if (!curVal) lbl.classList.add('is-placeholder');
621          trigger.appendChild(lbl);
622          var caret = document.createElement('i');
623          caret.className = 'fas fa-chevron-down maint-combo-caret';
624          trigger.appendChild(caret);
625          (function(idx, field, triggerEl, labelEl){
626            triggerEl.addEventListener('click', function(e){
627              e.preventDefault(); e.stopPropagation();
628              var cv = _areaData[idx][field] || '';
629              // Giữ lại giá trị cũ trong dropdown dù nó không (còn) nằm trong
630              // danh sách Details hiện tại (VD: Details đã đổi tên) — tránh
631              // mất dữ liệu / âm thầm đổi giá trị của dòng khi mới mở lại.
632              var curOpts = _componentNameOptions();
633              if (cv && curOpts.indexOf(cv) === -1) curOpts = [cv].concat(curOpts);
634              _openCombo(triggerEl, curOpts, cv, function(val){
635                _areaData[idx][field] = val;
636                var ph = _t('maint_js_component_select', '-- Chọn Component --');
637                labelEl.textContent = val || ph;
638                labelEl.classList.toggle('is-placeholder', !val);
639                syncHidden('maint-area-store-hidden', _areaData);
640                markDirty('area');
641              });
642            });
643          })(i, f, trigger, lbl);
644          td.appendChild(trigger);
645        } else {
646          var inp = document.createElement('input');
647          inp.type = 'text'; inp.value = row[f] || '';
648          applyInputStyle(inp, false);
649          (function(idx, field){
650            inp.addEventListener('input', function(){
651              _areaData[idx][field] = this.value;
652              syncHidden('maint-area-store-hidden', _areaData);
653              markDirty('area');
654            });
655          })(i, f);
656          td.appendChild(inp);
657        }
658        tr.appendChild(td);
659      });
660
661      // (2026-07-29) Cột Location (nhà máy) — KHOÁ, luôn hiển thị text tĩnh
662      // (2026-07-30 FIX) tránh data mồ côi khi đổi hệ máy tùy tiện. Dòng cũ
663      // giữ nguyên location_id đã gán; dòng mới (chưa có location_id) sẽ
664      // mặc định = ASM đang chọn ở sidebar (_areaCurrentLocationId).
665      var tdLoc = document.createElement('td');
666      cellStyle(tdLoc);
667      var curLocId = row.location_id || _areaCurrentLocationId || (_areaLocations[0] && _areaLocations[0].id) || '';
668      var foundLoc = _areaLocations.filter(function(l){ return l.id === curLocId; })[0];
669      tdLoc.textContent = foundLoc ? (foundLoc.name ? (foundLoc.code + ' - ' + foundLoc.name) : foundLoc.code) : '';
670      if (!row.location_id) { _areaData[i].location_id = curLocId; }
671      tr.appendChild(tdLoc);
672
673      var tdDel = document.createElement('td');
674      cellStyle(tdDel, 'text-align:center;width:40px');
675      if (!ro) {
676        var btn = document.createElement('button');
677        btn.innerHTML = '<i class="fas fa-trash"></i>';
678        btn.className = 'btn btn-sm btn-link p-0';
679        btn.style.color = '#ef4444'; btn.title = _t('maint_js_del_tooltip');
680        (function(idx){
681          btn.addEventListener('click', function(){
682            _areaData.splice(idx, 1);
683            var visLen = _areaFilteredIndices().length;
684            if (_areaPage > 0 && _areaPage * PAGE_SIZE >= visLen) _areaPage--;
685            syncHidden('maint-area-store-hidden', _areaData);
686            markDirty('area'); renderAreaTable();
687          });
688        })(i);
689        tdDel.appendChild(btn);
690      }
691      tr.appendChild(tdDel);
692      frag.appendChild(tr);
693    });
694    tbody.appendChild(frag);
695
696    renderPagBar('maint-area-pag', _areaPage, visibleIdx.length, function(p) {
697      _areaPage = p; renderAreaTable();
698    });
699    syncHidden('maint-area-store-hidden', _areaData);
700  }
701
702  // ── DETAILS TABLE ─────────────────────────────────────────────────────────
703  function renderDetailsTable() {
704    var tbody = document.getElementById('maint-details-js-tbody');
705    if (!tbody) return;
706    var ro = tbody.getAttribute('data-readonly') === 'true';
707    tbody.innerHTML = '';
708
709    if (!_detData.length) {
710      var tr = document.createElement('tr');
711      var td = document.createElement('td');
712      td.colSpan = 4; td.className = 'maint-settings-empty-cell';
713      td.textContent = _t('maint_js_no_data');
714      tr.appendChild(td); tbody.appendChild(tr);
715      syncHidden('maint-details-store-hidden', []);
716      renderPagBar('maint-det-pag', 0, 0, function(){});
717      return;
718    }
719
720    var frag = document.createDocumentFragment();
721    var start = _detPage * PAGE_SIZE;
722    _detData.slice(start, start + PAGE_SIZE).forEach(function(row, li) {
723      var i = start + li;
724      var tr = document.createElement('tr');
725      tr.className = 'maint-settings-row';
726
727      var td1 = document.createElement('td');
728      cellStyle(td1, 'width:25%;vertical-align:top');
729      if (ro) {
730        td1.textContent = row.component_name || '';
731        td1.classList.add('fw-semibold');
732      } else {
733        var inp = document.createElement('input');
734        inp.type = 'text'; inp.value = row.component_name || '';
735        applyInputStyle(inp, false);
736        (function(idx){
737          inp.addEventListener('input', function(){
738            _detData[idx].component_name = this.value;
739            syncHidden('maint-details-store-hidden', _detData); markDirty('details');
740          });
741        })(i);
742        td1.appendChild(inp);
743      }
744      tr.appendChild(td1);
745
746      var td2 = document.createElement('td');
747      cellStyle(td2, 'vertical-align:top');
748      if (ro) {
749        var pre = document.createElement('pre');
750        pre.className = 'maint-settings-pre';
751        if (isMobile()) pre.style.fontSize = '0.60rem';
752        pre.textContent = row.detail_text || '';
753        td2.appendChild(pre);
754      } else {
755        var ta = document.createElement('textarea');
756        ta.value = row.detail_text || '';
757        applyInputStyle(ta, true);
758        (function(idx){
759          ta.addEventListener('input', function(){
760            _detData[idx].detail_text = this.value;
761            syncHidden('maint-details-store-hidden', _detData); markDirty('details');
762          });
763          ta.addEventListener('keydown', function(e) {
764            if (e.altKey && e.key === 'Enter') {
765              e.preventDefault();
766              var s = this.selectionStart;
767              this.value = this.value.substring(0,s)+'\n'+this.value.substring(this.selectionEnd);
768              this.selectionStart = this.selectionEnd = s+1;
769              _detData[idx].detail_text = this.value;
770              syncHidden('maint-details-store-hidden', _detData); markDirty('details');
771            }
772          });
773        })(i);
774        td2.appendChild(ta);
775      }
776      tr.appendChild(td2);
777
778      // ── Cột "🌡️ Tủ điện" (checklist_type) ──────────────────────────────
779      var td3 = document.createElement('td');
780      cellStyle(td3, 'text-align:center;vertical-align:top');
781      var cb = document.createElement('input');
782      cb.type = 'checkbox';
783      cb.checked = (row.checklist_type === 'electrical_cabinet');
784      cb.disabled = ro;
785      cb.title = _t('maint_details_col_cabinet_hint');
786      cb.style.width = '18px'; cb.style.height = '18px'; cb.style.cursor = ro ? 'default' : 'pointer';
787      if (!ro) {
788        (function(idx){
789          cb.addEventListener('change', function(){
790            _detData[idx].checklist_type = this.checked ? 'electrical_cabinet' : 'standard';
791            syncHidden('maint-details-store-hidden', _detData); markDirty('details');
792          });
793        })(i);
794      }
795      td3.appendChild(cb);
796      tr.appendChild(td3);
797
798      var tdDel = document.createElement('td');
799      cellStyle(tdDel, 'text-align:center;width:40px;vertical-align:top');
800      if (!ro) {
801        var btn = document.createElement('button');
802        btn.innerHTML = '<i class="fas fa-trash"></i>';
803        btn.className = 'btn btn-sm btn-link p-0';
804        btn.style.color = '#ef4444'; btn.title = _t('maint_js_del_tooltip');
805        (function(idx){
806          btn.addEventListener('click', function(){
807            _detData.splice(idx, 1);
808            if (_detPage > 0 && _detPage * PAGE_SIZE >= _detData.length) _detPage--;
809            syncHidden('maint-details-store-hidden', _detData);
810            markDirty('details'); renderDetailsTable();
811          });
812        })(i);
813        tdDel.appendChild(btn);
814      }
815      tr.appendChild(tdDel);
816      frag.appendChild(tr);
817    });
818    tbody.appendChild(frag);
819
820    renderPagBar('maint-det-pag', _detPage, _detData.length, function(p) {
821      _detPage = p; renderDetailsTable();
822    });
823    syncHidden('maint-details-store-hidden', _detData);
824  }
825
826  // ── BREAK TIME TABLE ──────────────────────────────────────────────────────
827  function renderBtTable() {
828    var tbody = document.getElementById('maint-bt-js-tbody');
829    if (!tbody) return;
830    var ro = tbody.getAttribute('data-readonly') === 'true';
831    tbody.innerHTML = '';
832
833    if (!_btData.length) {
834      var tr = document.createElement('tr');
835      var td = document.createElement('td');
836      td.colSpan = 3; td.className = 'maint-settings-empty-cell';
837      td.textContent = _t('maint_js_bt_empty', 'Chưa có khung giờ. Nhấn "Thêm khung giờ" để bắt đầu.');
838      tr.appendChild(td); tbody.appendChild(tr);
839      syncHidden('maint-bt-store-hidden', []);
840      renderPagBar('maint-bt-pag', 0, 0, function(){});
841      return;
842    }
843
844    var frag = document.createDocumentFragment();
845    var start = _btPage * PAGE_SIZE;
846    _btData.slice(start, start + PAGE_SIZE).forEach(function(row, li) {
847      var i = start + li;
848      var tr = document.createElement('tr');
849      tr.className = 'maint-settings-row';
850
851      ['start_time','end_time'].forEach(function(f) {
852        var td = document.createElement('td');
853        cellStyle(td);
854        if (ro) {
855          td.textContent = row[f] || '';
856        } else {
857          var inp = document.createElement('input');
858          inp.type = 'text'; inp.value = row[f] || '';
859          inp.placeholder = 'HH:MM';
860          applyInputStyle(inp, false);
861          (function(idx, field){
862            inp.addEventListener('input', function(){
863              _btData[idx][field] = this.value;
864              syncHidden('maint-bt-store-hidden', _btData); markDirty('bt');
865            });
866          })(i, f);
867          td.appendChild(inp);
868        }
869        tr.appendChild(td);
870      });
871
872      var tdDel = document.createElement('td');
873      cellStyle(tdDel, 'text-align:center;width:40px');
874      if (!ro) {
875        var btn = document.createElement('button');
876        btn.innerHTML = '<i class="fas fa-trash"></i>';
877        btn.className = 'btn btn-sm btn-link p-0';
878        btn.style.color = '#ef4444';
879        (function(idx){
880          btn.addEventListener('click', function(){
881            _btData.splice(idx, 1);
882            if (_btPage > 0 && _btPage * PAGE_SIZE >= _btData.length) _btPage--;
883            syncHidden('maint-bt-store-hidden', _btData);
884            markDirty('bt'); renderBtTable();
885          });
886        })(i);
887        tdDel.appendChild(btn);
888      }
889      tr.appendChild(tdDel);
890      frag.appendChild(tr);
891    });
892    tbody.appendChild(frag);
893
894    renderPagBar('maint-bt-pag', _btPage, _btData.length, function(p) {
895      _btPage = p; renderBtTable();
896    });
897    syncHidden('maint-bt-store-hidden', _btData);
898  }
899
900  // ── styleHeaders: no-op (CSS handles it) ──────────────────────────────────
901  function styleHeaders() {}
902
903  // ── Spinner overlay ───────────────────────────────────────────────────────
904  function _getOrCreateSpinner(msgId) {
905    var spinnerId = msgId + '-js-spinner';
906    var el = document.getElementById(spinnerId);
907    if (el) return el;
908    var dashDiv = document.getElementById(msgId);
909    if (!dashDiv) return null;
910    el = document.createElement('div');
911    el.id = spinnerId;
912    el.style.cssText = 'margin-bottom:6px;min-height:22px';
913    dashDiv.parentNode.insertBefore(el, dashDiv);
914    return el;
915  }
916
917  function showSaving(msgId) {
918    var sp = _getOrCreateSpinner(msgId);
919    if (!sp) return;
920    sp.innerHTML = '<span style="color:#f59e0b;font-weight:600">' +
921      '<i class="fas fa-spinner fa-spin me-1"></i>' + _t('maint_js_saving_db') + '</span>';
922    sp._watching = true;
923    var dashDiv = document.getElementById(msgId);
924    if (!dashDiv || dashDiv._spinObs) return;
925    dashDiv._spinObs = true;
926    new MutationObserver(function() {
927      if (sp._watching) { sp.innerHTML = ''; sp._watching = false; }
928    }).observe(dashDiv, { childList: true, subtree: true });
929  }
930
931  function setupSaveProgress() {
932    // area/details/bt: force syncHidden BEFORE Dash reads State(store-hidden)
933    // This prevents race condition where Dash snapshots store before JS writes it.
934    var syncMap = [
935      { btnId: 'maint-area-save-btn',    msgId: 'maint-area-msg',    key: 'area',
936        storeId: 'maint-area-store-hidden',    getData: function(){ return _areaData; } },
937      { btnId: 'maint-details-save-btn', msgId: 'maint-details-msg', key: 'details',
938        storeId: 'maint-details-store-hidden', getData: function(){ return _detData;  } },
939      { btnId: 'maint-bt-save-btn',      msgId: 'maint-bt-msg',      key: 'bt',
940        storeId: 'maint-bt-store-hidden',      getData: function(){ return _btData;   } },
941      // staff/type/status also get explicit sync on save
942      { btnId: 'maint-staff-save-btn',   msgId: 'maint-staff-msg',   key: 'staff',
943        storeId: 'maint-staff-store-hidden',   getData: function(){ return _staffData; } },
944      { btnId: 'maint-type-save-btn',    msgId: 'maint-type-msg',    key: 'type',
945        storeId: 'maint-type-store-hidden',    getData: function(){ return _typeData;  } },
946      { btnId: 'maint-status-save-btn',  msgId: 'maint-status-msg',  key: 'status',
947        storeId: 'maint-status-store-hidden',  getData: function(){ return _statusData;} },
948      { btnId: 'maint-period-save-btn',  msgId: 'maint-period-msg',  key: 'period',
949        storeId: 'maint-period-store-hidden',  getData: function(){ return _periodData;} },
950    ];
951    syncMap.forEach(function(cfg) {
952      var btn = document.getElementById(cfg.btnId);
953      if (!btn || btn._progressBound) return;
954      btn._progressBound = true;
955      btn.addEventListener('click', function() {
956        // Force-sync store FIRST (synchronous) so Dash State reads the latest value
957        syncHidden(cfg.storeId, cfg.getData());
958        if (cfg.msgId.indexOf('bt') < 0 && cfg.msgId.indexOf('staff') < 0 &&
959            cfg.msgId.indexOf('type') < 0 && cfg.msgId.indexOf('status') < 0 &&
960            cfg.msgId.indexOf('period') < 0) {
961          setTimeout(function() { showSaving(cfg.msgId); }, 80);
962        }
963        // Ẩn Save/Cancel/badge NGAY lúc bấm (không đợi server round-trip) —
964        // data-original thật do Python trả về sau đó sẽ tự đồng bộ lại qua
965        // MutationObserver (xem watch data-original bên dưới).
966        clearDirty(cfg.key);
967      });
968    });
969  }
970
971  // ── Auto-dismiss ──────────────────────────────────────────────────────────
972  // (FIX 2026-08 <React removeChild crash>) TRƯỚC ĐÂY hàm này tự ý xoá DOM
973  // bằng el.innerHTML='' sau khi hết giờ — nhưng React (Dash) vẫn coi các
974  // node bên trong `el` là do NÓ quản lý (không biết bị JS xoá tay). Đến khi
975  // React re-render lại `el` (VD Lưu lần tiếp theo, banner có key mới), nó cố
976  // gỡ (removeChild) 1 node đã bị JS xoá mất từ trước -> ném lỗi
977  // "NotFoundError: Failed to execute 'removeChild' on 'Node'" ra console.
978  // Sửa: KHÔNG xoá DOM nữa — chỉ ẩn trực quan bằng opacity+visibility, giữ
979  // nguyên node để React tự thay thế đúng cách ở lần render kế tiếp.
980  function setupAutoDismiss(msgId) {
981    var el = document.getElementById(msgId);
982    if (!el || el._adObs) return;
983    el._adObs = true;
984    new MutationObserver(function() {
985      var inner = el.querySelector('[data-autohide]');
986      if (!inner || inner._adTimerSet) return;
987      inner._adTimerSet = true;
988      var ms = parseInt(inner.getAttribute('data-autohide') || '4000');
989      setTimeout(function() {
990        inner.style.transition = 'opacity 0.5s';
991        inner.style.opacity = '0';
992        setTimeout(function() {
993          // Chỉ ẩn trực quan (không tương tác, không chiếm chỗ) — KHÔNG gọi
994          // removeChild/innerHTML='' để tránh xung đột với React reconciler.
995          inner.style.visibility = 'hidden';
996          inner.style.pointerEvents = 'none';
997          inner.style.height = '0';
998          inner.style.overflow = 'hidden';
999          inner.style.margin = '0';
1000        }, 520);
1001      }, ms);
1002    }).observe(el, { childList: true, subtree: true });
1003  }
1004
1005  // ── Custom confirm modal ──────────────────────────────────────────────────
1006  // (FIX 2026-08 <item 3>) Mở modal xác nhận qua ĐÚNG đường Dash (click nút ẩn
1007  // "maint-settings-confirm-open-trigger" -> callback Python set is_open=True),
1008  // KHÔNG còn gọi window.bootstrap.Modal(...).show() thẳng lên DOM của dbc.Modal
1009  // nữa. dbc.Modal là component React (react-bootstrap), không phải bootstrap.js
1010  // gốc — ép mở bằng bootstrap.js khiến state is_open nội bộ của Dash/React
1011  // không bao giờ đồng bộ True, để lại backdrop/overlay kẹt lại phía trên sau khi
1012  // đóng, khiến modal Settings chính trông như "không đóng được" dù thực ra nó
1013  // đã đóng phía dưới lớp backdrop kẹt lại đó.
1014  var _maintConfirmCallback = null;
1015
1016  function showMaintConfirm(onConfirm) {
1017    _maintConfirmCallback = onConfirm;
1018    var trigger = document.getElementById('maint-settings-confirm-open-trigger');
1019    if (trigger) { trigger.click(); return; }
1020    // Fallback cực hiếm (trigger không có trong DOM vì lý do gì đó) — giữ lại
1021    // overlay tự vẽ như phương án dự phòng cuối cùng.
1022    var overlay = document.createElement('div');
1023    overlay.id = 'maint-dirty-confirm-overlay';
1024    overlay.style.cssText = 'position:fixed;top:0;left:0;width:100vw;height:100vh;background:rgba(0,0,0,0.5);z-index:99999;display:flex;align-items:center;justify-content:center';
1025    var dark = isDark();
1026    var bg = dark?'#1e293b':'#fff', tc = dark?'#e2e8f0':'#1e293b', bord = dark?'rgba(255,255,255,0.12)':'rgba(0,0,0,0.12)';
1027    var box = document.createElement('div');
1028    box.style.cssText = 'background:'+bg+';color:'+tc+';border:1px solid '+bord+';border-radius:8px;padding:24px 28px;min-width:320px;max-width:440px;box-shadow:0 20px 60px rgba(0,0,0,0.4);font-family:inherit';
1029    box.innerHTML = '<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px;font-size:1.05rem;font-weight:600"><i class="fas fa-exclamation-triangle" style="color:#f59e0b"></i>' + _t('maint_settings_unsaved_title') + '</div><p style="margin:0 0 20px;font-size:0.9rem;opacity:0.85">' + _t('maint_settings_unsaved_body') + '</p><div style="display:flex;justify-content:flex-end;gap:10px"><button id="_maint-confirm-cancel" style="padding:6px 18px;border-radius:5px;border:1px solid '+bord+';background:transparent;color:'+tc+';cursor:pointer;font-size:0.88rem">' + _t('maint_settings_confirm_back') + '</button><button id="_maint-confirm-ok" style="padding:6px 18px;border-radius:5px;border:none;background:#ef4444;color:#fff;cursor:pointer;font-size:0.88rem;font-weight:600">' + _t('maint_settings_confirm_exit') + '</button></div>';
1030    overlay.appendChild(box); document.body.appendChild(overlay);
1031    document.getElementById('_maint-confirm-cancel').addEventListener('click', function(){ overlay.remove(); _maintConfirmCallback = null; });
1032    document.getElementById('_maint-confirm-ok').addEventListener('click', function(){ overlay.remove(); if (_maintConfirmCallback){ _maintConfirmCallback(); _maintConfirmCallback = null; } });
1033  }
1034
1035  // (FIX 2026-08 <item 3>) Khi bấm nút OK thật trong modal (id 'maint-settings-
1036  // confirm-ok-btn'), Dash TỰ đóng cả 'maint-settings-confirm-modal' LẪN
1037  // 'maint-settings-modal' qua 2 callback Python riêng (đã đăng ký Input đúng
1038  // trên nút này) — JS ở đây CHỈ cần chạy phần "revert dữ liệu về bản đã lưu",
1039  // không cần tự tay ẩn modal/backdrop hay giả lập click lại nút Đóng nữa
1040  // (làm vậy trước đây chính là nguồn gây ra tình trạng đóng "chưa dứt điểm").
1041  document.addEventListener('click', function(e) {
1042    var okBtn = e.target.closest('#maint-settings-confirm-ok-btn');
1043    if (okBtn && _maintConfirmCallback) {
1044      var cb = _maintConfirmCallback; _maintConfirmCallback = null;
1045      setTimeout(cb, 80);
1046    }
1047    var cancelBtn = e.target.closest('#maint-settings-confirm-cancel-btn');
1048    if (cancelBtn) { _maintConfirmCallback = null; }
1049  });
1050
1051  // ── Dirty-check ───────────────────────────────────────────────────────────
1052  // (FIX 2026-08) "Thoát không lưu" trước đây gọi clearDirty() — hàm này chỉ
1053  // coi dữ liệu HIỆN TẠI (có thể đang là nội dung vừa Upload nhưng CHƯA lưu)
1054  // là baseline mới, KHÔNG hề revert lại dữ liệu/att "data-init" về đúng bản
1055  // đã lưu trong DB (giống hệt bug đã sửa ở nút Cancel — xem comment ở đó).
1056  // Hậu quả: đóng modal qua đường "Thoát không lưu" (khác với bấm Cancel
1057  // từng tab) để lại "data-init" bị kẹt sai lệch, gây hiện tượng mở lại modal
1058  // thấy "Chưa có cấu hình" + badge "chưa lưu" dù không sửa gì. Sửa: thay vì
1059  // tự tính lại baseline, giả lập click nút Cancel của TỪNG tab (nếu đang
1060  // hiện, tức đang dirty) — tái dùng đúng logic revert-về-data-original +
1061  // đồng bộ lại data-init đã có sẵn ở đó.
1062  function _revertAllTabsToOriginal() {
1063    ['maint-area-cancel-btn', 'maint-details-cancel-btn', 'maint-bt-cancel-btn', 'maint-auto-cancel-btn', 'maint-pmfreq-cancel-btn'].forEach(function(id){
1064      var btn = document.getElementById(id);
1065      if (btn && btn.style.display !== 'none') btn.click();
1066    });
1067    clearDirty(); // dọn nốt các tab không có nút Cancel riêng (staff/type/status/period)
1068  }
1069
1070  // (FIX 2026-08 <item 3>) closeBtn/nút-X giờ CHỈ cần gọi showMaintConfirm() +
1071  // revert dữ liệu khi người dùng xác nhận — KHÔNG còn phải tự re-click
1072  // closeBtn/dc theo kiểu "giả lập" nữa, vì cả 2 callback Python (đóng confirm-
1073  // modal VÀ đóng settings-modal) đã lắng nghe TRỰC TIẾP trên nút OK thật
1074  // ('maint-settings-confirm-ok-btn') rồi — chỉ cần Dash nhận đúng click đó
1075  // (qua round-trip is_open bình thường) là cả 2 modal tự đóng đúng cách.
1076  function setupDirtyCheck() {
1077    var closeBtn = document.getElementById('maint-settings-close-btn');
1078    if (closeBtn && !closeBtn._dirtyBound) {
1079      closeBtn._dirtyBound = true;
1080      closeBtn.addEventListener('click', function(e) {
1081        if (_anyDirty()) { e.preventDefault(); e.stopImmediatePropagation();
1082          showMaintConfirm(function(){ _revertAllTabsToOriginal(); }); }
1083      }, true);
1084    }
1085    var modalEl = document.getElementById('maint-settings-modal');
1086    if (!modalEl || modalEl._xDirtyBound) return;
1087    modalEl._xDirtyBound = true;
1088    modalEl.addEventListener('click', function(e) {
1089      var xBtn = e.target.closest('.modal-header .btn-close, .modal-header [data-bs-dismiss="modal"]');
1090      if (!xBtn || !_anyDirty()) return;
1091      e.preventDefault(); e.stopImmediatePropagation();
1092      showMaintConfirm(function(){ _revertAllTabsToOriginal(); });
1093    }, true);
1094  }
1095
1096  // ── ADD buttons ───────────────────────────────────────────────────────────
1097  function bindAddButtons() {
1098    var areaFilterChk = document.getElementById('maint-area-filter-loc-chk');
1099    if (areaFilterChk && !areaFilterChk._bound) {
1100      areaFilterChk._bound = true;
1101      areaFilterChk.addEventListener('change', function() {
1102        _areaFilterByLocation = !!this.checked;
1103        _areaPage = 0;
1104        renderAreaTable();
1105      });
1106    }
1107    var aBtn = document.getElementById('maint-area-add-system-btn');
1108    if (aBtn && !aBtn._bound) {
1109      aBtn._bound = true;
1110      aBtn.addEventListener('click', function(e) {
1111        e.preventDefault(); e.stopImmediatePropagation();
1112        _areaData.push({ system_name:'', equipment_name:'', area_name:'', component_id:'',
1113                         location_id: _areaCurrentLocationId || (_areaLocations[0] && _areaLocations[0].id) || '' });
1114        // Dòng mới luôn thuộc hệ máy đang chọn nên luôn nằm trong danh sách
1115        // đang lọc — nhảy tới trang cuối của danh sách ĐÃ LỌC (không phải
1116        // _areaData.length, nếu không sẽ nhảy sai trang khi đang bật lọc).
1117        _areaPage = Math.floor((_areaFilteredIndices().length - 1) / PAGE_SIZE);
1118        syncHidden('maint-area-store-hidden', _areaData);
1119        markDirty('area'); renderAreaTable();
1120        // Focus last input on new row
1121        setTimeout(function(){
1122          var tbody = document.getElementById('maint-area-js-tbody');
1123          if (!tbody) return;
1124          var rows = tbody.querySelectorAll('tr.maint-settings-row');
1125          var last = rows[rows.length - 1];
1126          if (last) { var inp = last.querySelector('input'); if (inp) inp.focus(); }
1127        }, 30);
1128      });
1129    }
1130    var dBtn = document.getElementById('maint-details-add-btn');
1131    if (dBtn && !dBtn._bound) {
1132      dBtn._bound = true;
1133      dBtn.addEventListener('click', function(e) {
1134        e.preventDefault(); e.stopImmediatePropagation();
1135        _detData.push({ component_name:'', detail_text:'' });
1136        _detPage = Math.floor((_detData.length - 1) / PAGE_SIZE);
1137        syncHidden('maint-details-store-hidden', _detData);
1138        markDirty('details'); renderDetailsTable();
1139        setTimeout(function(){
1140          var tbody = document.getElementById('maint-details-js-tbody');
1141          if (!tbody) return;
1142          var rows = tbody.querySelectorAll('tr.maint-settings-row');
1143          var last = rows[rows.length - 1];
1144          if (last) { var inp = last.querySelector('input'); if (inp) inp.focus(); }
1145        }, 30);
1146      });
1147    }
1148    var bBtn = document.getElementById('maint-bt-add-btn');
1149    if (bBtn && !bBtn._bound) {
1150      bBtn._bound = true;
1151      bBtn.addEventListener('click', function(e) {
1152        e.preventDefault(); e.stopImmediatePropagation();
1153        _btData.push({ start_time:'', end_time:'' });
1154        _btPage = Math.floor((_btData.length - 1) / PAGE_SIZE);
1155        syncHidden('maint-bt-store-hidden', _btData);
1156        markDirty('bt'); renderBtTable();
1157        setTimeout(function(){
1158          var tbody = document.getElementById('maint-bt-js-tbody');
1159          if (!tbody) return;
1160          var rows = tbody.querySelectorAll('tr.maint-settings-row');
1161          var last = rows[rows.length - 1];
1162          if (last) { var inp = last.querySelector('input'); if (inp) inp.focus(); }
1163        }, 30);
1164      });
1165    }
1166
1167    // ── Cancel buttons — Area/Details/Break Time. FIX (2026-08): revert lại
1168    // "data-original" (snapshot ĐÃ LƯU gần nhất trong DB — Python quản lý,
1169    // KHÔNG đổi khi Upload) thay vì "data-init" (có thể đang là dữ liệu vừa
1170    // Upload nhưng CHƯA lưu — nếu revert theo data-init thì Cancel sau khi
1171    // Upload sẽ giữ nguyên dữ liệu upload thay vì hủy nó, sai với ý nghĩa
1172    // "Hủy" của người dùng).
1173    var aCancelBtn = document.getElementById('maint-area-cancel-btn');
1174    if (aCancelBtn && !aCancelBtn._cancelBound) {
1175      aCancelBtn._cancelBound = true;
1176      aCancelBtn.addEventListener('click', function(e) {
1177        e.preventDefault(); e.stopImmediatePropagation();
1178        var tbody = document.getElementById('maint-area-js-tbody');
1179        if (tbody) {
1180          var raw = tbody.getAttribute('data-original');
1181          if (raw === null) raw = tbody.getAttribute('data-init') || '[]';
1182          try { _areaData = JSON.parse(raw); } catch(e2) { _areaData = []; }
1183          // FIX (2026-08): đồng bộ lại "data-init" khớp với dữ liệu vừa
1184          // revert. Trước đây chỉ set _areaData trong bộ nhớ mà KHÔNG cập
1185          // nhật attribute "data-init" trên DOM — nếu trước đó user đã
1186          // Upload file (data-init đang là dữ liệu upload, KHÁC data-original),
1187          // attribute này bị "kẹt" sai lệch. Lần mở modal kế tiếp, Python
1188          // re-render Tbody với data-init="[]" (Area luôn nạp async) — DOM
1189          // patch coi đây là 1 thay đổi thật (từ giá trị upload cũ → "[]"),
1190          // kích hoạt observer "data-init" tưởng nhầm là dữ liệu THẬT (rỗng)
1191          // thay vì hiểu đây là tín hiệu "cần fetch lại" → hiện nhầm "Chưa
1192          // có cấu hình" + badge "chưa lưu" (baseline cũ không khớp) cho
1193          // đến khi đóng/mở lại modal lần 2. Set lại data-init ở đây giữ 2
1194          // attribute luôn đồng bộ, tránh mutation giả ở lần mở kế tiếp.
1195          tbody.setAttribute('data-init', raw);
1196          syncHidden('maint-area-store-hidden', _areaData);
1197          _areaPage = 0;
1198          renderAreaTable();
1199        }
1200        // FIX <1>: Cancel phải xoá luôn dòng thông báo Upload ("✅ Đọc N
1201        // dòng thành công...") — trước đây chỉ ẩn 2 nút Save/Cancel + badge
1202        // (qua clearDirty) nhưng để nguyên nội dung #maint-area-msg.
1203        var msg = document.getElementById('maint-area-msg');
1204        if (msg) msg.innerHTML = '';
1205        clearDirty('area');
1206      });
1207    }
1208    var dCancelBtn = document.getElementById('maint-details-cancel-btn');
1209    if (dCancelBtn && !dCancelBtn._cancelBound) {
1210      dCancelBtn._cancelBound = true;
1211      dCancelBtn.addEventListener('click', function(e) {
1212        e.preventDefault(); e.stopImmediatePropagation();
1213        var tbody = document.getElementById('maint-details-js-tbody');
1214        if (tbody) {
1215          var raw = tbody.getAttribute('data-original');
1216          if (raw === null) raw = tbody.getAttribute('data-init') || '[]';
1217          try { _detData = JSON.parse(raw); } catch(e2) { _detData = []; }
1218          tbody.setAttribute('data-init', raw); // xem giải thích ở nút Cancel Area phía trên
1219          syncHidden('maint-details-store-hidden', _detData);
1220          _detPage = 0;
1221          renderDetailsTable();
1222        }
1223        var msg = document.getElementById('maint-details-msg');
1224        if (msg) msg.innerHTML = '';
1225        clearDirty('details');
1226      });
1227    }
1228    var bCancelBtn = document.getElementById('maint-bt-cancel-btn');
1229    if (bCancelBtn && !bCancelBtn._cancelBound) {
1230      bCancelBtn._cancelBound = true;
1231      bCancelBtn.addEventListener('click', function(e) {
1232        e.preventDefault(); e.stopImmediatePropagation();
1233        var tbody = document.getElementById('maint-bt-js-tbody');
1234        if (tbody) {
1235          var raw = tbody.getAttribute('data-original');
1236          if (raw === null) raw = tbody.getAttribute('data-init') || '[]';
1237          try { _btData = JSON.parse(raw); } catch(e2) { _btData = []; }
1238          tbody.setAttribute('data-init', raw); // xem giải thích ở nút Cancel Area phía trên
1239          syncHidden('maint-bt-store-hidden', _btData);
1240          _btPage = 0;
1241          renderBtTable();
1242        }
1243        var msg = document.getElementById('maint-bt-msg');
1244        if (msg) msg.innerHTML = '';
1245        clearDirty('bt');
1246      });
1247    }
1248  }
1249
1250  // ── Area tab: fetch authoritative config from server ───────────────────────
1251  // Extracted to module scope (previously an inner closure of initTables())
1252  // so it can ALSO be called from the "data-open-nonce" MutationObserver
1253  // below — needed to fix the "phải đóng/mở modal 2 lần mới thấy config"
1254  // bug (see full explanation on the data-open-nonce observer).
1255  var _areaFetchInFlight = false;
1256  function _fetchAreaConfig(retryLeft) {
1257    var at = document.getElementById('maint-area-js-tbody');
1258    if (!at) return;
1259    if (retryLeft === undefined) retryLeft = 1;
1260    if (_areaFetchInFlight) {
1261      // FIX (2026-08) <duplicate fetch>: initTables() (qua childList observer
1262      // khi Dash tạo tbody Area MỚI) VÀ observer theo dõi "data-open-nonce"
1263      // (bắt cả trường hợp Dash chỉ PATCH lại node cũ) đều có thể gọi hàm
1264      // này gần như đồng thời mỗi lần mở modal. Trước đây cờ này chỉ chặn
1265      // việc ghi đè innerHTML "loading" 2 lần, KHÔNG chặn gọi fetch() lần 2
1266      // → 2 request chạy song song, tuy cùng trả kết quả giống nhau nhưng
1267      // tốn API call thừa và tăng khả năng đụng độ thời điểm cập nhật DOM.
1268      // Giờ: nếu đã có 1 request đang chạy, bỏ qua hẳn lần gọi này (request
1269      // đang chạy khi xong sẽ tự cập nhật đầy đủ data-init/data-original/
1270      // baseline/dirty UI cho TẤT CẢ các trigger, không cần gọi thêm).
1271      return;
1272    }
1273    _areaFetchInFlight = true;
1274    at.innerHTML = '<tr><td colspan="5" class="maint-settings-empty-cell">' + _t('maint_js_loading') + '</td></tr>';
1275    fetch('/api/maint_area_config')
1276      .then(function(r){ if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
1277      .then(function(data){
1278        _areaFetchInFlight = false;
1279        _areaData = Array.isArray(data) ? data : [];
1280        var _json = JSON.stringify(_areaData);
1281        at.setAttribute('data-init', _json);
1282        // FIX: tab Area nạp dữ liệu KHÔNG đồng bộ (fetch riêng, không
1283        // qua data-init lúc Dash render modal — xem comment đầu hàm).
1284        // Baseline (_captureBaseline) đã chạy TRƯỚC KHI fetch này trả
1285        // về (async), nên nếu không cập nhật lại data-original ở đây,
1286        // baseline sẽ kẹt ở "[]" ban đầu → tab Area bị coi là "dirty"
1287        // ngay cả khi chưa sửa gì. LUÔN ghi đè data-original bằng kết
1288        // quả fetch mới nhất (KHÔNG chỉ khi chưa có, như bản cũ) — vì
1289        // mỗi lần hàm này chạy nghĩa là 1 lần Modal thực sự (re)mở và
1290        // xin dữ liệu THẬT từ server, nên đây luôn là baseline đúng
1291        // nhất tại thời điểm mở, bất kể tbody có bị DOM-reuse giữ lại
1292        // giá trị "data-original" cũ từ phiên mở trước hay không.
1293        at.setAttribute('data-original', _json);
1294        syncHidden('maint-area-store-hidden', _areaData);
1295        _areaPage = 0; renderAreaTable();
1296        _captureBaseline('area'); _refreshDirtyUI('area');
1297      })
1298      .catch(function(e){
1299        if (retryLeft > 0) {
1300          setTimeout(function(){ _fetchAreaConfig(retryLeft - 1); }, 300);
1301        } else {
1302          _areaFetchInFlight = false;
1303          console.error('[maint_settings] fetch error:', e);
1304          _areaData = []; renderAreaTable();
1305        }
1306      });
1307  }
1308
1309  // ── INIT ──────────────────────────────────────────────────────────────────
1310  function initTables() {
1311    // (Component dropdown) Đọc dữ liệu tab Details TRƯỚC tab Area, vì cột
1312    // Component của Area giờ là dropdown lấy value từ danh sách component
1313    // của tab Details — cần _detData sẵn sàng trước khi renderAreaTable().
1314    var dtEarly = document.getElementById('maint-details-js-tbody');
1315    if (dtEarly) {
1316      try { _detData = JSON.parse(dtEarly.getAttribute('data-init') || '[]'); } catch(e) { _detData = []; }
1317    }
1318    var at = document.getElementById('maint-area-js-tbody');
1319    if (at) {
1320      // (2026-07-29) GIAI ĐOẠN 1b mở rộng đa nhà máy — danh sách Location để
1321      // render dropdown chọn nhà máy cho từng dòng Khu vực bảo trì.
1322      try { _areaLocations = JSON.parse(at.getAttribute('data-locations') || '[]'); } catch(e) { _areaLocations = []; }
1323      _areaCurrentLocationId = at.getAttribute('data-current-location') || '';
1324      // Mỗi lần modal Area mở lại: reset về mặc định "chỉ hiện hệ máy đang
1325      // chọn" (không giữ trạng thái bỏ-lọc của phiên trước, tránh gây nhầm
1326      // lẫn "sao giờ thấy toàn bộ hệ máy khác").
1327      _areaFilterByLocation = true;
1328      var _areaFilterChk = document.getElementById('maint-area-filter-loc-chk');
1329      if (_areaFilterChk) _areaFilterChk.checked = true;
1330      var cachedInit = at.getAttribute('data-init') || '[]';
1331      var hasCachedData = false;
1332      try { var cached = JSON.parse(cachedInit); if (cached.length > 0) { _areaData = cached; hasCachedData = true; } } catch(e) {}
1333      if (!hasCachedData) {
1334        // (2026-07-30 FIX) 2 lỗi trong fetch cũ:
1335        //  1) không check r.ok — response 500 (body {"error":...}) bị coi như
1336        //     data hợp lệ (không phải mảng) -> render rỗng NGAY, không rơi
1337        //     vào .catch() nên không hề retry.
1338        //  2) không có retry — nếu request đầu ngay sau khi Save (server có
1339        //     backoff riêng, xem app.py) vẫn trượt do timing, JS bỏ cuộc
1340        //     luôn -> user phải tự đóng/mở modal lần 2 mới thấy dữ liệu.
1341        // Giờ: check r.ok, và nếu fail thì tự retry 1 lần sau 300ms trước khi
1342        // mới thật sự coi là rỗng.
1343        // FIX (2026-08) <flash bug>: Bảng Area nạp async (fetch riêng, xong
1344        // sau initTables() 1 khoảng). Trước đây, dòng "_KEYS.forEach
1345        // (_captureBaseline); _refreshAllDirtyUI();" ở CUỐI initTables() vẫn
1346        // chạy NGAY khi tbody vừa được tạo mới hoàn toàn (element mới hoàn
1347        // toàn KHÔNG có sẵn attribute "data-original" từ phiên trước) →
1348        // baseline tạm rơi về "[]", trong khi biến _areaData trong bộ nhớ JS
1349        // (persist xuyên suốt session trang, không mất khi đóng/mở modal)
1350        // vẫn còn đang giữ dữ liệu THẬT từ lần mở trước → so sánh lệch →
1351        // hiện nhầm badge "chưa lưu" + nút Save/Cancel trong ~1-2s cho tới
1352        // khi fetch xong (_fetchAreaConfig tự tính lại baseline ĐÚNG và ẩn
1353        // đi). Ép UI "sạch" ngay lập tức trong lúc chờ fetch — không để
1354        // đoạn code cuối initTables() tính dirty cho tab 'area' bằng dữ liệu
1355        // tạm/chưa đủ tin cậy này (xem đoạn loại trừ 'area' phía dưới).
1356        _setKeyDirtyUI('area', false);
1357        _fetchAreaConfig(1);
1358      } else {
1359        syncHidden('maint-area-store-hidden', _areaData);
1360        _areaPage = 0; renderAreaTable();
1361        // hasCachedData=true nghĩa là data-init đang giữ dữ liệu THẬT (VD
1362        // vừa Upload, chưa đóng modal) — tính baseline/dirty ngay tại đây
1363        // (KHÔNG dựa vào đoạn "_KEYS.forEach" cuối initTables(), vì đoạn đó
1364        // giờ loại trừ 'area' — xem lý do ở nhánh fetch phía trên).
1365        _captureBaseline('area'); _refreshDirtyUI('area');
1366      }
1367    }
1368    var dt = document.getElementById('maint-details-js-tbody');
1369    if (dt) {
1370      try { _detData = JSON.parse(dt.getAttribute('data-init') || '[]'); } catch(e){ _detData=[]; }
1371      syncHidden('maint-details-store-hidden', _detData); _detPage = 0; renderDetailsTable();
1372    }
1373    var bt = document.getElementById('maint-bt-js-tbody');
1374    if (bt) {
1375      try { _btData = JSON.parse(bt.getAttribute('data-init') || '[]'); } catch(e){ _btData=[]; }
1376      syncHidden('maint-bt-store-hidden', _btData); _btPage = 0; renderBtTable();
1377    }
1378    var stf = document.getElementById('maint-staff-js-tbody');
1379    if (stf) {
1380      try { _staffData = JSON.parse(stf.getAttribute('data-init') || '[]'); } catch(e){ _staffData=[]; }
1381      syncHidden('maint-staff-store-hidden', _staffData); renderStaffTable();
1382    }
1383    var typ = document.getElementById('maint-type-js-tbody');
1384    if (typ) {
1385      try { _typeData = JSON.parse(typ.getAttribute('data-init') || '[]'); } catch(e){ _typeData=[]; }
1386      syncHidden('maint-type-store-hidden', _typeData); renderTypeTable();
1387    }
1388    var sts = document.getElementById('maint-status-js-tbody');
1389    if (sts) {
1390      try { _statusData = JSON.parse(sts.getAttribute('data-init') || '[]'); } catch(e){ _statusData=[]; }
1391      syncHidden('maint-status-store-hidden', _statusData); renderStatusTable();
1392    }
1393    var prd = document.getElementById('maint-period-js-tbody');
1394    if (prd) {
1395      try { _periodData = JSON.parse(prd.getAttribute('data-init') || '[]'); } catch(e){ _periodData=[]; }
1396      syncHidden('maint-period-store-hidden', _periodData); renderPeriodTable();
1397    }
1398    bindAddButtons(); setupSaveProgress(); setupDirtyCheck();
1399    // Nạp baseline (data-original) cho 6 tab còn lại (trừ 'area') rồi tự
1400    // tính lại trạng thái dirty — an toàn hơn ép về "sạch" như trước: nếu
1401    // vì lý do nào đó data-init != data-original ngay lúc mở modal, badge/
1402    // nút vẫn hiện đúng thay vì bị ẩn oan.
1403    // FIX (2026-08) <flash bug tái phát>: TRƯỚC ĐÂY dòng dưới đây chạy
1404    // "_KEYS.forEach(...)" cho CẢ 'area' — dù comment phía trên (nhánh
1405    // fetch, ~dòng 1141) khẳng định đã loại trừ, code thực tế thì KHÔNG.
1406    // Tab 'area' không được Python render sẵn "data-original" (baseline
1407    // tab này chỉ do JS set SAU KHI _fetchAreaConfig() trả về — xem hàm
1408    // đó), nên với 1 tbody Area MỚI hoàn toàn (Dash tạo lại khi mở modal),
1409    // _captureBaseline('area') đọc "data-original" = null → baseline tạm
1410    // = "[]", trong khi _areaData trong bộ nhớ JS (persist xuyên session)
1411    // vẫn giữ dữ liệu THẬT từ lần mở trước → _refreshDirtyUI('area') so
1412    // sánh lệch → bật nhầm Save/Cancel/badge trong lúc chờ fetch, y hệt
1413    // bug ban đầu. Giờ loại 'area' ra khỏi vòng lặp này thật sự — baseline/
1414    // dirty của 'area' CHỈ được tính bởi: (a) nhánh hasCachedData trong
1415    // initTables() phía trên, (b) _fetchAreaConfig() khi fetch xong, hoặc
1416    // (c) observer "data-original" đổi (sau Save) — cả 3 nơi đó đều dùng
1417    // đúng dữ liệu đáng tin cậy tại thời điểm gọi.
1418    _KEYS.forEach(function(k) { if (k !== 'area') _captureBaseline(k); });
1419    _KEYS.forEach(function(k) { if (k !== 'area') _refreshDirtyUI(k); });
1420    ['maint-area-msg','maint-details-msg','maint-bt-msg','maint-staff-msg','maint-type-msg','maint-status-msg','maint-period-msg','maint-auto-msg','maint-pmfreq-msg'].forEach(setupAutoDismiss);
1421
1422    // FIX: modal body được inject bởi Dash lazy-load AFTER applyScale() đã chạy lần đầu.
1423    // Lúc đó #maint-settings-modal-body chưa có → applyModalStyle() không detect được
1424    // → modal bị route sai vào applyLargeModalStyle (fixed height) thay vì
1425    //   applyAutoHeightXlModalStyle (max-height theo viewport).
1426    // Dispatch resize để utils_js.py applyScale() chạy lại — bây giờ #maint-settings-modal-body
1427    // đã có trong DOM nên detection đúng và height được set chính xác.
1428    setTimeout(function() { window.dispatchEvent(new Event('resize')); }, 60);
1429    setTimeout(function() { window.dispatchEvent(new Event('resize')); }, 300);
1430  }
1431
1432  // ── Observers ─────────────────────────────────────────────────────────────
1433  var _initPending = false;
1434
1435  function _tryInit() {
1436    var at = document.getElementById('maint-area-js-tbody');
1437    var dt = document.getElementById('maint-details-js-tbody');
1438    var bt = document.getElementById('maint-bt-js-tbody');
1439    if (at && dt && bt) { _initPending = false; initTables(); }
1440    else if (_initPending) { setTimeout(_tryInit, 60); }
1441  }
1442
1443  var _WATCHED = ['maint-area-js-tbody','maint-details-js-tbody','maint-bt-js-tbody','maint-staff-js-tbody','maint-type-js-tbody','maint-status-js-tbody','maint-period-js-tbody'];
1444
1445  // FIX (2026-08) <banner tự auto-dismiss không hoạt động ở tab Tần suất PM
1446  // + tab Tự động PM>: 2 tab này KHÔNG dùng cơ chế bảng JS (_js_tbody) như
1447  // 7 tab trên — chúng là bảng Dash render server-side thuần tuý, và các
1448  // sub-tab này được Dash MOUNT LAZY (chỉ tạo DOM khi user bấm vào tab đó
1449  // lần đầu). Vì vậy, lúc initTables() chạy lần đầu (ngay khi mở modal, ở
1450  // tab Area) thì #maint-pmfreq-msg / #maint-auto-msg CHƯA tồn tại trong
1451  // DOM -> setupAutoDismiss(id) gọi document.getElementById() trả về null
1452  // -> return sớm, KHÔNG gắn observer -> banner "Đã đồng bộ / Đã lưu" hiện
1453  // ra vĩnh viễn, không bao giờ tự ẩn (không có bất kỳ nơi nào gọi lại
1454  // setupAutoDismiss cho 2 id này sau đó, vì chúng không nằm trong
1455  // _WATCHED ở trên). Sửa: theo dõi riêng 2 id này trong CÙNG observer
1456  // document.body bên dưới — ngay khi DOM của chúng được Dash tạo ra lần
1457  // đầu (mount lazy khi click tab), gắn observer auto-dismiss ngay lập tức.
1458  var _LAZY_MSG_IDS = ['maint-pmfreq-msg', 'maint-auto-msg'];
1459
1460  // ── Observer 1: watch for Dash injecting our specific tbody IDs ────────────
1461  // CRITICAL: only match addedNodes with our exact tbody IDs.
1462  // Do NOT check if target is inside modal — that fires on every DOM change
1463  // inside the modal (including our own renderPagBar/renderAreaTable calls)
1464  // causing an infinite re-render loop.
1465  new MutationObserver(function(muts) {
1466    var foundIds = {};
1467    muts.forEach(function(m) {
1468      m.addedNodes.forEach(function(n) {
1469        if (n.nodeType !== 1) return;
1470        // Direct match
1471        if (_WATCHED.indexOf(n.id) >= 0) { foundIds[n.id] = n; return; }
1472        // Subtree match (Dash wraps in container div)
1473        if (n.querySelector) {
1474          _WATCHED.forEach(function(id) {
1475            var found = n.querySelector('#' + id);
1476            if (found) foundIds[id] = found;
1477          });
1478        }
1479        // (FIX <banner auto-dismiss lazy tab>) Bắt riêng lúc #maint-pmfreq-msg
1480        // / #maint-auto-msg lần đầu xuất hiện trong DOM (mount lazy khi user
1481        // bấm sang tab đó) -> gắn observer auto-dismiss ngay, không chờ
1482        // initTables() (lúc đó đã chạy xong từ trước, không chạy lại nữa).
1483        _LAZY_MSG_IDS.forEach(function(msgId) {
1484          if (n.id === msgId) { setupAutoDismiss(msgId); return; }
1485          if (n.querySelector && n.querySelector('#' + msgId)) setupAutoDismiss(msgId);
1486        });
1487      });
1488    });
1489
1490    var ids = Object.keys(foundIds);
1491    if (!ids.length) return; // nothing relevant — ignore
1492
1493    // Extra guard: if ALL mutations are inside pag divs, ignore them entirely
1494    // (renderPagBar updating button text/disabled triggers observer otherwise)
1495    var allInPag = muts.every(function(m) {
1496      var t = m.target;
1497      while (t) {
1498        if (_PAG_IDS.indexOf(t.id) >= 0) return true;
1499        t = t.parentElement;
1500      }
1501      return false;
1502    });
1503    if (allInPag) return;
1504
1505    // Check if primary tbodies are all present → full init
1506    var at = foundIds['maint-area-js-tbody']    || document.getElementById('maint-area-js-tbody');
1507    var dt = foundIds['maint-details-js-tbody'] || document.getElementById('maint-details-js-tbody');
1508    var bt = foundIds['maint-bt-js-tbody']      || document.getElementById('maint-bt-js-tbody');
1509
1510    if (at && dt && bt && !_initPending) {
1511      _initPending = true;
1512      setTimeout(function() { _initPending = false; initTables(); }, 80);
1513    } else {
1514      // Secondary tabs: only init the ones just added
1515      if (foundIds['maint-staff-js-tbody']) {
1516        var el = foundIds['maint-staff-js-tbody'];
1517        try{_staffData=JSON.parse(el.getAttribute('data-init')||'[]');}catch(e){_staffData=[];}
1518        syncHidden('maint-staff-store-hidden',_staffData); renderStaffTable();
1519      }
1520      if (foundIds['maint-type-js-tbody']) {
1521        var el2 = foundIds['maint-type-js-tbody'];
1522        try{_typeData=JSON.parse(el2.getAttribute('data-init')||'[]');}catch(e){_typeData=[];}
1523        syncHidden('maint-type-store-hidden',_typeData); renderTypeTable();
1524      }
1525      if (foundIds['maint-status-js-tbody']) {
1526        var el3 = foundIds['maint-status-js-tbody'];
1527        try{_statusData=JSON.parse(el3.getAttribute('data-init')||'[]');}catch(e){_statusData=[];}
1528        syncHidden('maint-status-store-hidden',_statusData); renderStatusTable();
1529      }
1530      if (foundIds['maint-period-js-tbody']) {
1531        var el4 = foundIds['maint-period-js-tbody'];
1532        try{_periodData=JSON.parse(el4.getAttribute('data-init')||'[]');}catch(e){_periodData=[];}
1533        syncHidden('maint-period-store-hidden',_periodData); renderPeriodTable();
1534      }
1535      bindAddButtons(); setupDirtyCheck();
1536      // Nạp baseline cho các tab vừa được Dash inject vào DOM (tab phụ mở
1537      // lazy khi click qua) rồi tính lại dirty UI cho đúng tab đó.
1538      ['staff', 'type', 'status', 'period'].forEach(function (k) {
1539        if (foundIds[_TBODY_ID[k]]) { _captureBaseline(k); _refreshDirtyUI(k); }
1540      });
1541    }
1542  }).observe(document.body, { childList: true, subtree: true });
1543
1544  // FIX (2026-08) <3>: theo dõi CẢ "data-init" LẪN "data-original".
1545  //  - "data-init" đổi (VD sau Upload file, hoặc sau Save Python trả về dữ
1546  //    liệu vừa lưu) → nạp lại dữ liệu vào bảng + render lại.
1547  //  - "data-original" đổi (CHỈ xảy ra khi Save thành công — Python cố ý
1548  //    KHÔNG đổi data-original khi Upload, xem cb_maintenance.py) → nạp lại
1549  //    baseline cho tab đó rồi tính lại dirty UI.
1550  // Nhờ tách 2 attribute này, Upload (chỉ đổi data-init) sẽ tự động làm dữ
1551  // liệu lệch baseline → Save/Cancel + badge "chưa lưu" tự xuất hiện, không
1552  // cần gọi markDirty() thủ công ở đâu cả.
1553  // FIX (2026-08) <2><3>: bug "phải đóng/mở modal Maintenance Settings 2 lần
1554  // mới thấy config Area load đúng" (kèm badge 'Có thay đổi chưa lưu' không
1555  // biến mất). Root cause thật sự (khác với các fix Cancel/close phía trên
1556  // — những fix đó xử lý 1 nguồn gây lệch data, còn đây là lỗi XẢY RA MỖI
1557  // LẦN MỞ LẠI MODAL, kể cả khi KHÔNG hề Upload/Cancel gì trước đó):
1558  //   - Tab Area luôn được Python render với data-init="[]" (cố ý — dữ liệu
1559  //     thật lấy qua fetch('/api/maint_area_config') riêng, xem comment ở
1560  //     _fetchAreaConfig()), coi "[]" chỉ là placeholder chờ fetch.
1561  //   - Khi bấm nút mở lại Modal, Dash re-render <Tbody id="maint-area-js-
1562  //     tbody">. NẾU React/Dash chỉ PATCH lại node cũ (thay vì tạo mới hẳn)
1563  //     — hoàn toàn có thể xảy ra vì cùng id + cùng vị trí trong cây — thì
1564  //     observer "childList" (theo dõi node MỚI được thêm vào DOM) sẽ KHÔNG
1565  //     bắn, vì tbody không hề bị remove/add lại. Chỉ có observer "attributes"
1566  //     bên dưới bắn (vì data-init đổi giá trị) — và code CŨ ở đây hiểu lầm
1567  //     "[]" là dữ liệu THẬT (rỗng), tự render "Chưa có cấu hình" + tính dirty
1568  //     sai (baseline cũ trong _original['area'] không khớp []) → hiện badge.
1569  //   - Do đó user phải đóng/mở LẦN 2: lần này DOM có thể bị tạo mới thật sự
1570  //     (hoặc do timing khác) → childList observer bắt được → initTables()
1571  //     chạy đúng luồng fetch.
1572  // Sửa triệt để: Python thêm 1 attribute "data-open-nonce" LUÔN đổi giá trị
1573  // mỗi lần modal mở (xem _build_area_tab trong ui_maintenance.py) — bất kể
1574  // node bị patch hay tạo mới, thuộc tính này LUÔN được ghi lại nên LUÔN kích
1575  // hoạt observer. Khi nonce đổi, coi đây là tín hiệu "Modal Area tab vừa mở
1576  // lại" và LUÔN gọi _fetchAreaConfig() để lấy dữ liệu THẬT — bỏ qua hoàn
1577  // toàn giá trị "[]" tạm thời mà Python vừa ghi vào data-init trong cùng
1578  // batch mutation này (không tự render rỗng theo nó).
1579  new MutationObserver(function(muts) {
1580    var changedByKey = {}; // key -> { init:bool, original:bool, nonce:bool }
1581    muts.forEach(function(m) {
1582      var el = m.target;
1583      var key = _KEY_BY_TBODY[el.id];
1584      if (!key) return;
1585      var info = changedByKey[key] || (changedByKey[key] = {});
1586      if (m.attributeName === 'data-init') info.init = true;
1587      else if (m.attributeName === 'data-original') info.original = true;
1588      else if (m.attributeName === 'data-open-nonce') info.nonce = true;
1589    });
1590    Object.keys(changedByKey).forEach(function(key) {
1591      var info = changedByKey[key];
1592      if (key === 'area' && info.nonce) {
1593        // Modal Area tab vừa (re)mở — luôn fetch lại dữ liệu thật, KHÔNG
1594        // render trực tiếp theo data-init="[]" tạm thời (dù nó có đổi
1595        // trong cùng batch mutation này hay không).
1596        _fetchAreaConfig(1);
1597        return;
1598      }
1599      if (info.init) {
1600        var tb = document.getElementById(_TBODY_ID[key]);
1601        var map = {
1602          area:    function(d){ _areaData=d;   _areaPage=0;  renderAreaTable(); },
1603          details: function(d){ _detData=d;    _detPage=0;   renderDetailsTable(); },
1604          bt:      function(d){ _btData=d;     _btPage=0;    renderBtTable(); },
1605          staff:   function(d){ _staffData=d;  renderStaffTable(); },
1606          type:    function(d){ _typeData=d;   renderTypeTable(); },
1607          status:  function(d){ _statusData=d; renderStatusTable(); },
1608          period:  function(d){ _periodData=d; renderPeriodTable(); },
1609        };
1610        var fn = map[key];
1611        if (fn && tb) {
1612          try { fn(JSON.parse(tb.getAttribute('data-init')||'[]')); } catch(e) { fn([]); }
1613          _refreshDirtyUI(key);
1614        }
1615      }
1616      if (info.original) {
1617        _captureBaseline(key);
1618        _refreshDirtyUI(key);
1619      }
1620    });
1621  }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['data-init', 'data-original', 'data-open-nonce'] });
1622
1623  new MutationObserver(function() { styleHeaders(); })
1624    .observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] });
1625
1626  // ── STAFF TAB ─────────────────────────────────────────────────────────────
1627  var _staffData = [];
1628
1629  function renderStaffTable() {
1630    // Staff tab giờ luôn READ-ONLY (live từ Shift Schedule) — không còn
1631    // input editable, không còn nút xoá dòng, không còn Thêm/Lưu/Hủy.
1632    var tb = document.getElementById('maint-staff-js-tbody');
1633    if (!tb) return;
1634    var html = '';
1635    if (!_staffData.length) {
1636      html = '<tr><td colspan="3" class="maint-settings-empty-cell">' + _t('maint_js_no_shift_employees') + '</td></tr>';
1637    } else {
1638      _staffData.forEach(function(row) {
1639        html += '<tr class="maint-settings-row">';
1640        html += '<td class="maint-settings-cell">'+esc(row.name||'')+'</td>';
1641        html += '<td class="maint-settings-cell">'+esc(row.email||'')+'</td>';
1642        html += '<td class="maint-settings-cell">'+esc(row.role||'')+'</td></tr>';
1643      });
1644    }
1645    tb.innerHTML = html;
1646  }
1647
1648  // ── TYPE TAB ──────────────────────────────────────────────────────────────
1649  var _typeData = [];
1650
1651  function renderTypeTable() {
1652    var tb = document.getElementById('maint-type-js-tbody');
1653    if (!tb) return;
1654    var ro = tb.getAttribute('data-readonly') === 'true';
1655    var html = '';
1656    if (!_typeData.length) {
1657      html = '<tr><td colspan="2" class="maint-settings-empty-cell">' + esc(_t('maint_js_type_empty', 'Chưa có loại. Nhấn "Thêm loại" để bắt đầu.')) + '</td></tr>';
1658    } else {
1659      _typeData.forEach(function(row, i){
1660        html += '<tr class="maint-settings-row" data-idx="'+i+'">';
1661        html += '<td class="maint-settings-cell"><input class="maint-settings-input" data-field="type_name" value="'+esc(row.type_name||'')+'" '+(ro?'disabled':'')+'/></td>';
1662        html += '<td class="maint-settings-cell text-center">'+((!ro)?'<button class="btn btn-sm btn-outline-danger py-0 px-1 type-del-btn" data-idx="'+i+'">&#215;</button>':'')+'</td></tr>';
1663      });
1664    }
1665    tb.innerHTML = html;
1666    tb.querySelectorAll('input').forEach(function(inp){
1667      inp.addEventListener('input', function(){
1668        var idx = parseInt(inp.closest('tr').dataset.idx);
1669        _typeData[idx][inp.dataset.field] = inp.value;
1670        syncHidden('maint-type-store-hidden', _typeData);
1671        markDirty('type');
1672      });
1673    });
1674    tb.querySelectorAll('.type-del-btn').forEach(function(btn){
1675      btn.addEventListener('click', function(){
1676        _typeData.splice(parseInt(btn.dataset.idx), 1);
1677        syncHidden('maint-type-store-hidden', _typeData);
1678        markDirty('type'); renderTypeTable();
1679      });
1680    });
1681  }
1682
1683  document.addEventListener('click', function(e) {
1684    var t = e.target;
1685    if (t.closest && t.closest('#maint-type-add-btn')) {
1686      _typeData.push({id:'new_'+Date.now(), type_name:''});
1687      syncHidden('maint-type-store-hidden', _typeData);
1688      markDirty('type'); renderTypeTable();
1689      setTimeout(function(){
1690        var tb = document.getElementById('maint-type-js-tbody');
1691        if (!tb) return;
1692        var last = tb.querySelectorAll('tr.maint-settings-row');
1693        last = last[last.length-1];
1694        if (last) { var inp = last.querySelector('input'); if (inp) inp.focus(); }
1695      }, 30);
1696    }
1697    if (t.closest && t.closest('#maint-type-cancel-btn')) {
1698      var tb = document.getElementById('maint-type-js-tbody');
1699      if (tb) {
1700        var _raw = tb.getAttribute('data-original'); if (_raw === null) _raw = tb.getAttribute('data-init') || '[]'; try { _typeData = JSON.parse(_raw); } catch(e2) { _typeData = []; }
1701        syncHidden('maint-type-store-hidden', _typeData);
1702        renderTypeTable();
1703      }
1704      clearDirty('type');
1705    }
1706  });
1707
1708  // ── STATUS TAB ────────────────────────────────────────────────────────────
1709  var _statusData = [];
1710
1711  function renderStatusTable() {
1712    var tb = document.getElementById('maint-status-js-tbody');
1713    if (!tb) return;
1714    var ro = tb.getAttribute('data-readonly') === 'true';
1715    var html = '';
1716    if (!_statusData.length) {
1717      html = '<tr><td colspan="2" class="maint-settings-empty-cell">' + esc(_t('maint_js_status_empty', 'Chưa có trạng thái. Nhấn "Thêm trạng thái" để bắt đầu.')) + '</td></tr>';
1718    } else {
1719      _statusData.forEach(function(row, i){
1720        html += '<tr class="maint-settings-row" data-idx="'+i+'">';
1721        html += '<td class="maint-settings-cell"><input class="maint-settings-input" data-field="status_name" value="'+esc(row.status_name||'')+'" '+(ro?'disabled':'')+'/></td>';
1722        html += '<td class="maint-settings-cell text-center">'+((!ro)?'<button class="btn btn-sm btn-outline-danger py-0 px-1 status-del-btn" data-idx="'+i+'">&#215;</button>':'')+'</td></tr>';
1723      });
1724    }
1725    tb.innerHTML = html;
1726    tb.querySelectorAll('input').forEach(function(inp){
1727      inp.addEventListener('input', function(){
1728        var idx = parseInt(inp.closest('tr').dataset.idx);
1729        _statusData[idx][inp.dataset.field] = inp.value;
1730        syncHidden('maint-status-store-hidden', _statusData);
1731        markDirty('status');
1732      });
1733    });
1734    tb.querySelectorAll('.status-del-btn').forEach(function(btn){
1735      btn.addEventListener('click', function(){
1736        _statusData.splice(parseInt(btn.dataset.idx), 1);
1737        syncHidden('maint-status-store-hidden', _statusData);
1738        markDirty('status'); renderStatusTable();
1739      });
1740    });
1741  }
1742
1743  document.addEventListener('click', function(e) {
1744    var t = e.target;
1745    if (t.closest && t.closest('#maint-status-add-btn')) {
1746      _statusData.push({id:'new_'+Date.now(), status_name:''});
1747      syncHidden('maint-status-store-hidden', _statusData);
1748      markDirty('status'); renderStatusTable();
1749      setTimeout(function(){
1750        var tb = document.getElementById('maint-status-js-tbody');
1751        if (!tb) return;
1752        var last = tb.querySelectorAll('tr.maint-settings-row');
1753        last = last[last.length-1];
1754        if (last) { var inp = last.querySelector('input'); if (inp) inp.focus(); }
1755      }, 30);
1756    }
1757    if (t.closest && t.closest('#maint-status-cancel-btn')) {
1758      var tb = document.getElementById('maint-status-js-tbody');
1759      if (tb) {
1760        var _raw = tb.getAttribute('data-original'); if (_raw === null) _raw = tb.getAttribute('data-init') || '[]'; try { _statusData = JSON.parse(_raw); } catch(e2) { _statusData = []; }
1761        syncHidden('maint-status-store-hidden', _statusData);
1762        renderStatusTable();
1763      }
1764      clearDirty('status');
1765    }
1766  });
1767
1768  // ── PERIOD TYPE TAB ───────────────────────────────────────────────────────
1769  var _periodData = [];
1770
1771  function renderPeriodTable() {
1772    var tb = document.getElementById('maint-period-js-tbody');
1773    if (!tb) return;
1774    var ro = tb.getAttribute('data-readonly') === 'true';
1775    var html = '';
1776    if (!_periodData.length) {
1777      html = '<tr><td colspan="3" class="maint-settings-empty-cell">' + esc(_t('maint_js_period_empty', 'Chưa có loại kỳ. Nhấn "Thêm loại kỳ" để bắt đầu.')) + '</td></tr>';
1778    } else {
1779      _periodData.forEach(function(row, i){
1780        html += '<tr class="maint-settings-row" data-idx="'+i+'">';
1781        html += '<td class="maint-settings-cell"><input class="maint-settings-input" data-field="period_name" value="'+esc(row.period_name||'')+'" '+(ro?'disabled':'')+'/></td>';
1782        html += '<td class="maint-settings-cell"><input class="maint-settings-input" data-field="period_value" value="'+esc(row.period_value||'')+'" '+(ro?'disabled':'')+' placeholder="vd: monthly"/></td>';
1783        html += '<td class="maint-settings-cell text-center">'+((!ro)?'<button class="btn btn-sm btn-outline-danger py-0 px-1 period-del-btn" data-idx="'+i+'">&#215;</button>':'')+'</td></tr>';
1784      });
1785    }
1786    tb.innerHTML = html;
1787    tb.querySelectorAll('input').forEach(function(inp){
1788      inp.addEventListener('input', function(){
1789        var idx = parseInt(inp.closest('tr').dataset.idx);
1790        _periodData[idx][inp.dataset.field] = inp.value;
1791        syncHidden('maint-period-store-hidden', _periodData);
1792        markDirty('period');
1793      });
1794    });
1795    tb.querySelectorAll('.period-del-btn').forEach(function(btn){
1796      btn.addEventListener('click', function(){
1797        _periodData.splice(parseInt(btn.dataset.idx), 1);
1798        syncHidden('maint-period-store-hidden', _periodData);
1799        markDirty('period'); renderPeriodTable();
1800      });
1801    });
1802  }
1803
1804  document.addEventListener('click', function(e) {
1805    var t = e.target;
1806    if (t.closest && t.closest('#maint-period-add-btn')) {
1807      _periodData.push({id:'new_'+Date.now(), period_name:'', period_value:''});
1808      syncHidden('maint-period-store-hidden', _periodData);
1809      markDirty('period'); renderPeriodTable();
1810      setTimeout(function(){
1811        var tb = document.getElementById('maint-period-js-tbody');
1812        if (!tb) return;
1813        var last = tb.querySelectorAll('tr.maint-settings-row');
1814        last = last[last.length-1];
1815        if (last) { var inp = last.querySelector('input'); if (inp) inp.focus(); }
1816      }, 30);
1817    }
1818    if (t.closest && t.closest('#maint-period-cancel-btn')) {
1819      var tb = document.getElementById('maint-period-js-tbody');
1820      if (tb) {
1821        var _raw = tb.getAttribute('data-original'); if (_raw === null) _raw = tb.getAttribute('data-init') || '[]'; try { _periodData = JSON.parse(_raw); } catch(e2) { _periodData = []; }
1822        syncHidden('maint-period-store-hidden', _periodData);
1823        renderPeriodTable();
1824      }
1825      clearDirty('period');
1826    }
1827  });
1828
1829})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.