# HR MANAGER – CORE RULES (Prompt rút gọn)

## 0) Mục tiêu
- Giữ **đúng quy trình dự án** nhưng **giảm context**.
- Ưu tiên: kiến trúc, luồng xử lý, quy tắc bắt buộc, pattern CRUD/AJAX, i18n, permission, popup.
- Tránh: mô tả CSS chi tiết, ví dụ HTML dài, lặp lại.

## 1) Kiến trúc & nguyên tắc tách lớp (BẮT BUỘC)
- **1 page = 1 file** trong `pages/`.
- **1 page = 1 module** trong `modules/`.
- **Module**: chỉ xử lý **data/business logic**, **không HTML**.
- **Page**: chỉ **HTML + gọi module**, **không SQL trực tiếp**.

## 2) index.php là entry point – thứ tự xử lý (BẮT BUỘC)
Tất cả handler (AJAX/POST/GET) phải nằm **TRƯỚC mọi HTML output**.

Thứ tự chuẩn:
1. `session_start()`
2. Auth check / require login (tuỳ route)
3. `require` DB/config cần thiết
4. **AJAX handlers** (`json_encode(...)` + `exit;`)
5. Load language (`lang/message_vn.php` hoặc `lang/message_eng.php`)
6. **POST/GET handlers** (kiểu redirect + `exit;`)
7. Routing `$_GET['pages']` → include page
8. Render HTML layout (header/menu/content/footer)

## 3) Quy tắc AJAX (BẮT BUỘC)
- Handler AJAX đặt trong `index.php` (không đặt trong page/module).
- Response **JSON tối giản** và luôn `exit;` ngay.
- Khi gọi CRUD bằng JS, gửi `ajax_submit=1` để server trả JSON.
- Không dùng `alert()` / `confirm()`.

## 4) Popup notice/confirm (BẮT BUỘC)
- Dùng `lib/popup_notice.php`.
- Thông báo:
  - Server-side: `setPopupMessage()` → redirect → `checkPopupMessage()` → `showPopupNotice()`
  - Client-side: `showPopupNoticeJS()` / `showConfirmPopupJS()`
- Z-index popup: **10001** (cao nhất).

## 5) i18n – ngôn ngữ (BẮT BUỘC)
- Mọi text hiển thị: `<?php echo $lang['key']; ?>`.
- Key phải tồn tại **song song** trong:
  - `lang/message_vn.php`
  - `lang/message_eng.php`
- Khi đổi ngôn ngữ:
  - Lưu `$_SESSION['lang']` (`vn|eng`)
  - Gọi `LoginModule::refreshPermissionName()` để cập nhật tên quyền theo ngôn ngữ
- AJAX change language pattern:
  - POST `action=change_lang`
  - GET `ajax_get_lang=1` trả JSON `$lang`
  - Client update mọi `[data-lang-key]`, update `#header-permission-name`, rồi reload content bằng `loadPageAjax(currentPage, false)`
- JS/PHP quote: khi echo `$lang` vào JS string, dùng **double quotes** cho outer string.

## 6) Permission / access control (BẮT BUỘC)
- Mọi page và mọi AJAX đều phải check session + permission.
- Quyền menu cấu hình ở `config/access.json`.
- Menu hiển thị dựa trên `canAccess()`.
- Map page → menu id (chuẩn):
  - `home` → `menu01`
  - `company` → `menu02_01_01`
  - `category` → `menu02_01_02`
  - `documentout` → `menu02_02_01`
  - `documentin` → `menu02_02_02`
  - `permission` → `menu08_01`
- Nếu không đủ quyền:
  - Có thể redirect `?pages=error&type=403` hoặc include inline `error.php` rồi `return;`.

## 7) Database rules (BẮT BUỘC)
- Chỉ dùng helper `DB::` (fetchAll/fetchOne/execute/query/beginTransaction/commit/rollback).
- **Không** gọi `sqlsrv_*` trực tiếp.
- **Luôn parameterized** (`?` placeholders), **không nối chuỗi SQL**.
- Luôn dùng schema `dbo.{TableName}`.
- Không `SELECT *` (chỉ lấy cột cần thiết).
- Pagination: ưu tiên `ROW_NUMBER()` (SQL Server 2008+).
- SQL Server **không có boolean type** → **KHÔNG** viết `(condition) = 0/1`.
  - Dùng `NOT (condition)` hoặc điều kiện ngược lại (VD: `col IS NULL OR col = ''`).
