# Màn hình "Kho tài liệu" — Bộ hồ sơ (Managed Records)

> Tài liệu kỹ thuật cho màn `/managed-records` (đường dẫn & data mẫu: `src/default-data/khotailieu.ts`).
> Phần dùng chung toàn dự án (fixture, `TIMEOUT`, helper `PW`, quy ước prefix testId) xem `tests/README.md`.
>
> **Khảo sát trực tiếp bằng Playwright MCP ngày 2026-07-27** trên môi trường **sitdev**
> (`https://sunecm-dev.sharepoint.vn/#`), tài khoản `ecm01` (Thủ thư — hiển thị "Nguyễn Minh Hoàng").
> Mọi testId/locator trong tài liệu này đều đã tự tay xác minh trên DOM thật, trừ những chỗ đánh dấu `⚠️ chưa khảo sát`.

> 📌 **Phạm vi tài liệu — chỉ mô tả màn hình.**
> File này lưu **thông tin mô tả màn**: field, nút, testId, cấu trúc modal/bảng, thông báo, luồng
> thao tác. **Không** ghi vào đây logic của test / kỳ vọng của case / quy tắc nghiệp vụ — những thứ
> đó thuộc về spec (`tests/<mục>/<mục>.<mã case>.spec.ts`), file bước dùng chung
> (`tests/<mục>/<mục>.steps.ts`) và file tiền điều kiện của mục (`tests/<mục>/<mục>.md`).
> Viết case mới **không** kéo theo việc cập nhật file này, trừ khi phát hiện thông tin **màn hình**
> bị sai/thiếu (testId đổi, field mới, thông báo khác…).

---

## 0. Bộ file đi kèm

| File                                        | Nội dung                                                                 |
| ------------------------------------------- | ------------------------------------------------------------------------ |
| `KHO-TAI-LIEU.md` (file này)                | Màn danh sách + modal Bộ hồ sơ (tạo mới / chi tiết) + tab con            |
| `KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md`         | Modal **Thêm thư mục**                                                   |
| `KHO-TAI-LIEU.MODAL-TAO-TAI-LIEU.md`        | Modal **Tạo mới tài liệu**                                               |
| `KHO-TAI-LIEU.MODAL-PHAN-QUYEN-NANG-CAO.md` | Modal **Phân quyền nâng cao** + modal con Phân quyền cấu trúc BHS        |
| `KHO-TAI-LIEU.MODAL-CHON-NGUOI-DUNG.md`     | Modal **Chọn người dùng/nhóm người dùng** (org-chart, dùng chung)        |
| `KHO-TAI-LIEU.MODAL-CAP-NHAT-QUYEN-TAI-LIEU.md` | Modal **Cập nhật quyền tài liệu** (chi tiết TL → ... → Kiểm tra phân quyền) |
| `KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md`           | Màn chi tiết Tài liệu → tab **Tài liệu số**: upload file, bảng file, dialog tài liệu trùng |
| `KHO-TAI-LIEU.MODAL-KHOI-PHUC-CAU-TRUC-DA-XOA.md` | Modal **"Khôi phục Cấu trúc hồ sơ"** (`btn-more` → Quản lý cấu trúc đã xóa) + dialog xoá item |
| `KHO-TAI-LIEU.PHAN-QUYEN-THEO-VAI.md`       | **Logic theo vai** (ẩn/hiện element, chặn truy cập, dựng tiền điều kiện) |
| `KHO-TAI-LIEU.functions.ts`                 | **Toàn bộ hàm thao tác** — spec import từ đây, không copy code dài       |
| `KHO-TAI-LIEU.TODO-DEV.md`                  | Danh sách chỗ **thiếu / sai `data-testid`** cần chuyển DEV bổ sung       |

### Bảng hàm trong `KHO-TAI-LIEU.functions.ts`

```ts
import {
  createBoHoSo,
  createThuMuc,
  createTaiLieu,
  chuyenHoatDong,
  openBoHoSo,
  openPhanQuyenNangCao,
  switchTabPhanQuyen,
  openRowActionMenu,
  openMoreMenu,
  fillUserPickerPhanQuyen,
  searchTrongPhanQuyen,
  chonBoLocPhanQuyen,
  checkOPhanQuyen,
  checkKeThua,
  checkQuyenTheoNguoiDung,
  expectToastThanhCong,
  getRecordModal,
  getActiveTabPane,
  getCauTrucRow,
  ITEM_PREFIX,
} from "../../src/screen-instructions/KHO-TAI-LIEU.functions";
```

| Hàm                                                                  | Mục | Công dụng                                                                        |
| -------------------------------------------------------------------- | :-: | -------------------------------------------------------------------------------- |
| `createBoHoSo(page, pw, tenHoSo, opts?)` → `recordUrl`               |  3  | Mở form → chờ metadata → điền toàn bộ field bắt buộc → Lưu lại → trả `recordUrl` |
| `openBoHoSo(page, recordUrl)`                                        | 4.0 | Mở lại 1 BHS/TL theo URL — **đi 2 bước qua màn danh sách** (xem ⚠️ mục 4.0)      |
| `chuyenHoatDong(page, recordUrl?)`                                   | 4.2 | Bấm "Chuyển Hoạt động" + xác nhận dialog                                         |
| `openMoreMenu(page, scope?)`                                         | 4.3 | Hover `btn-more` (header BHS **hoặc** header tab Cấu trúc) → mở dropdown         |
| `createThuMuc(page, pw, recordUrl, ten, opts?)`                      |  5  | Tạo thư mục (xem `KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md`)                            |
| `openModalThemThuMuc(page, recordUrl, opts?)` → `Locator`            |  5  | **Chỉ mở** modal "Thêm thư mục" (không điền, không lưu) — cho case quan sát field. ⚡ `opts.moLaiBoHoSo: false` → bỏ qua mở lại BHS + vào tab (tiết kiệm ~37 s/lần) khi tạo nhiều thư mục liên tiếp |
| `dongModalThuMucNeuDangMo(page)`                                     |  5  | Đóng modal thư mục bằng nút "Hủy" **nếu còn mở** — dùng sau khi chỉ quan sát, hoặc sau thao tác lưu bị app chặn |
| `layDanhSachOptionSelectThuMuc(page, select)` → `string[]`           |  3  | Mở dropdown 1 select của modal TM → đọc các option **chọn được** (bỏ option `disabled`) → Escape |
| `getIndexThuMuc(page, modal?)`                                       |  5  | Input **disabled** `txt-ma-thu-muc` — mã prefix tự sinh (`A`, `B`, `A.1`…)        |
| `doiThuMucCapCha(page, tenCha, modal?)`                              | 3b  | Đổi "Thư mục/Hồ sơ cấp cha" (`tree-sel-thu-muc-cha`, tree-select) — app tự cập nhật Chỉ mục / Độ mật / Trạng thái / phân quyền theo cha |
| `getSelectCapCha(page, modal?)`                                      | 3b  | Locator ô cấp cha (`tree-sel-thu-muc-cha`)                                        |
| `docGiaTriSelectThuMuc(modal, testId)` → `string`                    | 3b  | Đọc giá trị select của modal TM theo testId (`THU_MUC_FIELD.*`), rỗng → `""`      |
| `layNguoiTrongKhoiQuyenThuMuc(page, permTestId, modal?)` → `string[]` |  8  | Tên người trong 1 khối quyền của modal TM — **hover avatar đọc popover** (khối chỉ hiện chữ viết tắt) |
| `demChipKhoiQuyenThuMuc(page, permTestId, modal?)` → `number`         |  8  | Số chip (người/nhóm) trong 1 khối quyền của modal TM                              |
| `xoaChipDauTienKhoiQuyenThuMuc(page, permTestId, modal?)`             |  8  | Xoá chip **đầu tiên** của 1 khối quyền (chỉ xoá theo vị trí — chip không có tên trong DOM) |
| `THU_MUC_FIELD`                                                      |  3  | testId các field modal TM: `INDEX`, `TEN`, `CAP_CHA`, `DO_MAT`, `TRANG_THAI_HIEN_THI`, `DON_VI_SOAN_THAO` |
| `openManCapNhatThuMuc(page, recordUrl, tenThuMuc)`                   | 4.4 | Bảng Cấu trúc → menu dòng → "Cập nhật" → màn cập nhật TM (⚠️ đổi `page.url()`)   |
| `openTabThuMuc(page, "Thông tin"\|"Phân quyền", modal?)`             |  8  | Chuyển tab trong modal Thêm / Cập nhật thư mục                                   |
| `datQuyenDocLap(page, modal?)`                                       |  8  | "Đặt quyền độc lập" + xác nhận dialog (badge tab đổi sang `Độc lập`)             |
| `khoiPhucKeThuaTrongModal(page, modal?)`                             |  8  | "Khôi phục kế thừa" + xác nhận (⚠️ xoá sạch quyền riêng, thay bằng quyền cha)   |
| `openChiTietTaiLieu(page, recordUrl, tenTaiLieu)`                    |  8  | Tab Cấu trúc hồ sơ → click tên TL → màn chi tiết TL (⚠️ đổi `page.url()`)       |
| `openCapNhatQuyenTaiLieu(page)`                                      |  8  | Chi tiết TL → `btn-more` → "Kiểm tra phân quyền" → modal Cập nhật quyền tài liệu |
| `checkBadgeTabPhanQuyen(page, "Kế thừa"\|"Độc lập", modal?)`         |  8  | Assert badge trạng thái của tab Phân quyền                                       |
| `expectPickerPhanQuyenChiDoc(page, modal?)`                          |  8  | Assert tab Phân quyền đang kế thừa → **không có ô nhập nào**                     |
| `expectPickerPhanQuyenChinhSuaDuoc(page, modal?, keyword?)`          |  8  | Assert 5 ô nhập quyền đã enable — gõ thử `ecm02` vào từng ô, có gợi ý, rồi xoá   |
| `expectPickerNhapDuoc(page, picker, keyword?, label?)`               |  8  | Kiểm chứng 1 people-picker nhập được (click → gõ → có gợi ý → Backspace xoá)     |
| `themQuyenTrongModalThuMuc(page, account, permTestIds?, modal?)`   |  8  | Thêm 1 người/nhóm vào các trường quyền của modal TM (mặc định cả 5 trường)        |
| `luuModalThuMuc(page, modal?, label?)`                               |  8  | Bấm "Xác nhận" ở footer modal TM + chờ toast "Thành công"                         |
| `getThuMucModal(page)` / `getPickerPhanQuyenThuMuc(page, i, modal?)` |  8  | Locator modal thư mục (lọc theo tab "Phân quyền") và 1 trong 5 picker quyền      |
| `createTaiLieu(page, pw, recordUrl, ten, opts?)` → `Record<testId, giá trị>` |  5  | Tạo tài liệu (xem `KHO-TAI-LIEU.MODAL-TAO-TAI-LIEU.md`). ⚡ `opts.moTuMenuDongThuMuc: true` → mở modal từ **menu dòng thư mục** (cho vai không có `btn-tao-moi` ở toolbar); `opts.readFields` → đọc lại giá trị field ngay trước khi Lưu |
| `docGiaTriTruongTaiLieu(scope, testId)` → `string`                   |  5  | Đọc giá trị 1 field của modal TL/BHS — tự nhận select / input / text đã khoá     |
| `layTinhTrangTuTieuDe(page)` → `string`                              | 3d  | **Tình trạng** của BHS/TL đang mở, đọc dòng cuối `lbl-modal-title`               |
| `getModalTaiLieu(page)` / `getPaneTaiLieu(page)`                     | TLS | Modal **chi tiết Tài liệu** (lọc theo `tab-digitizedATM`) / tab-pane đang mở — 🚨 `getRecordModal` trỏ nhầm modal xem trước file |
| `openTabTaiLieuSo(page)`                                             | TLS | Bảo đảm tab **"Tài liệu số"** đang mở (idempotent, click `label` + `force`)       |
| `openTabThongTinTaiLieu(page)`                                       | TLS | Bảo đảm tab **"Thông tin"** (`tab-general`) của màn chi tiết TL đang mở — tab chứa form thuộc tính để **sửa** tài liệu đã lưu |
| `uploadFileTaiLieu(page, duongDan, opts?)` → `tenTep[]`              | TLS | Upload 1/nhiều file + xử lý dialog "tài liệu trùng" + chờ toast + assert dòng file |
| `sampleFile(ten)` / `SAMPLE_FILE` / `SAMPLE_FILES_DIR`               | TLS | File mẫu trong `src/sample-files/` (⚠️ `.doc`, `.xls` **ngoài `accept`**)         |
| `LUA_CHON_TRUNG`                                                     | TLS | 3 nút của dialog trùng: `Toàn bộ` / `Chỉ tài liệu không trùng` / `Không`          |
| `getDialogTaiLieuTrung(page)`                                        | TLS | Locator dialog "Danh sách tài liệu trùng" (lọc theo `tbl-file-trung`)             |
| `getDongFileTaiLieu(page, tenTep)` / `layDanhSachTenTep(page)`       | TLS | Dòng file theo tên tệp / danh sách tên tệp đang hiển thị                          |
| `docThongTinFile(page, tenTep)`                                      | TLS | Dung lượng, thời gian upload, người upload, trạng thái OCR của 1 file             |
| `openMenuDongFile(page, tenTep)` → `Locator`                         | TLS | Menu `"..."` của dòng file (🚨 phải hover dòng trước — nút là div `absolute inset-0`) |

