# sub-investigator-codebase-2 — LENS: HÌNH DẠNG FORM (YC-032)

> Lane: investigator-codebase #2 · S194 · 2026-08-13
> Câu hỏi: khuôn nhập HĐ HIỆN TẠI là gì, khoảng cách tới "document-view cuộn từ trên xuống" bao xa.
> Quy tắc: mọi số từ lệnh vừa chạy, kèm lệnh. 0-hit kèm control-dương.

## 0. Khung

Kết luận sớm (chi tiết §A-§D):
1. Form tạo HĐ hiện tại = **form field rời rạc 2 cột** (grid `grid-cols-2`), KHÔNG tab / KHÔNG accordion, chia **3 khối** (Header · Ngân sách · bảng Chi tiết) — cách "document-view" xa **toàn bộ tầng render**, KHÔNG có gì tái dụng ở tầng UI.
2. Repo **CÓ form-engine thật** (`{{placeholder}}` docx + LibreOffice convert) nhưng nó là engine **XUẤT RA FILE**, không phải engine **NHẬP LIỆU trong trình duyệt**. Chỗ gần "ô nhập theo spec" nhất = `fe-admin/src/components/DynamicForm.tsx` (150 dòng) — nhưng nó cũng vẫn là **lưới 2 cột**, không phải thân văn bản.
3. Ống dữ liệu để chứa "văn bản trọn" **ĐÃ CÓ SẴN**: `Contract.DraftData nvarchar(max)` (JSON) sống từ Mig 5, BE round-trip đủ, **FE hardcode `null` 4/4 site** ⇒ lớp "cơ-chế đúng, dữ-liệu không có" lần nữa.
4. fe-admin có màn **y hệt** (file byte-identical) nhưng đã bị **ẩn khỏi menu admin có chủ đích** ⇒ mirror 2 app là **KHÔNG cần**, làm 1 app (fe-user).

## A. Form hiện tại

### A1. Cấu trúc màn (fe-user/src/pages/contracts/ContractCreatePage.tsx, 868 dòng)

`wc -l fe-user/src/pages/contracts/ContractCreatePage.tsx` → **868**

Layout = **2 panel** (`:132` `grid ... lg:grid-cols-[320px_1fr]`):
- Panel 1 (`:134-248`) aside: ô tìm kiếm + list HĐ theo `?type=` + nút "Thêm mới".
- Panel 2 (`:251-286`) main: 3 trạng thái — empty (`:252`) · `ContractHeaderForm` create (`:261`) · `ContractEditForm` edit (`:274`).

**KHÔNG có tab, KHÔNG có accordion, KHÔNG có stepper** trong màn nhập. Kiểm: `grep -c "Tabs\|accordion\|<Stepper" fe-user/src/pages/contracts/ContractCreatePage.tsx` → 0 (control dương cùng thước: `grep -c "<Input"` → 13).

### A2. Đếm ô nhập (lệnh + số)

`grep -c "<Input" …ContractCreatePage.tsx` → **13**
`grep -c "<Select" …` → **6**
`grep -c "<Textarea" …` → **2**
`grep -c 'type="checkbox"' …` → **2**
`grep -c "useState" …` → **18**

Vì file chứa **2 form** (create + edit) + 1 preview, con số trên là tổng cả 2. Bóc theo form:

| Form | Field NGƯỜI DÙNG nhập được | Nguồn |
|---|---|---|
| **Create** (`ContractHeaderForm :294-473`) | **11** = 8 trong `FormFields` (Loại HĐ · Template · NCC · Dự án · Tên HĐ · Giá trị · checkbox bypass · Nội dung — `:807-866`) + Quy trình duyệt V2 (`:414`) + Tên ngân sách (`:439`) + Số tiền NS (`:448`) | `:395-461` |
| **Edit** (`ContractEditForm :539-772`) | **6 sửa được** (Template `:674` · Tên HĐ `:695` · Giá trị `:699` · Nội dung `:712` · NS tên `:722` · NS số tiền `:731`) + **4 chỉ-đọc** (Loại HĐ `:670` · NCC `:687` · Dự án `:691` · checkbox bypass `:702` disabled) | `:667-759` |
| **Chi tiết (bảng dòng)** | bảng `ContractDetailsTab`, cột theo `HEADERS_BY_TYPE :529-537`; **type 1 (Thầu phụ) = 7 cột** `['Hạng mục','ĐVT','Khối lượng','Đơn giá','Thành tiền','Hoàn thành','Ghi chú']` | `:530` |