- **SERVER-SIDE PAGINATION (BẮT BUỘC)**: Không bao giờ fetch toàn bộ records vào PHP memory để phân trang/phân mảnh. Luôn dùng SQL `ROW_NUMBER()` với `$limit`/`$offset`.
- **Initial PHP load phải dùng cùng pagination/search logic với AJAX handler**. Không viết 2 code path riêng (một cho PHP render, một cho AJAX search) — nếu có filter param, truyền vào SQL query, không PHP-slice.
- **Batch file existence check**: Thêm `CASE WHEN FileUrl IS NOT NULL AND FileUrl != '' THEN 1 ELSE 0 END AS HasFileUrl` vào SQL SELECT. Dùng `HasFileUrl` từ server cho view button visibility, **KHÔNG** gọi N+1 AJAX `ajax_check_file_exists` mỗi row. Xoá `checkRowFilesAndToggleViewButtons()` / `updateTableViewButtons()` khi đã chuyển qua server-side check.
- **checkDuplicate() – single query**: Không loop từng field gọi `SELECT COUNT(*)`. Dùng 1 query với `SUM(CASE WHEN field = ? THEN 1 ELSE 0 END) AS [field]` cho tất cả field cần kiểm tra.

## 8) BaseModule pattern
- Module extends `BaseModule`.
- Khai báo: `$table`, `$primaryKey`, `$fillable`, `$searchable`.
- Các thao tác CRUD/search/counter theo methods chuẩn của BaseModule.

## 9) Quy tắc các page data (list/search/pagination) (BẮT BUỘC)
- **Tìm kiếm tự động (auto-search)** – gõ là ra, **không cần Enter**:
  - Bắt sự kiện `input` trên ô search, **debounce 300ms**
  - Gọi AJAX `index.php?ajax_search={pageName}&keyword=...&page=1&per_page=...`
  - Server trả JSON: `{success, data, total, page, perPage, totalPages}`
  - Client gọi tuần tự: `updateTable(data)` → `updatePagination(...)` → `updateUrl()`
- **Initial PHP load = same logic as AJAX**: PHP render initial table dùng **cùng module method** với AJAX handler (cùng pagination, cùng filter param). Không fetch 9999 rows + PHP-slice. Không gọi `performSearch()` lại từ JS sau khi PHP đã render (tránh duplicate fetch).
- **AJAX handler** trong `index.php` (trước HTML):
  - Dùng Module tương ứng (`DocumentOutModule`, `DocumentInModule`, `CompanyModule`…)
  - Nếu keyword rỗng → `getAllWithJoins()`, ngược lại → `searchAccentInsensitive()`
- **Cập nhật table** (`updateTable`):
  - Nếu `data` rỗng → hiển thị message "không có dữ liệu" / "không tìm thấy"
  - Nếu có data → build HTML rows, gán `data-id`, `data-file-url`, `data-quantity` lên `<tr>`
  - View button visibility dùng `row.HasFileUrl` (từ server-side SQL `CASE WHEN`), **KHÔNG** gọi `updateTableViewButtons()` / `checkRowFilesAndToggleViewButtons()` (đã xoá).
- **Cập nhật pagination** (`updatePagination`):
  - Cập nhật text "Hiển thị X-Y / Z bản ghi"
  - Render lại page buttons dạng `javascript:goToPage(n)` (không dùng `<a href="?pages=...">` reload)
- **Cập nhật URL** (`updateUrl`):
  - `history.pushState({page, keyword, perPage}, '', 'index.php?pages=...&search=...&per_page=...&page=...')`
  - **Không reload trang**, chỉ thay đổi URL trên address bar
- **popstate handler**: khi nhấn Back/Forward → restore `currentPage`, `currentKeyword`, `currentPerPage` → gọi `performSearch()`
- **Rows-per-page dropdown**: chọn giá trị → cập nhật `currentPerPage`, reset `currentPage=1`, gọi `performSearch()` (không reload)
- **FK JOIN – tên cột đúng** (hay sai nhất):
  - `DocumentOut.DocType` → JOIN `DocType.Id_Type` (KHÔNG phải `DocumentOut.Id_Type`)
  - `Document.TypeDoc` → JOIN `DocPageType.Id_Type` (KHÔNG phải `Document.Id_Type`)
  - `DocumentOut.ID_Company` → JOIN `Company.ID_Company`
  - `Document.ID_Company` → JOIN `Company.ID_Company`