> `TLS` = `KHO-TAI-LIEU.TAB-TAI-LIEU-SO.md`.
| `openRowActionMenu(page, tenItem, mniTestId)`                        | 4.4 | Hover dòng bảng Cấu trúc → mở menu `btn-more-action-<id>` → click 1 mục          |
| `openRowActionDropdown(page, tenItem)` → `Locator`                   | 4.4 | Như trên nhưng **không click** — trả dropdown để assert nội dung menu            |
| `openTaoMoiDropdown(page)` → `Locator`                               | 4.4 | Hover mũi tên cạnh nút "Tạo mới" (tab Cấu trúc) → trả dropdown đang mở           |
| `getRowActionTrigger(page, tenItem)` / `getVisibleDropdown(page)`     | 4.4 | Locator nút `btn-more-action-<id>` của 1 dòng / dropdown đang mở — dùng khi cần kiểm tra **sự tồn tại** (vai không có quyền có thể không có menu) |
| `xoaItemCauTruc(page, tenItem, opts?)` → `noiDungDialog`             | 4.4 | Menu dòng → `mni-xoa` → xác nhận dialog xoá (`opts.noiDungMongDoi` lệch → `expect.soft` cảnh báo, **vẫn xoá tiếp**) |
| `layTenCacDongCauTruc(page)` → `string[]`                            | 4.4 | Tên hiển thị của **mọi dòng** trong bảng Cấu trúc hồ sơ — assert item đã biến mất |
| `phanTichDongCauTruc(danhSachHienThi[])` → `DongCauTruc[]`            | 4.4 | Tách `"A.1 Tên"` thành `{ ma, ten, hienThi }` — cần cho assert **quan hệ cha–con** |
| `nhanBanThuMuc(page, tenThuMuc, opts?)` → `tenBanSao`                | 4.4 | Menu dòng → `mni-nhan-ban` → chờ toast `"Nhân bản hoàn thành"` → trả **tên trong ô "Tên"** của màn cập nhật bản sao (modal **để mở** cho case assert rồi `luuModalThuMuc`). 🚨 Đổi `page.url()`; **chưa khảo sát MCP** |
| `nhanBanTaiLieu(page, tenTaiLieu, opts?)` → `tenBanSao \| ""`         | 4.4 | Như trên nhưng cho **dòng tài liệu**; app có mở màn chi tiết bản sao thì đọc tên + bấm `btn-save`, không mở thì trả `""`. **Chưa khảo sát MCP** |
| `TOAST_NHAN_BAN` / `PREFIX_BAN_SAO`                                  | 4.4 | Nội dung toast nhân bản + tiền tố `"Copy of "` của tên bản sao — ⚠️ do QA cung cấp, chưa khảo sát |
| `layTenCacDongCauTrucMoiTrang(page)` → `string[]`                    | 4.4 | Như trên nhưng **gộp cả các trang phân trang** (bảng mặc định `15 / trang`) — dùng khi cấu trúc có nhiều item |
| `moRongToanBoCayCauTruc(page)`                                       | 4.4 | Bấm hết nút expand của bảng Cấu trúc hồ sơ — **bắt buộc** trước khi đọc dòng của cây > 2 cấp (cây chỉ mở sẵn tới cấp 1) |
| `XOA_CONFIRM` / `BTN_XOA_XAC_NHAN` / `BTN_XOA_HUY` / `MDL_XOA_XAC_NHAN` / `TOAST_XOA` | 4.4 | Nội dung 2 dialog xoá (item rỗng / thư mục có dữ liệu bên trong), testId của dialog + 2 nút, và toast `"Xóa thành công"` |
| `openQuanLyCauTrucDaXoa(page)` → `Locator`                           | 11  | `btn-more` (BHS) → `btn-quan-ly-cau-truc-da-xoa` → modal **"Khôi phục Cấu trúc hồ sơ"** |
| `getModalCauTrucDaXoa(page)`                                         | 11  | Locator modal khôi phục (lọc theo tiêu đề — `lbl-modal-title` trùng với modal BHS) |
| `layDanhSachCauTrucDaXoa(page, modal?)` → `CauTrucDaXoa[]`           | 11  | Đọc mọi dòng đã xoá: `{ ten, thoiGianXoa, nguoiThucHien, viTri }`                |
| `tickItemCauTrucDaXoa(page, tenItem, modal?)`                        | 11  | Tick 1 item — 🚨 checkbox chỉ hiện khi **hover dòng**                            |
| `getNutKhoiPhuc(page, modal?)`                                       | 11  | Nút "Khôi phục" (không có testId) — `disabled` khi chưa tick dòng nào            |
| `khoiPhucCauTrucDaXoa(page, tenItem(s), opts?)`                      | 11  | Luồng đầy đủ: mở modal → tick → "Khôi phục" → chờ modal đóng (⚠️ **không có toast**) |
| `dongModalCauTrucDaXoa(page, modal?)`                                | 11  | Bấm "Đóng" nếu modal còn mở                                                      |
| `MODAL_CAU_TRUC_DA_XOA_TITLE` / `EMPTY_CAU_TRUC_DA_XOA`              | 11  | Tiêu đề modal + text bảng rỗng (`"Chưa có dữ liệu"`)                             |
| `openPhanQuyenNangCao(page, recordUrl?)`                             |  6  | `btn-more` → "Kiểm tra phân quyền"                                               |
| `switchTabPhanQuyen(page, "permission" \| "user")`                   |  6  | Chuyển tab trong modal Phân quyền nâng cao                                       |
| `moItemPhanQuyen(page, code)`                                        |  6  | **Bảo đảm** 1 thư mục đang expand (idempotent) — dùng thay `expandItemPhanQuyen` |
| `expandItemPhanQuyen(page, code)`                                    |  6  | Toggle expand 1 thư mục (⚠️ gọi lúc đang mở sẽ thu gọn lại)                      |
| `ngatKeThua(page, code)` / `khoiPhucKeThua(page, code)`              |  6  | Ngắt / khôi phục kế thừa quyền của 1 item                                        |
| `themQuyenChoNguoiDung(page, code, account, quyen, noiDung?)`        |  6  | Cấp quyền trực tiếp cho 1 người trên 1 item                                      |
| `expectKhongCoQuyenXem(page)` / `tryOpenBoHoSo(page, url)`           |  7  | Mở BHS không assert + assert toast `"Bạn không có quyền xem!"`                   |
| `fillUserPickerPhanQuyen(page, account, clearFirst?)`                |  6  | People-picker "Người dùng/Nhóm" của tab Theo người dùng                          |
| `searchTrongPhanQuyen(page, keyword, tab?)`                          |  6  | Search trong modal Phân quyền nâng cao                                           |
| `chonBoLocPhanQuyen(page, "Quyền"\|"Phân loại", option)`             |  6  | Tick 1 option của dropdown lọc rồi đóng                                          |
| `checkOPhanQuyen(page, code, cot, expected, label)`                  |  6  | Assert 1 ô bảng theo **mã prefix item** (`""`/`"A"`/`"B"`…)                      |
| `getCodeCuaItemPhanQuyen(page, tenItem)` → `code`                    |  6  | Lấy mã prefix của item theo tên (khỏi hard-code `"A"`, `"A.1"`)                  |
| `checkNguoiTrongOQuyen(page, code, cot, tenHienThi, label?)`          |  6  | Assert 1 người trong 1 ô quyền — **tự bấm nút `+n`** khi ô gom bớt avatar         |
| `checkNguoiCoQuyenO5Cot(page, code, tenHienThi, label?)`             |  6  | Như trên nhưng cho cả 5 cột quyền của 1 item                                     |
| `getNguoiTrongOQuyen(page, code, cot)` → `string[]`                  |  6  | Đọc danh sách tên trong 1 ô quyền (tự mở rộng `+ n người khác`)                  |
| `checkQuyenGiongItemCha(page, codeCha, codeCon, tenCha?, tenCon?)`   |  6  | Assert item con có quyền **giống hệt** item cha ở cả 5 cột                       |
| `getQuyen5Cot(page, code)` → `Quyen5Cot`                             |  6  | Snapshot quyền của 1 item ở cả 5 cột (để so trước/sau 1 thao tác)                |
| `checkQuyen5CotNhuCu(page, code, truoc, label?)`                     |  6  | Assert quyền của 1 item **không đổi** so với snapshot                            |
| `PQ_COT_QUYEN`                                                       |  6  | Hằng 5 cột quyền theo thứ tự hiển thị                                            |
| `checkKeThua(page, code, expected, label)`                           |  6  | Assert cột Kế thừa = `"Kế thừa"` \| `"Quyền riêng tư"`                           |
| `checkQuyenTheoNguoiDung(page, code, perms[], label)`                |  6  | Assert cột Quyền (tab Theo người dùng) chứa đủ các quyền                         |
| `expectToastThanhCong(page, label?, timeout?)`                       | 9.8 | Chờ toast "Thành công" (đã lọc khỏi toast "Đang xử lý" chạy trước)               |
| `rinhToast(page, noiDung, timeout?)` → `Promise<boolean>`            | 9.8 | **Rình toast từ TRƯỚC khi bấm nút** — dùng khi giữa click và assert có bước khác (đóng modal xác nhận, `waitForTimeout`…) làm lỡ mất toast (toast chỉ sống ~3 s) |
| `getToast(page, noiDung)`                                            | 9.8 | Locator 1 toast theo nội dung — dùng khi có nhiều toast cùng lúc                 |
| `getRecordModal` / `getActiveTabPane` / `getCauTrucRow`              |  —  | Locator getter khi cần assert tuỳ biến                                           |
| `ITEM_PREFIX` / `FOLDER_PICKER` / `FOLDER_PICKER_LABELS`             | 6/8 | Hằng mã prefix (`BHS = ""`, `"A"`, `"B"`…) và **testId**/nhãn 5 khối quyền của TM |
| `layTenHienThiNguoiDangDangNhap(page)` → `ten`                       | 9.10 | **Cách chuẩn** lấy tên hiển thị: hover avatar ở màn danh sách → đọc dropdown user |
| `layTenDoiTuongPhanQuyen({ account, page? })` → `ten`                | 9.10 | Tên hiển thị của 1 đối tượng phân quyền: có `page` → lấy động; không có (nhóm) → dùng tên nhóm |
| `TEN_HIEN_THI`                                                       | 9.10 | Ánh xạ account id → tên hiển thị — ⚠️ **chỉ dùng khi không có `page` của account đó** |
| `chonOptionSelect(page, select, nhan)`                               |  3  | Chọn 1 option **theo nhãn** của 1 select đã scope sẵn (vd `sel-do-mat`) — danh sách ngắn nên không cần gõ search |
| `nhapCauTrucTuExcel(page, duongDanFile, opts?)`                      | 4.4 | Tab Cấu trúc → `btn-more` → `mni-nhap-tu-excel` → chọn file → "Tiếp theo" → "Cập nhật" → chờ toast `"Tạo mới thành công"`. 🚨 **Modal chưa khảo sát MCP** — bám `input[type=file]` + **nhãn nút** |
| `getModalNhapExcel(page)`                                            | 4.4 | Locator modal **"Nhập excel"** (lọc bỏ modal BHS rồi lấy lớp trên cùng) — ⚠️ chưa khảo sát |
| `templateFile(ten)` / `TEMPLATE_FILE` / `TEMPLATE_FILES_DIR`         | 4.4 | File Excel mẫu để **nhập cấu trúc hồ sơ** trong `src/template-files/` (khác `src/sample-files/` — file mềm để upload) |
| `pickFirstOption(page, control)`                                     |  5  | Chọn option **đầu tiên** của 1 dropdown/tree-dropdown đã scope sẵn theo modal |
| `taoShortcutTaiLieu(page, tenTaiLieu, tenBoHoSoDich, opts?)` → `tenMacDinh` | 10 | Menu dòng tài liệu → `mni-tao-shortcut` → điền modal "Tạo mới Shortcut" (tên / BHS đích / vị trí lưu) → "Lưu lại" → chờ toast `"Tạo shortcut thành công"`. Trả về **tên app điền sẵn** ở ô tên. 🚨 Modal **chưa khảo sát MCP**, không có testId nào |
| `getModalShortcut(page)`                                             | 10  | Locator modal "Tạo mới Shortcut" (lọc theo ô `input[placeholder="Nhập tên shortcut"]`) — ⚠️ chưa khảo sát |
| `TOAST_TAO_SHORTCUT` / `PREFIX_SHORTCUT`                             | 10  | Nội dung toast + tiền tố `"Shortcut of "` của tên shortcut — ⚠️ do QA cung cấp, chưa khảo sát |