⇒ **Trả lời (A): 11 field ở form tạo, 10 ô ở form sửa (6 sửa + 4 khoá), + 1 bảng chi tiết 7 cột.** Tổ chức = **1 khung `<form>` cuộn dọc, các ô xếp lưới 2 cột trong 2 `card` trắng** (`:393` Header card, `:483` Chi tiết card). Không tab/accordion.

### A3. Khoảng cách tới "document-view"

Form hiện tại KHÔNG mang bất kỳ mảnh nào của **thân hợp đồng**: 0 hit "ĐIỀU" trong toàn bộ 3 file pin.
`grep -c "ĐIỀU \|Điều [0-9]" fe-user/src/pages/contracts/ContractCreatePage.tsx fe-user/src/components/contracts/ContractDetailContent.tsx fe-user/src/pages/contracts/MyContractsPage.tsx` → **0 / 0 / 0**.
Control dương cùng thước (chứng regex sống): `git grep -c "Điều [0-9]" -- 'docs/*.md'` → `docs/_archive/forms-spec-raw.md:1` ⇒ 0-hit ở FE là **vắng thật**, không phải thước hỏng.

### A4. 🔴 Ống chứa "văn bản trọn" ĐÃ CÓ, FE bỏ trống

`Contract.DraftData` = `nvarchar(max)` JSON, khai từ `src/Backend/SolutionErp.Domain/Contracts/Contract.cs:25` (`// JSON field values (render template)`), cấu hình `ContractConfiguration.cs:21`, sinh từ **Mig 5** `20260421051248_AddContractsWorkflow.cs:45`.
BE **round-trip đủ**: Create nhận (`ContractFeatures.cs:27` + gán `:105`) · Update nhận + gán (`:146`, `:207`) · trả ra DTO (`ContractDtos.cs:46`) · list/detail select (`ContractFeatures.cs:851`).
FE **hardcode null 4/4 site**: `fe-user/…/ContractCreatePage.tsx:366` + `:582` và `fe-admin/…:366` + `:582` (`git grep -n "draftData"` — 4 hit FE, đều `draftData: null`).
Ca **duy nhất** có ghi thật: `CreateContractFromEvaluationFeatures.cs:104` `DraftData = pe.PaymentTerms` (carry-forward điều khoản thanh toán từ PE).
⇒ Dựng document-view **không cần migration** cho chỗ chứa dữ liệu: `DraftData` là chỗ đặt JSON các ô điền trong thân HĐ.

## B. Có sẵn gì gần document-view

### B1. form-engine = engine XUẤT FILE, không phải engine NHẬP

Đo: `wc -l` các file
- `src/Backend/SolutionErp.Infrastructure/Forms/DocxRenderer.cs` → **81**
- `…/Forms/XlsxRenderer.cs` → 47 · `…/Forms/FormRenderer.cs` → 24 · `…/Forms/LibreOfficePdfConverter.cs` → **96**
- `src/Backend/SolutionErp.Application/Forms/FormFeatures.cs` → **310** · `…/Forms/Services/IFormRenderer.cs` → 15

Làm được tới đâu:
- **Thay `{{fieldName}}`** trong docx: regex `\{\{([a-zA-Z0-9_\.]+)\}\}` (`DocxRenderer.cs:14`), gộp run trước khi replace nên bắt được placeholder bị Word cắt (`:56-77`), quét cả header/footer (`:39-42`), bảng ăn theo vì `Descendants<Paragraph>` (`:79`).
- **KHÔNG có vòng lặp bảng**: `// Không support {{#loop}}...{{/loop}} (table lặp)` — `DocxRenderer.cs:10`. ⇒ **danh mục hạng mục N dòng của HĐ Thầu phụ KHÔNG render được** bằng engine hiện tại nếu đi đường docx-render.
- Endpoint: `POST /api/forms/templates/{id}/render` (`FormsController.cs:25-28`) → `RenderTemplateCommand(TemplateId, Dictionary<string,string?>)` (`FormFeatures.cs:59`). Nhận **dictionary phẳng string→string**, không nhận mảng.
- **PDF**: `IPdfConverter` = LibreOffice headless `soffice --headless --convert-to` (`IPdfConverter.cs:4-9`), impl `LibreOfficePdfConverter.cs` (96 dòng); DI `Infrastructure/DependencyInjection.cs:36` `AddSingleton<IDocumentConverter, LibreOfficeDocumentConverter>()`. Upload `.doc/.xls` cũ **tự convert** sang `.docx/.xlsx` (`FormFeatures.cs:188-239`) ⇒ **2 file `.doc` của owner nạp vào được** mà không cần Word.

