# Building the Sáng Kiến Application Form — TypeScript Component Guide

This guide explains how to turn the three artifacts in this folder into a working web form that:

1. Renders the full UMP "Sáng kiến – Cải tiến kỹ thuật" application (cover + Mẫu 01–04 + Bản Cam Kết).
2. Captures user input into a `FormData` object whose shape matches `data_blank.json` exactly.
3. POSTs that object to a backend endpoint that uses **`docxtpl`** (or `docx-templates`) to render `template_application_form.docx` and return a downloadable `.docx`.

---

## 1. The Three Artifacts and Their Roles

| File | Role | Used by |
|---|---|---|
| `template_application_form.docx` | The Word document with `{{ jinja2 }}` placeholders. **Do not edit by hand**; round-trips through Word will preserve placeholders only if they live in single text runs. | Backend renderer |
| `data_blank.json` | The canonical, flat, snake_case shape. **Every key here matches a placeholder in the docx 1:1.** This is the contract between the frontend and the renderer. | Frontend `FormData` type, backend context object |
| `bieu_mau_sang_kien_template.json` | Vietnamese-labeled mirror of the same structure. Use it as the source of human labels when you want to avoid hard-coding Vietnamese strings inside components. | Frontend label lookups, i18n |

The form has six top-level sections corresponding to the six top-level keys of `data_blank.json`:

```
trang_bia      → Cover page
mau_01         → Mẫu số 01: Báo cáo mô tả sáng kiến
mau_02         → Mẫu số 02: Đơn đề nghị công nhận sáng kiến
mau_03         → Mẫu số 03: Bản xác nhận tỷ lệ (%) đóng góp
mau_04         → Mẫu số 04: Phiếu đánh giá sáng kiến (đánh giá của hội đồng)
ban_cam_ket    → Bản cam kết (tác giả bài báo khoa học)
```

---

## 2. TypeScript Types (derive these from `data_blank.json`)

Place this file at `src/types/sangKien.ts`. The shape mirrors `data_blank.json` exactly — if you add or rename a field in the docx, update this file, the JSON, and the placeholder in the same commit.