> 📌 **Quy tắc bổ sung hàm**: thao tác dài (> ~10 dòng) dùng ở ≥ 2 spec → viết thành hàm trong
> `KHO-TAI-LIEU.functions.ts` rồi cập nhật bảng trên. Code chi tiết trong tài liệu này là
> **giải thích cơ chế bên trong hàm**, chỉ dùng khi cần viết biến thể.

---

## 1. Thông tin màn hình

| Mục                 | Giá trị                                                                           |
| ------------------- | --------------------------------------------------------------------------------- |
| URL danh sách       | `khoTaiLieuUrl` = `/managed-records` (export từ `src/default-data/khotailieu.ts`) |
| URL chi tiết BHS/TL | `/managed-records?itemId=<id>` — lấy bằng `page.url()` **ngay sau khi Lưu**       |
| Data mẫu form       | `khoTaiLieuDefaultData` (export từ `src/default-data/khotailieu.ts`)              |
| Môi trường khảo sát | sitdev — `BASE_URL` đọc từ `.env.sitdev`                                          |

> ⚠️ **URL chi tiết của Tài liệu cũng có dạng `?itemId=...`** giống BHS. Sau khi tạo tài liệu,
> `page.url()` **đã bị thay** bằng URL của tài liệu vừa tạo → phải lưu `recordUrl` của BHS **trước**
> khi tạo thư mục/tài liệu.

---

## 2. Màn danh sách (khảo sát sơ bộ)