- Bố cục page chuẩn:
  - Permission check
  - `<div class="content">`
  - Title (`.page-title`)
  - Search bar
  - Table
  - Pagination
  - Action buttons

## 10) CRUD UI pattern (BẮT BUỘC)
- Edit button: **chỉ truyền ID** trong `onclick`, sau đó AJAX fetch lấy data.
- Không nhét dữ liệu record vào `onclick` (tránh lỗi ký tự đặc biệt).
- Submit:
  - `FormData + ajax_submit=1` → JSON → `showPopupNoticeJS` → `loadPageAjax(...)`
- Delete:
  - `showConfirmPopupJS` → fetch `ajax_submit=1` → JSON → notice → reload page content.

## 11) CSS/Theme rules (BẮT BUỘC)
- Thứ tự load CSS:
  1) `all.css`
  2) `base.css`
  3) `layout.css`
  4) `components.css`
  5) `pages.css`
- **Tất cả màu phải dùng CSS variables** `var(--...)` (không hardcode).
- Ưu tiên dùng class có sẵn trong `components.css` trước khi tạo class mới.
- Nếu cần thêm CSS mới: **phải hỏi user trước**.
- `.table-container`: `overflow: visible`.

## 12) JS & page loading – SPA compliance (BẮT BUỘC)

### 12a) SPA architecture
- `loadPageAjax()` chỉ replace nội dung `#page-content`, **không reload** header/sidebar/footer.
- Khi thêm page mới, **bắt buộc** cập nhật 3 chỗ trong `index.php`:
  1. Mảng `ajaxPages` – thêm page name để click bị intercept (không full reload).
  2. Hàm `updateSidebarActive()` – thêm map `pageName → menuId` để sidebar active đúng.
  3. Block `ajax_load_page` routing – thêm `elseif` để server trả đúng file page.

### 12b) Navigation – cấm dùng window.location (BẮT BUỘC)
- **KHÔNG** dùng `window.location.href`, `window.location.assign()`, `window.location.replace()`, `window.location.reload()` trong page scripts.
- Để navigate nội bộ: **chỉ dùng `loadPageAjax('pageName')`**.
- Nếu `loadPageAjax` không khả dụng (edge case): `console.error('[SPA] loadPageAjax is not available')` – **không fallback sang `window.location.*`**.
- Ngoại lệ: `window.location.href = 'logout.php'` (thoát hệ thống) là full navigation hợp lý.

### 12c) IIFE wrap – cô lập scope (BẮT BUỘC)
- **Toàn bộ** JS trong page phải nằm trong **1 IIFE duy nhất**: `(function() { ... })();`
- Các hàm cần gọi từ HTML onclick: expose ra `window` (VD: `window.editCompany = editCompany;`).
- **KHÔNG** để code JS ngoài IIFE (gây xung đột biến khi chuyển trang AJAX nhiều lần).
- Nếu page có nhiều `<script>` block, **mỗi block** phải có IIFE riêng hoặc đảm bảo biến dùng chung được lưu trên `window`.

### 12d) KHÔNG dùng DOMContentLoaded (BẮT BUỘC)
- **KHÔNG** dùng `document.addEventListener('DOMContentLoaded', ...)` trong page scripts.
- Khi page load qua AJAX, DOM đã sẵn sàng → `DOMContentLoaded` **không fire** → code bên trong không chạy.
- Thay thế: **chạy code ngay lập tức** (top-level trong IIFE).
- **KHÔNG** dùng `setTimeout(fn, 100)` hack để đợi DOM ready – cũng không cần thiết.

### 12e) langKeys & biến dùng chung (BẮT BUỘC)
- `langKeys` phải lưu trên `window`, dùng `Object.assign` để merge keys mới (KHÔNG dùng `||` vì bỏ qua keys mới khi chuyển trang AJAX):
  ```
  var _pageLangKeys = { key1: '...', key2: '...' };
  window.langKeys = Object.assign(window.langKeys || {}, _pageLangKeys);
  var langKeys = window.langKeys;
  ```