```ts
// src/types/sangKien.ts

export interface NgayKy {
  ngay: string;   // "DD"  — keep as string so leading zeros survive
  thang: string;  // "MM"
  nam: string;    // "YYYY"
}

export interface TrangBia {
  ten_sang_kien: string;
  tac_gia: string;
  don_vi: string;
  thong_tin_lien_he: string;
  nam: string;
}

export interface DanhSachApDungRow {
  tt: string;
  ten_to_chuc: string;
  dia_chi: string;
  linh_vuc: string;
}

export interface TinhHieuQua {
  loi_ich_kinh_te: string;
  hieu_qua_giang_day: string;
  tang_nang_suat: string;
  nang_cao_hieu_qua: string;
  nang_cao_chat_luong: string;
  giam_chi_phi: string;
  cai_thien_moi_truong: string;
  bao_ve_suc_khoe: string;
  an_toan_lao_dong: string;
  nang_cao_nhan_thuc: string;
}

export interface Mau01 {
  mo_dau: string;
  ten_sang_kien: string;
  linh_vuc_ap_dung: string;
  tinh_trang_da_biet: string;
  muc_dich: string;
  cac_buoc_thuc_hien: string;
  dieu_kien_ap_dung: string;
  linh_vuc_ap_dung_2: string;       // distinct from linh_vuc_ap_dung above — see PDF §4.2
  ket_qua_thu_duoc: string;
  danh_sach_ap_dung: DanhSachApDungRow[];
  tinh_moi: string;
  tinh_hieu_qua: TinhHieuQua;
  thong_tin_bao_mat: string;
  ngay_ky: NgayKy;
  lanh_dao_don_vi: string;
  tac_gia_sang_kien: string;
}

export interface TacGiaRow {
  stt: string;
  ho_ten: string;
  ngay_sinh: string;
  noi_cong_tac: string;
  chuc_danh: string;
  trinh_do: string;
  ty_le: string;     // percentage as string, e.g. "60"
}

export interface ThamGiaRow {
  stt: string;
  ho_ten: string;
  ngay_sinh: string;
  noi_cong_tac: string;
  chuc_danh: string;
  trinh_do: string;
  noi_dung_ho_tro: string;
}

export interface PhanLoai {
  giai_phap_ky_thuat: boolean;
  sang_kien_tu_nckh: boolean;
  sang_kien_tu_sach: boolean;
}

export interface Mau02 {
  don_vi: string;
  danh_sach_tac_gia: TacGiaRow[];
  ten_sang_kien: string;
  chu_dau_tu: string;
  linh_vuc_ap_dung: string;
  ngay_ap_dung: string;
  noi_dung: string;
  phan_loai: PhanLoai;                       // mutually exclusive in practice — radio-style group
  thong_tin_bao_mat: string;
  dieu_kien_ap_dung: string;
  danh_gia_tac_gia: string;
  danh_gia_to_chuc: string;
  danh_sach_tham_gia: ThamGiaRow[];
  ngay_ky: NgayKy;
  lanh_dao_don_vi: string;
  tac_gia_sang_kien: string;
}

export interface TyLeDongGopRow {
  stt: string;
  ho_ten: string;
  don_vi: string;
  phan_tram: string;
  chu_ky: string;
}

export interface Mau03 {
  ngay_ky: NgayKy;
  ten_sang_kien: string;
  tac_gia_chinh: string;
  chuc_vu_don_vi: string;
  ty_le_dong_gop: TyLeDongGopRow[];          // sum of phan_tram MUST equal 100
  tac_gia_chinh_ky: string;
}

export interface DiemNhanXet {
  nhan_xet: string;
  diem: string;                              // numeric in a string, e.g. "35"
}

export interface Mau04 {
  ten_sang_kien: string;
  tac_gia: string;
  chuc_vu_don_vi: string;
  tinh_moi: DiemNhanXet;                     // max 40
  tinh_hieu_qua: DiemNhanXet;                // max 60
  tong_cong: string;                         // = tinh_moi.diem + tinh_hieu_qua.diem (compute, do not free-edit)
  ket_luan: string;
  ngay_ky: NgayKy;
  thanh_vien_hoi_dong: string;
}

export interface VaiTroBaiBao {
  tac_gia_chinh: boolean;                    // mutually exclusive with dong_tac_gia
  dong_tac_gia: boolean;
}

export interface CamKetChecks {
  quyen_so_huu_1: boolean;
  quyen_so_huu_2: boolean;
  dong_thuan: boolean;
  bai_bao_uy_tin: boolean;
  tuan_thu_phap_luat: boolean;
}

export interface BanCamKet {
  ngay_ky: NgayKy;
  tac_gia_dang_ky: string;
  cccd: string;
  don_vi: string;
  ten_bai_bao: string;
  nam_xet: string;                            // year, e.g. "2026"
  vai_tro: VaiTroBaiBao;
  cam_ket: CamKetChecks;
  nguoi_cam_ket: string;
}

export interface SangKienFormData {
  trang_bia: TrangBia;
  mau_01: Mau01;
  mau_02: Mau02;
  mau_03: Mau03;
  mau_04: Mau04;
  ban_cam_ket: BanCamKet;
}
```

The blank initial value is just the JSON file imported and cast:

```ts
import blank from '../../assets/data_blank.json';
import type { SangKienFormData } from '../types/sangKien';

export const emptyForm: SangKienFormData = blank as SangKienFormData;
```

---

## 3. Field-by-Field Rendering Reference

The table below is the **single source of truth** for which UI control to render. Sections that aren't filled by the applicant (Mẫu 04 is filled by the council; signatures are filled at print time) should still be present in the schema so the template can render placeholders.

### 3.1 `trang_bia` — Cover

| Path | Control | Notes |
|---|---|---|
| `ten_sang_kien` | `textarea` (2 rows) | Required, max ~300 chars |
| `tac_gia` | `text` | Required |
| `don_vi` | `text` | Required |
| `thong_tin_lien_he` | `text` | "phone, email" — accept free format |
| `nam` | `number` (year picker) | Default = current year |

### 3.2 `mau_01` — Báo cáo mô tả sáng kiến