| Element                  | Locator                                                                                                                     | Ghi chú                                |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- |
| Ô tìm kiếm toàn hệ thống | `getByTestId("txt-search")` (placeholder `Tìm kiếm`)                                                                        | Nằm trên thanh header, không thuộc màn |
| **Avatar user** (header) | `getByTestId("avatar-container")` (bên trong có `avatar-image`, hiển thị chữ viết tắt vd `MH`)                               | **Hover** → dropdown thông tin user (mục 9.10) |
| Breadcrumb               | `getByTestId("lbl-breadcrumb")` = `"Kho tài liệu"`                                                                          | —                                      |
| Nút **Tạo mới**          | `getByTestId("btn-create-hstl")`                                                                                            | Mở modal "Tạo mới bộ hồ sơ" (mục 3)    |
| Nút **Tải lên**          | ❌ chưa có testId — `getByText("Tải lên")`                                                                                  | → DEV bổ sung                          |
| Nút **Xuất excel**       | ❌ chưa có testId — `getByText("Xuất excel")`                                                                               | → DEV bổ sung                          |
| Nút mở panel lọc         | ❌ chưa có testId (icon bên phải toolbar)                                                                                   | → DEV bổ sung                          |
| Cột bảng                 | `STT`, `Mã hồ sơ`, `Tên hồ sơ`, `Loại hồ sơ`, `Mục lưu trữ`, `Dự án`, `Độ mật`, `Tình trạng`, `Đơn vị sở hữu` + 1 cột trống | —                                      |
| Phân trang               | `.ant-pagination` (mặc định `15 / trang`)                                                                                   | —                                      |

Mở 1 BHS: click `button` chứa mã/tên hồ sơ trong dòng → URL đổi thành `?itemId=...` và mở modal chi tiết.

---

## 3. Modal "Tạo mới bộ hồ sơ"

Mở bằng `btn-create-hstl`. `lbl-modal-title` = `"Tạo mới bộ hồ sơ"`.
**Chờ 5–10 s sau khi mở** — form nạp metadata (danh mục, field động) khá chậm.

### 3a. Field trong form

Cột "required" lấy từ marker `span.icon-required` trong `label` — đã đối chiếu với message validate khi bấm Lưu với form trống.

| #   | testId                        | Loại           | required | Nhãn                | Ghi chú                                                                                                |
| --- | ----------------------------- | -------------- | :------: | ------------------- | ------------------------------------------------------------------------------------------------------ |
| 1   | `txt-tenHoSo`                 | text           |    ✔     | Tên hồ sơ           | Placeholder `Nhập tên hồ sơ`                                                                           |
| 2   | `tree-sel-mucLuuTru`          | tree-select    |    ✔     | Thư mục lưu trữ     | Placeholder `Chọn thư mục lưu trữ`                                                                     |
| 3   | `sel-companyInvestor`         | select         |    ✔     | Công ty, tổ chức    | —                                                                                                      |
| 4   | `tree-sel-owner-department`   | tree-select    |    ✔     | Đơn vị sở hữu       | —                                                                                                      |
| 5   | `sel-loaiBoHoSo`              | select         |    ✔     | Loại hồ sơ          | Chọn loại "dự án" → **hiện thêm `sel-duAn`** (cũng bắt buộc). 🚨 Dropdown **chỉ nạp 10 option đầu** → phải gõ search mới ra option khác (mục 3e) |
| 5b  | `sel-duAn`                    | select         |   (✔)    | Dự án               | Chỉ xuất hiện với loại hồ sơ dạng dự án                                                                |
| 6   | _(không có testId)_           | text read-only |    ✔     | **Mã hồ sơ**        | `span#basic_soHieuBoHoSo` — **hệ thống tự sinh** sau khi chọn Loại hồ sơ / Dự án. → DEV bổ sung testId |
| 7   | `sel-securityLevel`           | select         |          | Độ mật              | Mặc định `Thường`. Options: `Thường`, `Mật`, `Tuyệt mật`                                               |
| 8   | `sel-cauTrucHoSo`             | select         |          | Cấu trúc hồ sơ      | Quyết định bộ field động + cấu trúc thư mục mẫu                                                        |
| 9   | `txt-soHieuHoSo`              | text           |          | Số hiệu hồ sơ       | Khác `Mã hồ sơ`                                                                                        |
| 10  | `sel-trangThai`               | select         |          | Trạng thái hiển thị | Mặc định `Public`. Options: `Private`, `Public`                                                        |
| 11  | `sel-storageTerm`             | select         |          | Thời hạn lưu trữ    | Mặc định `Vĩnh viễn`                                                                                   |
| 12  | `sel-hardCopyStatus`          | select         |    ✔     | Tình trạng cập nhật | Options (sitdev): `TEST`, `Đã đủ`, `Đang cập nhật`                                                     |
| 13  | `sel-tags-tuKhoa`             | tags (multi)   |          | Từ khóa             | —                                                                                                      |
| 14  | `txa-ghiChu`                  | textarea       |          | Ghi chú             | —                                                                                                      |
| 15  | `pp-multi-usersRightOwner`    | people-picker  |          | Quyền Owner         | **Tự điền sẵn người tạo**                                                                              |
| 16  | `pp-multi-usersRightAdd`      | people-picker  |          | Quyền tạo mới       | —                                                                                                      |
| 17  | `pp-multi-usersRightEdit`     | people-picker  |          | Quyền cập nhật      | —                                                                                                      |
| 18  | `pp-multi-usersRightDownload` | people-picker  |          | Quyền tải file      | —                                                                                                      |
| 19  | `pp-multi-usersRightViewers`  | people-picker  |          | Quyền Xem           | —                                                                                                      |

**Field động theo cấu trúc/loại hồ sơ** (metadata) — nằm ở cột phải khối "Thông tin chung",
testId sinh theo _tên field cấu hình_ nên **khác nhau giữa các BHS**. Ví dụ đang thấy trên sitdev:

| testId             | Loại       | Nhãn            |
| ------------------ | ---------- | --------------- |
| `date-Ngaytailieu` | datepicker | Ngày tài liệu   |
| `txa-Donvitailieu` | textarea   | Đơn vị tài liệu |

> ⚠️ **Không hard-code các testId động này trong spec dùng chung** — chúng phụ thuộc cấu hình
> "Cấu trúc hồ sơ" / "Loại hồ sơ". Muốn duyệt hết field động: đọc các `.ant-form-item` trong khối
> `lbl-information-card` rồi lọc theo prefix.

### 3b. Nút của modal

| testId                          | Mô tả                                                             |
| ------------------------------- | ----------------------------------------------------------------- |
| `lbl-modal-title`               | Tiêu đề modal. Sau khi lưu đổi thành `"<Mã hồ sơ>\n<Tình trạng>"` |
| `btn-close-modal`               | Đóng modal (icon X)                                               |
| `btn-save`                      | **Lưu lại**                                                       |
| `lbl-information-card`          | Khối "Thông tin chung" (+ `-title`)                               |
| `lbl-access-permission-card`    | Khối "Phân quyền truy cập" (+ `-title`)                           |
| `lbl-related-info`              | Khối "Thông tin liên quan" (+ `-title`)                           |
| `btn-add-related-ecm`           | Nút **Thêm** → mở thẳng pop-up "Tìm hồ sơ liên quan"              |
| `btn-add-related-more-options`  | Mũi tên cạnh nút Thêm — **hover** để mở dropdown 2 lựa chọn       |
| `pp-multi-<quyền>-select`       | Phần select bên trong people-picker                               |
| `pp-multi-<quyền>-orgchart-btn` | Nút mở modal **Chọn người dùng/nhóm người dùng** (file MD riêng)  |

### 3c. Validate khi bấm Lưu

Bấm `btn-save` khi thiếu field bắt buộc:

- Mỗi field lỗi hiện `.ant-form-item-explain-error` — **nội dung khảo sát được**:
  `Trường tên hồ sơ bắt buộc!`, `Trường thư mục lưu trữ bắt buộc!`, `Trường công ty, tổ chức bắt buộc!`,
  `Trường đơn vị sở hữu bắt buộc!`, `Trường loại hồ sơ bắt buộc!`, `Trường mã hồ sơ bắt buộc!`,
  `Trường tình trạng cập nhật bắt buộc!`, `Trường dự án bắt buộc!`
- Đồng thời **tiêu đề modal `lbl-modal-title` bị nối thêm banner lỗi**:
  - Nhiều lỗi → `"Có lỗi tại N ô nhập liệu được khoanh đỏ trên màn hình. Xem chi tiết"`
  - Đúng 1 lỗi → `"Trường <Tên field> bắt buộc!"`

```ts
await pw.clickButton("btn-save");
await expect(
  page.locator(".ant-form-item-explain-error").first(),
).toContainText("Trường tên hồ sơ bắt buộc!", {
  timeout: TIMEOUT.VALIDATE_WAITING,
});
```

> ⚠️ `.ant-form-item-explain-error` **không scope theo modal** sẽ bắt cả lỗi của modal nền
> (vd modal BHS đang mở phía sau modal Thêm thư mục). Luôn scope
> `page.locator(".ant-modal-content:visible").last().locator(".ant-form-item-explain-error")`.

### 3e. 🚨 Dropdown `sel-*` chỉ nạp ~10 option đầu — muốn chọn option khác phải **gõ search**

Khảo sát 2026-07-30 trên sitdev:

| Select                | Mở ra thấy                                            | Gõ search                                              |
| --------------------- | ----------------------------------------------------- | ------------------------------------------------------ |
| `sel-loaiBoHoSo`      | **10** option đầu — **không** có `AUTO-TEST-TYPE`      | gõ `AUTO` → còn đúng `AUTO-TEST-TYPE`                  |
| `sel-companyInvestor` | **10** option đầu                                     | gõ `a` → app trả bộ kết quả khác (lọc ở **server**)    |
| `sel-hardCopyStatus`  | 3 option (`TEST`, `Đã đủ`, `Đang cập nhật`)           | gõ `a` → `Đang cập nhật`                               |
| `sel-securityLevel`   | 3 option (`Thường`, `Mật`, `Tuyệt mật`)               | gõ `a` → `Mật`, `Tuyệt mật` (**không phân biệt dấu**)  |

- Mọi `sel-*` trên đều có class `ant-select-show-search`; ô search là
  `input.ant-select-selection-search-input` **bên trong** element `sel-*` và **không** readonly
  (khác people-picker).
- ✅ Không cần tự xử lý: `PW.inputDropDownList(testId, optionText)` **đã tự gõ search rồi mới chọn**
  (xem `tests/README.md` mục 4c). `createBoHoSo` dùng nó để chọn loại hồ sơ **`AUTO-TEST-TYPE`**.

**Loại hồ sơ `AUTO-TEST-TYPE`** (loại dựng riêng cho automation) — đo 2026-07-30 sau khi chọn:

| Mục                        | Giá trị                                                          |
| -------------------------- | ---------------------------------------------------------------- |
| `sel-duAn`                 | **không xuất hiện** → không phải loại "dự án", khỏi phải điền Dự án |
| Mã hồ sơ (`#basic_soHieuBoHoSo`) | tự sinh dạng **`ATT-001`**                                  |
| `sel-cauTrucHoSo`          | **để trống** (hệ thống không tự chọn cấu trúc hồ sơ)             |

> ⚠️ Loại hồ sơ quyết định **bộ field động + cấu trúc thư mục mẫu**, nên khi đổi loại hồ sơ dùng
> trong `createBoHoSo` thì cần chạy lại ít nhất 1 case có tạo thư mục/tài liệu để chắc các field
> bắt buộc của modal "Thêm thư mục" không đổi.

### 3d. Lưu thành công

- URL đổi thành `/managed-records?itemId=<id>` → **`recordUrl = page.url()`**
- `lbl-modal-title` đổi thành `"<Mã hồ sơ>\n<Tình trạng>"`, vd `"ee-001-BĐSBA_T1_T005_33\nKhai báo"`
- Modal chuyển sang **màn chi tiết / cập nhật** (mục 4)

> ✅ Dùng hàm **`createBoHoSo`** — trả về `recordUrl`.

```ts
const ts = Date.now();
const recordUrl = await createBoHoSo(admin, pw, `AT-UC77-001-${ts}`);

// Gán thêm quyền ngay khi tạo:
const url2 = await createBoHoSo(admin, pw, `AT-UC77-002-${ts}`, {
  peoplePickers: [{ testId: "pp-multi-usersRightViewers", account: "ecm05" }],
  readFields: ["sel-loaiBoHoSo"], // đọc lại giá trị hệ thống tự chọn
});
```

---

## 4. Màn chi tiết Bộ hồ sơ (`?itemId=<id>`)

### 4.0 ⚠️ Mở lại BHS bằng URL — BẮT BUỘC đi 2 bước

App là SPA hash-route. `page.goto()` khi **chỉ đổi phần query sau dấu `#`** (vd từ
`?itemId=A` sang `?itemId=B`) **KHÔNG khiến app render lại** — modal cũ vẫn nằm nguyên trên màn,
mọi `.ant-modal-content:visible.last()` sau đó sẽ trỏ nhầm.

Đã kiểm chứng: đi qua màn danh sách (URL **không có** query) rồi mới goto URL đích thì app render đúng.

```ts
// ✅ Dùng hàm openBoHoSo(page, recordUrl) — bên trong làm đúng 2 bước này:
await page.goto(`${BASE_URL}${khoTaiLieuUrl}`); // bước 1: về danh sách
await page.waitForTimeout(TIMEOUT.HARD_WAITING);
await page.goto(recordUrl); // bước 2: mở phiếu
await page.waitForTimeout(TIMEOUT.HARD_WAITING);
```

`page.reload()` cũng reset đúng nhưng chậm hơn nhiều — chỉ dùng khi cần state hoàn toàn sạch.

### 4.1 Tabs

| testId                | Tab            | Nội dung                                                  |
| --------------------- | -------------- | --------------------------------------------------------- |
| `lbl-tab-thongTin`    | Thông tin      | Form giống mục 3 (một số field khoá — xem 4.5)            |
| `lbl-tab-cauTrucHoSo` | Cấu trúc hồ sơ | Bảng cây Thư mục / Tài liệu (mục 4.4)                     |
| `lbl-tab-luuTru`      | Lưu trữ        | "Danh sách cặp hồ sơ" — có `btn-tao-moi`, mặc định trống  |
| `tab-lich-su`         | (icon) Lịch sử | "Lịch sử hoạt động" — danh sách log, không phải bảng AntD |

### 4.2 Nút trên header modal chi tiết

| testId                          | Nhãn                      | Điều kiện hiển thị                    |
| ------------------------------- | ------------------------- | ------------------------------------- |
| `lbl-tenHoSo`                   | (label) tên hồ sơ         | Luôn                                  |
| `btn-save`                      | Lưu lại                   | Luôn                                  |
| `btn-chuyen-luu-tru`            | Chuyển lưu trữ            | Luôn                                  |
| `btn-chuyen-hoat-dong`          | Chuyển Hoạt động          | **Chỉ khi tình trạng = `Khai báo`**   |
| `btn-chuyen-luu-tru-toan-bo-tl` | Chuyển lưu trữ toàn bộ TL | **Chỉ khi tình trạng = `Hoạt động`**  |
| `btn-more`                      | "..." (dropdown-trigger)  | Luôn — **hover** để mở menu (mục 4.3) |
| `btn-close-modal`               | Đóng                      | Luôn                                  |

**Chuyển Hoạt động** → mở confirm dialog `.ant-modal-confirm`:

| Phần     | Giá trị                                                                               |
| -------- | ------------------------------------------------------------------------------------- |
| Title    | `"Chuyển hoạt động"`                                                                  |
| Nội dung | `"Thao tác này sẽ chuyển hoạt động Bộ Hồ sơ. Bạn có chắc chắn muốn chuyển hoạt động"` |
| Nút      | `Hủy bỏ` / `Chuyển hoạt động`                                                         |

Sau khi xác nhận: `lbl-modal-title` đổi phần tình trạng `Khai báo` → `Hoạt động`,
`btn-chuyen-hoat-dong` biến mất, `btn-chuyen-luu-tru-toan-bo-tl` xuất hiện.

> ✅ Dùng hàm **`chuyenHoatDong(page, recordUrl?)`**.

### 4.3 ⚠️ `btn-more` xuất hiện 2 lần trên cùng trang

Khi đang ở tab **Cấu trúc hồ sơ**, `page.getByTestId("btn-more")` khớp **2** phần tử
(1 ở header modal BHS, 1 ở header bảng Cấu trúc) → strict-mode violation. Phải scope:

```ts
const recordModal = page.locator(".ant-modal-content:visible").last();
await recordModal.getByTestId("btn-more").first().hover(); // menu của BHS
// hoặc
const pane = page.locator(".ant-tabs-tabpane-active").last();
await pane.getByTestId("btn-more").hover(); // menu của bảng Cấu trúc
```

**Menu `btn-more` của Bộ hồ sơ** (hover → dropdown):

| testId                            | Nhãn                        | Ghi chú                                                        |
| --------------------------------- | --------------------------- | -------------------------------------------------------------- |
| `btn-thay-doi-nhan`               | Thay đổi nhãn               | Đo 2026-08-14 (BHS **Hoạt động**)                              |
| `btn-chuyen-khai-bao`             | Chuyển khai báo             | Đo 2026-08-14 — chỉ thấy khi BHS đang **Hoạt động**            |
| `btn-kiem-tra-phan-quyen`         | Kiểm tra phân quyền         | —                                                              |
| `btn-dong-bo-ocr`                 | Đồng bộ OCR                 | —                                                              |
| `btn-quan-ly-cau-truc-da-xoa`     | Quản lý cấu trúc đã xóa     | → `KHO-TAI-LIEU.MODAL-KHOI-PHUC-CAU-TRUC-DA-XOA.md` (mục 11)   |
| `btn-xoa`                         | Xóa hồ sơ                   | Đo 2026-07-27 (BHS **Khai báo**); 2026-08-14 ở BHS Hoạt động **không thấy** |
| `btn-import-excel-vi-tri-luu-tru` | Import excel vị trí lưu trữ | —                                                              |

> ⚠️ **Menu đổi theo tình trạng BHS** (và theo quyền của vai) — bảng trên là hợp của 2 lần đo:
> 2026-07-27 trên BHS `Khai báo` và 2026-08-14 trên BHS `Hoạt động` (`ecm01`, Owner).
> Đừng assert "menu có đúng N mục" nếu chưa đo lại đúng tình trạng của case.

