ปรับปรุง phase 6 ให้ครบถ้วน

This commit is contained in:
Suphonchai Phoonsawat 2026-08-20 11:15:04 +07:00
parent 93570d38d8
commit a196b8f9a8
14 changed files with 747 additions and 40 deletions

View file

@ -14,11 +14,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQL + MinIO + Keycloak (OIDC, 3 public clients พร้อม PKCE: `word-addin-officejs`, `word-versioning-web`, `word-addin-vsto`)
**สถานะปัจจุบัน: Phase 0–6 เสร็จ** (backend + add-in + web portal ครบ — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — งานต่อคือ phase 7 (tests ครบชุด), 8 (README + ไล่ acceptance criteria) ตามแผน
**สถานะปัจจุบัน: Phase 0–6 เสร็จ** (backend + add-in + web portal ครบ รวม 6.4/FR-12: รายการเอกสารต่อ template, document detail, re-generate — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — งานต่อคือ phase 7 (tests ครบชุด), 8 (ไล่ acceptance criteria) ตามแผน
## เอกสารหลัก (อ่านก่อนเริ่มงานใดๆ)
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–11, acceptance criteria 1–14, data models, platform constraints)
- `specs/spec-officejs.md` — **source of truth ของ requirements** (FR-1–12, acceptance criteria 1–15, data models, platform constraints)
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 9 phases พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
@ -28,7 +28,7 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
1. **ห้ามออกแบบ flow ที่ดัก native save** (Ctrl+S / File>Save) — Office.js ไม่มี before-save event ที่ cancel ได้ ใช้ปุ่มใน task pane เท่านั้น
2. **Custom Document Properties = primary flag** ตรวจจับ tracked document; **Custom XML Part = secondary เท่านั้น** (ต้อง delete แล้ว add ใหม่ทุกครั้ง, มี known bug บน Mac บาง build — ต้อง catch แบบไม่ throw)
3. **ห้าม redirect iframe ของ task pane ไป Keycloak ตรงๆ** — ต้องใช้ Office Dialog API (`displayDialogAsync` + `messageParent`); ส่วน `/web` เป็น top-level page ใช้ OIDC redirect ปกติ
4. **Token เก็บใน memory เท่านั้น** — ห้าม localStorage (theme preference ของ `/web` ยกเว้นได้)
4. **Token เก็บใน memory เท่านั้น** — ห้าม localStorage · ของ `/web` ที่ persist ได้มีเฉพาะ theme preference (localStorage) และ refresh token + PKCE verifier (sessionStorage อายุสั้น — จำเป็นเพื่อข้าม redirect/reload ตามที่แผนอนุญาต)
5. **Backend คือ source of truth** ของ version history และ field values
6. **Field key = content control (SDT) tag** ใช้ค่าเดียวกันทั้ง generate (backend) / extract (add-in) / sync (DB)
7. **Docx generation + metadata stamping ทำฝั่ง backend เท่านั้น** (OpenXML, แก้ที่ run level ใน SDT เพื่อรักษา formatting)
@ -47,6 +47,8 @@ Infra ทั้งหมดรันผ่าน `docker-compose.yml`: PostgreSQ
**Field sync policy (FR-11):** field ขาด (ผู้ใช้ลบ control) → เก็บค่าเดิม + mark missing **ไม่ fail request**; ส่งมาเกินนิยาม → ignore
**เอกสารของ template (FR-12):** `GET /api/templates/{id}/documents` (รายการต่อ template) + `GET /api/documents/{id}` (detail + ค่า fields ล่าสุด — merge นิยาม × ค่าแบบรองรับ orphan สองทาง) · re-generate ทำฝั่งเว็บ: ฟอร์มสร้างเอกสาร prefilled จากค่าปัจจุบัน (`?from={documentId}`) แล้วผ่าน endpoint generate เดิม → **เอกสารใหม่ v1 เอกสารต้นทางไม่ถูกแตะ**
**MinIO:** bucket `docx-versions` (snapshot ต่อ version, key `{documentId}/v{N:D4}_{clientVersionId}.docx`) + `docx-templates` (ไฟล์ template) — private ทั้งคู่, เข้าถึงผ่าน backend เท่านั้น (proxy stream หรือ presigned URL อายุสั้นจาก `download-link` — ซึ่ง add-in ใช้เปิดในเบราว์เซอร์ของเครื่อง)
**Template:** template.docx author ด้วย Word (ใส่ content controls) → upload → backend parse SDT tags → admin ตั้ง label/type/required → delete = deactivate (soft) เพราะ document เก่ายังอ้างอยู่
@ -59,7 +61,8 @@ python3 scripts/dev-token.py # token PKCE จริงส
# backend (http://localhost:5075 + https://localhost:7232 — dev เปิด https อัตโนมัติด้วย dev cert)
cd backend/WordVersioning.Api
dotnet run # API + swagger
dotnet run # API + swagger (ผ่าน launchSettings — เปิด browser ให้)
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5075 dotnet run --no-launch-profile # แบบไม่เปิด browser — ต้องใส่ env เอง ไม่งั้นไปอ่าน appsettings.json (DB password ว่าง) → 500
dotnet ef migrations add <Name>
dotnet test
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว

View file

@ -6,6 +6,8 @@
- 📝 **สร้างเอกสารจาก template** — กรอก form บนเว็บ → ระบบ generate .docx จาก template (แทนที่ค่าใน content controls) พร้อม stamp metadata และขึ้นเป็น **version 1 อัตโนมัติ**
- 🗂️ **Template management** — upload ไฟล์ .docx ที่มี content controls แล้วระบบ **detect fields อัตโนมัติ** ตั้งชื่อ/ชนิด/required ต่อ field ผ่านหน้าเว็บ
- 📋 **รายการเอกสารต่อ template + detail** — ดูเอกสารทุกฉบับที่สร้างจาก template, ค่า fields ปัจจุบัน และ version history (ขยายแต่ละ version เห็นค่าตอนนั้น) บนหน้าเว็บ
- ⟳ **Re-generate** — สร้างเอกสารใหม่จากค่าปัจจุบันของเอกสารเดิม (แก้ไขค่าได้ก่อน) — เป็น v1 ของเอกสารใหม่ **เอกสารต้นทางและ version history ไม่ถูกแตะ**
- 📌 **ตรวจจับ tracked document** — เปิดไฟล์ใน Word (Desktop Win/Mac หรือ Online) แล้ว add-in รู้ทันทีว่าไฟล์นี้อยู่ในระบบ พร้อมแสดง version history
- 💾 **Save & Version** — ปุ่มใน task pane สร้าง version ใหม่ (stamp metadata + อัปโหลด snapshot ไป backend)
- 🔄 **Field sync** — ทุกครั้งที่สร้าง version ระบบ extract ค่าจาก content controls ของเอกสารไป update fields ใน DB ให้ sync กับเนื้อหาจริงเสมอ
@ -17,7 +19,7 @@
|---|---|---|
| **Add-in** (`/addin`) | Office.js, TypeScript, React, Fluent UI | Task pane ใน Word — ตรวจจับ document, Save & Version, version history, field extraction |
| **Backend** (`/backend`) | ASP.NET Core 8, EF Core, PostgreSQL, MinIO, OpenXML | Version API, template system, docx generation, JWT validation |
| **Web Portal** (`/web`) | Nuxt.js, TailwindCSS | จัดการ template + สร้างเอกสารจาก form (light/dark theme) |
| **Web Portal** (`/web`) | Nuxt.js, TailwindCSS | จัดการ template + สร้างเอกสารจาก form + ดูรายการเอกสาร/detail + re-generate (light/dark theme) |
| **Auth** | Keycloak (OIDC + PKCE) | Public clients ทั้ง add-in และเว็บ, backend validate ผ่าน JWKS |
| **Storage** | MinIO (S3-compatible) | bucket `docx-versions` (snapshot ต่อ version) + `docx-templates` (ไฟล์ template) — private ทั้งคู่ |
@ -25,6 +27,7 @@
1. Admin upload template (.docx ที่มี content controls) → ระบบ detect fields อัตโนมัติ
2. ผู้ใช้กรอก form บนเว็บ → ได้ไฟล์ .docx ที่เป็น version 1 แล้ว → เปิดใน Word
3. Add-in รู้จักเอกสารทันที → แก้ไขเนื้อหา → กด **Save & Version** → เกิด version ใหม่ + fields ใน DB sync ตามเนื้อหาล่าสุด
4. กลับมาที่เว็บ anytime — ดูรายการเอกสารของ template / detail (ค่า fields + ทุก version) / **re-generate** เป็นเอกสารใหม่จากค่าปัจจุบัน
## สถานะโปรเจกต์
@ -36,14 +39,16 @@
| 3 | Template system + docx generation + field sync (FR-9–11) | ✅ เสร็จ |
| 4 | Add-in services layer — auth (PKCE + Dialog API), metadata, file/API, field extraction (FR-8, FR-11) | ✅ เสร็จ |
| 5 | Task pane UI — sign-in, Start Versioning, Save & Version, history (FR-1–4) | ✅ เสร็จ — ทดสอบจริงบน Word for Mac แล้ว (login/tracking/Save & Version ผ่าน, แก้ปัญหาที่เจอ 4 ประเด็น — ดู Troubleshooting) เหลือยืนยัน download + AC เต็มชุด |
| 6 | Web Portal UI (FR-9–10) | ⏳ รอ implement |
| 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement |
| 6 | Web Portal UI (FR-9–10) | ✅ เสร็จ — login ผ่าน Keycloak, template management, dynamic form, theme toggle |
| 6.4 | รายการเอกสารต่อ template + document detail + re-generate (FR-12) | ✅ เสร็จ (เพิ่มทีหลัง v4) |
| 7 | Tests ครบชุด (backend integration + web Vitest) | ⏳ รอ implement |
| 8 | ไล่ acceptance criteria เต็มชุด | ⏳ รอดำเนินการ (ส่วน README เติมครบแล้ว) |
**ใช้ได้แล้วตอนนี้:** infra ทั้งชุด + backend API ครบทั้ง version API (upload / history / download) และ template system (upload template พร้อม auto-detect fields, generate เอกสารเป็น v1, field sync ตอนสร้าง version) — ลองเล่นได้ผ่าน Swagger หรือ curl (ตัวอย่างด้านล่าง) · ฝั่ง add-in ครบทั้ง services layer และ task pane UI พร้อม unit tests 45 ตัว
**ใช้ได้แล้วตอนนี้:** ครบทั้ง 3 ส่วน — backend API ครบทุก endpoint (version + template + generate + field sync + รายการเอกสาร/detail), add-in ครบทั้ง services และ task pane UI (unit tests 47 ตัว) ทดสอบจริงบน Word for Mac แล้ว, web portal ใช้งานได้ทุกหน้า (login → จัดการ template → สร้างเอกสาร → ดูรายการ/detail → re-generate) · เหลือ: tests ครบชุด (Phase 7) + ไล่ AC ด้วยมือ (Phase 8)
## เอกสาร
- [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–11), data models, acceptance criteria (1–14)
- [specs/spec-officejs.md](specs/spec-officejs.md) — spec เต็มรูปแบบ: functional requirements (FR-1–12), data models, acceptance criteria (1–15)
- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 9 phases พร้อม definition of done
สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0)
@ -102,8 +107,9 @@ dotnet run # รันทั้ง http://localhost:5075 แ
**API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`):
```
# Documents / versions (FR-5–7, 11)
# Documents / versions (FR-5–7, 11, 12)
POST /api/documents/{documentId}/versions # อัปโหลด version ใหม่ + field sync
GET /api/documents/{documentId} # detail: ข้อมูลเอกสาร + template + ค่าล่าสุดทุก field (FR-12)
GET /api/documents/{documentId}/versions # version history ใหม่ → เก่า (พร้อม snapshot fields)
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก MinIO)
@ -111,9 +117,10 @@ GET /api/documents/{documentId}/versions/{versionId}/download-link # presigne
# Generate จาก template (FR-10)
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata
# Templates (FR-9)
# Templates (FR-9, 12)
GET /api/templates # รายการ (?includeInactive=true สำหรับ admin)
GET /api/templates/{id} # รายละเอียด + field definitions
GET /api/templates/{id}/documents # รายการเอกสารที่สร้างจาก template นี้ (FR-12)
POST /api/templates # upload .docx → detect fields อัตโนมัติ
PUT /api/templates/{id} # แก้ metadata + field definitions (label/type/required/ลำดับ)
PUT /api/templates/{id}/file # แทนที่ไฟล์ → re-detect (บอก tag ที่เพิ่ม/หาย)
@ -146,7 +153,9 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
# → { "versionId": "...", "versionNumber": 1 }
```
### ลองเล่น template → generate → field sync (Phase 3)
### ลองเล่น template → generate → field sync (ผ่าน curl — ใช้ UI บนเว็บก็ได้ ดูหัวข้อ Web Portal)
> ขั้นตอนเดียวกันนี้ทำผ่านหน้าเว็บได้สะดวกกว่า (ดูหัวข้อ "4) Web Portal") — ตัวอย่าง curl นี้เผื่อใช้กับ Swagger/automation
**0) เตรียมไฟล์ template** — เขียน .docx ใน Word แล้วแทรก content controls ในจุดที่จะเป็น field (Developer tab → Rich Text Content Control → Properties ตั้ง **Tag** ให้ unique ต่อไฟล์ เช่น `employee_name`, `salary`)
@ -184,7 +193,7 @@ curl -H "Authorization: Bearer $TOKEN" -o ไฟล์ที่สร้าง.d
เปิดไฟล์ที่ได้ตรวจได้ว่า: ค่าถูกทุก field (formatting ของ template คงเดิม) และมี custom properties `SavedByAddIn` / `DocumentTrackingId` (unzip ดู `docProps/custom.xml` หรือ Word → File → Info → Properties → Advanced Properties)
**4) Field sync ตอนสร้าง version ถัดไป** — upload v2 พร้อมแนบ `fields` (map tag→text ที่ extract จาก content controls — ในอนาคตฝั่ง add-in ทำให้อัตโนมัติ):
**4) Field sync ตอนสร้าง version ถัดไป** — upload v2 พร้อมแนบ `fields` (map tag→text ที่ extract จาก content controls — ฝั่ง add-in ทำให้อัตโนมัติตอนกด Save & Version แล้ว):
```bash
curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
@ -202,7 +211,7 @@ curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
cd addin
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
npm run build # webpack build + type-check ทั้ง program
npm test # Jest — unit tests (45 ตัว)
npm test # Jest — unit tests (47 ตัว)
npm run lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
npm run validate # ตรวจ manifest.xml
npm start # dev server https://localhost:3000 — sideload ผ่าน manifest.xml
@ -246,15 +255,31 @@ npm run build
- ไล่ทดสอบตาม acceptance criteria: login ผ่าน dialog → เปิดไฟล์ใหม่เห็นปุ่ม Start Versioning → กดแล้วได้ v1 → แก้ไขแล้ว Save & Version → history เพิ่ม + ดาวน์โหลด version เก่าเปิดได้
- ⚠️ **ห้าม `cp` ทับ manifest ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/`** — `npm start` สร้าง symlink ไว้ การทับทำให้ครั้งถัดไปขึ้น `EEXIST` — ถาวรแก้ที่ `addin/manifest.xml` ต้นทางแล้ว restart Word (ถ้าเจอ EEXIST แล้ว: ลบไฟล์ใน wef ทิ้งแล้ว `npm start` ใหม่)
### 4) Web Portal (ยังเป็น scaffold)
### 4) Web Portal
```bash
cd web && npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชน add-in)
```
logic จริง (login, template UI, dynamic form) มาตาม Phase 6
เปิด <http://localhost:5173> แล้วกดเข้าสู่ระบบด้วย `testuser` / `Test1234!` (ผ่าน Keycloak — client `word-versioning-web` ใช้ PKCE เหมือนกันทั้งระบบ) หน้าที่มีให้ใช้:
> คำแนะนำฉบับเต็ม (sideload บน Word Desktop/Online, สร้าง template แรก, สร้างเอกสารจากเว็บ) จะเติมเมื่อ implement ครบตาม Phase 8 ของแผน
| หน้า | ทำอะไร |
|---|---|
| `/` หน้าหลัก | card รายการ template ที่ active → เข้าฟอร์มสร้างเอกสาร หรือดูเอกสารที่สร้างแล้วของ template นั้น |
| `/templates` | ตาราง template ทั้งหมด (แสดงตัวที่ deactivate ได้) — แก้ไข / activate / deactivate / ดูรายการเอกสาร |
| `/templates/new` | upload template → เห็น field list ที่ระบบ detect อัตโนมัติทันที |
| `/templates/{id}/edit` | แก้ metadata + ตั้ง label/type (`text\|number\|date`)/required/ลำดับ + แทนที่ไฟล์ (บอก tag ที่เพิ่ม/หาย) |
| `/templates/{id}/documents` | **รายการเอกสารที่สร้างจาก template** — จำนวน version, กิจกรรมล่าสุด, เข้าไปดู detail |
| `/documents/create/{templateId}` | dynamic form ตาม field definitions (validate ฝั่ง client + backend) → สร้างเป็น v1 + ดาวน์โหลด |
| `/documents/{documentId}` | **detail เอกสาร** — ค่า fields ปัจจุบัน + version history (กดขยายแถวเห็นค่า fields ตอนนั้น + ดาวน์โหลดต่อ version) + ปุ่ม **⟳ re-generate** |
**สร้าง template แรกจากไฟล์จริง:** เขียน .docx ใน Word แล้วแทรก content controls ในจุดที่จะเป็น field (Developer tab → Rich Text Content Control → Properties ตั้ง **Tag** ให้ unique ต่อไฟล์ เช่น `employee_name`, `salary`) → หน้า `/templates/new` upload ไฟล์ → ระบบ detect tags ออกมาเป็นรายการ fields → กด "ตั้งค่าต่อ" ไปใส่ label/type/required ให้เรียบร้อย → กลับมาหน้าหลักเพื่อสร้างเอกสาร
**Re-generate (สร้างเอกสารใหม่จากข้อมูลเดิม):** ที่หน้า detail ของเอกสาร กด **"⟳ สร้างเอกสารใหม่จากข้อมูลนี้"** → เข้าฟอร์มสร้างเอกสารโดย prefill ค่าปัจจุบันของทุก field ไว้ให้แก้ไขก่อน → กดสร้าง → ได้**เอกสารใหม่เป็น v1** (documentId ใหม่) โดยเอกสารต้นทางและ version history ของมันไม่ถูกแตะ · template ที่ deactivate ไปแล้วจะกดไม่ได้ (ปุ่มถูก disable พร้อมเหตุผล)
Theme toggle (light/dark) อยู่มุมขวาบนของทุกหน้า — จำค่าไว้หลัง reload
**Config:** ค่า default ชี้ dev ในพื้นที่อยู่ใน `nuxt.config.ts` — override ผ่าน env ได้ทั้งหมด (ดู [web/.env.example](web/.env.example)): `NUXT_PUBLIC_API_BASE_URL`, `NUXT_PUBLIC_KEYCLOAK_AUTHORITY`, `NUXT_PUBLIC_KEYCLOAK_CLIENT_ID`
## Troubleshooting (จากการทดสอบจริงบน Word for Mac)
@ -268,6 +293,7 @@ logic จริง (login, template UI, dynamic form) มาตาม Phase 6
| กดดาวน์โหลด version แล้ว**ไม่มีไฟล์โผล่ที่ไหนเลย** | **webview ของ Word ทิ้ง blob download (`a[download]`) แบบเงียบๆ** | ขอ presigned URL อายุ 2 นาทีจาก `download-link` แล้วเปิดใน**เบราว์เซอร์ของเครื่อง** (`openBrowserWindow`) — ไฟล์ลงโฟลเดอร์ Downloads ของเบราว์เซอร์ |
| แก้ webpack.config.js แล้วค่าเก่ายังติดอยู่ | webpack **ไม่ restart เองเมื่อ config เปลี่ยน** + dev-server ตัวเก่าถือ port 3000 อยู่ (ตัวใหม่ขึ้นไม่ได้) | kill dev-server เดิม (`lsof -ti :3000 | xargs kill`) แล้ว `npm start` ใหม่ทุกครั้งที่แก้ config |
| `npm start` ขึ้น `EEXIST ... wef/...manifest.xml` | มีการ `cp` ทับ symlink ของ sideload จนกลายเป็นไฟล์ธรรมดา | ลบไฟล์นั้นใน wef folder ทิ้งแล้ว `npm start` ใหม่ (ดูคำเตือนในหัวข้อ Sideload) |
| รัน backend ด้วย `dotnet run --no-launch-profile` แล้ว API ตอบ **500** (`No password has been provided`) | `--no-launch-profile` ตัด `ASPNETCORE_ENVIRONMENT=Development` (ปกติตั้งโดย launchSettings) ทิ้ง → ไปอ่าน `appsettings.json` ที่ DB password ว่าง | ใส่ env เอง: `ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5075 dotnet run --no-launch-profile` หรือรัน `dotnet run` เฉยๆ |
| curl token จาก `admin-cli` แล้วเรียก backend ได้ 401 | token ของ admin-cli **ไม่มี claim `aud`** (ไม่ได้อยู่ใน realm-export จึงไม่มี audience mapper) | ใช้ `python3 scripts/dev-token.py` (เดิน PKCE จริงด้วย client `word-versioning-web`) |
**กติกาที่สรุปได้:** ใน dev ทุก service ที่ task pane (หน้า https) จะเรียก **ต้องเป็น HTTPS ด้วย cert จาก office-addin-dev-certs ทั้งหมด** — Keycloak (8443) และ backend (7232)

View file

@ -335,6 +335,69 @@ public class DocumentsController(
}
}
/// <summary>FR-12 — detail ของเอกสาร: ข้อมูลหัวเรื่อง + template + ค่าล่าสุดของทุก field
/// (นิยามจาก TemplateField merge กับ DocumentFieldValue — รองรับ template เปลี่ยนนิยามหลังเอกสารสร้างแล้ว)</summary>
[HttpGet("{documentId:guid}")]
[ProducesResponseType(typeof(DocumentDetail), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DocumentDetail>> GetDetail(Guid documentId, CancellationToken ct)
{
var doc = await db.Documents
.AsNoTracking()
.Where(d => d.Id == documentId)
.Select(d => new
{
d.Id,
d.FileName,
d.CreatedAt,
d.TemplateId,
TemplateName = d.Template != null ? d.Template.Name : null,
TemplateIsActive = d.Template != null && d.Template.IsActive,
})
.FirstOrDefaultAsync(ct);
if (doc is null)
return NotFound($"ไม่พบ document {documentId}");
var fields = new List<DocumentFieldItem>();
if (doc.TemplateId is not null)
{
// โหลดสองข้างแยกแล้ว merge ใน memory (แบบเดียวกับ FieldSyncService) —
// ไม่ใช้ Join เพราะ inner join จะทิ้ง orphan ทั้งสองฝั่งเงียบๆ
var defs = await db.TemplateFields
.AsNoTracking()
.Where(f => f.TemplateId == doc.TemplateId)
.OrderBy(f => f.SortOrder)
.Select(f => new { f.Key, f.Label, f.Type, f.IsRequired })
.ToListAsync(ct);
var values = await db.DocumentFieldValues
.AsNoTracking()
.Where(v => v.DocumentId == documentId)
.ToDictionaryAsync(v => v.FieldKey, ct);
foreach (var def in defs)
{
values.TryGetValue(def.Key, out var value);
fields.Add(new DocumentFieldItem(
def.Key, def.Label, def.Type, def.IsRequired,
value?.Value ?? string.Empty,
value?.SourceVersionId, value?.UpdatedByUserId, value?.UpdatedAt));
}
// ค่าที่เก็บไว้แต่ key ไม่มีในนิยามปัจจุบัน (replace ไฟล์แล้ว tag หาย) — ต่อท้ายโดยไม่มีนิยาม
foreach (var (key, value) in values.Where(kv => defs.All(d => d.Key != kv.Key)).OrderBy(kv => kv.Key))
{
fields.Add(new DocumentFieldItem(
key, null, null, null,
value.Value,
value.SourceVersionId, value.UpdatedByUserId, value.UpdatedAt));
}
}
return Ok(new DocumentDetail(
doc.Id, doc.FileName, doc.CreatedAt, doc.TemplateId, doc.TemplateName, doc.TemplateIsActive, fields));
}
/// <summary>FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version)</summary>
[HttpGet("{documentId:guid}/versions")]
[ProducesResponseType(typeof(IReadOnlyList<VersionListItem>), StatusCodes.Status200OK)]

View file

@ -314,6 +314,39 @@ public class TemplatesController(
return NoContent();
}
/// <summary>FR-12 — รายการเอกสารที่สร้างจาก template นี้ (เรียงตามกิจกรรมล่าสุด)
/// ใช้ได้แม้ template ถูก deactivate (เอกสารเก่ายังต้องเข้าถึงได้ตาม policy FR-9)</summary>
[HttpGet("{id:guid}/documents")]
[ProducesResponseType(typeof(IReadOnlyList<TemplateDocumentListItem>), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<IReadOnlyList<TemplateDocumentListItem>>> ListDocuments(Guid id, CancellationToken ct)
{
if (!await db.Templates.AnyAsync(t => t.Id == id, ct))
return NotFound($"ไม่พบ template {id}");
// cast (int?) ใน Max จำเป็น — ทำให้ SQL MAX คืน NULL บนชุดว่างได้ (pattern เดียวกับ UploadVersion)
// LatestActivity ต้อง compute ใน projection — EF จะยกเข้า SQL ORDER BY ให้
var rows = await db.Documents
.AsNoTracking()
.Where(d => d.TemplateId == id)
.Select(d => new
{
d.Id,
d.FileName,
d.CreatedAt,
VersionCount = d.Versions.Count,
LatestVersionNumber = d.Versions.Max(v => (int?)v.VersionNumber) ?? 0,
LatestVersionAt = d.Versions.Max(v => (DateTimeOffset?)v.CreatedAt),
LatestActivity = d.Versions.Max(v => (DateTimeOffset?)v.CreatedAt) ?? d.CreatedAt,
})
.OrderByDescending(x => x.LatestActivity)
.ThenBy(x => x.FileName)
.ToListAsync(ct);
return Ok(rows.Select(r =>
new TemplateDocumentListItem(r.Id, r.FileName, r.CreatedAt, r.VersionCount, r.LatestVersionNumber, r.LatestVersionAt)).ToList());
}
private static TemplateDetail ToDetail(Template template) => new(
template.Id, template.Name, template.Description, template.IsActive,
template.CreatedAt, template.UpdatedAt, FieldDtos(template));

View file

@ -42,3 +42,37 @@ public record VersionListItem(
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt);
/// <summary>item ของ GET /api/templates/{templateId}/documents — รายการเอกสารของ template หนึ่งตัว (FR-12)</summary>
public record TemplateDocumentListItem(
Guid DocumentId,
string FileName,
DateTimeOffset CreatedAt,
int VersionCount,
int LatestVersionNumber,
DateTimeOffset? LatestVersionAt);
/// <summary>field หนึ่งตัวของ document (FR-12) — นิยามจาก TemplateField + ค่าล่าสุดจาก DocumentFieldValue
/// Label/Type/IsRequired เป็น null เมื่อมีค่าเก็บไว้แต่ key ไม่มีในนิยาม template ปัจจุบัน
/// (เช่น replace ไฟล์แล้ว tag หาย) — SourceVersionId/UpdatedByUserId/UpdatedAt เป็น null เมื่อ
/// field เพิ่มเข้า template หลังเอกสารนี้ถูกสร้าง (ยังไม่เคยมีค่า)</summary>
public record DocumentFieldItem(
string Key,
string? Label,
string? Type,
bool? IsRequired,
string Value,
Guid? SourceVersionId,
string? UpdatedByUserId,
DateTimeOffset? UpdatedAt);
/// <summary>response ของ GET /api/documents/{documentId} (FR-12) —
/// TemplateId null คือเอกสาร track เองจาก add-in (ไม่มีนิยาม field, Fields ว่าง)</summary>
public record DocumentDetail(
Guid Id,
string FileName,
DateTimeOffset CreatedAt,
Guid? TemplateId,
string? TemplateName,
bool TemplateIsActive,
IReadOnlyList<DocumentFieldItem> Fields);

View file

@ -1,7 +1,7 @@
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
> สถานะ: Phase 0–6 เสร็จ (backend + add-in + web portal — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — ต่อไป: phase 7 (tests ครบชุด), 8 (README + AC)
> สถานะ: Phase 0–6 เสร็จ (backend + add-in + web portal รวม 6.4/FR-12: รายการเอกสารต่อ template + detail + re-generate — ตรวจอัตโนมัติผ่านทั้งหมด เหลือยืนยันด้วยมือ: click-through บน browser จริง + ส่วน sideload ที่เหลือ) — ต่อไป: phase 7 (tests ครบชุด), 8 (README ✅ + ไล่ AC)
> วันที่แผน: 2026-08-18 (ฉบับแก้ไข v2: เพิ่ม FR-9 ถึง FR-11 — ระบบ template, สร้างเอกสาร v1 ผ่านเว็บ และ field sync)
---
@ -23,7 +23,7 @@
- หน้าจอสร้างเอกสารครั้งแรก = **เว็บแยก (standalone)** — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที
- Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template)
- Field sync = **เฉพาะ fields จาก template** (ผูกกับ content controls) ไม่เก็บ full text
- **FR-9 ถึง FR-11 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ได้รับการ update เป็นฉบับ v2 แล้ว (ครอบ FR-1–11 + AC-1–14) ให้ถือ spec เป็น source of truth ของ requirements
- **FR-9 ถึง FR-12 เป็น requirement ที่เพิ่มภายหลัง spec เดิม** — spec ปัจจุบันเป็นฉบับ v4 (ครอบ FR-1–12 + AC-1–15: v2 เพิ่ม template/portal/field sync, v3 เพิ่มข้อจำกัดแพลตฟอร์มจากการทดสอบจริง, v4 เพิ่มรายการเอกสารต่อ template + detail + re-generate) ให้ถือ spec เป็น source of truth ของ requirements
---
@ -102,14 +102,18 @@
tailwind.config.ts # darkMode: 'class'
/pages
index.vue # หน้าหลัก: เลือก template เพื่อสร้างเอกสาร
auth/callback.vue # ปลายทาง OIDC redirect (แลก token แล้วพากลับ)
templates/index.vue # รายการ template (admin)
templates/new.vue # upload template + ตั้งค่า field defs
templates/[id]/edit.vue # แก้ไข template + field defs
documents/create/[templateId].vue # dynamic form สร้างเอกสาร (FR-10)
templates/[id]/documents.vue # รายการเอกสารของ template (6.4/FR-12)
documents/create/[templateId].vue # dynamic form สร้างเอกสาร (FR-10) + prefill re-generate
documents/[documentId].vue # detail เอกสาร: fields + version history + re-generate (6.4/FR-12)
/composables
useAuth.ts # OIDC PKCE + browser redirect ปกติ
useApi.ts # fetch wrapper + Bearer token + refresh
useTheme.ts # light/dark toggle + persist + system default
/utils # oidc / fieldValidation / snapshot — pure functions (unit test ได้)
/assets/css/tailwind.css
package.json
/keycloak # Phase 1
@ -421,6 +425,21 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- ดาวน์โหลดในเว็บใช้ blob + `a[download]` ได้ตามปกติ (browser จริง — ต่างจาก webview ของ Word ที่ต้องใช้ presigned link ตามกติกา #12)
- ⚠️ **เหลือตรวจด้วยมือ (ทำแทนไม่ได้):** เปิด browser จริง → login ผ่าน Keycloak → สร้าง/แก้ template → กรอกฟอร์มสร้างเอกสาร → ดาวน์โหลด → เปิดใน Word แล้ว add-in เห็น v1 ทันที (AC-11/12 เต็มรูปแบบ) + theme toggle จำค่าหลัง reload — ใน dev DB มี template "สัญญาทดสอบเว็บ" (เกิดจากสคริปต์ตรวจ) ไว้ click-through ได้ทันที และลบผ่านหน้าเว็บได้เอง
### 6.4 รายการเอกสารต่อ template + Document detail + Re-generate — FR-12 *(เพิ่มทีหลัง, v4)*
- [x] Backend `GET /api/templates/{templateId}/documents` — รายการเอกสารของ template (ชื่อไฟล์, versionCount, version ล่าสุด + เวลา) เรียงกิจกรรมล่าสุดก่อน, 404 เมื่อ template ไม่มี, ใช้ได้แม้ deactivate
- [x] Backend `GET /api/documents/{documentId}` — detail: หัวเรื่อง + template + ค่าล่าสุดทุก field (merge `TemplateField` defs × `DocumentFieldValue` แบบ 3 query ใน memory — รองรับ orphan สองทาง: def ใหม่ไม่มีค่า / ค่าเก่าไม่มี def)
- [x] `templates/[id]/documents.vue` — ตารางเอกสารของ template + ลิงก์เข้า detail (จุดเข้า: ปุ่ม "เอกสาร" ในตาราง template + ลิงก์บน card หน้าหลัก)
- [x] `documents/[documentId].vue` — fields ปัจจุบัน + version history ขยายแถวเห็น snapshot ต่อ version (`normalizeSnapshot` รองรับ snapshot ทั้ง 2 รูปแบบ) + ดาวน์โหลดต่อ version
- [x] ปุ่ม re-generate บนหน้า detail → ฟอร์มสร้างเอกสาร prefilled (`?from={documentId}`) → generate เป็น**เอกสารใหม่ v1** เอกสารต้นทางไม่ถูกแตะ (frontend composition ผ่าน endpoint เดิม — ไม่เพิ่ม backend)
หมายเหตุจากการทำจริง (2026-08-20):
- ออกแบบ backend ร่วมกับ Plan agent: query รายการใช้ correlated aggregate subquery (cast `(int?)` ใน `Max` จำเป็น — pattern เดียวกับ UploadVersion) + `LatestActivity` ต้อง compute ใน projection ก่อน `OrderBy` — index เดิมพอ ไม่ต้อง migrate
- กติกา merge ของ detail สำคัญกับข้อมูลจริง: ทดสอบกับเอกสารที่ template ถูก replace ไฟล์มาแล้ว เจอครบทั้ง 3 กรณี (def+ค่า / def ใหม่ไม่มีค่า → `""` + nulls / ค่าเก่าไม่มี def → label null ต่อท้าย)
- re-generate เลือก frontend composition (detail → prefill ฟอร์ม → generate เดิม) ตามที่ผู้ใช้เลือก "ตาม flow": policy deactivate ได้ฟรีจาก Generate (400 เดิม) + กรณี template เพิ่ม required field ทีหลัง ฟอร์ม prefilled ให้ผู้ใช้เติมค่าที่ขาดแทนที่จะตายเงียบ
- ตรวจอัตโนมัติผ่านหมด: `dotnet build` 0 warnings · curl ครบทั้ง list/detail/404×2/เอกสารไม่ผูก template (`templateId: null`, fields ว่าง) · `npm run build` exit 0 + SSR ทุก route ใหม่ 200 · จำลอง re-generate ตามลำดับ request ที่หน้าเว็บส่ง → เอกสารใหม่ v1 ค่า prefill ตรง + **versionCount เอกสารต้นทางคงเดิม** · รายการเรียงล่าสุดก่อนถูก
- หน้า create เพิ่ม: banner บอกบริบท re-generate + เตือน/disable เมื่อ template deactivate; "สร้างอีกฉบับ" คืนค่า prefill (ไม่ใช่ฟอร์มว่าง) เมื่ออยู่ในบริบท re-generate
**DoD:** login ผ่าน Keycloak บนเว็บได้ → สร้าง/แก้ template จากไฟล์จริง detect fields ถูก → กรอก form สร้างเอกสาร ได้ docx ครบค่า → เปิดใน Word แล้ว add-in รู้จักทันทีเห็น v1 (AC-11, 12 เต็มรูปแบบ) + theme toggle ทำงานทุกหน้าและจำค่าได้หลัง reload
> 🟡 implement + ตรวจอัตโนมัติผ่านหมด (2026-08-20 — รายละเอียดในหมายเหตุ) · **ส่วน "เปิดใน Word แล้ว add-in รู้จักทันที" ต้องยืนยันด้วยมือ** ร่วมกับ AC checklist ที่เหลือใน Phase 8
@ -450,7 +469,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
## Phase 8: README + ตรวจ acceptance criteria
- [ ] `README.md`: วิธีรัน local dev (docker compose, dotnet run, npm start, **npm run dev ของ /web**), วิธี sideload add-in บน Desktop (Win/Mac) + Word Online, ค่า connection config, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก, **วิธีสร้าง template แรก (ตัวอย่างไฟล์ docx ที่มี content controls) และสร้างเอกสารจากเว็บ** — ตรง spec ส่วน 11 (backend ข้อ 9)
- [x] `README.md`: วิธีรัน local dev (docker compose, dotnet run, npm start, **npm run dev ของ /web**), วิธี sideload add-in บน Desktop (Win/Mac) + Word Online, ค่า connection config, เข้า MinIO console, เข้า Keycloak admin console, สร้าง test user แรก, **วิธีสร้าง template แรก (ตัวอย่างไฟล์ docx ที่มี content controls) และสร้างเอกสารจากเว็บ** — ตรง spec ส่วน 11 (backend ข้อ 9) — ✅ เติมครบแล้ว (2026-08-20) รวม web portal walkthrough ทุกหน้า + re-generate + troubleshooting
- [ ] ไล่ checklist acceptance criteria ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ
---
@ -466,6 +485,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
| 4 | FR-8 (client auth), FR-11 (extraction), โครง FR-1/2 | AC-2 (ส่วน dialog) |
| 5 | FR-1, FR-2, FR-3, FR-4, FR-11 (ส่วนส่งค่า) | AC-1, 2, 4, 5, 6, 7, 8, 9, 10, 13 |
| 6 | FR-9 (UI), FR-10 (UI) | AC-11, AC-12 (เต็มรูปแบบ) |
| 6.4 (ทีหลัง, v4) | FR-12 | AC-15 |
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
| 8 | — | ทบทวน AC-1 ถึง AC-14 ทั้งหมด |
@ -474,6 +494,7 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- **AC-12:** กรอก form บนเว็บ → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 1 ทันที **โดยไม่ต้องกด Start Versioning**
- **AC-13:** แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
- **AC-14:** ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
- **AC-15:** (v4) หน้ารายการเอกสารของ template + หน้า detail (ค่าทุก field + version history ขยายเห็นค่าต่อ version + ดาวน์โหลด) + re-generate ได้เอกสารใหม่ v1 โดยเอกสารต้นทางไม่มี version ใหม่ — รายละเอียดตาม FR-12
---

View file

@ -1,5 +1,6 @@
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
> ฉบับแก้ไข v4 (2026-08-20): เพิ่ม FR-12 — รายการเอกสารต่อ template + document detail + re-generate (Web Portal)
> ฉบับแก้ไข v3 (2026-08-19): เพิ่มข้อจำกัดแพลตฟอร์มที่พบจากการทดสอบจริงบน Word for Mac (dialog บังคับ HTTPS, mixed content, รูปแบบ slice.data หลายแบบ, webview ทิ้ง blob download) + กลไกดาวน์โหลด version ผ่าน presigned link
> ฉบับแก้ไข v2 (2026-08-18): เพิ่ม Web Portal (Nuxt.js) + Template System (FR-9 ถึง FR-11) + field sync — implementation plan อยู่ที่ `docs/plans/officejs-implementation-plan.md`
@ -114,7 +115,9 @@
nuxt.config.ts # devServer port 5173
tailwind.config.ts # darkMode: 'class'
/pages # เลือก template สร้างเอกสาร / จัดการ template / dynamic form
# + รายการเอกสารต่อ template + detail เอกสาร + re-generate (FR-12, v4)
/composables # useAuth (PKCE redirect), useApi, useTheme (light/dark)
/utils # oidc (PKCE helpers), fieldValidation, snapshot — pure functions ไว้ unit test
package.json
/keycloak
@ -245,6 +248,12 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
- field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → **เก็บค่าเดิม + mark missing ใน snapshot แต่ไม่ fail request**; ส่งมาเกินนิยาม → ignore
- `UpdatedByUserId` มาจาก JWT claims ของผู้กด Save & Version เสมอ
### FR-12: รายการเอกสารต่อ template + Document detail + Re-generate (เพิ่มเติม v4)
- `GET /api/templates/{templateId}/documents` — รายการเอกสารที่สร้างจาก template หนึ่งตัว (ชื่อไฟล์, จำนวน version, version ล่าสุด + เวลา) เรียงตามกิจกรรมล่าสุด — ใช้ได้แม้ template ถูก deactivate (เอกสารเก่ายังต้องเข้าถึงได้ตาม FR-9)
- `GET /api/documents/{documentId}` — detail ของเอกสาร: ข้อมูลหัวเรื่อง + template + **ค่าล่าสุดของทุก field** (นิยามจาก `TemplateField` merge กับ `DocumentFieldValue`) — รองรับ template เปลี่ยนนิยามหลังเอกสารสร้างแล้ว: field ที่เพิ่มเข้าทีหลังแสดงค่าว่าง, ค่าที่ key ไม่มีในนิยามปัจจุบันยังแสดงโดยไม่มี label
- หน้าเว็บ (Web Portal): หน้ารายการเอกสารของ template + หน้า detail ที่แสดงค่า fields ปัจจุบันและ version history (ขยายแต่ละ version เห็น snapshot ค่าตอนนั้น + ดาวน์โหลด)
- **Re-generate:** จากหน้า detail ของเอกสารที่สร้างจาก template → เข้าฟอร์มสร้างเอกสารโดย **prefill ค่าล่าสุดของทุก field** ให้แก้ไขก่อน → สร้างเป็น **เอกสารใหม่ (v1 ใหม่ + documentId ใหม่)** ผ่าน endpoint generate เดิม — เอกสารต้นทางและ version history ของมัน**ไม่ถูกแตะ** (ไม่เกิด version ใหม่) — template ที่ deactivate จะ re-generate ไม่ได้ตามกติกาของ FR-10
---
## 7. Data Model (Backend)
@ -364,6 +373,7 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
12. (v2) กรอก form บนเว็บสร้างเอกสาร → ดาวน์โหลด .docx ที่ค่าครบถูกต้อง formatting ไม่พัง → เปิดใน Word แล้ว add-in รายงานเป็น tracked document เห็น version 1 ทันที โดยไม่ต้องกด Start Versioning
13. (v2) แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
14. (v2) ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
15. (v4) เข้าหน้ารายการเอกสารของ template บนเว็บ → เห็นเอกสารที่สร้างจาก template นั้นครบ พร้อมจำนวน version และเวลาล่าสุด · เข้าหน้า detail → เห็นค่าทุก field ปัจจุบัน + version history (ขยายเห็นค่าต่อ version, ดาวน์โหลดได้) · กด re-generate แล้วสร้างเอกสารใหม่เป็น v1 โดย**เอกสารต้นทางไม่มี version ใหม่**
---
@ -399,3 +409,4 @@ Custom XML Part (namespace `urn:myaddin:versioning`) — เก็บ compact hi
3. หน้าจัดการ template: list / upload (โชว์ fields ที่ detect อัตโนมัติ) / แก้ field definitions / deactivate
4. หน้าสร้างเอกสาร: dynamic form สร้างจาก field definitions (input type ตาม field type) + validate + ดาวน์โหลดผลลัพธ์
5. เขียน unit test ของ form validation + useTheme (Vitest)
6. (v4/FR-12) หน้ารายการเอกสารของ template + หน้า detail ของเอกสาร (ค่า fields ปัจจุบัน + version history พร้อม snapshot ต่อ version + ดาวน์โหลดต่อ version) + re-generate ผ่านฟอร์ม prefilled ค่าปัจจุบัน

View file

@ -0,0 +1,233 @@
<script setup lang="ts">
/** FR-12 UI — detail ของเอกสาร: ค่าล่าสุดของทุก field + version history (ขยายแถวเห็นค่าตอนนั้น)
* + ปุ่ม re-generate: พาไปฟอร์มสร้างเอกสารแบบ prefill ค่าปัจจุบัน → ได้เอกสารใหม่ v1 (ไม่แตะเอกสารเดิม) */
import type { DocumentDetail, VersionListItem } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
const documentId = computed(() => String(route.params.documentId))
const detail = ref<DocumentDetail | null>(null)
const versions = ref<VersionListItem[]>([])
const loading = ref(true)
const loadError = ref<string | null>(null)
const expandedVersionId = ref<string | null>(null)
const downloadingVersionId = ref<string | null>(null)
const downloadError = ref<string | null>(null)
const fieldOrderKeys = computed(() => detail.value?.fields.filter((f) => f.label !== null).map((f) => f.key) ?? [])
function snapshotOf(v: VersionListItem) {
return normalizeSnapshot(v.fields, fieldOrderKeys.value)
}
/** โชว์ label ของ field ถ้ามีนิยาม ไม่มีก็โชว์ key ตรงๆ */
function fieldLabel(key: string): string {
return detail.value?.fields.find((f) => f.key === key)?.label ?? key
}
async function load() {
loading.value = true
loadError.value = null
try {
const [d, vs] = await Promise.all([
apiJson<DocumentDetail>(`/api/documents/${documentId.value}`),
apiJson<VersionListItem[]>(`/api/documents/${documentId.value}/versions`),
])
detail.value = d!
versions.value = vs ?? []
useHead({ title: `${d?.fileName} — e-Court Documents Versioning` })
} catch (err) {
loadError.value = err instanceof Error ? err.message : 'โหลดข้อมูลเอกสารไม่สำเร็จ'
} finally {
loading.value = false
}
}
/** re-generate — เข้าฟอร์มสร้างเอกสารของ template เดิมโดย prefill ค่าปัจจุบัน (flow เดิม ไม่แตะเอกสารฉบับนี้) */
function regenerate() {
if (!detail.value?.templateId) return
navigateTo(`/documents/create/${detail.value.templateId}?from=${detail.value.id}`)
}
async function download(v: VersionListItem) {
downloadingVersionId.value = v.versionId
downloadError.value = null
try {
const base = (detail.value?.fileName ?? 'document').replace(/\.docx$/i, '')
await downloadFile(`/api/documents/${documentId.value}/versions/${v.versionId}/download`, `${base}_v${v.versionNumber}.docx`)
} catch (err) {
downloadError.value = err instanceof Error ? err.message : 'ดาวน์โหลดไม่สำเร็จ'
} finally {
downloadingVersionId.value = null
}
}
onMounted(load)
</script>
<template>
<AuthGate>
<section class="space-y-8">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
กำลังโหลด…
</div>
<AlertBox v-else-if="loadError" type="error">
{{ loadError }}
<NuxtLink to="/" class="ml-1 font-medium underline">กลับหน้าหลัก</NuxtLink>
</AlertBox>
<template v-else-if="detail">
<!-- หัวเรื่อง + การกระทำ -->
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<h1 class="text-2xl font-bold break-all">{{ detail.fileName }}</h1>
<p class="mt-1 text-sm text-slate-500 dark:text-slate-400">
<template v-if="detail.templateId">
จาก template
<NuxtLink
:to="`/templates/${detail.templateId}/documents`"
class="font-medium text-sky-700 hover:underline dark:text-sky-400"
>
{{ detail.templateName }}
</NuxtLink>
</template>
<template v-else>เอกสารที่ track จาก add-in (ไม่ได้สร้างจาก template)</template>
· สร้างเมื่อ {{ formatDateTime(detail.createdAt) }}
</p>
</div>
<div class="flex flex-col items-end gap-1">
<button
v-if="detail.templateId"
type="button"
:disabled="!detail.templateIsActive"
:title="detail.templateIsActive ? '' : 'template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้'"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:cursor-not-allowed disabled:opacity-50"
@click="regenerate"
>
⟳ สร้างเอกสารใหม่จากข้อมูลนี้
</button>
<p v-if="detail.templateId && !detail.templateIsActive" class="text-xs text-slate-500 dark:text-slate-400">
template ถูก deactivate — เอกสารฉบับนี้ยังใช้ได้ แต่สร้างใหม่ไม่ได้
</p>
</div>
</div>
<!-- ค่าล่าสุดของทุก field -->
<div class="space-y-3">
<h2 class="text-lg font-semibold">ค่า fields ปัจจุบัน</h2>
<div
v-if="detail.fields.length === 0"
class="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
เอกสารนี้ไม่มี field values (ไม่ได้สร้างจาก template หรือ template ไม่มี fields)
</div>
<div v-else class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">field</th>
<th class="px-4 py-3">ค่า</th>
<th class="px-4 py-3">type</th>
<th class="px-4 py-3">อัปเดตเมื่อ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="f in detail.fields" :key="f.key">
<td class="px-4 py-3">
{{ f.label ?? f.key }}
<span v-if="!f.label" class="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300">
ไม่มีในนิยามปัจจุบัน
</span>
<span class="block font-mono text-xs text-slate-400">{{ f.key }}</span>
</td>
<td class="max-w-72 px-4 py-3 break-words">{{ f.value || '—' }}</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ f.type ?? '—' }}</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
{{ f.updatedAt ? formatDateTime(f.updatedAt) : 'ยังไม่เคยมีค่า' }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- version history -->
<div class="space-y-3">
<h2 class="text-lg font-semibold">Version history ({{ versions.length }})</h2>
<div
v-if="versions.length === 0"
class="rounded-md border border-dashed border-slate-300 p-6 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
ยังไม่มี version
</div>
<div v-else class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">version</th>
<th class="px-4 py-3">เวลา</th>
<th class="px-4 py-3">ผู้สร้าง</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<template v-for="v in versions" :key="v.versionId">
<tr class="cursor-pointer hover:bg-slate-50 dark:hover:bg-slate-700/40" @click="expandedVersionId = expandedVersionId === v.versionId ? null : v.versionId">
<td class="px-4 py-3 font-medium">v{{ v.versionNumber }}</td>
<td class="px-4 py-3">{{ formatDateTime(v.createdAt) }}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500 dark:text-slate-400">{{ v.createdByUserId.slice(0, 8) }}…</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<button
type="button"
:disabled="downloadingVersionId === v.versionId"
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 disabled:opacity-50 dark:border-slate-600 dark:hover:bg-slate-700"
@click.stop="download(v)"
>
{{ downloadingVersionId === v.versionId ? '…' : '⬇ ดาวน์โหลด' }}
</button>
<span class="ml-2 text-xs text-slate-400">{{ expandedVersionId === v.versionId ? '▲ ซ่อน fields' : '▼ ดู fields' }}</span>
</td>
</tr>
<tr v-if="expandedVersionId === v.versionId">
<td colspan="4" class="bg-slate-50 px-4 py-4 dark:bg-slate-900">
<p class="mb-2 text-xs font-medium text-slate-500 dark:text-slate-400">ค่า fields ของ v{{ v.versionNumber }}</p>
<dl class="grid gap-x-6 gap-y-1.5 text-sm sm:grid-cols-2">
<div v-for="e in snapshotOf(v)" :key="e.key" class="flex gap-2">
<dt class="w-44 shrink-0 truncate text-slate-500 dark:text-slate-400" :title="e.key">
{{ fieldLabel(e.key) }}
</dt>
<dd class="break-words">
{{ e.value || '—' }}
<span v-if="e.missing" class="ml-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900 dark:text-amber-300">
หายจากเอกสาร
</span>
</dd>
</div>
<p v-if="snapshotOf(v).length === 0" class="text-sm text-slate-500 dark:text-slate-400">
version นี้ไม่มี snapshot ของ fields
</p>
</dl>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<AlertBox v-if="downloadError" type="error">{{ downloadError }}</AlertBox>
</div>
</template>
</section>
</AuthGate>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด */
import type { GenerateDocumentResponse, TemplateDetail } from '~/types/api'
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
const route = useRoute()
const { apiJson, downloadFile } = useApi()
@ -10,6 +11,10 @@ const detail = ref<TemplateDetail | null>(null)
const values = ref<Record<string, string>>({})
const issues = ref<Record<string, string>>({})
/** เอกสารต้นทางของ re-generate (จาก ?from=) — ใช้ทั้ง prefill และ banner บอกบริบท */
const fromDoc = ref<{ id: string; fileName: string } | null>(null)
const prefill = ref<Record<string, string>>({})
const loading = ref(true)
const loadError = ref<string | null>(null)
@ -29,12 +34,33 @@ useHead({
),
})
function blankValues(): Record<string, string> {
return Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
}
async function load() {
loading.value = true
loadError.value = null
try {
detail.value = (await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!
values.value = Object.fromEntries(detail.value.fields.map((f) => [f.key, '']))
const from = typeof route.query.from === 'string' ? route.query.from : null
if (from) {
try {
const src = await apiJson<DocumentDetail>(`/api/documents/${from}`)
if (src?.templateId) {
fromDoc.value = { id: src.id, fileName: src.fileName }
// prefill เฉพาะ key ที่มีใน defs ปัจจุบัน — key ที่หายไปหลัง replace ไฟล์โชว์ไม่ได้ (generate ก็ ignore)
prefill.value = Object.fromEntries(
detail.value.fields.map((f) => [f.key, src.fields.find((s) => s.key === f.key)?.value ?? '']),
)
}
} catch {
// ไม่พบเอกสารต้นทาง — ปล่อยให้เป็นฟอร์มว่างตามปกติ
}
}
values.value = { ...blankValues(), ...prefill.value }
} catch (err) {
loadError.value = err instanceof Error ? err.message : 'โหลด template ไม่สำเร็จ'
} finally {
@ -80,13 +106,13 @@ async function download() {
}
}
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม */
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
function resetForm() {
result.value = null
submitError.value = null
downloadError.value = null
issues.value = {}
values.value = Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
values.value = { ...blankValues(), ...prefill.value }
}
onMounted(load)
@ -175,6 +201,15 @@ onMounted(load)
</p>
</div>
<AlertBox v-if="fromDoc">
กำลังสร้างเอกสารใหม่จากค่าปัจจุบันของ <strong class="break-all">{{ fromDoc.fileName }}</strong>
— แก้ค่าได้ก่อนกดสร้าง และเอกสารต้นทางจะไม่ถูกแตะ (ไม่เกิด version ใหม่)
</AlertBox>
<AlertBox v-if="!detail.isActive">
template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ (เอกสารที่สร้างไว้แล้วยังใช้ได้)
</AlertBox>
<div
v-for="f in sortedFields"
:key="f.key"
@ -210,7 +245,7 @@ onMounted(load)
<button
type="submit"
:disabled="submitting || sortedFields.length === 0"
:disabled="submitting || sortedFields.length === 0 || !detail.isActive"
class="rounded-md bg-sky-600 px-4 py-2 text-sm font-medium text-white hover:bg-sky-700 disabled:opacity-50"
>
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}

View file

@ -50,12 +50,12 @@ onMounted(async () => {
</div>
<div v-else class="grid gap-4 sm:grid-cols-2">
<NuxtLink
<div
v-for="t in templates"
:key="t.id"
:to="`/documents/create/${t.id}`"
class="block rounded-lg border border-slate-200 bg-white p-5 transition hover:border-sky-400 hover:shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-500"
class="flex flex-col rounded-lg border border-slate-200 bg-white p-5 transition hover:border-sky-400 hover:shadow-sm dark:border-slate-700 dark:bg-slate-800 dark:hover:border-sky-500"
>
<NuxtLink :to="`/documents/create/${t.id}`" class="flex-1">
<h2 class="font-semibold">{{ t.name }}</h2>
<p class="mt-1 line-clamp-2 min-h-10 text-sm text-slate-600 dark:text-slate-300">
{{ t.description ?? '—' }}
@ -63,8 +63,22 @@ onMounted(async () => {
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
</p>
<p class="mt-3 text-sm font-medium text-sky-600 dark:text-sky-400">สร้างเอกสาร →</p>
</NuxtLink>
<div class="mt-3 flex gap-4 border-t border-slate-100 pt-3 text-xs dark:border-slate-700">
<NuxtLink
:to="`/documents/create/${t.id}`"
class="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
สร้างเอกสาร →
</NuxtLink>
<NuxtLink
:to="`/templates/${t.id}/documents`"
class="text-slate-500 hover:text-sky-600 hover:underline dark:text-slate-400 dark:hover:text-sky-400"
>
ดูเอกสารที่สร้างแล้ว
</NuxtLink>
</div>
</div>
</div>
</section>
</AuthGate>

View file

@ -0,0 +1,140 @@
<script setup lang="ts">
/** FR-12 UI — รายการเอกสารที่สร้างจาก template หนึ่งตัว (เข้าได้แม้ template ถูก deactivate) */
import type { TemplateDetail, TemplateDocumentListItem } from '~/types/api'
const route = useRoute()
const { apiJson } = useApi()
const templateId = computed(() => String(route.params.id))
const template = ref<TemplateDetail | null>(null)
const documents = ref<TemplateDocumentListItem[]>([])
const loading = ref(true)
const loadError = ref<string | null>(null)
async function load() {
loading.value = true
loadError.value = null
try {
const [tpl, docs] = await Promise.all([
apiJson<TemplateDetail>(`/api/templates/${templateId.value}`),
apiJson<TemplateDocumentListItem[]>(`/api/templates/${templateId.value}/documents`),
])
template.value = tpl!
documents.value = docs ?? []
useHead({ title: `เอกสารของ ${tpl?.name} — e-Court Documents Versioning` })
} catch (err) {
loadError.value = err instanceof Error ? err.message : 'โหลดรายการเอกสารไม่สำเร็จ'
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<AuthGate>
<section class="space-y-5">
<div v-if="loading" class="flex items-center gap-3 py-10 text-sm text-slate-500 dark:text-slate-400">
<span
class="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-slate-600 dark:border-slate-600 dark:border-t-slate-300"
/>
กำลังโหลด…
</div>
<AlertBox v-else-if="loadError" type="error">
{{ loadError }}
<NuxtLink to="/templates" class="ml-1 font-medium underline">กลับรายการ template</NuxtLink>
</AlertBox>
<template v-else-if="template">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<p class="text-xs text-slate-500 dark:text-slate-400">เอกสารที่สร้างจาก template</p>
<h1 class="text-2xl font-bold">{{ template.name }}</h1>
<p v-if="template.description" class="mt-1 text-sm text-slate-600 dark:text-slate-300">
{{ template.description }}
</p>
</div>
<div class="flex gap-2">
<NuxtLink
:to="`/templates/${template.id}/edit`"
class="rounded-md border border-slate-300 px-3 py-1.5 text-sm hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-800"
>
แก้ไข template
</NuxtLink>
<NuxtLink
v-if="template.isActive"
:to="`/documents/create/${template.id}`"
class="rounded-md bg-sky-600 px-4 py-1.5 text-sm font-medium text-white hover:bg-sky-700"
>
+ สร้างเอกสารใหม่
</NuxtLink>
</div>
</div>
<AlertBox v-if="!template.isActive">
template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ แต่เอกสารที่สร้างไว้แล้วยังใช้งานได้ตามปกติ
</AlertBox>
<div
v-if="documents.length === 0"
class="rounded-lg border border-dashed border-slate-300 p-8 text-center text-sm text-slate-500 dark:border-slate-600 dark:text-slate-400"
>
ยังไม่มีเอกสารจาก template นี้<template v-if="template.isActive">
—
<NuxtLink
:to="`/documents/create/${template.id}`"
class="font-medium text-sky-600 hover:underline dark:text-sky-400"
>
สร้างเอกสารแรก
</NuxtLink></template
>
</div>
<div v-else class="overflow-x-auto rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-800">
<table class="w-full text-sm">
<thead class="border-b border-slate-200 text-left text-xs uppercase text-slate-500 dark:border-slate-700 dark:text-slate-400">
<tr>
<th class="px-4 py-3">ไฟล์เอกสาร</th>
<th class="px-4 py-3">สร้างเมื่อ</th>
<th class="px-4 py-3 text-center">versions</th>
<th class="px-4 py-3">กิจกรรมล่าสุด</th>
<th class="px-4 py-3 text-right">การกระทำ</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100 dark:divide-slate-700">
<tr v-for="d in documents" :key="d.documentId">
<td class="max-w-72 px-4 py-3">
<NuxtLink
:to="`/documents/${d.documentId}`"
class="font-medium text-sky-700 hover:underline dark:text-sky-400"
>
{{ d.fileName }}
</NuxtLink>
</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ formatDateTime(d.createdAt) }}</td>
<td class="px-4 py-3 text-center">{{ d.versionCount }}</td>
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">
<template v-if="d.latestVersionAt">
v{{ d.latestVersionNumber }} · {{ formatDateTime(d.latestVersionAt) }}
</template>
<template v-else>—</template>
</td>
<td class="px-4 py-3 text-right">
<NuxtLink
:to="`/documents/${d.documentId}`"
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
ดูรายละเอียด
</NuxtLink>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</section>
</AuthGate>
</template>

View file

@ -135,8 +135,14 @@ onMounted(load)
<td class="px-4 py-3 text-slate-500 dark:text-slate-400">{{ formatDateTime(t.updatedAt) }}</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<NuxtLink
:to="`/templates/${t.id}/edit`"
:to="`/templates/${t.id}/documents`"
class="rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
เอกสาร
</NuxtLink>
<NuxtLink
:to="`/templates/${t.id}/edit`"
class="ml-2 rounded-md border border-slate-300 px-2.5 py-1 text-xs hover:bg-slate-100 dark:border-slate-600 dark:hover:bg-slate-700"
>
แก้ไข
</NuxtLink>

View file

@ -54,3 +54,48 @@ export interface GenerateDocumentResponse {
/** path สัมพัทธ์ของ backend — เติม apiBaseUrl ตอนเรียก */
downloadUrl: string
}
/** item ของ GET /api/templates/{templateId}/documents (FR-12) */
export interface TemplateDocumentListItem {
documentId: string
fileName: string
createdAt: string
versionCount: number
latestVersionNumber: number
latestVersionAt: string | null
}
/** field หนึ่งตัวของ document (FR-12) — label/type null = มีค่าแต่ key ไม่มีในนิยาม template ปัจจุบัน
* sourceVersionId/updatedByUserId/updatedAt null = field เพิ่มเข้า template หลังเอกสารนี้ถูกสร้าง */
export interface DocumentFieldItem {
key: string
label: string | null
type: string | null
isRequired: boolean | null
value: string
sourceVersionId: string | null
updatedByUserId: string | null
updatedAt: string | null
}
/** response ของ GET /api/documents/{documentId} (FR-12) */
export interface DocumentDetail {
id: string
fileName: string
createdAt: string
templateId: string | null
templateName: string | null
templateIsActive: boolean
fields: DocumentFieldItem[]
}
/** item ของ GET /api/documents/{documentId}/versions — fields คือ snapshot ที่ parse แล้ว
* (2 รูปแบบ — ผ่าน normalizeSnapshot ก่อนใช้) */
export interface VersionListItem {
versionId: string
versionNumber: number
createdAt: string
createdByUserId: string
contentHash: string
fields: unknown
}

43
web/app/utils/snapshot.ts Normal file
View file

@ -0,0 +1,43 @@
/**
* เปลี่ยน snapshot fields ของ version (จาก GET .../versions) ให้เป็น list ที่ UI ใช้แสดงได้
* snapshot มี 2 รูปตามที่ backend เก็บ (phase 3):
* - เอกสารผูก template: { key: { value: string, missing: boolean } }
* - เอกสารไม่ผูก template: map ดิบ { key: value }
* pure function — แยกไว้ให้ unit test ได้ (Phase 7)
*/
export interface SnapshotEntry {
key: string
value: string
missing: boolean
}
export function normalizeSnapshot(fields: unknown, orderKeys?: string[]): SnapshotEntry[] {
if (fields === null || fields === undefined || typeof fields !== 'object') return []
const entries = new Map<string, SnapshotEntry>()
for (const [key, raw] of Object.entries(fields as Record<string, unknown>)) {
if (raw !== null && typeof raw === 'object' && 'value' in raw) {
const obj = raw as { value?: unknown; missing?: unknown }
entries.set(key, {
key,
value: obj.value === null || obj.value === undefined ? '' : String(obj.value),
missing: obj.missing === true,
})
} else {
entries.set(key, { key, value: raw === null || raw === undefined ? '' : String(raw), missing: false })
}
}
const list = [...entries.values()]
if (orderKeys?.length) {
// เรียงตามลำดับ field defs ของ template — key ที่ไม่มีใน defs ต่อท้ายตามตัวอักษร
const rank = new Map(orderKeys.map((k, i) => [k, i]))
list.sort((a, b) => {
const ra = rank.get(a.key) ?? orderKeys.length
const rb = rank.get(b.key) ?? orderKeys.length
return ra === rb ? a.key.localeCompare(b.key) : ra - rb
})
}
return list
}