- Tạo alias trong mỗi script block: `var langKeys = window.langKeys;`
- Các biến state dùng chung (VD: `currentKeyword`, `currentPage`, `currentPerPage`): lưu trên `window` hoặc khai báo `var` ở top-level script đầu tiên, không dùng `const`/`let` ở scope IIFE nếu cần truy cập từ script khác.

### 12f) Permission check (BẮT BUỘC)
- **Mọi page** phải có permission check ở đầu file PHP (trước HTML output).
- Pattern chuẩn:
  ```php
  $user_perm_id = isset($_SESSION['user_permission_id']) ? (int)$_SESSION['user_permission_id'] : 0;
  $access_check_json = @file_get_contents(__DIR__ . '/../config/access.json');
  $access_check_data = $access_check_json ? json_decode($access_check_json, true) : null;
  $allowed_levels = [];
  if ($access_check_data && isset($access_check_data['permissions'][$menu_perm_key])) {
    $allowed_levels = array_map('intval', explode(',', $access_check_data['permissions'][$menu_perm_key]));
  }
  if (!in_array($user_perm_id, $allowed_levels)) {
    $error_code = '403'; $error_icon = 'fa-ban';
    $error_title = $lang['error_forbidden_title'];
    $error_message = $lang['error_forbidden_message'];
    include __DIR__ . '/error.php'; return;
  }
  ```

### 12g) <script> tag
- `<script>` tag phải đóng đầy đủ (`</script>`), không thiếu.
- Nếu page có nhiều `<script>` block, đảm bảo biến dùng chung được share qua `window.*`.

### 12h) Event listeners – tránh leak khi loadPageAjax (BẮT BUỘC)
- Vì page được load nhiều lần qua `loadPageAjax()`, **tuyệt đối không** gắn listener global (`document/window/...addEventListener`) bằng **anonymous function** mà không remove.
- Với các handler global (đặc biệt **click-outside-to-close dropdown**):
  - Lưu handler trên `window` (hoặc registry trên `window`) theo key.
  - Luôn `removeEventListener` handler cũ trước khi `addEventListener` lại.
  - Nếu dropdown nằm trong `.popup-container` thì ưu tiên bind trên `.popup-container` (không bind thẳng `document`).

### 12i) SPA data fetching – cấm dùng fetch() không có ajax_load_page (BẮT BUỘC)
- Để fetch dữ liệu JSON từ server:
  - **Luôn** tạo dedicated endpoint trong `index.php` (trước HTML output) → `exit` với JSON.
  - **KHÔNG** dùng `fetch('index.php?pages=...')` trực tiếp — URL này đi qua full page render (header + menu + footer), response không phải JSON thuần.
- Dùng `loadPageAjax('pageName&param=val')` để tải lại nội dung page (tự thêm `ajax_load_page=1`, chỉ render page file).
- Nếu cần fetch dữ liệu nhẹ (roster JSON, v.v.), tạo handler riêng ở index.php với prefix `ajax_` và response `Content-Type: application/json`.

### 12j) Global hàm cho HTML onclick (BẮT BUỘC)
- Mọi function gọi từ `onclick="fn()"` trong HTML phải được expose qua `window.fn = fn`.
- `function fn() {}` (declaration) trong IIFE **không** global → onclick không tìm thấy.
- Luôn kiểm tra các function tham chiếu từ HTML attributes sau khi viết.

### 12k) Loading indicator cho AJAX data (BẮT BUỘC)
- Dùng class chung `.loading-indicator` (đã có sẵn trong `components.css`):
  - HTML: `<div class="loading-indicator" id="xxx-loading"><i class="fas fa-spinner fa-spin"></i> $lang['xxx_loading']</div>`
  - Key `xxx_loading` thêm song song vào cả 2 file lang.
- Table/footer/search ẩn (`display:none`) khi chưa có data, hiện sau khi AJAX thành công.
- Khi AJAX lỗi: hiển thị message lỗi trong `.loading-indicator` (không để spinner quay mãi).