| Path | Control | Notes |
|---|---|---|
| `mo_dau` | `textarea` (rich, 8+ rows) | Required |
| `ten_sang_kien` | `text` | Auto-mirror from `trang_bia.ten_sang_kien` (allow override) |
| `linh_vuc_ap_dung` | `text` | E.g. "quản lý giáo dục" |
| `tinh_trang_da_biet` | `textarea` | Required |
| `muc_dich` | `textarea` | Required |
| `cac_buoc_thuc_hien` | `textarea` (rich) | Required |
| `dieu_kien_ap_dung` | `textarea` | |
| `linh_vuc_ap_dung_2` | `text` | **Different** from `linh_vuc_ap_dung` — this is the field inside §4.2; keep both even if they often duplicate |
| `ket_qua_thu_duoc` | `textarea` | Required |
| `danh_sach_ap_dung[]` | dynamic table | columns: TT, Tên tổ chức/cá nhân, Địa chỉ, Lĩnh vực. Allow add/remove rows |
| `tinh_moi` | `textarea` | Required |
| `tinh_hieu_qua.*` (10 fields) | `textarea` (3–4 rows each) | All optional individually but at least one should be filled |
| `thong_tin_bao_mat` | `textarea` | Optional |
| `ngay_ky.{ngay,thang,nam}` | three `number` inputs OR a single date picker that decomposes on submit | |
| `lanh_dao_don_vi` | `text` | Name of unit leader (signature label) |
| `tac_gia_sang_kien` | `text` | Auto-mirror from `trang_bia.tac_gia` |

### 3.3 `mau_02` — Đơn đề nghị công nhận sáng kiến

| Path | Control | Notes |
|---|---|---|
| `don_vi` | `text` | Auto-mirror from `trang_bia.don_vi` |
| `danh_sach_tac_gia[]` | dynamic table | 7 columns. **Validation: sum of `ty_le` should equal 100.** |
| `ten_sang_kien` | `text` | Auto-mirror |
| `chu_dau_tu` | `text` | Often the unit name |
| `linh_vuc_ap_dung` | `text` | Auto-mirror from `mau_01.linh_vuc_ap_dung` |
| `ngay_ap_dung` | `date` | The day the innovation began being used |
| `noi_dung` | `textarea` (rich) | Required |
| `phan_loai.*` (3 booleans) | `radio` group rendered as 3 checkboxes | Spec says "đánh dấu ☑" — applicant typically picks ONE. Enforce single-selection in UI but keep all three booleans in the data shape because the docx renders three independent ☑/☐. |
| `thong_tin_bao_mat` | `textarea` | Optional |
| `dieu_kien_ap_dung` | `textarea` | |
| `danh_gia_tac_gia` | `textarea` | Required |
| `danh_gia_to_chuc` | `textarea` | If §danh_sach_ap_dung has rows, this should be filled |
| `danh_sach_tham_gia[]` | dynamic table | 7 columns; people who tested the innovation |
| `ngay_ky` | as above | |
| `lanh_dao_don_vi`, `tac_gia_sang_kien` | `text` | |

### 3.4 `mau_03` — Bản xác nhận tỷ lệ đóng góp

| Path | Control | Notes |
|---|---|---|
| `ngay_ky` | as above | |
| `ten_sang_kien`, `tac_gia_chinh`, `chuc_vu_don_vi` | `text` | Auto-mirror where possible |
| `ty_le_dong_gop[]` | dynamic table | columns: STT, Họ và tên, Đơn vị, % đóng góp, Chữ ký. **Validation: sum of `phan_tram` MUST equal 100** (block submit otherwise). The signature column is filled by hand on the printed copy — leave a blank in the rendered docx. |
| `tac_gia_chinh_ky` | `text` | Name of main author at signature |

### 3.5 `mau_04` — Phiếu đánh giá (council use)

This page is filled by the evaluation council, not the applicant. In the applicant-facing UI, **either hide this section behind a role check or render it read-only** so the data round-trips when the docx is regenerated.

| Path | Control | Notes |
|---|---|---|
| `ten_sang_kien`, `tac_gia`, `chuc_vu_don_vi` | `text` | Pre-filled |
| `tinh_moi.{nhan_xet,diem}` | textarea + number (0–40) | |
| `tinh_hieu_qua.{nhan_xet,diem}` | textarea + number (0–60) | |
| `tong_cong` | computed | = `Number(tinh_moi.diem) + Number(tinh_hieu_qua.diem)`; render as readonly |
| `ket_luan` | `textarea` | |
| `ngay_ky`, `thanh_vien_hoi_dong` | as above | |

### 3.6 `ban_cam_ket` — Bản cam kết (article-author commitment)

This is the section described by the PDF you provided. It only applies when the innovation is based on a published article.

