# Modal "Phân quyền nâng cao" — màn Kho tài liệu

> Tài liệu con của [`KHO-TAI-LIEU.md`](./KHO-TAI-LIEU.md). Hàm dùng chung nằm trong `KHO-TAI-LIEU.functions.ts`.
> Khảo sát trực tiếp bằng Playwright MCP ngày **2026-07-27** trên sitdev (`ecm01` Owner + `ecm06` VIEW).
>
> 📌 Sau khi BHS đã tạo, đây là **nơi duy nhất** xem và sửa được phân quyền —
> khối "Phân quyền truy cập" trên màn chi tiết bị app ẩn (xem `KHO-TAI-LIEU.md` mục 4.6).

> 📌 **Phạm vi tài liệu — chỉ mô tả màn hình** (field, nút, testId, cấu trúc modal/bảng, thông báo,
> luồng thao tác). **Không** ghi logic test / kỳ vọng của case / quy tắc nghiệp vụ vào đây — chỗ của
> chúng là spec `tests/<mục>/`, `<mục>.steps.ts` và `<mục>.md`. Xem `KHO-TAI-LIEU.md` mục đầu file.

---

## 1. Cách mở

```ts
const modal = page.locator(".ant-modal-content:visible").last();
await modal.getByTestId("btn-more").first().hover(); // ⚠️ .first() — xem mục 4.3 file chính
await page.waitForTimeout(3000);
await page.locator(".ant-dropdown:visible").last()
  .getByTestId("btn-kiem-tra-phan-quyen").click();
await page.waitForTimeout(10000); // modal load chậm
```

`lbl-modal-title` = `"Phân quyền nâng cao"`.

> ⚠️ **Chỉ Owner của BHS mới có `btn-more`.** Vai VIEW/không quyền không có nút này → không mở được
> modal (xem `KHO-TAI-LIEU.PHAN-QUYEN-THEO-VAI.md` mục 2).

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

---

## 2. Hai tab

| testId                                | Nhãn              | Ghi chú                                                                    |
| ------------------------------------- | ----------------- | ---------------------------------------------------------------------------- |
| `tab-phan-quyen-nang-cao-permission`  | Theo phân quyền   | Tab mặc định khi mở modal                                                    |
| `tab-phan-quyen-nang-cao-user`        | Theo người dùng   | Owner BHS thấy cả 2 tab. ⚠️ Chưa có vai nào khác mở được modal để đối chứng. |

> ✅ Dùng hàm **`switchTabPhanQuyen(page, "permission" | "user")`**.

---

## 3. Mã prefix item (`code`) — khái niệm cốt lõi

Mọi testId của dòng và ô trong bảng đều gắn hậu tố là **mã prefix của item** trong cấu trúc BHS:

| Item                              | `code`  | testId dòng    | Cấp trong bảng          |
| --------------------------------- | ------- | -------------- | ----------------------- |
| **Bản thân Bộ hồ sơ** (dòng gốc)  | `""`    | `tbl-row-`     | `ant-table-row-level-0` |
| Item gốc thứ 1                    | `"A"`   | `tbl-row-A`    | `ant-table-row-level-1` |
| Item gốc thứ 2                    | `"B"`   | `tbl-row-B`    | `ant-table-row-level-1` |
| Item con thứ 1 **bên trong** A    | `"A.1"` | `tbl-row-A.1`  | `ant-table-row-level-2` |
| Item con thứ 2 bên trong A        | `"A.2"` | `tbl-row-A.2`  | `ant-table-row-level-2` |

`code` cũng là prefix hiển thị trong bảng Cấu trúc hồ sơ (`"A. Tên thư mục"`, `"A.1 Tên tài liệu"`)
và trong input disabled của ô "Tên" ở modal Thêm thư mục.

> ⚠️ **KHÔNG dùng selector prefix `^=`** — `tbl-row-` là tiền tố của `tbl-row-A`, và `cell-title-row-A`
> là tiền tố của `cell-title-row-A.1`. Luôn match **chính xác**: `page.getByTestId("tbl-row-A")`.

> ✅ Dùng hằng `ITEM_PREFIX` và các hàm `checkOPhanQuyen` / `checkKeThua`.

---

## 4. Tab "Theo phân quyền"