## 13) Notification badge — HTML & CSS (BẮT BUỘC)
- Cả 3 loại `.notification-badge`, `.notification-badge-footer` đều mặc định `display: none`.
- Khi cần hiện: JS `element.style.display = 'flex'`.
- Badge `position: absolute` — thẻ `<i>` chứa nó phải có `position: relative`.
- **Khi thêm menu mới (cả 3 cấp) vào sidebar (`control_menu.php`) HOẶC footer (`control_footer.php`), bắt buộc thêm badge span bên trong thẻ `<i>` của menu item đó, giá trị mặc định `1`**:
  ```html
  <!-- Sidebar -->
  <i class="fas fa-icon"><span class="notification-badge">1</span></i>
  <!-- Footer nav-item (L1) -->
  <i class="fas fa-icon"><span class="notification-badge-footer">1</span></i>
  <!-- Footer submenu-item (L2/L3) -->
  <i class="fas fa-icon"><span class="notification-badge-footer">1</span></i>
  ```
- CSS selectors phải match đúng DOM structure:
  - Sidebar L1: `.sidebar .nav-item i .notification-badge` (dùng `.sidebar`, **không** `.sidebar-content` vì submenu nằm ngoài)
  - Sidebar L2/L3: `.sidebar .submenu-item i .notification-badge`
  - Footer nav-item: `.footer .footer-nav-item i .notification-badge-footer`
  - Footer submenu-item: `.footer .footer-submenu-item i .notification-badge-footer`
- Khi thêm L1 footer menu có submenu trong `control_footer.php`: bắt buộc thêm CSS class JS identifier (VD: `.workplan-menu`), thêm event listener toggle submenu, và thêm vào danh sách global click-outside exclusion.

## 14) Realtime badge data — server-realtime (BẮT BUỘC)

### 14a) fetch-badge.php — dual logic
- `count` (`menu05_01` — Đăng ký lịch làm việc): **per-user** — lấy `$_SESSION['user_id']` → truy vấn `MaBophan` từ `dbo.DataWork` → chỉ check 1 file roster của bộ phận đó.
- `pending` (`menu03_05_01` — Lịch tuần bộ phận): **aggregate** — quét tất cả bộ phận từ `dbo.Properti_Vitri`, đếm số file roster có `permission = 0 || 3`.
- Cả `count` và `pending` trả về qua `badges` object, không qua `count`/`pending` riêng.

### 14b) 3-level badge propagation
- Định nghĩa `$leafParents` map: leaf menu ID → `[L2_parent, L1_parent]`.
- Leaf trực thuộc L1 (không L2): `['menu03_01' => ['menu03']]`.
- Leaf có L2: `['menu03_05_01' => ['menu03_05', 'menu03']]`.
- Hàm `propagateBadge()`: set leaf = value; nếu value > 0, cộng dồn vào từng parent (`+=`).
- Tất cả menu ID (leaf + intermediate + L1) khởi tạo = 0 trong `$badges`.
- Kết quả trả về: `{success: true, badges: {menuId: count, ...}}`.

### 14c) Deadline filter (menu05_01)
- Đọc `weekly_report_day` từ `config/setting.json` (giá trị: mon/tue/wed/thu/fri/sat/sun).
- Map day → số ISO `date('N')` (1=Mon—7=Sun).
- Chỉ hiển thị badge nếu `currentDay >= configuredDay` (nếu chưa đến hạn → `count = 0`).

### 14d) Server.js — không broadcast badge data
- Badge data là per-user (cần session cookies) → Node.js server **không thể** broadcast.
- `server.js` chỉ xử lý: `notification` (popup), `/send-notification`, `/reload-settings`.
- Xoá: `fetchRosterStatus()`, `checkBadge()`, `lastBadgeData`, `pollTimer`, `request_badge` event.

### 14e) realtime.js — HTTP fallback là primary
- `socket.on('badge_update')` **không dùng** (server không broadcast badge).
- `pollBadge()` fetch `fetch-badge.php` mỗi `REALTIME_DELAY` giây (từ `setting.json`).
- Dùng `data.badges` trực tiếp: `applyBadgeData(data.badges);`.
- Socket.IO chỉ dùng cho `notification` event.
- **Leaf menu** (có trang thật) hiển thị **số**, **parent menu** (L1/L2 có submenu) hiển thị **"new"** hoặc **"..."** hoặc **tổng count con** (tuỳ config trong `applyBadgeData`).
- "Khác" container: badge = "new" nếu có bất kỳ menu con nào > 0, hoặc = tổng count.

