ปรับปรุง phase 6 ให้ครบถ้วน
This commit is contained in:
parent
93570d38d8
commit
a196b8f9a8
14 changed files with 747 additions and 40 deletions
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -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`)
|
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
|
- `docs/plans/officejs-implementation-plan.md` — implementation plan แบ่ง 9 phases พร้อม checklist, DoD, dependency ระหว่าง phase และตารา mapping Phase↔FR↔AC
|
||||||
|
|
||||||
เมื่อ requirement เปลี่ยน: แก้ spec ก่อน แล้ว sync ไปยัง plan
|
เมื่อ 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 เท่านั้น
|
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)
|
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 ปกติ
|
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
|
5. **Backend คือ source of truth** ของ version history และ field values
|
||||||
6. **Field key = content control (SDT) tag** ใช้ค่าเดียวกันทั้ง generate (backend) / extract (add-in) / sync (DB)
|
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)
|
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
|
**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 ใช้เปิดในเบราว์เซอร์ของเครื่อง)
|
**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 เก่ายังอ้างอยู่
|
**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)
|
# backend (http://localhost:5075 + https://localhost:7232 — dev เปิด https อัตโนมัติด้วย dev cert)
|
||||||
cd backend/WordVersioning.Api
|
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 ef migrations add <Name>
|
||||||
dotnet test
|
dotnet test
|
||||||
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว
|
dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว
|
||||||
|
|
|
||||||
52
README.md
52
README.md
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
- 📝 **สร้างเอกสารจาก template** — กรอก form บนเว็บ → ระบบ generate .docx จาก template (แทนที่ค่าใน content controls) พร้อม stamp metadata และขึ้นเป็น **version 1 อัตโนมัติ**
|
- 📝 **สร้างเอกสารจาก template** — กรอก form บนเว็บ → ระบบ generate .docx จาก template (แทนที่ค่าใน content controls) พร้อม stamp metadata และขึ้นเป็น **version 1 อัตโนมัติ**
|
||||||
- 🗂️ **Template management** — upload ไฟล์ .docx ที่มี content controls แล้วระบบ **detect fields อัตโนมัติ** ตั้งชื่อ/ชนิด/required ต่อ field ผ่านหน้าเว็บ
|
- 🗂️ **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
|
- 📌 **ตรวจจับ tracked document** — เปิดไฟล์ใน Word (Desktop Win/Mac หรือ Online) แล้ว add-in รู้ทันทีว่าไฟล์นี้อยู่ในระบบ พร้อมแสดง version history
|
||||||
- 💾 **Save & Version** — ปุ่มใน task pane สร้าง version ใหม่ (stamp metadata + อัปโหลด snapshot ไป backend)
|
- 💾 **Save & Version** — ปุ่มใน task pane สร้าง version ใหม่ (stamp metadata + อัปโหลด snapshot ไป backend)
|
||||||
- 🔄 **Field sync** — ทุกครั้งที่สร้าง version ระบบ extract ค่าจาก content controls ของเอกสารไป update fields ใน DB ให้ sync กับเนื้อหาจริงเสมอ
|
- 🔄 **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 |
|
| **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 |
|
| **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 |
|
| **Auth** | Keycloak (OIDC + PKCE) | Public clients ทั้ง add-in และเว็บ, backend validate ผ่าน JWKS |
|
||||||
| **Storage** | MinIO (S3-compatible) | bucket `docx-versions` (snapshot ต่อ version) + `docx-templates` (ไฟล์ template) — private ทั้งคู่ |
|
| **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 อัตโนมัติ
|
1. Admin upload template (.docx ที่มี content controls) → ระบบ detect fields อัตโนมัติ
|
||||||
2. ผู้ใช้กรอก form บนเว็บ → ได้ไฟล์ .docx ที่เป็น version 1 แล้ว → เปิดใน Word
|
2. ผู้ใช้กรอก form บนเว็บ → ได้ไฟล์ .docx ที่เป็น version 1 แล้ว → เปิดใน Word
|
||||||
3. Add-in รู้จักเอกสารทันที → แก้ไขเนื้อหา → กด **Save & Version** → เกิด version ใหม่ + fields ใน DB sync ตามเนื้อหาล่าสุด
|
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) | ✅ เสร็จ |
|
| 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) | ✅ เสร็จ |
|
| 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 เต็มชุด |
|
| 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 |
|
| 6 | Web Portal UI (FR-9–10) | ✅ เสร็จ — login ผ่าน Keycloak, template management, dynamic form, theme toggle |
|
||||||
| 7–8 | Tests ครบชุด + เอกสารฉบับเต็ม | ⏳ รอ implement |
|
| 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
|
- [docs/plans/officejs-implementation-plan.md](docs/plans/officejs-implementation-plan.md) — implementation plan แบ่ง 9 phases พร้อม definition of done
|
||||||
|
|
||||||
สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0)
|
สำหรับผู้เริ่มพัฒนา: อ่าน spec ก่อน แล้วตามด้วย plan (เริ่มจาก Phase 0)
|
||||||
|
|
@ -102,8 +107,9 @@ dotnet run # รันทั้ง http://localhost:5075 แ
|
||||||
**API ที่มีตอนนี้** (ทุก endpoint ต้องแนบ `Authorization: Bearer <token>`):
|
**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
|
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 # version history ใหม่ → เก่า (พร้อม snapshot fields)
|
||||||
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
|
GET /api/documents/{documentId}/versions/{versionId}/download # ดาวน์โหลด version (proxy stream จาก MinIO)
|
||||||
GET /api/documents/{documentId}/versions/{versionId}/download-link # presigned URL อายุสั้น (ให้เบราว์เซอร์โหลดตรงจาก 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)
|
# Generate จาก template (FR-10)
|
||||||
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata
|
POST /api/documents/generate # { templateId, values } → เอกสาร v1 พร้อม metadata
|
||||||
|
|
||||||
# Templates (FR-9)
|
# Templates (FR-9, 12)
|
||||||
GET /api/templates # รายการ (?includeInactive=true สำหรับ admin)
|
GET /api/templates # รายการ (?includeInactive=true สำหรับ admin)
|
||||||
GET /api/templates/{id} # รายละเอียด + field definitions
|
GET /api/templates/{id} # รายละเอียด + field definitions
|
||||||
|
GET /api/templates/{id}/documents # รายการเอกสารที่สร้างจาก template นี้ (FR-12)
|
||||||
POST /api/templates # upload .docx → detect fields อัตโนมัติ
|
POST /api/templates # upload .docx → detect fields อัตโนมัติ
|
||||||
PUT /api/templates/{id} # แก้ metadata + field definitions (label/type/required/ลำดับ)
|
PUT /api/templates/{id} # แก้ metadata + field definitions (label/type/required/ลำดับ)
|
||||||
PUT /api/templates/{id}/file # แทนที่ไฟล์ → re-detect (บอก tag ที่เพิ่ม/หาย)
|
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 }
|
# → { "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`)
|
**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)
|
เปิดไฟล์ที่ได้ตรวจได้ว่า: ค่าถูกทุก 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
|
```bash
|
||||||
curl -X POST http://localhost:5075/api/documents/$DOC_ID/versions \
|
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
|
cd addin
|
||||||
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
|
npx office-addin-dev-certs install # ครั้งแรกครั้งเดียว (https localhost)
|
||||||
npm run build # webpack build + type-check ทั้ง program
|
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 lint # eslint (ใช้ eslint.config.mjs ของโปรเจกต์)
|
||||||
npm run validate # ตรวจ manifest.xml
|
npm run validate # ตรวจ manifest.xml
|
||||||
npm start # dev server https://localhost:3000 — sideload ผ่าน 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 เก่าเปิดได้
|
- ไล่ทดสอบตาม 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` ใหม่)
|
- ⚠️ **ห้าม `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
|
```bash
|
||||||
cd web && npm run dev # http://localhost:5173 (ห้ามใช้ 3000 — ชน add-in)
|
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)
|
## 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 ของเบราว์เซอร์ |
|
| กดดาวน์โหลด 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 |
|
| แก้ 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) |
|
| `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`) |
|
| 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)
|
**กติกาที่สรุปได้:** ใน dev ทุก service ที่ task pane (หน้า https) จะเรียก **ต้องเป็น HTTPS ด้วย cert จาก office-addin-dev-certs ทั้งหมด** — Keycloak (8443) และ backend (7232)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
/// <summary>FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version)</summary>
|
||||||
[HttpGet("{documentId:guid}/versions")]
|
[HttpGet("{documentId:guid}/versions")]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<VersionListItem>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(IReadOnlyList<VersionListItem>), StatusCodes.Status200OK)]
|
||||||
|
|
|
||||||
|
|
@ -314,6 +314,39 @@ public class TemplatesController(
|
||||||
return NoContent();
|
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(
|
private static TemplateDetail ToDetail(Template template) => new(
|
||||||
template.Id, template.Name, template.Description, template.IsActive,
|
template.Id, template.Name, template.Description, template.IsActive,
|
||||||
template.CreatedAt, template.UpdatedAt, FieldDtos(template));
|
template.CreatedAt, template.UpdatedAt, FieldDtos(template));
|
||||||
|
|
|
||||||
|
|
@ -42,3 +42,37 @@ public record VersionListItem(
|
||||||
|
|
||||||
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
|
/// <summary>response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้</summary>
|
||||||
public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt);
|
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);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
# Implementation Plan: Word Versioning Add-in (Office.js + .NET 8)
|
||||||
|
|
||||||
> แผนงานสร้างระบบจาก spec: [`specs/spec-officejs.md`](../../specs/spec-officejs.md)
|
> แผนงานสร้างระบบจาก 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)
|
> วันที่แผน: 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 รู้จักทันที
|
- หน้าจอสร้างเอกสารครั้งแรก = **เว็บแยก (standalone)** — กรอกนอก Word → ดาวน์โหลด .docx ที่ backend stamp metadata + สร้าง v1 ไว้แล้ว → เปิดใน Word แล้ว add-in รู้จักทันที
|
||||||
- Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template)
|
- Template = **หลาย template + หน้าจัดการเต็มรูปแบบ** (CRUD + นิยาม field ต่อ template)
|
||||||
- Field sync = **เฉพาะ fields จาก template** (ผูกกับ content controls) ไม่เก็บ full text
|
- 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'
|
tailwind.config.ts # darkMode: 'class'
|
||||||
/pages
|
/pages
|
||||||
index.vue # หน้าหลัก: เลือก template เพื่อสร้างเอกสาร
|
index.vue # หน้าหลัก: เลือก template เพื่อสร้างเอกสาร
|
||||||
|
auth/callback.vue # ปลายทาง OIDC redirect (แลก token แล้วพากลับ)
|
||||||
templates/index.vue # รายการ template (admin)
|
templates/index.vue # รายการ template (admin)
|
||||||
templates/new.vue # upload template + ตั้งค่า field defs
|
templates/new.vue # upload template + ตั้งค่า field defs
|
||||||
templates/[id]/edit.vue # แก้ไข 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
|
/composables
|
||||||
useAuth.ts # OIDC PKCE + browser redirect ปกติ
|
useAuth.ts # OIDC PKCE + browser redirect ปกติ
|
||||||
useApi.ts # fetch wrapper + Bearer token + refresh
|
useApi.ts # fetch wrapper + Bearer token + refresh
|
||||||
useTheme.ts # light/dark toggle + persist + system default
|
useTheme.ts # light/dark toggle + persist + system default
|
||||||
|
/utils # oidc / fieldValidation / snapshot — pure functions (unit test ได้)
|
||||||
/assets/css/tailwind.css
|
/assets/css/tailwind.css
|
||||||
package.json
|
package.json
|
||||||
/keycloak # Phase 1
|
/keycloak # Phase 1
|
||||||
|
|
@ -421,6 +425,21 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||||
- ดาวน์โหลดในเว็บใช้ blob + `a[download]` ได้ตามปกติ (browser จริง — ต่างจาก webview ของ Word ที่ต้องใช้ presigned link ตามกติกา #12)
|
- ดาวน์โหลดในเว็บใช้ 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 ได้ทันที และลบผ่านหน้าเว็บได้เอง
|
- ⚠️ **เหลือตรวจด้วยมือ (ทำแทนไม่ได้):** เปิด 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
|
**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
|
> 🟡 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
|
## 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 ทีละข้อ พร้อมจดผล/ปัญหาที่เจอ
|
- [ ] ไล่ 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) |
|
| 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 |
|
| 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 | FR-9 (UI), FR-10 (UI) | AC-11, AC-12 (เต็มรูปแบบ) |
|
||||||
|
| 6.4 (ทีหลัง, v4) | FR-12 | AC-15 |
|
||||||
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
|
| 7 | — | คุ้มกัน regression ทุก AC (รวม AC-13, 14) |
|
||||||
| 8 | — | ทบทวน AC-1 ถึง AC-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-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-13:** แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
|
||||||
- **AC-14:** ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
|
- **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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Spec: Word Versioning Add-in (Office.js Cross-Platform + .NET Backend)
|
# 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
|
> ฉบับแก้ไข 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`
|
> ฉบับแก้ไข 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
|
nuxt.config.ts # devServer port 5173
|
||||||
tailwind.config.ts # darkMode: 'class'
|
tailwind.config.ts # darkMode: 'class'
|
||||||
/pages # เลือก template สร้างเอกสาร / จัดการ template / dynamic form
|
/pages # เลือก template สร้างเอกสาร / จัดการ template / dynamic form
|
||||||
|
# + รายการเอกสารต่อ template + detail เอกสาร + re-generate (FR-12, v4)
|
||||||
/composables # useAuth (PKCE redirect), useApi, useTheme (light/dark)
|
/composables # useAuth (PKCE redirect), useApi, useTheme (light/dark)
|
||||||
|
/utils # oidc (PKCE helpers), fieldValidation, snapshot — pure functions ไว้ unit test
|
||||||
package.json
|
package.json
|
||||||
|
|
||||||
/keycloak
|
/keycloak
|
||||||
|
|
@ -245,6 +248,12 @@ POST /api/documents/generate { templateId, values: { <fieldKey>: <value> } }
|
||||||
- field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → **เก็บค่าเดิม + mark missing ใน snapshot แต่ไม่ fail request**; ส่งมาเกินนิยาม → ignore
|
- field ที่ส่งมาขาด (ผู้ใช้ลบ content control ใน Word) → **เก็บค่าเดิม + mark missing ใน snapshot แต่ไม่ fail request**; ส่งมาเกินนิยาม → ignore
|
||||||
- `UpdatedByUserId` มาจาก JWT claims ของผู้กด Save & Version เสมอ
|
- `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)
|
## 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
|
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 ถูกเก็บ
|
13. (v2) แก้ค่าใน content control ที่ Word แล้วกด Save & Version → field value ใน DB เปลี่ยนตาม + snapshot ต่อ version ถูกเก็บ
|
||||||
14. (v2) ลบ content control ออกจาก Word แล้วกด Save & Version → flow ยังสำเร็จ ค่าเดิมของ field นั้นคงอยู่ (mark missing) ไม่ error
|
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
|
3. หน้าจัดการ template: list / upload (โชว์ fields ที่ detect อัตโนมัติ) / แก้ field definitions / deactivate
|
||||||
4. หน้าสร้างเอกสาร: dynamic form สร้างจาก field definitions (input type ตาม field type) + validate + ดาวน์โหลดผลลัพธ์
|
4. หน้าสร้างเอกสาร: dynamic form สร้างจาก field definitions (input type ตาม field type) + validate + ดาวน์โหลดผลลัพธ์
|
||||||
5. เขียน unit test ของ form validation + useTheme (Vitest)
|
5. เขียน unit test ของ form validation + useTheme (Vitest)
|
||||||
|
6. (v4/FR-12) หน้ารายการเอกสารของ template + หน้า detail ของเอกสาร (ค่า fields ปัจจุบัน + version history พร้อม snapshot ต่อ version + ดาวน์โหลดต่อ version) + re-generate ผ่านฟอร์ม prefilled ค่าปัจจุบัน
|
||||||
|
|
|
||||||
233
web/app/pages/documents/[documentId].vue
Normal file
233
web/app/pages/documents/[documentId].vue
Normal 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>
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด */
|
/** FR-10 UI — dynamic form สร้างเอกสารจาก field definitions ของ template → generate เป็น v1 + ดาวน์โหลด
|
||||||
import type { GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
* FR-12 — รองรับ re-generate: ?from={documentId} → prefill ค่าล่าสุดของเอกสารนั้นให้แก้ก่อนสร้าง */
|
||||||
|
import type { DocumentDetail, GenerateDocumentResponse, TemplateDetail } from '~/types/api'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { apiJson, downloadFile } = useApi()
|
const { apiJson, downloadFile } = useApi()
|
||||||
|
|
@ -10,6 +11,10 @@ const detail = ref<TemplateDetail | null>(null)
|
||||||
const values = ref<Record<string, string>>({})
|
const values = ref<Record<string, string>>({})
|
||||||
const issues = 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 loading = ref(true)
|
||||||
const loadError = ref<string | null>(null)
|
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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = null
|
loadError.value = null
|
||||||
try {
|
try {
|
||||||
detail.value = (await apiJson<TemplateDetail>(`/api/templates/${templateId.value}`))!
|
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) {
|
} catch (err) {
|
||||||
loadError.value = err instanceof Error ? err.message : 'โหลด template ไม่สำเร็จ'
|
loadError.value = err instanceof Error ? err.message : 'โหลด template ไม่สำเร็จ'
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -80,13 +106,13 @@ async function download() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม */
|
/** กลับไปกรอกฟอร์มใหม่เพื่อสร้างอีกฉบับจาก template เดิม (ถ้าเป็น re-generate → คืนค่า prefill ไม่ใช่ฟอร์มว่าง) */
|
||||||
function resetForm() {
|
function resetForm() {
|
||||||
result.value = null
|
result.value = null
|
||||||
submitError.value = null
|
submitError.value = null
|
||||||
downloadError.value = null
|
downloadError.value = null
|
||||||
issues.value = {}
|
issues.value = {}
|
||||||
values.value = Object.fromEntries((detail.value?.fields ?? []).map((f) => [f.key, '']))
|
values.value = { ...blankValues(), ...prefill.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
|
|
@ -175,6 +201,15 @@ onMounted(load)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AlertBox v-if="fromDoc">
|
||||||
|
กำลังสร้างเอกสารใหม่จากค่าปัจจุบันของ <strong class="break-all">{{ fromDoc.fileName }}</strong>
|
||||||
|
— แก้ค่าได้ก่อนกดสร้าง และเอกสารต้นทางจะไม่ถูกแตะ (ไม่เกิด version ใหม่)
|
||||||
|
</AlertBox>
|
||||||
|
|
||||||
|
<AlertBox v-if="!detail.isActive">
|
||||||
|
template นี้ถูก deactivate แล้ว — สร้างเอกสารใหม่ไม่ได้ (เอกสารที่สร้างไว้แล้วยังใช้ได้)
|
||||||
|
</AlertBox>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="f in sortedFields"
|
v-for="f in sortedFields"
|
||||||
:key="f.key"
|
:key="f.key"
|
||||||
|
|
@ -210,7 +245,7 @@ onMounted(load)
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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)' }}
|
{{ submitting ? 'กำลังสร้างเอกสาร…' : 'สร้างเอกสาร (version 1)' }}
|
||||||
|
|
|
||||||
|
|
@ -50,21 +50,35 @@ onMounted(async () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid gap-4 sm:grid-cols-2">
|
<div v-else class="grid gap-4 sm:grid-cols-2">
|
||||||
<NuxtLink
|
<div
|
||||||
v-for="t in templates"
|
v-for="t in templates"
|
||||||
:key="t.id"
|
:key="t.id"
|
||||||
:to="`/documents/create/${t.id}`"
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<h2 class="font-semibold">{{ t.name }}</h2>
|
<NuxtLink :to="`/documents/create/${t.id}`" class="flex-1">
|
||||||
<p class="mt-1 line-clamp-2 min-h-10 text-sm text-slate-600 dark:text-slate-300">
|
<h2 class="font-semibold">{{ t.name }}</h2>
|
||||||
{{ t.description ?? '—' }}
|
<p class="mt-1 line-clamp-2 min-h-10 text-sm text-slate-600 dark:text-slate-300">
|
||||||
</p>
|
{{ t.description ?? '—' }}
|
||||||
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
</p>
|
||||||
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
|
<p class="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||||
</p>
|
{{ t.fieldCount }} fields · อัปเดต {{ formatDateTime(t.updatedAt) }}
|
||||||
<p class="mt-3 text-sm font-medium text-sky-600 dark:text-sky-400">สร้างเอกสาร →</p>
|
</p>
|
||||||
</NuxtLink>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</AuthGate>
|
</AuthGate>
|
||||||
|
|
|
||||||
140
web/app/pages/templates/[id]/documents.vue
Normal file
140
web/app/pages/templates/[id]/documents.vue
Normal 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>
|
||||||
|
|
@ -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-slate-500 dark:text-slate-400">{{ formatDateTime(t.updatedAt) }}</td>
|
||||||
<td class="px-4 py-3 text-right whitespace-nowrap">
|
<td class="px-4 py-3 text-right whitespace-nowrap">
|
||||||
<NuxtLink
|
<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"
|
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>
|
</NuxtLink>
|
||||||
|
|
|
||||||
|
|
@ -54,3 +54,48 @@ export interface GenerateDocumentResponse {
|
||||||
/** path สัมพัทธ์ของ backend — เติม apiBaseUrl ตอนเรียก */
|
/** path สัมพัทธ์ของ backend — เติม apiBaseUrl ตอนเรียก */
|
||||||
downloadUrl: string
|
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
43
web/app/utils/snapshot.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue