From a196b8f9a864f84beb5dd3e0ba3b0847b4a99126 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Thu, 20 Aug 2026 11:15:04 +0700 Subject: [PATCH] =?UTF-8?q?=E0=B8=9B=E0=B8=A3=E0=B8=B1=E0=B8=9A=E0=B8=9B?= =?UTF-8?q?=E0=B8=A3=E0=B8=B8=E0=B8=87=20phase=206=20=E0=B9=83=E0=B8=AB?= =?UTF-8?q?=E0=B9=89=E0=B8=84=E0=B8=A3=E0=B8=9A=E0=B8=96=E0=B9=89=E0=B8=A7?= =?UTF-8?q?=E0=B8=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 11 +- README.md | 52 +++- .../Controllers/DocumentsController.cs | 63 +++++ .../Controllers/TemplatesController.cs | 33 +++ .../WordVersioning.Api/Models/VersionDtos.cs | 34 +++ docs/plans/officejs-implementation-plan.md | 29 ++- specs/spec-officejs.md | 11 + web/app/pages/documents/[documentId].vue | 233 ++++++++++++++++++ .../pages/documents/create/[templateId].vue | 47 +++- web/app/pages/index.vue | 38 ++- web/app/pages/templates/[id]/documents.vue | 140 +++++++++++ web/app/pages/templates/index.vue | 8 +- web/app/types/api.ts | 45 ++++ web/app/utils/snapshot.ts | 43 ++++ 14 files changed, 747 insertions(+), 40 deletions(-) create mode 100644 web/app/pages/documents/[documentId].vue create mode 100644 web/app/pages/templates/[id]/documents.vue create mode 100644 web/app/utils/snapshot.ts diff --git a/CLAUDE.md b/CLAUDE.md index 382196a..d2c209a 100644 --- a/CLAUDE.md +++ b/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`) -**สถานะปัจจุบัน: 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 dotnet test dotnet test --filter "FullyQualifiedName~GenerateTests" # รัน test เดี่ยว diff --git a/README.md b/README.md index 7229681..d8ee592 100644 --- a/README.md +++ b/README.md @@ -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 `): ``` -# 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 +เปิด แล้วกดเข้าสู่ระบบด้วย `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) diff --git a/backend/WordVersioning.Api/Controllers/DocumentsController.cs b/backend/WordVersioning.Api/Controllers/DocumentsController.cs index f495ab1..f43d555 100644 --- a/backend/WordVersioning.Api/Controllers/DocumentsController.cs +++ b/backend/WordVersioning.Api/Controllers/DocumentsController.cs @@ -335,6 +335,69 @@ public class DocumentsController( } } + /// FR-12 — detail ของเอกสาร: ข้อมูลหัวเรื่อง + template + ค่าล่าสุดของทุก field + /// (นิยามจาก TemplateField merge กับ DocumentFieldValue — รองรับ template เปลี่ยนนิยามหลังเอกสารสร้างแล้ว) + [HttpGet("{documentId:guid}")] + [ProducesResponseType(typeof(DocumentDetail), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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(); + 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)); + } + /// FR-6 — version history เรียงใหม่ → เก่า (พร้อม snapshot fields ต่อ version) [HttpGet("{documentId:guid}/versions")] [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] diff --git a/backend/WordVersioning.Api/Controllers/TemplatesController.cs b/backend/WordVersioning.Api/Controllers/TemplatesController.cs index 18f35a2..1e284cd 100644 --- a/backend/WordVersioning.Api/Controllers/TemplatesController.cs +++ b/backend/WordVersioning.Api/Controllers/TemplatesController.cs @@ -314,6 +314,39 @@ public class TemplatesController( return NoContent(); } + /// FR-12 — รายการเอกสารที่สร้างจาก template นี้ (เรียงตามกิจกรรมล่าสุด) + /// ใช้ได้แม้ template ถูก deactivate (เอกสารเก่ายังต้องเข้าถึงได้ตาม policy FR-9) + [HttpGet("{id:guid}/documents")] + [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task>> 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)); diff --git a/backend/WordVersioning.Api/Models/VersionDtos.cs b/backend/WordVersioning.Api/Models/VersionDtos.cs index ebec4d2..9f1f7e8 100644 --- a/backend/WordVersioning.Api/Models/VersionDtos.cs +++ b/backend/WordVersioning.Api/Models/VersionDtos.cs @@ -42,3 +42,37 @@ public record VersionListItem( /// response ของ GET .../download-link — presigned URL อายุสั้นให้เปิดในเบราว์เซอร์ของผู้ใช้ public record VersionDownloadLinkResponse(string Url, DateTimeOffset ExpiresAt); + +/// item ของ GET /api/templates/{templateId}/documents — รายการเอกสารของ template หนึ่งตัว (FR-12) +public record TemplateDocumentListItem( + Guid DocumentId, + string FileName, + DateTimeOffset CreatedAt, + int VersionCount, + int LatestVersionNumber, + DateTimeOffset? LatestVersionAt); + +/// field หนึ่งตัวของ document (FR-12) — นิยามจาก TemplateField + ค่าล่าสุดจาก DocumentFieldValue +/// Label/Type/IsRequired เป็น null เมื่อมีค่าเก็บไว้แต่ key ไม่มีในนิยาม template ปัจจุบัน +/// (เช่น replace ไฟล์แล้ว tag หาย) — SourceVersionId/UpdatedByUserId/UpdatedAt เป็น null เมื่อ +/// field เพิ่มเข้า template หลังเอกสารนี้ถูกสร้าง (ยังไม่เคยมีค่า) +public record DocumentFieldItem( + string Key, + string? Label, + string? Type, + bool? IsRequired, + string Value, + Guid? SourceVersionId, + string? UpdatedByUserId, + DateTimeOffset? UpdatedAt); + +/// response ของ GET /api/documents/{documentId} (FR-12) — +/// TemplateId null คือเอกสาร track เองจาก add-in (ไม่มีนิยาม field, Fields ว่าง) +public record DocumentDetail( + Guid Id, + string FileName, + DateTimeOffset CreatedAt, + Guid? TemplateId, + string? TemplateName, + bool TemplateIsActive, + IReadOnlyList Fields); diff --git a/docs/plans/officejs-implementation-plan.md b/docs/plans/officejs-implementation-plan.md index 330d70f..2d8d78a 100644 --- a/docs/plans/officejs-implementation-plan.md +++ b/docs/plans/officejs-implementation-plan.md @@ -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: { : } } - ดาวน์โหลดในเว็บใช้ 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: { : } } ## 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: { : } } | 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: { : } } - **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 --- diff --git a/specs/spec-officejs.md b/specs/spec-officejs.md index 1b1e050..6b97516 100644 --- a/specs/spec-officejs.md +++ b/specs/spec-officejs.md @@ -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: { : } } - 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 ค่าปัจจุบัน diff --git a/web/app/pages/documents/[documentId].vue b/web/app/pages/documents/[documentId].vue new file mode 100644 index 0000000..6c2fddd --- /dev/null +++ b/web/app/pages/documents/[documentId].vue @@ -0,0 +1,233 @@ + + + diff --git a/web/app/pages/documents/create/[templateId].vue b/web/app/pages/documents/create/[templateId].vue index f995113..ea5e99b 100644 --- a/web/app/pages/documents/create/[templateId].vue +++ b/web/app/pages/documents/create/[templateId].vue @@ -1,6 +1,7 @@ + + diff --git a/web/app/pages/templates/index.vue b/web/app/pages/templates/index.vue index 206f7c2..56574f3 100644 --- a/web/app/pages/templates/index.vue +++ b/web/app/pages/templates/index.vue @@ -135,8 +135,14 @@ onMounted(load) {{ formatDateTime(t.updatedAt) }} + เอกสาร + + แก้ไข diff --git a/web/app/types/api.ts b/web/app/types/api.ts index e7764b0..361f512 100644 --- a/web/app/types/api.ts +++ b/web/app/types/api.ts @@ -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 +} diff --git a/web/app/utils/snapshot.ts b/web/app/utils/snapshot.ts new file mode 100644 index 0000000..7472e67 --- /dev/null +++ b/web/app/utils/snapshot.ts @@ -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() + for (const [key, raw] of Object.entries(fields as Record)) { + 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 +}