### 14f) "Khác" container badge aggregation
- Container "Khác" (`more-menu`) dùng `id="more-menu"` — **không** `id="menu05"` (tránh conflict với menu05 thật).
- JS `applyBadgeData()` tính tổng từ `data[id]` (không DOM) cho các menu con trong `#more-menu > .footer-submenu` (chỉ main submenu, skip nested).
- Khi thêm L1 container có submenu động (như "Khác"), không dùng `id` trùng với menu thật.

### 14g) Badge CSS — resize cho text "new"/"..."
- Badge parent: `min-width:16px; width:auto; padding:0 4px;` (tự co giãn: tròn khi số, pill khi text).
- Leaf vẫn giữ `width:16px; height:16px` (tròn).

## 15) SPA compliance — checklist tối ưu (BẮT BUỘC)
- **IIFE**: Toàn bộ JS trong page phải nằm trong `(function(){...})()`. Các hàm gọi từ HTML onclick phải expose qua `window.fn = fn`.
- **langKeys**: Dùng `window.langKeys = Object.assign(window.langKeys || {}, _pageLangKeys); var langKeys = window.langKeys;`. **Không** dùng `<?php echo $lang['key']; ?>` trực tiếp trong JS string (trừ page không có i18n).
- **Event listener cleanup**: Listener trên `document`, `window`, hoặc element bị replace (`innerHTML`) phải dùng registry pattern: `if(window._key){el.removeEventListener('evt',window._key)} window._key=function(e){...}; el.addEventListener('evt',window._key);`.
- **window.location.href/reload**: **Cấm tuyệt đối** (rule 12b). Chỉ dùng `loadPageAjax('pageName', false)`.
- **Inline styles → CSS classes**: Không viết `style="..."` trong HTML. Tạo class trong `pages.css`.
- **Hardcode colors**: `#f1c40f`, `#e67e22` → `var(--accent)`, `var(--warning)`. Mọi màu phải dùng CSS variable.
- **Duplicate CSS**: Kiểm tra class đã có trong `components.css` trước khi viết lại (VD: `.template-btn`, `.template-square-btn`, `.template-icon-btn`).
- **Numeric fields** dùng `type="text"` + `oninput="this.value=this.value.replace(/[^0-9]/g,'')"` thay vì `type="number"` (tránh scroll wheel thay đổi giá trị ngoài ý muốn).

## 16) CSS edit safety (BẮT BUỘC)
- Dùng `replaceAll` phải verify pattern match **chỉ** target intended blocks.
- Sau mọi edit CSS: đọc lại file để kiểm tra:
  - Không có property orphan (treo ngoài selector).
  - Không mất class/block gốc.
  - Các `{ }` cân bằng.
- Khi chèn block mới giữa 2 block cũ, kiểm tra không làm hỏng cấu trúc block xung quanh.

## 17) Dropdown permission status icon (BẮT BUỘC)
- Cột màu `fas fa-circle` trong custom-select-option cho department schedule (`dsPermColor()`):
  - `-1` (chưa gửi): `var(--border)`
  - `0` (chờ duyệt): `#b8860b` (vàng) — **tách riêng khỏi rejected**
  - `2` (từ chối): `#e74c3c` (đỏ)
  - `3` (gửi lại-chờ duyệt): `#9c27b0` (tím)
  - `1` (đã duyệt): `#2ecc71` (xanh)
  - `""` (draft/lưu nháp): coi như `-1`

## 18) Week navigation pattern (BẮT BUỘC)
- Dùng `loadPageAjax('pageName&week_offset=X')` để chuyển tuần.
- PHP tính `$weekOffset = (int)($_GET['week_offset'] ?? 0)`, cộng `$weekOffset * 7` ngày vào `DateTime('monday this week')`.
- JS `wsWeekOffset` đồng bộ với PHP.
- Giới hạn điều hướng ±1 tuần (tuỳ yêu cầu), disable nút tương ứng.

## 19) Popup result – dùng toast (BẮT BUỘC)
- Kết quả xử lý (thành công/lỗi) trong popup: dùng `showPopupNoticeJS()` (toast).
- **KHÔNG** nhúng inline message div vào `.popup-content`.
- Đóng popup ngay sau khi xử lý xong, toast sẽ thông báo.
- Mobile: table chuyển sang card view.
- Mỗi `td` có `data-label` lấy từ `$lang` key.