**Menu `btn-more` của Tài liệu** (khi đang mở modal chi tiết 1 tài liệu):

| testId                    | Nhãn                |
| ------------------------- | ------------------- |
| `btn-kiem-tra-phan-quyen` | Kiểm tra phân quyền |
| `btn-dong-bo-ocr`         | Đồng bộ OCR         |
| `btn-tao-shortcut`        | Tạo shortcut        |
| `btn-xoa`                 | Xóa tài liệu        |
| `btn-quan-ly-file-da-xoa` | Quản lý file đã xóa |

### 4.4 Tab "Cấu trúc hồ sơ"

Vào tab: `getByTestId("lbl-tab-cauTrucHoSo").click()` rồi chờ ~5 s.
Mọi locator bên dưới scope vào `page.locator(".ant-tabs-tabpane-active").last()`.

**Toolbar của tab:**

| testId                      | Mô tả                                                                                          |
| --------------------------- | ---------------------------------------------------------------------------------------------- |
| `btn-tao-moi`               | Nút **Tạo mới** (split button — phần chữ)                                                      |
| _(không có testId)_         | Mũi tên dropdown cạnh "Tạo mới": `.ant-dropdown-trigger` **thứ nhất** trong pane → DEV bổ sung |
| `btn-more`                  | "..." của bảng → menu `mni-keo-tha`, `mni-nhap-tu-excel`, `mni-xuat-excel`                     |
| `txt-search-ho-so-tai-lieu` | Ô tìm (placeholder `Tìm hồ sơ, tài liệu`)                                                      |
| `sel-phan-loai`             | Dropdown **Phân loại**                                                                         |
| `btn-hien-thi-bo-loc`       | Mở panel lọc nâng cao                                                                          |
| `btn-xoa-bo-loc`            | Xoá điều kiện lọc                                                                              |
| `btn-dong-bo-loc`           | Đóng panel lọc                                                                                 |
| `undefined-title`           | 🐞 **BUG testId** — chính là label "Cấu trúc hồ sơ" → DEV sửa                                  |

**Dropdown của nút "Tạo mới"** (hover `.ant-dropdown-trigger` đầu tiên trong pane):

| testId                    | Nhãn                |
| ------------------------- | ------------------- |
| `mni-them-thu-muc`        | Thư mục             |
| `mni-them-tai-lieu`       | Tài liệu            |
| `mni-tao-ho-so-lien-quan` | Tạo hồ sơ liên quan |

**Panel lọc nâng cao** (sau khi bấm `btn-hien-thi-bo-loc`):
`txt-key-word` (Tìm tên, mã TM/TL), `txt-so-hieu-tai-lieu`, `sel-cap-mat-hs`,
`dtp-ngay-ban-hanh-tu` / `dtp-ngay-ban-hanh-den`, `dtp-effective-date-tu` / `dtp-effective-date-den`,
`sel-dang-van-ban-tl`, `txt-don-vi-phe-duyet`, `txt-don-vi-xuat-ban`, `sel-warehouses`.

> ⚠️ `dtp-effective-date-tu` và `dtp-effective-date-den` **bị lặp 2 lần** trong panel lọc
> (2 cặp element cùng testId) → phải dùng `.first()` / `.nth()`. Đã ghi vào `KHO-TAI-LIEU.TODO-DEV.md`.
> Ngoài ra `txt-so-hieu-tai-lieu`, `sel-dang-van-ban-tl`, `txt-don-vi-phe-duyet`, `txt-don-vi-xuat-ban`
> **trùng testId với field trong modal Tạo mới tài liệu** → khi cả hai cùng mở phải scope theo modal.

**Cột bảng** (16 cột, theo `.ant-table-thead th`):
`""`, `Tên Hồ sơ / Tài liệu`, `""`, `Trạng thái hiển thị`, `Loại tài liệu`, `Hình thức tài liệu`,
`Mã tài liệu`, `Mã tài liệu gốc`, `Shortcut`, `Độ mật`, `Cập nhật lần cuối`, `Số bản`, `Phân quyền`,
`Tình trạng OCR thuộc tính`, `Người upload file`, `""`.

**Header của tab** còn hiển thị bộ đếm dạng text: `Thư mục: 2`, `Tài liệu: 1`, `File đính kèm: 0`, `Dung lượng: 0 B`.

**Cấu trúc 1 dòng** — testId có **hậu tố là id của item** nên không thể hard-code:

| Element            | Locator                                    | Ghi chú                                                     |
| ------------------ | ------------------------------------------ | ----------------------------------------------------------- |
| Link tên item      | `[data-testid^="lnk-ten-ho-so-tai-lieu-"]` | Text hiển thị = `"<mã prefix>. <tên>"`, vd `"A. Thư mục 1"` |
| Nút menu hành động | `[data-testid^="btn-more-action-"]`        | `div.ant-dropdown-trigger`, **phủ toàn bộ ô cuối dòng**     |

Nút menu **luôn có trong DOM** (không phải hover mới xuất hiện) nhưng thực tế cần `row.hover()` trước
khi hover vào nó để dropdown mở ổn định.

**Cây thư mục ở tab này expand sẵn** — tài liệu nằm trong thư mục hiện luôn dưới dạng
`tr.ant-table-row-level-1`, tên hiển thị `"A.1 <tên tài liệu>"`. (Khác bảng Phân quyền nâng cao — ở đó
phải expand, xem mục 6.)

> 🚨 **Chỉ đúng với cấu trúc 2 cấp.** Đo trên DOM thật 2026-08-14 (BHS nhập từ `template3A.xlsx`,
> cây 4 cấp): bảng **chỉ hiện cấp 0 + cấp 1** (`A. Thư mục gốc A`, `A.1`, `A.2`, `A.3`) — item ở cấp
> sâu hơn (gồm **tài liệu**) không hiển thị cho tới khi bấm nút expand của dòng.
> ✅ Dùng hàm **`moRongToanBoCayCauTruc(page)`** trước khi đọc/assert danh sách dòng.
> ⚠️ Nút expand dùng class AntD chuẩn `.ant-table-row-expand-icon-collapsed`, **chưa khảo sát riêng
> bằng MCP** trên bảng này.

Ô đầu mỗi dòng có **checkbox** `label.ant-checkbox-wrapper` (chọn nhiều item).

**Menu hành động của dòng THƯ MỤC:**

| testId                    | Nhãn                |
| ------------------------- | ------------------- |
| `mni-them-thu-muc`        | Thêm Thư mục        |
| `mni-them-tai-lieu`       | Thêm Tài liệu       |
| `mni-tao-ho-so-lien-quan` | Tạo hồ sơ liên quan |
| `mni-nhan-ban`            | Nhân bản            |
| `mni-cap-nhat`            | Cập nhật            |
| `mni-keo-tha`             | Kéo thả             |
| `mni-chuyen-len`          | Chuyển lên          |
| `mni-chuyen-xuong`        | Chuyển xuống        |
| `mni-xoa`                 | Xóa                 |

**Menu hành động của dòng TÀI LIỆU:**

| testId             | Nhãn         |
| ------------------ | ------------ |
| `mni-nhan-ban`     | Nhân bản     |
| `mni-cap-nhat`     | Cập nhật     |
| `mni-tao-shortcut` | Tạo shortcut |
| `mni-chuyen-len`   | Chuyển lên   |
| `mni-chuyen-xuong` | Chuyển xuống |
| `mni-xoa`          | Xóa          |

> ⚠️ Các `mni-*` xuất hiện ở **cả** dropdown toolbar lẫn dropdown từng dòng → luôn click qua
> `page.locator(".ant-dropdown:visible").last()`, không dùng `page.getByTestId("mni-...")` trần.

> ✅ Dùng hàm **`openRowActionMenu(page, tenItem, mniTestId)`** (mở menu + click 1 mục), hoặc
> **`openRowActionDropdown(page, tenItem)`** khi chỉ cần **assert nội dung menu** mà không click.
> Tương tự, dropdown của nút "Tạo mới" dùng **`openTaoMoiDropdown(page)`** (trả về locator dropdown).

**Hành vi đã kiểm chứng của một số mục menu:**

| Mục                                   | Kết quả                                                                                                                |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| `mni-cap-nhat`                        | Mở màn chi tiết item (giống click vào tên) — 🚨 **đổi `page.url()` sang `itemId` của item đó**                         |
| `mni-them-tai-lieu` (từ dòng thư mục) | Mở modal Tạo mới tài liệu với `sel-folder-storage` **đã điền sẵn** thư mục đó                                          |
| `mni-xoa`                             | Confirm dialog `.ant-modal-confirm`: title `"Xóa"`, nội dung `"Bạn có chắc chắn muốn xóa?"`, nút `Hủy bỏ` / `Xác nhận` |

**2 dialog xoá khác nhau** tuỳ item có dữ liệu bên trong hay không:

| Trường hợp                                | `.ant-modal-confirm-content`                                                                                     | Nút xác nhận                        |
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| Item **rỗng** (TM không có gì / tài liệu) | `"Bạn có chắc chắn muốn xóa?"`                                                                                    | **`btn-xoa-xac-nhan`** (nhãn `Xác nhận`) |
| Thư mục **có TM/TL bên trong**            | `"Thao tác này sẽ xóa tất cả dữ liệu Thư mục/Tài liệu bên trong! Bạn có chắc chắn thực hiện Xóa?"`                | **`btn-xoa-xac-nhan`**              |