| Path | Control | Notes |
|---|---|---|
| `ngay_ky` | as above | |
| `tac_gia_dang_ky` | `text` | Required, ~120 chars |
| `cccd` | `text` | National ID (12 digits) or passport — accept either format, validate length 8–12 |
| `don_vi` | `text` | Auto-mirror from `trang_bia.don_vi` |
| `ten_bai_bao` | `textarea` (2 rows) | Article title (VN or international) |
| `nam_xet` | `number` (year) | The year of submission. Defaults to `trang_bia.nam`. **This year also appears in the form's subtitle** ("...năm {nam_xet}..."). |
| `vai_tro.tac_gia_chinh` / `vai_tro.dong_tac_gia` | radio-style pair of checkboxes | Mutually exclusive — selecting one clears the other. |
| `cam_ket.quyen_so_huu_1` | checkbox | "Tôi là chủ sở hữu hợp pháp..." |
| `cam_ket.quyen_so_huu_2` | checkbox | "Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH..." — at least ONE of `quyen_so_huu_1` / `quyen_so_huu_2` must be checked |
| `cam_ket.dong_thuan` | checkbox (required) | "Tất cả đồng tác giả đã biết, đồng ý..." |
| `cam_ket.bai_bao_uy_tin` | checkbox (required) | "Cam kết bài báo không thuộc 'Tạp chí săn mồi'" |
| `cam_ket.tuan_thu_phap_luat` | checkbox (required) | "Tuân thủ pháp luật sở hữu trí tuệ" |
| `nguoi_cam_ket` | `text` | Same person as `tac_gia_dang_ky`, used as signature label |

---

## 4. Recommended File Layout

```
src/
  types/
    sangKien.ts              # types from §2
  assets/
    data_blank.json          # imported as initial state
    bieu_mau_sang_kien_template.json   # imported for VN labels (optional)
  hooks/
    useSangKienForm.ts       # wraps react-hook-form, handles auto-mirror + validation
  components/
    sang-kien/
      SangKienForm.tsx       # top-level form, tabs/accordions for the 6 sections
      sections/
        TrangBiaSection.tsx
        Mau01Section.tsx
        Mau02Section.tsx
        Mau03Section.tsx
        Mau04Section.tsx     # gated behind role
        BanCamKetSection.tsx
      controls/
        DateTriple.tsx       # renders ngay/thang/nam as one row
        DynamicTable.tsx     # generic add/remove rows for arrays
        ExclusiveCheckGroup.tsx  # for vai_tro and phan_loai
        TextArea.tsx
  api/
    renderDocx.ts            # POST FormData → returns Blob (docx)
```

---

## 5. Recommended Stack

- **React 18+** with **TypeScript**
- **react-hook-form** for state, with **zod** resolvers for validation
- **shadcn/ui** or your existing component library for inputs
- **Backend**: any service that runs `python-docx-template` — a 30-line FastAPI/Flask endpoint is enough

A minimal zod schema for one section (sketch):

```ts
import { z } from 'zod';

export const ngayKySchema = z.object({
  ngay:  z.string().regex(/^\d{1,2}$/),
  thang: z.string().regex(/^\d{1,2}$/),
  nam:   z.string().regex(/^\d{4}$/),
});

export const banCamKetSchema = z.object({
  ngay_ky: ngayKySchema,
  tac_gia_dang_ky: z.string().min(1, 'Bắt buộc'),
  cccd: z.string().min(8).max(12),
  don_vi: z.string().min(1),
  ten_bai_bao: z.string().min(1),
  nam_xet: z.string().regex(/^\d{4}$/),
  vai_tro: z
    .object({
      tac_gia_chinh: z.boolean(),
      dong_tac_gia:  z.boolean(),
    })
    .refine(
      v => Number(v.tac_gia_chinh) + Number(v.dong_tac_gia) === 1,
      { message: 'Phải chọn đúng một vai trò' }
    ),
  cam_ket: z
    .object({
      quyen_so_huu_1: z.boolean(),
      quyen_so_huu_2: z.boolean(),
      dong_thuan:        z.boolean(),
      bai_bao_uy_tin:    z.boolean(),
      tuan_thu_phap_luat: z.boolean(),
    })
    .refine(v => v.quyen_so_huu_1 || v.quyen_so_huu_2, {
      path: ['quyen_so_huu_1'],
      message: 'Phải chọn ít nhất một mục về quyền sở hữu',
    })
    .refine(v => v.dong_thuan && v.bai_bao_uy_tin && v.tuan_thu_phap_luat, {
      message: 'Phải xác nhận tất cả các cam kết bắt buộc',
    }),
  nguoi_cam_ket: z.string().min(1),
});
```

---

## 6. Auto-Mirroring Fields