| Element      | Locator                                                                               |
| ------------ | ------------------------------------------------------------------------------------- |
| Ô tìm kiếm   | `getByTestId("input-search-tab-theo-phan-quyen")` (placeholder `Tìm hồ sơ, tài liệu`) |
| Bảng         | `getByTestId("tbl-phan-quyen-nang-cao")`                                              |

### 4a. Header cột

| testId                              | Nhãn                 | Hậu tố ô tương ứng                  |
| ----------------------------------- | -------------------- | ----------------------------------- |
| `col-header-title`                  | Tên hồ sơ / Tài liệu | `cell-title-row-<code>`             |
| `col-header-hasUniquePermission`    | Kế thừa              | `cell-hasUniquePermission-row-<code>` |
| `col-header-usersRightOwner`        | Quyền Owner          | `cell-usersRightOwner-row-<code>`   |
| `col-header-usersRightAdd`          | Quyền tạo mới        | `cell-usersRightAdd-row-<code>`     |
| `col-header-usersRightEdit`         | Quyền cập nhật       | `cell-usersRightEdit-row-<code>`    |
| `col-header-usersRightDownload`     | Quyền tải file       | `cell-usersRightDownload-row-<code>` |
| `col-header-usersRightViewers`      | Quyền xem            | `cell-usersRightViewers-row-<code>` |

**Giá trị ô:**

- `cell-hasUniquePermission-row-<code>`: `"Kế thừa"` hoặc `"Quyền riêng tư"`
- `cell-usersRight*-row-<code>`: `button` chứa các `avatar-container` → text là **tên hiển thị**
  của người dùng, rỗng nếu không ai có quyền đó

### 🚨 4a-1. Ô quyền nhiều người → bị gom vào nút "+ n người khác", phải bấm mới xem hết

Ô quyền chỉ render 2 avatar đầu; những người còn lại **không có trong DOM**, chỉ còn 1 nút
text `"+ 1 người khác"` ở cuối ô → assert trần `toContainText("<tên>")` **fail oan** dù người đó
thật sự có quyền. Bấm nút này thì app render nốt danh sách **ngay trong ô** (không popover),
và xuất hiện thêm 1 nút thu gọn.

DOM thật của ô (xác nhận 2026-07-28 qua DevTools do dev/QA gửi):

```html
<td class="ant-table-cell">
  <button data-testid="cell-usersRightOwner-row-A" title type="button">
    <span class="text-left text-xs text-ellipsis ... overflow-hidden block cursor-pointer">
      <div class="... people-picker flex-1 flex w-full">
        <div>
          <div class="_displayLimit_k9vgr_316">        <!-- 1 avatar = 1 khối này -->
            <span class="ant-tag _displayLimit__tagName_k9vgr_320">
              <div data-testid="avatar-container">…</div>
            </span>
          </div>
          <div class="_displayLimit_k9vgr_316">…</div>  <!-- avatar thứ 2 -->
          <div class="_expand_k9vgr_338">
            <div class="_expand__others_k9vgr_348">+ 1 người khác</div>   <!-- ← nút mở rộng -->
            <div class="_collapse_k9vgr_358">…</div>                      <!-- nút thu gọn -->
          </div>
        </div>
      </div>
    </span>
  </button>
</td>
```

- ❌ **Nút mở rộng / thu gọn không có `data-testid`** → đã ghi vào `KHO-TAI-LIEU.TODO-DEV.md`.
- Class là **CSS-module có hash** (`_expand__others_k9vgr_348`) — hash đổi theo build nên chỉ
  được match theo **tiền tố class**: `[class*="_expand__others"]`, kèm fallback theo text
  `/\+\s*\d+\s*người khác/`.

Cách xử lý: đọc `innerText` của ô trước (người bị gom không có trong DOM nên không pass giả) →
chưa thấy tên thì bấm nút mở rộng → assert lại chính ô đó.

> ✅ Dùng hàm **`checkNguoiTrongOQuyen(page, code, cot, tenHienThi, label?)`** hoặc
> **`checkNguoiCoQuyenO5Cot(page, code, tenHienThi, label?)`** — đã bao gồm bước mở rộng.
> Chỉ dùng `checkOPhanQuyen` trần cho giá trị text ngắn (vd cột "Kế thừa"), **không** cho tên người.