### B2. `DynamicForm` — thứ gần "ô nhập theo spec" nhất, nhưng vẫn là lưới

`git grep -l "DynamicForm"` → **2 file, cả 2 đều fe-admin**: `fe-admin/src/components/DynamicForm.tsx`, `fe-admin/src/pages/forms/FormsPage.tsx`.
- Spec = JSON `Record<fieldKey, {label,type,required,placeholder,hint,options}>`, 6 kiểu ô (`DynamicForm.tsx:23-30`).
- Render = `div className="grid grid-cols-1 gap-3 md:grid-cols-2"` (`:68`) ⇒ **lưới 2 cột**, mỗi ô có nhãn + mã field mono góc phải (`:85`). **Không có chỗ cho văn xuôi giữa các ô.**
- Chỗ dùng duy nhất = trang quản trị mẫu (preview spec), `FormsPage.tsx` (510 dòng) — **KHÔNG** nối vào màn nhập HĐ.
- 0-hit ở fe-user: `git grep -rn "DynamicForm" -- 'fe-user/'` → 0. Control dương cùng thước: `git grep -c "ContractDetailsTab" -- 'fe-user/'` → 4 file có hit ⇒ thước sống.

### B3. Kho mẫu trên đĩa — 2 file owner cần thì CHƯA có trong repo

`ls src/Backend/SolutionErp.Api/wwwroot/templates/` → **5 file**:
`SOL-CCM-FO-002.01.v01 Bang kiem tra hop dong.docx` · `002.04.v01 Dieu kien chung hop dong tron goi.docx` · `002.05.v01 Hop dong Giao khoan.docx` · `002.07.V01 Don dat hang.xlsx` · `SOL-CCM-RG-001.v02 QD ma so hop dong.docx`
⇒ **002.02 và 002.03 (2 file owner chỉ định) 0-hit trong repo** (`git grep -n "002\.02\|002\.03" -- '*.cs' '*.tsx'` → 0 hit mã nguồn; chỉ hit trong `.claude/auto-memory/project_sol_ccm_sp002_signing_matrix.md:41` = ghi chú). Control dương: `002.05`/`002.07` **có** trên đĩa cùng thư mục ⇒ thư mục đúng, file thiếu thật.
⚠️ Kèm bằng chứng cũ đáng tin: memory `office-document` S107 đã đo **2 template hiện có = 0 placeholder `{{}}`** (mẫu gốc dạng "Số: ………"), tức kể cả file có sẵn cũng **chưa placeholder-hoá**.

### B4. Nghề "render 1 văn bản dài trong trình duyệt" — 0 tiền lệ

`git grep -rln "contenteditable\|dangerouslySetInnerHTML\|tiptap\|slate\|quill\|ProseMirror\|react-quill" -- 'fe-user/' 'fe-admin/'` → **0 file**.
Control dương cùng thước: `git grep -rln "useMemo" -- 'fe-user/src'` → nhiều chục file ⇒ thước sống, kết luận **repo chưa từng có rich-text/editor nào**.
`package.json` 2 app: 0 gói editor/docx (`git grep -n "\"docx\"\|editor\|quill\|tiptap" -- 'fe-user/package.json' 'fe-admin/package.json'` → 0 hit; control dương: `"@tanstack/react-query"` có hit).

## C. 1 app hay 2 app

- **File màn tạo HĐ 2 app hiện GIỐNG NHAU TỪNG BYTE**: `diff fe-admin/src/pages/contracts/ContractCreatePage.tsx fe-user/…` → **0 dòng khác** (cả 2 đều 868 dòng).
- Route **cả 2 app đều có**: `fe-admin/src/App.tsx:82-84` (`/contracts`, `/contracts/new`, `/contracts/:id`) · `fe-user/src/App.tsx:64-65` (+ `:73` `/contracts/workflow-matrix` chỉ fe-user).
- 🔴 **Nhưng menu admin đã ẩn trọn nhánh HĐ có chủ đích**: `fe-admin/src/components/Layout.tsx:234-249` `ADMIN_HIDDEN_KEYS` chứa `'Contracts'` (+ `PurchaseEvaluations`, `KeHoachKyKet`, `HopDongCung`), và `:255` `isAdminHidden = ADMIN_HIDDEN_KEYS.has(key) || key.startsWith('Ct_')`. Chú thích ngay trên đó khai rõ: route + quyền **còn nguyên**, chỉ bớt lối đi trên menu.
- fe-admin **thiếu** `MyContractsPage.tsx` và `ContractListPanel.tsx` (`git ls-files 'fe-admin/src/pages/contracts/*'` → 4 file: ContractCreate/ContractDetail/ContractsList/HardCopies; fe-user → 4 file khác gồm MyContracts).