**Dialog xoá item rỗng — đo lại DOM 2026-08-14:** modal có testId **`mdl-xoa-xac-nhan`**,
nút huỷ **`btn-xoa-huy`** (`Hủy bỏ`), nút xác nhận **`btn-xoa-xac-nhan`** (`Xác nhận`).
Xác nhận xong app bắn toast **`"Xóa thành công"`** (`.ant-message-success`, sống ~3 s) — hằng `TOAST_XOA`.

> ⚠️ Dòng thứ 2 của bảng (thư mục **có dữ liệu bên trong**) vẫn **chưa khảo sát bằng MCP** —
> nội dung do người dùng cung cấp 2026-08-05 (dùng cho case `tests/1.4.7/1.4.7.616.spec.ts`).
> ✅ Dùng hàm **`xoaItemCauTruc(page, tenItem, { noiDungMongDoi })`** — nội dung lệch mong đợi thì
> báo bằng `expect.soft` nhưng **vẫn bấm xác nhận xoá**. Hàm vẫn chỉ chờ toast **"mềm"** (toast
> sống ~3 s, dễ lỡ) → tín hiệu cứng để assert là **dòng biến mất khỏi bảng**.

Item vừa xoá **không mất hẳn**: nó nằm trong "Quản lý cấu trúc đã xóa" và khôi phục lại được —
xem **mục 11**.

### 4.5 Field bị khoá sau khi tạo

Sau khi BHS đã lưu, các field sau **không còn là input** (render thành text, testId biến mất khỏi DOM):
`sel-companyInvestor`, `tree-sel-owner-department`, `sel-loaiBoHoSo`, `sel-duAn`, `sel-cauTrucHoSo`.

Còn sửa được: `txt-tenHoSo`, `tree-sel-mucLuuTru`, `sel-securityLevel`, `txt-soHieuHoSo`,
`sel-trangThai`, `sel-storageTerm`, `sel-hardCopyStatus`, `sel-tags-tuKhoa`, `txa-ghiChu`
và các field động.

```ts
// Assert field đã bị khoá:
await expect(page.getByTestId("sel-loaiBoHoSo")).toHaveCount(0);
```

### 4.6 🚨 Khối "Phân quyền truy cập" KHÔNG hiển thị trên màn chi tiết

Trên màn chi tiết/cập nhật, cột chứa `lbl-access-permission-card` được app gắn thuộc tính **`hidden`**
(kiểm chứng 2026-07-27: `<div class="flex-1 mdm:w-full mdm:block" hidden>`, `getComputedStyle → display:none`).
Đã thử ở cả 2 trạng thái (`Khai báo`, `Hoạt động`) và cả viewport 1200 lẫn 1600 → **luôn ẩn**,
không phải do responsive hay collapse.

Hệ quả cho automation:

- `pp-multi-usersRight*` **vẫn còn trong DOM** nên `getByTestId(...)` tìm thấy, nhưng
  `.isVisible()` = false → mọi `click`/`hover`/`fill` sẽ **timeout**.
- ⚠️ Vì `innerText` của element ẩn vẫn đọc được, một assert kiểu `toContainText` có thể **pass giả**.
  Muốn assert quyền hiện tại của BHS, đọc từ **modal Phân quyền nâng cao** (mục 6), đừng đọc từ picker.
- **Chỉ gán quyền cho BHS được ở 2 chỗ**:
  1. Form **Tạo mới bộ hồ sơ** (mục 3) — dùng `createBoHoSo(..., { peoplePickers })`
  2. Modal **Phân quyền nâng cao** → click tên item → nút **"Thêm"** — dùng `themQuyenChoNguoiDung(...)`

Trên màn chi tiết chỉ còn 2 khối: `lbl-information-card` ("Thông tin chung") và
`lbl-related-info` ("Thông tin liên quan").

---

## 5. Thông tin liên quan

### 5a. Pop-up "Tìm hồ sơ liên quan"

Mở bằng click thẳng `btn-add-related-ecm`, **hoặc** hover `btn-add-related-more-options` →
click `btn-options-relaled-record` _(sic — DEV gõ nhầm "relaled")_.

- Chờ ~6 s cho danh sách load
- Tiêu đề: text `"Tìm hồ sơ liên quan"` (**không** dùng `lbl-modal-title`)
- Ô search: `input[placeholder="Nhập tên hồ sơ, số hồ sơ"]`
- Cột: `""`, `Mã hồ sơ`, `Tên hồ sơ`, `Độ mật`, `Thư mục lưu trữ`, `Dự án`, `""`, `""`
- **Toàn bộ pop-up không có `data-testid` nào** → DEV bổ sung

### 5b. Pop-up "Tìm tài liệu liên quan"

Hover `btn-add-related-more-options` → click `btn-options-related-file`. Chờ ~7 s.

- Ô search: `input[placeholder="Nhập tên tài liệu, mã tài liệu"]`
- Cột: `""`, `Mã tài liệu`, `Tên tài liệu`, `Độ mật`, `Loại tài liệu`, `Đơn vị sở hữu`, `""`, `""`
- Cũng **không có `data-testid`**

### 5c. Chọn dòng & thêm

Checkbox **luôn hiển thị** (không cần hover). Nút "Thêm" (`btn-add-related`) **chỉ xuất hiện
sau khi tick ≥ 1 dòng** — trước đó footer chỉ có "Hủy".

```ts
const modal = page.locator(".ant-modal-content:visible").last();
const searchInput = modal.locator(
  'input[placeholder="Nhập tên hồ sơ, số hồ sơ"]',
);
await searchInput.fill(targetName);
await searchInput.press("Enter");
await page.waitForTimeout(10000); // kết quả load chậm

const firstRow = modal.locator(".ant-table-tbody tr.ant-table-row").first();
await expect(firstRow).toContainText(targetName, {
  timeout: TIMEOUT.DATA_LOADING,
});
await firstRow.locator(".ant-checkbox-wrapper").click();
await expect(page.getByTestId("btn-add-related")).toBeVisible({
  timeout: TIMEOUT.CONTROL_LOADING,
});
await pw.clickButton("btn-add-related");
await pw.isVisible("related-item-table");
```

### 5d. Bảng `related-item-table`

Cột: `""`, `Mã hồ sơ/ Tài liệu`, `Tên hồ sơ/ Tài liệu`, `Dự án`, `""`, `Mục lưu trữ/ Loại TL`, `""`.
Dữ liệu gom nhóm: `tr.ant-table-row-level-0` = nhóm (`"Hồ sơ (1)"`), `tr.ant-table-row-level-1` = từng item.

Cấu trúc `td` của dòng level-1:

| `td` | Nội dung                                                                          |
| :--: | --------------------------------------------------------------------------------- |
|  0   | indent                                                                            |
|  1   | `button` > `div.cssMaHSTLChild` — mã HS/TL                                        |
|  2   | `span.cssContentColumn` — tên HS/TL                                               |
|  3   | `button` — Dự án                                                                  |
|  4   | `div.buttonLoaiBoRelated` — **nút Loại bỏ liên kết** (luôn hiện, không cần hover) |
|  5   | Mục lưu trữ / Loại TL                                                             |

```ts
const rows = page
  .getByTestId("related-item-table")
  .locator("tr.ant-table-row-level-1");
await expect(rows).toHaveCount(1);
const ma = (
  await rows.first().locator(".cssMaHSTLChild").textContent()
)?.trim();
// Bỏ liên kết:
await rows.first().locator(".buttonLoaiBoRelated").click();
```

---

## 6. Modal "Phân quyền nâng cao"

→ Xem file riêng: **`KHO-TAI-LIEU.MODAL-PHAN-QUYEN-NANG-CAO.md`**

Đây là **nơi duy nhất** xem/sửa được phân quyền của BHS sau khi tạo (xem mục 4.6).

---

## 7. Phân quyền theo vai

→ Xem file riêng: **`KHO-TAI-LIEU.PHAN-QUYEN-THEO-VAI.md`**

Logic ẩn/hiện element theo vai, điều kiện mở được BHS, và cách dựng tiền điều kiện "vai X có quyền Y"
đều nằm ở đó — file này chỉ mô tả những gì hiển thị trên màn hình.

---

## 8. Tạo Thư mục / Tài liệu

→ Xem file riêng: **`KHO-TAI-LIEU.MODAL-TAO-THU-MUC.md`** và **`KHO-TAI-LIEU.MODAL-TAO-TAI-LIEU.md`**

---

## 9. Lưu ý quan trọng khi viết case

1. **Chờ sau khi vào màn**: `pw.wait(TIMEOUT.HARD_WAITING)` (10 s) trước thao tác đầu tiên.
   Modal Tạo mới BHS và modal Tạo tài liệu nạp metadata chậm → chờ thêm **5–10 s** sau khi mở.
2. **Mở lại BHS phải đi 2 bước** qua màn danh sách (mục 4.0) — `goto` thẳng URL `?itemId=` không reset SPA.
3. **Lưu `recordUrl` của BHS ngay sau khi Lưu**, trước khi tạo thư mục/tài liệu — vì tạo tài liệu
   xong `page.url()` sẽ trỏ sang tài liệu vừa tạo.