Several values logically duplicate. Wire these one-way bindings inside `useSangKienForm`:

```
trang_bia.ten_sang_kien  →  mau_01.ten_sang_kien
                          →  mau_02.ten_sang_kien
                          →  mau_03.ten_sang_kien
                          →  mau_04.ten_sang_kien

trang_bia.tac_gia        →  mau_01.tac_gia_sang_kien
                          →  mau_02.tac_gia_sang_kien
                          →  mau_04.tac_gia
                          →  ban_cam_ket.tac_gia_dang_ky
                          →  ban_cam_ket.nguoi_cam_ket
                          →  mau_03.tac_gia_chinh
                          →  mau_03.tac_gia_chinh_ky

trang_bia.don_vi         →  mau_02.don_vi
                          →  ban_cam_ket.don_vi

trang_bia.nam            →  ban_cam_ket.nam_xet (default only)
```

Use `useWatch` to populate these on first focus of the destination field; allow the user to override (some authors sign Mẫu 03 with a fuller title than they put on the cover).

---

## 7. Submitting and Rendering

The frontend never builds the docx — it only POSTs the form data. Two options:

### 7a. Python backend (recommended — matches the template author's environment)

```python
# server/render.py
from io import BytesIO
from docxtpl import DocxTemplate
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from pydantic import BaseModel

app = FastAPI()
TEMPLATE_PATH = "template_application_form.docx"

@app.post("/render")
def render(payload: dict):                    # accept full FormData object
    doc = DocxTemplate(TEMPLATE_PATH)
    doc.render(payload)
    buf = BytesIO()
    doc.save(buf)
    buf.seek(0)
    return StreamingResponse(
        buf,
        media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
        headers={"Content-Disposition": 'attachment; filename="sang_kien.docx"'},
    )
```

### 7b. Node backend

Use `docxtemplater` with the same template — it understands `{{ }}` and `{% %}` tags via the `nunjucks-docxtemplater-module` or manual angular-parser equivalent. This is more setup; pick 7a if you have the choice.

### Frontend call

```ts
// src/api/renderDocx.ts
import type { SangKienFormData } from '../types/sangKien';

export async function renderDocx(data: SangKienFormData): Promise<Blob> {
  const res = await fetch('/api/render', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });
  if (!res.ok) throw new Error(`Render failed: ${res.status}`);
  return await res.blob();
}

// usage inside component:
const onSubmit = async (data: SangKienFormData) => {
  const blob = await renderDocx(data);
  const url = URL.createObjectURL(blob);
  const a = Object.assign(document.createElement('a'), {
    href: url, download: 'sang_kien.docx',
  });
  a.click();
  URL.revokeObjectURL(url);
};
```

---

## 8. Validation Rules to Enforce Before Submit

1. **`trang_bia`**: all five fields non-empty.
2. **`mau_01.danh_sach_ap_dung`**: every row has all four columns filled OR remove the row.
3. **`mau_02.danh_sach_tac_gia`**: same; **plus** sum of `ty_le` (parsed as int) === 100.
4. **`mau_02.phan_loai`**: at least one of the three booleans is `true` (typical case: exactly one).
5. **`mau_03.ty_le_dong_gop`**: sum of `phan_tram` === 100.
6. **`mau_04.tinh_moi.diem`** ≤ 40, **`mau_04.tinh_hieu_qua.diem`** ≤ 60, `tong_cong = sum`.
7. **`ban_cam_ket`**:
   - exactly one of `vai_tro.tac_gia_chinh` / `vai_tro.dong_tac_gia` is true;
   - at least one of `cam_ket.quyen_so_huu_1` / `quyen_so_huu_2` is true;
   - `cam_ket.dong_thuan` AND `cam_ket.bai_bao_uy_tin` AND `cam_ket.tuan_thu_phap_luat` are all true.
8. **`ngay_ky.nam`** is a 4-digit year ≥ current year.

---

## 9. Common Pitfalls

- **Don't change keys in `data_blank.json` without updating the docx.** Open the docx, search for `{{ key }}`, and replace there too. The docx uses Jinja2-style `{{ ... }}` tags inside Word text runs; if Word splits the placeholder across runs (which it can do silently), `docxtpl` will fail. Use the included unpack/pack scripts when editing.
- **Booleans must stay booleans, not "true"/"false" strings.** The `{% if %}` checks treat strings as truthy.
- **Empty arrays render as one empty row** because the table loops `{% tr for item in ... %}` over the array. If you want to suppress empty tables, filter out fully-empty rows on the client before submit.
- **Vietnamese diacritics**: send `Content-Type: application/json; charset=utf-8`. Most fetch defaults are correct, but check the backend echoes `nguyễn` not `nguyá»…n`.
- **Dates as `string`**: keep them as strings throughout. JavaScript `Date` objects round-trip lossy through JSON and break leading zeros.