⇒ **Trả lời (C): làm 1 app — fe-user.** Mirror sang fe-admin là **công vô ích** (màn sẽ sống-không-menu, đúng tiền lệ KHKK admin ghi ở memory S187). Nếu vẫn muốn admin xem được thì chỉ cần **1 dòng**: bỏ `'Contracts'` khỏi `ADMIN_HIDDEN_KEYS` — nhưng đó là quyết định của owner, **KHÔNG** phải hệ quả kỹ thuật của YC-032.

## D. 3 cách dựng + đánh đổi

### D0. Đo THÂN 2 file .doc (số để cân 3 phương án)

`antiword -m UTF-8.txt '…002.02….doc' > /tmp/f02.txt` → **346 dòng** · `grep -c "ĐIỀU\|Điều [0-9]"` → **45** · `grep -c "^|"` (dòng bảng) → **61** · `grep -c "\.\.\.\.\|………"` (chỗ trống chấm) → **10**
Cấu trúc: mục lục (`:33-40`) → **PHẦN 1 ĐIỀU KIỆN RIÊNG** + **PHẦN 2 ĐIỀU KIỆN CHUNG** với **ĐIỀU 1→17** (`:44-62`) → `DANH MỤC HỢP ĐỒNG` (`:30`) → bảng có `TỔNG CỘNG` + `TỔNG CỘNG (SAU THUẾ)` (`:249`,`:251`) → `a) BẢNG TÓM TẮT CÁC ĐIỀU KIỆN CƠ BẢN` (`:312`) → `d) BẢNG TỔNG HỢP GIÁ TRỊ HỢP ĐỒNG (theo file đính kèm)` (`:340`).
002.03: **538 dòng**, 46 hit ĐIỀU. `diff f02 f03 | grep -c "^[<>]"` → **400 dòng khác nhau**.
🔴 ⇒ **2 form KHÔNG phải sinh đôi**: 002.03 dài hơn 55%, khác 400 dòng ⇒ dựng "1 thân + cờ nhân-công-vật-tư / nhân-công-thiết-bị" là **sai ngay từ giả thiết**; phải là **2 thân riêng** (dùng chung khung + chung máy ô-điền).

### D1. Ba cách, cân theo ĐÚNG thứ repo đang có

| | (i) HTML dựng tay | (ii) docx template render + overlay ô nhập | (iii) rich-text editor |
|---|---|---|---|
| **Repo đã có gì** | Tiền lệ số-hoá form giấy: `PeDetailTabs.tsx` **3.613 dòng** (Section 1→5 + mục `a. / b. / c.`, `grep -no "Section [0-9]"` 20+ hit) · `KhkkDetailContent.tsx` **1.140 dòng**. Chỗ chứa: `Contract.DraftData` sẵn | `DocxRenderer.cs` 81 dòng (`{{field}}`, gộp run, header/footer) · `LibreOfficePdfConverter.cs` 96 · endpoint `/forms/templates/{id}/render` · **prod CÓ soffice**: `ssh vietreport-vps … Test-Path 'C:\Program Files\LibreOffice\program\soffice.exe'` → **True** | **0 gói** — `git grep -rln "contenteditable\|tiptap\|slate-react\|quill\|ProseMirror"` trên `fe-user/src`+`fe-admin/src` → **0 file** (control dương `useMemo` → 18 file); `package.json` 0 hit editor/docx (control dương `@tanstack/react-query` `fe-user/package.json:18`) |
| **Cái ĐƯỢC** | Đúng thứ owner tả (cuộn từ trên xuống, ô điền NẰM TRONG thân); ô điền = React state ⇒ pre-fill 3 tầng (PE→KHKK→người nhập) là **gán giá trị mặc định**, hiển thị được "chỗ nào máy điền / chỗ nào anh phải điền"; validate từng ô; 0 migration (ghi `DraftData`) | Bản in **giống hệt bản Word gốc 100%** (đúng font/dấu/bảng ISO); tái dùng máy có sẵn; upload `.doc` cũ tự convert (`FormFeatures.cs:188-239`) | Sửa được câu chữ HĐ |
| **Cái MẤT / rủi ro thật** | Phải gõ tay thân 2 HĐ (346 + 538 dòng, 61 dòng bảng) thành TSX — khối lượng ngang `KhkkDetailContent`; **bản in ≠ bản Word** trừ khi làm thêm bước xuất; `fe-user` **chưa có hạ tầng in**: `git grep -rn "window.print\|@media print"` trên `fe-user/src` → **0 hit** (control dương: 80 file có `className`) | 🔴 **DocxRenderer KHÔNG có vòng lặp bảng** — `DocxRenderer.cs:10` `// Không support {{#loop}}...{{/loop}} (table lặp)` ⇒ `DANH MỤC HỢP ĐỒNG` N dòng **không render được**, phải viết thêm engine. 🔴 **2 file gốc chưa placeholder-hoá** (mẫu dạng "Số: …………", 10 chỗ chấm) và **chưa nằm trong repo** (`wwwroot/templates/` chỉ 5 file, không có 002.02/.03). Overlay ô-nhập lên docx trong trình duyệt = **không có đường nào trong repo** (0 viewer docx) ⇒ thực chất vẫn phải dựng UI nhập riêng ⇒ **thành (i) + thêm 1 lớp** | Sai bài: HĐ mẫu là văn bản **CỐ ĐỊNH**, chỉ có chỗ trống được điền; cho sửa tự do = mất khoá bản-cứng vế (4) + mất pre-fill có kiểm soát + đẻ phụ thuộc mới; 0 tiền lệ trong repo |

### D2. Đề xuất (lens hình-dạng form)

**(i) HTML dựng tay làm TRỤC — mượn (ii) làm đường XUẤT BẢN IN.**
Lý do đứng trên số, không trên lý thuyết: chỗ chứa dữ liệu **đã có** (`DraftData`, 0 migration); tiền lệ số-hoá form giấy **đã có 2 lần** (PE 3.613 dòng · KHKK 1.140 dòng) nên đội biết làm; còn (ii) — thứ nghe như "tái dùng" — thực tế **thiếu 3 mảnh** (vòng lặp bảng · placeholder trong file · viewer/overlay trong trình duyệt) nên chỉ là (i) cộng thêm việc. (ii) **vẫn dùng**, nhưng ở **đúng chỗ nó mạnh**: khi người nhập bấm "In", đẩy `DraftData` qua `/forms/templates/{id}/render` → docx → (`LibreOfficePdfConverter`) → PDF **giống bản Word gốc**, prod đã có soffice.
⇒ Việc dựng phải tách 2 nhánh song song: **nhánh NHẬP** (TSX 2 thân) ⟂ **nhánh IN** (placeholder-hoá 2 file .doc + thêm loop cho bảng danh mục). Nhánh IN có thể chậm 1 nhịp mà owner vẫn xem được hình.

## E. Chưa đo được

1. **Số ô điền THẬT của mỗi thân HĐ** — antiword làm phẳng bảng nên đếm "chỗ trống" chỉ ra **10** dòng có dấu chấm; con số ô thật (kể cả ô trong 61 dòng bảng + ô ẩn trong bảng tóm tắt `:312`) phải mở bằng công cụ đọc .doc có cấu trúc (agent `office-document`) — **KHÔNG suy từ số 10**.
2. **Bao nhiêu ô trong 2 thân đó đã có sẵn dữ liệu từ PE/KHKK** (vế (3) pre-fill 3 tầng) — cần map từng ô ↔ field; lane này chỉ đo được **ống**: bridge KHKK→HĐ đã gán sẵn 15 field (`CreateContractFromSigningPlanFeatures.cs:186-207`, dẫn theo chú thích `ContractCreatePage.tsx:547-551`), chưa đối chiếu với thân .doc.
3. **`Pdf:SofficePath` không khai trong bất kỳ `*.json`** (`git grep -n -i "soffice\|LibreOffice" -- '*.json'` → 0 hit) ⇒ chạy bằng đường mặc định `C:\Program Files\LibreOffice\program\soffice.exe`; prod Test-Path=True nhưng **máy dev của người thi công có thể không có** ⇒ nhánh IN sẽ chết câm ở local, không phải lỗi mã.
4. **Prod có HĐ Thầu phụ nào đang sống không** — chưa đo lượt này (memory S160: prod Contracts = 0 active/0 deleted, **cũ**, phải đo lại nếu dùng để quyết migration).
5. **Menu**: tách `01. HĐ Thầu phụ` thành 2 ruột (vế 1) chạm seed menu + `ContractType` — **ngoài lăng-kính lane này** (lane menu/BE đo).