## 20) Z-index hierarchy (BẮT BUỘC)
1. Popup notice/confirm: **10001**
2. Header submenu: 10000
3. Header/Footer: 9999
4. Sidebar: 9998

## 21) PDF viewer popup (BẮT BUỘC)
- Khi có trường file (FileUrl) cần xem, dùng popup PDF viewer riêng.
- Cấu trúc: `.popup-overlay` + `.popup-container.pdf-viewer-container` + `iframe`.
- Nút xem (eye icon) chỉ hiện khi file tồn tại trên server (check qua `ajax_check_file_exists`).
- Click eye → mở popup, gán `iframe.src = filePath`.
- Đóng popup → `iframe.src = ''` (giải phóng bộ nhớ).
- `.pdf-viewer-container`: `max-width: 1200px`, `max-height: 95vh` (rộng hơn popup mặc định).
- Popup content: `padding: 0; overflow-y: auto; max-height: 80vh`.
- Mobile: `max-height: 70vh`.

## 22) Ghi chú đặc thù
- Cột DB quirk: `Datawork.Permisson` (2 chữ s).
- Click-outside-to-close: nếu dropdown nằm trong `.popup-container` thì listener phải gắn trên `.popup-container` (không gắn document), vì popup chặn bubble.

## 23) Form change tracking cho popup nhập liệu (BẮT BUỘC nếu page có popup edit)
- Triển khai local trong IIFE của page, prefix `emp` (VD: `empInitFormTracking`, `empClearFormTracking`, `empStartChangePoll`, `empStopChangePoll`) để tránh xung đột global.
- Khi mở popup: gọi `empClearFormTracking()` để xoá tracking cũ và stop poll.
- Khi đóng popup: gọi `empClearFormTracking()` để dọn dẹp.
- Sau khi load data & options: gọi `setTimeout(empInitFormTracking, 100-200ms)` để lưu giá trị gốc.
- CSS class `.changed` được định nghĩa trong `components.css`: tự động thêm viền vàng `var(--accent)` vào `.input-wrapper`, `.custom-select-wrapper`, `.searchable-select-wrapper`, `.custom-date-wrapper`, `.toggle-switch-wrapper` khi giá trị khác gốc.
- Cơ chế: poll 300ms so sánh giá trị hiện tại với `form.dataset.orig` (JSON). Không dùng event listener `click` vì `stopPropagation()` trong dropdown handler chặn bubble.
- Tất cả logic phải nằm trong page IIFE, KHÔNG đưa vào global scope (tránh ảnh hưởng chéo).

## 24) Dropdown mutual exclusion (BẮT BUỘC)
- Mỗi page phải có hàm `closeAllDropdowns(except)` đóng tất cả `.custom-date-wrapper.open`, `.searchable-select-wrapper.open`, `.custom-select-wrapper.open` (trừ `except`).
- **Trước khi mở** dropdown nào, gọi `closeAllDropdowns(wrapper)` để đóng các dropdown đang mở khác.
- Click-outside vẫn giữ: đóng chính wrapper đó khi click ngoài vùng chứa nó.
- **KHỞI TẠO EVENT DROPDOWN (BẮT BUỘC)**:
  - **Không** `addEventListener(...)` bên trong các hàm `loadXxxOptions(...).then(...)` (fetch/cachedFetch), vì load lại options nhiều lần sẽ gắn listener trùng → dropdown **mở rồi đóng / lâu lâu không click được**.
  - Luôn tách thành `initXxxEvents()` (gắn listener 1 lần, có flag `xxxEventsInitialized`) và `loadXxxOptions()` (chỉ render options + gọi `initXxxEvents()`).
  - Click-outside phải dùng registry (VD: `bindOutsideClick(key, popupContainer, handler)`) với **key cố định theo dropdown** để remove handler cũ trước khi add.
- Áp dụng cho **tất cả page** có dropdown (home, company, category, documentout, documentin, employee_manager, ...).

---
Sử dụng file này làm prompt chính. Nếu cần chi tiết UI/HTML mẫu cho từng input type, tách ra file khác để tránh phình context.