4. **`btn-more` có 2 phần tử** khi ở tab Cấu trúc hồ sơ → luôn scope (mục 4.3).
5. **`mni-*` xuất hiện ở nhiều dropdown** → click qua `.ant-dropdown:visible` `.last()`.
6. **Nhiều modal chồng nhau** (BHS → Thêm thư mục → confirm): luôn dùng
   `page.locator(".ant-modal-content:visible").last()`; confirm dialog dùng `.ant-modal-confirm-btns`.
7. **`.ant-form-item-explain-error` phải scope theo modal** — nếu không sẽ bắt lỗi của modal nền.
8. **Toast** `.ant-message-notice` — hiện rồi tắt nhanh, **assert ngay sau click**, không
   `waitForTimeout` trước.

   🚨 **Gặp thật 2026-08-14**: toast AntD tự tắt sau **~3 s** (mặc định của `.ant-message`, chưa đo
   trực tiếp) — đúng bằng `TIMEOUT.CONTROL_LOADING` → chỉ cần
   chen 1 `waitForTimeout(CONTROL_LOADING)` giữa click và assert là **mất toast**: xem video thấy
   toast hiện rõ, còn Playwright báo `element(s) not found`. Khi giữa click và assert **bắt buộc**
   có bước khác (bấm tiếp 1 modal xác nhận, chờ app xử lý…) thì đừng assert sau — dùng
   **`rinhToast(page, noiDung)`**: gọi **trước** khi click để bắt đầu chờ, làm các bước phụ, rồi
   `await` promise đó.

   Thao tác tạo thư mục / tài liệu bắn **2 toast nối tiếp**:
   `"Đang xử lý"` trước, vài giây sau mới tới `"Thành công"` → lúc cả 2 cùng trong DOM thì
   `page.locator(".ant-message-notice")` khớp **2 element** (strict-mode violation).
   Luôn **lọc theo nội dung**: `getToast(page, "Thành công")`, hoặc dùng thẳng
   `expectToastThanhCong(page)` (đã lọc sẵn + chờ tới 60 s).
9. Field bắt buộc **không có dấu `*`**, marker là `span.icon-required` trong `label`.
10. **Tên hiển thị ≠ account id.** Bảng quyền/avatar chỉ hiển thị **tên**, nên assert theo người
    phải dùng tên hiển thị.

    ✅ **Cách chuẩn — lấy động, không hard-code**: `layTenHienThiNguoiDangDangNhap(page)`.
    Tên user **có thể bị đổi trong lúc test** nên đừng fix tên vào code.
    Cơ chế (khảo sát 2026-07-30): màn danh sách → **hover** `[data-testid="avatar-container"]`
    (avatar ở header) → mở `.ant-dropdown` với nội dung theo dòng:

    ```
    MH                      ← chữ viết tắt
    Nguyễn Minh Hoàng       ← TÊN HIỂN THỊ
    ecm01@yopmail.com
    Ngôn ngữ
    Tiếng Việt
    Đăng xuất
    ```

    Hàm lấy **dòng ngay trước dòng chứa `@`** nên không phụ thuộc thứ tự tuyệt đối.
    ⚠️ Hàm điều hướng về màn danh sách (chỗ duy nhất chỉ có 1 `avatar-container` — trong modal
    Phân quyền nâng cao testId này lặp ở từng ô quyền) → đừng gọi khi đang cần giữ modal.

    **Cần tên của người khác** (vd người vừa được cấp quyền)? → **inject fixture của account đó**
    rồi lấy tên từ phiên của họ, dùng `layTenDoiTuongPhanQuyen`:

    ```ts
    test("...", async ({ librarian, ecm06, ecm07 }) => {
      //            ↑ vai thao tác   ↑ người được cấp quyền (chỉ để lấy tên)
      const ten = await layTenDoiTuongPhanQuyen({ account: "ecm07", page: ecm07 });
      // Nhóm người dùng không đăng nhập được → bỏ `page`, hàm trả về chính tên nhóm:
      const tenNhom = await layTenDoiTuongPhanQuyen({ account: GROUP.ECM06 });
    });
    ```

    Đổi lại là **thêm 1 phiên browser cho mỗi account cần lấy tên** — chấp nhận được so với rủi ro
    assert bằng tên hard-code đã lạc hậu. Bộ case 1.4.6 đang làm theo cách này.

    ⚠️ `TEN_HIEN_THI` (bảng ánh xạ dưới đây) là **phương án cuối** — chỉ khi không thể có `page` của
    account đó. Đã xác minh trên sitdev, nhưng **có thể lạc hậu** nếu tên bị đổi:

    | Account | Tên hiển thị      | Account | Tên hiển thị   |
    | ------- | ----------------- | ------- | -------------- |
    | `ecm01` | Nguyễn Minh Hoàng | `ecm06` | Hoàng Văn Mạnh |
    | `ecm05` | Đỗ Mạnh Cường     | `ecm07` | Trần Lê Nguyên |
    |         |                   | `ecm08` | Lê Duy Nam     |

    (`ecm02`, `ecm03`, `ecm04`, `ecm09` chưa có trong bảng — nếu cần thì đăng nhập bằng account đó
    rồi gọi `layTenHienThiNguoiDangDangNhap`, khỏi phải bổ sung bảng.)

11. **Khối "Phân quyền truy cập" bị `hidden` trên màn chi tiết** (mục 4.6) — gán quyền BHS chỉ làm
    được lúc tạo mới hoặc qua modal Phân quyền nâng cao.
12. **Vai không phải Owner chỉ mở được BHS ở trạng thái "Hoạt động"**
    (`KHO-TAI-LIEU.PHAN-QUYEN-THEO-VAI.md` mục 1) — nhớ
    `chuyenHoatDong` trước khi kiểm tra quyền.
13. **Thao tác dài mới → bổ sung vào `KHO-TAI-LIEU.functions.ts`**, không copy code dài vào spec.

---

## 10. Modal "Tạo mới Shortcut"

> 🚨 **CHƯA KHẢO SÁT BẰNG MCP** — toàn bộ mục này do người dùng mô tả (2026-08-14). Phần đã khảo sát
> thật chỉ là **lối vào**: `mni-tao-shortcut` ở menu hành động của dòng tài liệu (mục 4.4) và
> `btn-tao-shortcut` ở menu `btn-more` của màn chi tiết tài liệu (mục 4.3).
> Khảo sát được rồi thì thay bằng testId thật và bỏ cảnh báo này.

Mở từ 1 **tài liệu**: menu dòng → "Tạo shortcut" (hoặc màn chi tiết TL → `btn-more` → "Tạo shortcut").
Tiêu đề: **"Tạo mới Shortcut"**.

**Modal không có `data-testid` nào** → mọi locator bám placeholder + class AntD:

| #   | Ô                         | Locator                                                  | Ghi chú                                                                              |
| --- | ------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| 1   | Tên shortcut              | `input[placeholder="Nhập tên shortcut"]`                 | App **điền sẵn** `"Shortcut of <tên tài liệu gốc>"`, sửa được                        |
| 2   | Bộ hồ sơ đích             | `.ant-select:not(.ant-tree-select)` đầu tiên trong modal | Placeholder `"Vui lòng chọn"`; **search ở server** → phải gõ tên rồi chọn             |
| 3   | Vị trí lưu trong BHS đích | `.ant-tree-select` đầu tiên trong modal                  | Tree-select; option là `.ant-select-tree-title`, chọn item trùng tên BHS = đặt ở gốc |
| —   | Nút lưu                   | nhãn **"Lưu lại"** (chưa rõ có `btn-save` không)         | Lưu xong bắn toast **`"Tạo shortcut thành công"`**                                    |

Kết quả: BHS đích có thêm **1 dòng tài liệu** mang tên shortcut trong tab "Cấu trúc hồ sơ";
tài liệu gốc ở BHS nguồn **vẫn còn**.

> ✅ Dùng hàm **`taoShortcutTaiLieu(page, tenTaiLieu, tenBoHoSoDich, opts?)`** — BHS nguồn phải đang
> mở sẵn ở tab "Cấu trúc hồ sơ". Hằng: `TOAST_TAO_SHORTCUT`, `PREFIX_SHORTCUT`.

---

## 11. Modal "Khôi phục Cấu trúc hồ sơ" (Quản lý cấu trúc đã xóa)

→ Xem file riêng: **`KHO-TAI-LIEU.MODAL-KHOI-PHUC-CAU-TRUC-DA-XOA.md`** (khảo sát MCP 2026-08-14).

Tóm tắt: `btn-more` của **header BHS** → `btn-quan-ly-cau-truc-da-xoa` → modal
`"Khôi phục Cấu trúc hồ sơ"` liệt kê Thư mục/Tài liệu đã xoá của BHS
(cột `Danh sách`, `Thời gian xóa`, `Người thực hiện`, `Vị trí`). Tick dòng (🚨 checkbox **chỉ hiện
khi hover dòng**) → nút **"Khôi phục"** (không có testId, `disabled` khi chưa tick) → app khôi phục
ngay: **không dialog xác nhận, không toast**, modal tự đóng, bảng Cấu trúc hồ sơ tự refresh và item
về đúng vị trí cũ.

> ✅ Dùng hàm **`khoiPhucCauTrucDaXoa(page, tenItem(s))`**.