```ts
await expect(page.getByTestId("cell-hasUniquePermission-row-A")).toHaveText("Kế thừa");
// Tên người → luôn qua hàm để tự mở rộng khi bị gom:
await checkNguoiTrongOQuyen(page, "B", PQ_COL.VIEW, "Đỗ Mạnh Cường");
```

> ℹ️ Bộ hồ sơ gốc **luôn** hiển thị `"Quyền riêng tư"` ở cột Kế thừa (không có cha để kế thừa).
> Thư mục/tài liệu mới tạo mặc định `"Kế thừa"`.

### 4b. 🚨 Bảng là CÂY — phải expand mới thấy item con

Khác bảng Cấu trúc hồ sơ (expand sẵn), ở đây item con **ẩn** cho tới khi expand thư mục cha.

Trigger expand là **`span` thứ 2 (index 1)** trong `td` đầu tiên của dòng
(`span.nth(0)` là `.ant-table-row-indent`, `span.nth(1)` chứa icon chevron):

```ts
await page.getByTestId("tbl-row-A").locator("td").first().locator("span").nth(1).click();
await page.waitForTimeout(TIMEOUT.DATA_LOADING);
// giờ mới thấy tbl-row-A.1
await expect(page.getByTestId("tbl-row-A.1")).toBeVisible();
```

> ✅ Dùng hàm **`moItemPhanQuyen(page, "A")`** — bản *idempotent* (chỉ expand khi dòng con chưa có
> trong DOM). `expandItemPhanQuyen` là **toggle**: gọi lúc đang mở sẽ thu gọn lại.

### 🚨 4b-1. Đóng modal con làm bảng re-mount → cây thu gọn lại

Mở rồi đóng modal con (`ngatKeThua`, `khoiPhucKeThua`, `themQuyenChoNguoiDung`…) khiến bảng
**render lại toàn bộ dòng**, kéo theo trạng thái expand bị reset về mặc định → dòng con **biến mất
khỏi DOM**, mọi `cell-*-row-A.1` sau đó sẽ `not found`.

Kiểm chứng 2026-07-29: gắn thêm attribute vào dòng `tbl-row-B`, mở modal con rồi đóng → attribute
mất sạch (dòng bị thay mới), số dòng trở về đúng danh sách cấp 1.

```ts
// ✅ Phải mở lại thư mục cha SAU MỖI lần đóng modal con:
for (const ten of [tenThuMucCon, tenTaiLieuCon]) {
  await moItemPhanQuyen(page, codeCha);          // bảo đảm đang expand
  const codeCon = await getCodeCuaItemPhanQuyen(page, ten);
  await ngatKeThua(page, codeCon);               // ← đóng modal con ⇒ cây thu gọn
  await moItemPhanQuyen(page, codeCha);          // ← mở lại trước khi đọc dòng con
  await checkKeThua(page, codeCon, "Quyền riêng tư", ten);
}
```

### 4c. Click vào tên item → modal con

Click `cell-title-row-<code>` → mở modal **"Phân quyền cấu trúc bộ hồ sơ"** (mục 6).

---

## 5. Tab "Theo người dùng"

| Element                          | Locator                                                                                                  |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| People picker "Người dùng/Nhóm"  | `modal.locator(".cssPeoplePickerPermissionTabModal.people-picker")` — ❌ không có testId                    |
| Ô tìm kiếm                       | `getByTestId("input-search-tab-theo-nguoi-dung")`                                                          |
| Dropdown lọc **Quyền**           | `modal.locator("div.cssDropdownSelectField").filter({ hasText: "Quyền" }).first()` — ❌ không có testId     |
| Dropdown lọc **Phân loại**       | `modal.locator("div.cssDropdownSelectField").filter({ hasText: "Phân loại" }).first()` — ❌ không có testId |

- Options của **Quyền**: `VIEW`, `EDIT`, `ADD`, `DOWNLOAD`, `OWNER`
- Options của **Phân loại**: `Kế thừa`, `Quyền riêng tư`
- Mỗi option là 1 `label.ant-checkbox-wrapper` → chọn được nhiều

> ⚠️ Hai dropdown dùng chung class `cssDropdownSelectField` → **bắt buộc filter thêm `hasText`**.
> Sau khi tick option, nhấn `Escape` để đóng dropdown trước khi assert bảng.