---

## 10. Quick Component Sketch (for one section)

A minimal `BanCamKetSection.tsx` to anchor the rest of the implementation:

```tsx
import { useFormContext } from 'react-hook-form';
import type { SangKienFormData } from '../../../types/sangKien';

export function BanCamKetSection() {
  const { register, watch, setValue } = useFormContext<SangKienFormData>();
  const vaiTro = watch('ban_cam_ket.vai_tro');

  // mutually-exclusive helper
  const pickRole = (role: 'tac_gia_chinh' | 'dong_tac_gia') => {
    setValue('ban_cam_ket.vai_tro.tac_gia_chinh', role === 'tac_gia_chinh');
    setValue('ban_cam_ket.vai_tro.dong_tac_gia',  role === 'dong_tac_gia');
  };

  return (
    <section>
      <h2>Bản Cam Kết</h2>

      <label>Tác giả đăng ký sáng kiến
        <input {...register('ban_cam_ket.tac_gia_dang_ky')} />
      </label>

      <label>CCCD/Hộ chiếu số
        <input {...register('ban_cam_ket.cccd')} />
      </label>

      <label>Đơn vị
        <input {...register('ban_cam_ket.don_vi')} />
      </label>

      <label>Tên bài báo
        <textarea rows={2} {...register('ban_cam_ket.ten_bai_bao')} />
      </label>

      <label>Năm xét
        <input type="number" {...register('ban_cam_ket.nam_xet')} />
      </label>

      <fieldset>
        <legend>Vai trò đối với bài báo</legend>
        <label>
          <input type="checkbox"
            checked={vaiTro?.tac_gia_chinh}
            onChange={() => pickRole('tac_gia_chinh')} />
          Tác giả chính
        </label>
        <label>
          <input type="checkbox"
            checked={vaiTro?.dong_tac_gia}
            onChange={() => pickRole('dong_tac_gia')} />
          Đồng tác giả
        </label>
      </fieldset>

      <fieldset>
        <legend>Cam kết nội dung</legend>
        <label><input type="checkbox" {...register('ban_cam_ket.cam_ket.quyen_so_huu_1')} />
          Tôi là chủ sở hữu hợp pháp của bài báo…
        </label>
        <label><input type="checkbox" {...register('ban_cam_ket.cam_ket.quyen_so_huu_2')} />
          Trường hợp bài báo là sản phẩm của nhiệm vụ NCKH…
        </label>
        <label><input type="checkbox" {...register('ban_cam_ket.cam_ket.dong_thuan')} />
          Tất cả đồng tác giả đã biết, đồng ý…
        </label>
        <label><input type="checkbox" {...register('ban_cam_ket.cam_ket.bai_bao_uy_tin')} />
          Cam kết bài báo không thuộc "Tạp chí săn mồi"
        </label>
        <label><input type="checkbox" {...register('ban_cam_ket.cam_ket.tuan_thu_phap_luat')} />
          Tuân thủ pháp luật sở hữu trí tuệ
        </label>
      </fieldset>

      <label>Người cam kết (ký tên)
        <input {...register('ban_cam_ket.nguoi_cam_ket')} />
      </label>
    </section>
  );
}
```

Replicate the same pattern for the other five sections, using `useFieldArray` from react-hook-form for the dynamic tables (`danh_sach_ap_dung`, `danh_sach_tac_gia`, `danh_sach_tham_gia`, `ty_le_dong_gop`).

---

## 11. End-to-End Sanity Check

Before shipping, run this contract test:

```ts
import blank from './assets/data_blank.json';
import { sangKienSchema } from './types/sangKien.zod';

// 1) The blank file must satisfy the zod schema *with optional fields permitted*.
sangKienSchema.partial().parse(blank);

// 2) A round-trip via the backend must return a non-empty Blob.
const out = await renderDocx(blank as any);
console.assert(out.size > 5000, 'docx render produced suspiciously small output');
```

If both assertions pass, the frontend, the schema, and the docx renderer are in agreement — and any bug from there onward is a UI bug, not a contract drift.