**Bảng ở tab này chỉ 3 cột** (dòng/ô vẫn dùng cùng quy ước `<code>`):

| Header testId                     | Ô testId                                 | Nội dung                          |
| --------------------------------- | ---------------------------------------- | --------------------------------- |
| `col-header-title`                | `cell-title-row-<code>`                  | Tên hồ sơ / Tài liệu              |
| `col-header-hasUniquePermission`  | `cell-hasUniquePermission-row-<code>`    | `Kế thừa` \| `Quyền riêng tư`     |
| `col-header-permMark`             | `cell-permMark-row-<code>`               | Quyền hiệu lực của user đang chọn |

Giá trị `cell-permMark-row-<code>` là tổ hợp các token `OWNER`, `ADD`, `EDIT`, `DOWNLOAD`, `VIEW`
(1 user có thể có nhiều quyền cùng lúc).

```ts
await switchTabPhanQuyen(admin, "user");
await fillUserPickerPhanQuyen(admin, "ecm05");
await checkQuyenTheoNguoiDung(admin, ITEM_PREFIX.A, ["VIEW"], "Thư mục 1");
```

---

## 6. Modal con "Phân quyền cấu trúc bộ hồ sơ"

Mở bằng click `cell-title-row-<code>`. `lbl-modal-title` = `"Phân quyền cấu trúc bộ hồ sơ"`.
Đây là **modal thứ 3** chồng lên → `.ant-modal-content:visible.last()`.

**Bố cục:**

| Khối             | Nội dung                                                                                        |
| ---------------- | ------------------------------------------------------------------------------------------------- |
| Nút hành động    | Tuỳ loại item + trạng thái kế thừa — xem bảng 6a                                                  |
| Cây bên trái     | Toàn bộ BHS + thư mục/tài liệu, mỗi item là 1 `button` có text `"<code>. <tên>"` — click để đổi item đang xem |
| Bảng bên phải    | Cột `Người dùng/Nhóm`, `Quyền`, `Thời gian chỉnh sửa`                                            |

Cột `Quyền` hiển thị các token `OWNER` / `ADD` / `EDIT` / `DOWNLOAD` / `VIEW` (một user có thể nhiều quyền).

> ℹ️ Không cần đóng modal con để xem item khác — **click item trong cây bên trái** là đủ,
> nút hành động và bảng cập nhật theo.

### 6a. Nút hành động theo trạng thái item

| Item                             | Nút hiển thị                        |
| -------------------------------- | ----------------------------------- |
| **Bộ hồ sơ gốc** (`code = ""`)   | Chỉ **"Thêm"** (không có cha để kế thừa) |
| Thư mục / tài liệu **đang kế thừa** | Chỉ **"Ngắt kế thừa"**              |
| Thư mục / tài liệu **quyền riêng**  | **"Kế thừa quyền"** + **"Thêm"**    |

### 6b. Ngắt kế thừa

```ts
const modalCon = page.locator(".ant-modal-content:visible").last();
await modalCon.getByRole("button", { name: "Ngắt kế thừa" }).click();
await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
await page.locator(".ant-modal-confirm-btns")
  .getByRole("button", { name: "Xác nhận" }).click();
```

Confirm dialog:

| Phần     | Giá trị                                                                                         |
| -------- | ------------------------------------------------------------------------------------------------- |
| Title    | `"Ngắt kế thừa quyền"`                                                                           |
| Nội dung | `"Thao tác này sẽ ngắt kế thừa quyền của thư mục/ tài liệu hiện tại. Bạn có muốn thực hiện?"`   |
| Nút      | `Hủy bỏ` / `Xác nhận`                                                                            |

**Sau khi Xác nhận** (đã kiểm chứng trực tiếp):

- ❗ **Không có toast** — đừng assert `"Thành công"`
- Modal con **không đóng**, nút đổi thành `"Kế thừa quyền"` + `"Thêm"`
- Cột `cell-hasUniquePermission-row-<code>` ở bảng cha **chưa đổi ngay** (vẫn `"Kế thừa"`);
  chỉ cập nhật sang `"Quyền riêng tư"` **sau khi đóng modal con**. Không cần mở lại modal cha.

```ts
// Assert đúng cách:
await modalCon.getByTestId("btn-close-modal").click();
await page.waitForTimeout(TIMEOUT.CONTROL_LOADING);
await checkKeThua(page, "B", "Quyền riêng tư", "Tài liệu B");
```

> ✅ Dùng hàm **`ngatKeThua(page, code)`** — đã bao gồm bước đóng modal con.

### 6c. Khôi phục kế thừa

Nút `"Kế thừa quyền"` → confirm dialog:

| Phần     | Giá trị                                                                          |
| -------- | ---------------------------------------------------------------------------------- |
| Title    | `"Kế thừa quyền"`                                                                 |
| Nội dung | `"Thao tác này sẽ kế thừa lại quyền của thư mục/ hồ sơ cha. Bạn có muốn thực hiện?"` |
| Nút      | `Hủy bỏ` / `Xác nhận`                                                             |

Sau khi Xác nhận: nút đổi lại thành `"Ngắt kế thừa"`, cột Kế thừa trở về `"Kế thừa"` (sau khi đóng modal con).

> ✅ Dùng hàm **`khoiPhucKeThua(page, code)`**.

### 6d. Modal "Thêm" — cấp quyền trực tiếp cho 1 người

Nút `"Thêm"` mở **modal thứ 4** (`lbl-modal-title` vẫn là `"Phân quyền cấu trúc bộ hồ sơ"`).

| Field            | Locator                                                                        | Ghi chú                                    |
| ---------------- | ------------------------------------------------------------------------------ | -------------------------------------------- |
| Hồ sơ/tài liệu   | text read-only, hiển thị `"<code>. <tên item>"`                                | —                                            |
| **Người nhận**   | ❌ `modal.locator(".people-picker").first()`                                   | Không có testId                              |
| **Quyền**        | ❌ `modal.locator(".ant-select").filter({ hasText: "Chọn quyền" }).first()`    | 5 option: `VIEW`, `EDIT`, `ADD`, `DOWNLOAD`, `OWNER` |
| Nội dung         | `modal.locator('textarea[placeholder="Nhập nội dung"]')`                       | —                                            |
| Nút **Gửi**      | `modal.getByRole("button", { name: "Gửi" })`                                   | —                                            |
| Nút **Đóng**     | `modal.getByRole("button", { name: "Đóng" })`                                  | —                                            |

**Hành vi khi bấm Gửi (đã kiểm chứng — trả lời câu hỏi mở của build cũ):**

Đây là **gán quyền TRỰC TIẾP và có hiệu lực ngay**, *không phải* gửi yêu cầu chờ duyệt:

- Toast `.ant-message-notice` = `"Thành công"`
- Modal "Thêm" tự đóng, quay về modal con
- Người vừa cấp **xuất hiện ngay** trong bảng quyền của item với đúng quyền đã chọn

```ts
await themQuyenChoNguoiDung(admin, ITEM_PREFIX.BHS, "ecm06", "VIEW", "cấp quyền xem");
// → ecm06 (Hoàng Văn Mạnh) có VIEW trên BHS ngay lập tức
```

> ✅ Dùng hàm **`themQuyenChoNguoiDung(page, code, account, quyen, noiDung?)`**.

> ❌ **Toàn bộ modal con + modal "Thêm" chỉ có `lbl-modal-title`, `btn-close-modal` và các `avatar-*`**
> — mọi field/nút khác phải bám text hoặc class. Xem `KHO-TAI-LIEU.TODO-DEV.md`.

---

## 7. ⚠️ Chưa khảo sát

- Bấm **"Ngắt kế thừa" trên THƯ MỤC** (mới thử trên tài liệu): nhiều khả năng giống hệt nhưng chưa xác nhận.
- Nút **"Đóng"** của modal "Thêm": chưa bấm → chưa rõ có confirm huỷ không.
- **Validate** modal "Thêm": chưa thử bấm Gửi khi bỏ trống Người nhận / Quyền.
- Cấp quyền cho **nhóm người dùng** (thay vì cá nhân) qua modal "Thêm".
- Hành vi khi user **không phải Owner của item** click vào tên item trong bảng
  (build cũ báo toast `"Bạn không được phân quyền truy cập thư mục này"`) — chưa kiểm chứng lại
  vì vai không phải Owner BHS **không mở được modal này** (không có `btn-more`).
