From 6940b3870dce0f4527366494d75e3db075feed1a Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 17 Sep 2026 13:43:16 +0700 Subject: [PATCH] =?UTF-8?q?docs:=20=E0=B8=A3=E0=B9=88=E0=B8=B2=E0=B8=87?= =?UTF-8?q?=E0=B9=81=E0=B8=9C=E0=B8=99=20N8=20=E0=B8=8A=E0=B8=B8=E0=B8=94?= =?UTF-8?q?=E0=B9=80=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3=20(Docum?= =?UTF-8?q?ent=20Set)=20+=20bookmark=20=E0=B8=A3=E0=B8=A7=E0=B8=A1?= =?UTF-8?q?=E0=B8=97=E0=B8=B1=E0=B9=89=E0=B8=87=E0=B8=8A=E0=B8=B8=E0=B8=94?= =?UTF-8?q?=20=E2=80=94=20=E0=B8=9B=E0=B8=B4=E0=B8=94=20gap=205.3.10=20?= =?UTF-8?q?=E0=B8=84=E0=B8=A3=E0=B8=B6=E0=B9=88=E0=B8=87=E0=B8=AB=E0=B8=A5?= =?UTF-8?q?=E0=B8=B1=E0=B8=87=20("=E0=B8=AB=E0=B8=A3=E0=B8=B7=E0=B8=AD?= =?UTF-8?q?=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B9=80?= =?UTF-8?q?=E0=B8=AD=E0=B8=81=E0=B8=AA=E0=B8=B2=E0=B8=A3"):=20=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B8=B2=E0=B8=87=20document=5Fsets=20+=20fi?= =?UTF-8?q?les.set=5Fid=20nullable=20(=E0=B9=84=E0=B8=9F=E0=B8=A5=E0=B9=8C?= =?UTF-8?q?=E0=B8=AB=E0=B8=99=E0=B8=B6=E0=B9=88=E0=B8=87=E0=B8=AD=E0=B8=A2?= =?UTF-8?q?=E0=B8=B9=E0=B9=88=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B9=80=E0=B8=94?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=A7=20onDelete=20set=20null=20=E2=80=94?= =?UTF-8?q?=20=E0=B8=A5=E0=B8=9A=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B9=84?= =?UTF-8?q?=E0=B8=9F=E0=B8=A5=E0=B9=8C=E0=B9=84=E0=B8=A1=E0=B9=88=E0=B8=AB?= =?UTF-8?q?=E0=B8=B2=E0=B8=A2=20=E0=B8=84=E0=B8=99=E0=B8=A5=E0=B8=B0?= =?UTF-8?q?=E0=B9=80=E0=B8=A3=E0=B8=B7=E0=B9=88=E0=B8=AD=E0=B8=87=E0=B8=81?= =?UTF-8?q?=E0=B8=B1=E0=B8=9A=20CASCADE=20=E0=B8=82=E0=B8=AD=E0=B8=87=20an?= =?UTF-8?q?notations)=20+=20=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B9=83?= =?UTF-8?q?=E0=B8=AB=E0=B8=A1=E0=B9=88=20/sets/[id]=20bookmark=20=E0=B8=A3?= =?UTF-8?q?=E0=B8=A7=E0=B8=A1=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B9=84=E0=B8=9F?= =?UTF-8?q?=E0=B8=A5=E0=B9=8C=E0=B8=82=E0=B8=AD=E0=B8=87=E0=B8=8A=E0=B8=B8?= =?UTF-8?q?=E0=B8=94=E0=B8=84=E0=B8=A5=E0=B8=B4=E0=B8=81=E0=B8=81=E0=B8=A3?= =?UTF-8?q?=E0=B8=B0=E0=B9=82=E0=B8=94=E0=B8=94=E0=B8=9C=E0=B9=88=E0=B8=B2?= =?UTF-8?q?=E0=B8=99=20deep=20link=20F12=20+=20=E0=B8=88=E0=B8=B1=E0=B8=94?= =?UTF-8?q?=E0=B8=81=E0=B8=B2=E0=B8=A3=E0=B8=8A=E0=B8=B8=E0=B8=94=E0=B8=9A?= =?UTF-8?q?=E0=B8=99=E0=B8=AB=E0=B8=99=E0=B9=89=E0=B8=B2=E0=B8=A3=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=E0=B8=81=E0=B8=B2=E0=B8=A3=20(=E0=B8=AA=E0=B8=A3?= =?UTF-8?q?=E0=B9=89=E0=B8=B2=E0=B8=87/=E0=B8=A2=E0=B9=89=E0=B8=B2?= =?UTF-8?q?=E0=B8=A2=E0=B9=80=E0=B8=82=E0=B9=89=E0=B8=B2-=E0=B8=AD?= =?UTF-8?q?=E0=B8=AD=E0=B8=81/=E0=B9=81=E0=B8=81=E0=B9=89=E0=B8=8A?= =?UTF-8?q?=E0=B8=B7=E0=B9=88=E0=B8=AD/=E0=B8=A5=E0=B8=9A=20=E2=80=94=20?= =?UTF-8?q?=E0=B8=97=E0=B8=B8=E0=B8=81=E0=B8=84=E0=B8=99=E0=B8=97=E0=B8=B5?= =?UTF-8?q?=E0=B9=88=20login=20=E0=B8=88=E0=B8=B1=E0=B8=94=E0=B8=81?= =?UTF-8?q?=E0=B8=B2=E0=B8=A3=E0=B9=84=E0=B8=94=E0=B9=89=20roles=20?= =?UTF-8?q?=E0=B8=A2=E0=B8=B1=E0=B8=87=20out=20of=20scope=20=E0=B8=95?= =?UTF-8?q?=E0=B8=B2=E0=B8=A1=205.3.23)=20=E2=80=94=20=E0=B8=A2=E0=B8=B1?= =?UTF-8?q?=E0=B8=87=E0=B9=84=E0=B8=A1=E0=B9=88=20implement=20(=E0=B9=80?= =?UTF-8?q?=E0=B8=AA=E0=B8=99=E0=B8=AD=202026-09-17=20=E0=B8=A3=E0=B8=AD?= =?UTF-8?q?=20/implement-feat=20N8=20=E0=B8=95=E0=B8=B2=E0=B8=A1=20governa?= =?UTF-8?q?nce:=20=E0=B8=95=E0=B9=89=E0=B8=AD=E0=B8=87=E0=B9=80=E0=B8=82?= =?UTF-8?q?=E0=B8=B5=E0=B8=A2=E0=B8=99=E0=B8=AA=E0=B9=80=E0=B8=9B=E0=B8=81?= =?UTF-8?q?=E0=B8=A5=E0=B8=87=20PLAN-FEAT.md=20=C2=A77=20+=20=E0=B8=82?= =?UTF-8?q?=E0=B8=A2=E0=B8=B2=E0=B8=A2=20whitelist=20=E0=B9=83=E0=B8=99=20?= =?UTF-8?q?CLAUDE.md=20=E0=B8=81=E0=B9=88=E0=B8=AD=E0=B8=99)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Code --- docs/PLAN-DOCSET.md | 130 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/PLAN-DOCSET.md diff --git a/docs/PLAN-DOCSET.md b/docs/PLAN-DOCSET.md new file mode 100644 index 0000000..63ece3c --- /dev/null +++ b/docs/PLAN-DOCSET.md @@ -0,0 +1,130 @@ +# แผน 5.3.10 — ชุดเอกสาร (Document Set) + bookmark รวมทั้งชุด (N8) + +> สถานะ: **ร่างแผน (ยังไม่ implement)** — เสนอ 2026-09-17 +> อ้างอิง: requirement 5.3.10 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม C**): *"แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้"* — ครึ่งแรก (รายไฟล์) ✅ ทำแล้วใน sidebar ครึ่งหลัง (ชุดของเอกสาร) คือ gap นี้ +> ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N8**) + ขยาย whitelist ใน `CLAUDE.md` + อัปเดต `docs/PLAN.md` §3 (schema) ก่อน — เป็น task แรกของแผนนี้ แล้วรันผ่าน `/implement-feat N8` + +## Context + +- ตอนนี้ไฟล์เป็น**ลิสต์แบน**หน้าเดียว (หน้า `/` เรียงตาม `createdAt`) — ไม่มี concept การจัดกลุ่มไฟล์ และ bookmark ดูได้เฉพาะภายในไฟล์ที่เปิด (แท็บ "ที่คั่นหน้า" + หน้าสรุป F15 ซึ่งเป็น annotation ไม่ใช่ bookmark) +- กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็น bookmark **รวมทุกไฟล์ของคดีเดียวกันในหน้าเดียว** แล้วคลิกกระโดดเข้าไปอ่านตรงจุดนั้น +- ของพร้อมใช้ทั้งหมด: โครงสร้างต้นไม้ bookmark + ลำดับ N5 (`pageIndex → yOffset → createdAt`), deep link F12 (`?page=` อ่านตอน mount), mock login e-Court (ทุกหน้าถูก `middleware.ts` บังคับ login แล้ว) + +## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-17) + +1. **ไฟล์หนึ่งอยู่ชุดเดียว** — column `set_id` (nullable) บนตาราง `files` — ไม่ทำ many-to-many (เอกสารหนึ่งฉบับผูกคดีเดียวตามธรรมชาติของงาน) +2. **หน้า bookmark รวมชุดเป็นหน้าใหม่ `/sets/[id]`** เข้าจากหน้ารายการ (หน้า `/` จัดกลุ่มการ์ดไฟล์ตามชุด + หัวชุดมีลิงก์เปิด) — ไม่ยัดลง sidebar ของ viewer (แคบ 288px และต้องเปิดไฟล์ก่อนถึงเห็น) +3. **จัดการชุดผ่าน UI บนหน้ารายการ** — ฟอร์มเล็ก "สร้างชุด" + ปุ่มบนการ์ดไฟล์ย้ายเข้า/ออกชุด + เมนูแก้ชื่อ/ลบชุด (ทุกคนที่ login จัดการได้ — roles ยัง out of scope ตาม 5.3.23) + +## Design + +### D1 — Schema (`db/schema.ts` + `drizzle/`) + +```ts +export const documentSets = pgTable("document_sets", { + id: uuid("id").primaryKey().defaultRandom(), + title: text("title").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), +}); + +// files เพิ่ม: +setId: uuid("set_id").references(() => documentSets.id, { onDelete: "set null" }), +// + index ("files_set_id_idx") บน set_id +``` + +- ตาราง `document_sets` ประกาศ**ก่อน** `files` (drizzle `references` ต้องเห็น table ก่อน) +- **ไม่เก็บผู้สร้างชุด** (ไม่มี user_id/user_name) — ชุดเป็นของกลางของคดี ใครก็จัดการได้ และไม่มี UI ใดในสเปกนี้ต้องโชว์ผู้สร้าง +- `onDelete: "set null"` — ลบชุดแล้วไฟล์ในชุดตกกลับเป็น "ไม่อยู่ชุด" **ไฟล์ไม่ถูกลบ** (คนละเรื่องกับ CASCADE ของ annotations ที่ผูกไฟล์) +- ไม่มี DTO/mapper ใหม่ — หน้าที่ใช้เป็น server component ทั้งหมด ส่งข้ามไป client เฉพาะ `{id, title}` ธรรมดา (createdAt ไม่ต้องส่ง — ไม่มี UI โชว์; ถ้าส่งต้อง ISO string ตาม invariant #5) +- migration: `/sync-db` (push) + **`npx drizzle-kit generate`** — rule data-layer: Docker migrate จาก SQL ใน `drizzle/` เท่านั้น + +### D2 — Server Actions (`app/actions/sets.ts`) + +`"use server"` + validate ก่อนทุกครั้ง (pattern `app/actions/files.ts`): + +- `createSet(title)` — title trim, ว่าง = error, ยาว ≤ 200 (เทียบ `files.title`) → insert +- `renameSet(setId, title)` — validate UUID + title เหมือนกัน + ชุุดมีจริง → update +- `deleteSet(setId)` — validate UUID + มีจริง → delete (ไฟล์ในชุดกลายเป็น null อัตโนมัติจาก FK) +- `setFileSet(fileId, setId | null)` — validate ไฟล์มีจริง + (ถ้าไม่ null) ชุดมีจริง → update `files.set_id` +- **ไม่ publish SSE event** — การจัดชุดไม่แตะข้อมูลรายไฟล์ใน viewer (annotation/bookmark เดิมทั้งหมด) จึงไม่มีอะไรให้ sync ผ่าน `/api/annotations/stream` (ซึ่งผูก `fileId` รายไฟล์อยู่แล้ว) — เหมือน `uploadFile` ที่ใช้ `revalidatePath` อย่างเดียว +- **revalidatePath ทุก action:** `/` เสมอ + `/sets/{setId}` เมื่อแก้ชุดนั้น (rename) — กัน Router Cache ฝั่ง client ค้างหน้าเก่า (เหตุผลเดียวกับ `revalidateFilePages`) +- สิทธิ์: ทุกคนที่ login — action เรียก `getSessionUser()` defense ซ้อน middleware (คืน error ถ้าไม่มี session) + +### D3 — หน้ารายการ `/` จัดกลุ่มตามชุด + +- query `files` + `documentSets` → จัดกลุ่ม: **section ต่อชุด** (เรียง `createdAt`) — หัวชุด = ชื่อชุด + จำนวนไฟล์ + **ลิงก์ "bookmark ของชุด" → `/sets/{id}`** (icon Bookmark) + เมนู ⋯ (แก้ชื่อ / ลบชุด — confirm ก่อนลบ) +- การ์ดไฟล์ในชุดเรียง `createdAt` เหมือนเดิม + ปุ่ม Annotate เดิม + **ปุ่ม "ใส่ชุด"** เปิด dropdown เลือกชุดที่มีอยู่ / "ออกจากชุด" (ถ้าอยู่ชุด) +- ไฟล์ที่ `set_id` null → section **"ไม่อยู่ชุด"** ไว้ท้ายสุด (ใกล้ฟอร์มสร้าง/อัปโหลด — workflow อัปโหลดแล้วค่อยย้ายเข้าชุด) — ซ่อนเมื่อว่าง +- ฟอร์มเล็ก **"สร้างชุด"** — section ของตัวเองคู่ "เพิ่มเอกสาร" (client component: input ชื่อ + ปุ่ม, `useTransition` + action `createSet`) +- client component ใหม่: `components/CreateSetForm.tsx` (ฟอร์มสร้างชุด), `components/SetFileMenu.tsx` (dropdown ย้ายเข้า/ออกชุดบนการ์ด + เมนูแก้ชื่อ/ลบของหัวชุด — รับ prop list ชุุด `{id, title}[]` จาก server) + +### D4 — หน้า `/sets/[id]` — bookmark รวมทั้งชุด (`app/sets/[id]/page.tsx`) + +Server component `force-dynamic` (มี login check ซ้อน middleware เหมือนหน้า `/`): + +- โหลด: ชุุด (ไม่มี = 404) + ไฟล์ในชุุด (`orderBy createdAt`) + bookmark ของทุกไฟล์ (`inArray(fileId, …)` `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` — N5) +- แสดง: หัวชุด (ชื่อ + "N เอกสาร · M รายการ") + ลิงก์กลับ `/` +- **จัดกลุ่มตามไฟล์** (หัวไฟล์ = ชื่อไฟล์) ตามลำดับไฟล์ในชุด → ข้างในแสดง**ต้นไม้ bookmark 3 ระดับ** (indent เหมือน sidebar — build `childrenOf`/`roots` จาก array ที่เรียง N5 แล้ว + orphan → root + cycle guard seen ชุดเดียวร่วมทุก root เหมือน pattern N7/route เดิม) **read-only**: ชื่อ + "หน้า N · ผู้บันทึก · วันที่" + โน้ต — คลิกทั้งแถว = ลิงก์ **`/annotate/{fileId}?page={หน้า}`** กระโดดเข้า viewer ตรงหน้า (deep link F12 — ⚠️ implement ต้องเช็คจาก F12 จริงว่า `?page=` เป็น 1-based หรือ 0-based ให้ตรงกับปุ่มกระโดดของหน้าสรุป F15) +- ไม่มีปุ่มแก้ไข/ลบ bookmark ที่นี่ (จัดการที่ viewer เท่านั้น) + ไม่มี SSE (snapshot ณ ตอนโหลด เหมือนหน้าสรุป F15) +- ปุ่มส่งออก JSON (N7) **ไม่มี**หน้านี้ — N7 รับ `fileId` เดียว การขยายเป็นชุดอยู่ใน out of scope + +### D5 — สิทธิ์/การเข้าถึง + +- `middleware.ts` บังคับ login ทุก path อยู่แล้ว — `/sets/*` + actions เข้า่ายใน net อัตโนมัติ +- ใครก็จัดการชุดได้ (สร้าง/แก้ชื่อ/ลบ/ย้ายไฟล์) — ไม่มี roles (5.3.23 ยัง out of scope) + +## Docs/rules sync (plan-first — ทำเป็น task แรก ก่อนแตะโค้ด) + +| ไฟล์ | สิ่งที่แก้ | +|---|---| +| `docs/PLAN.md` §3 | เพิ่มตาราง `document_sets` + column `files.set_id` (FK SET NULL) — rule data-layer: ห้ามเพิ่มตารางโดยไม่แก้ PLAN.md ก่อน | +| `docs/PLAN-FEAT.md` §7 | หัวข้อใหม่ **N8 · ชุดเอกสาร + bookmark รวมทั้งชุด** (format ตาม N5–N7 + อ้างแผนฉบับเต็ม `docs/PLAN-DOCSET.md`) | +| `CLAUDE.md` | whitelist ฟีเจอร์เสริมหลังแผน เพิ่ม N8 + โครงสร้างไฟล์เพิ่ม `app/actions/sets.ts`, `app/sets/[id]/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` | +| `.claude/rules/data-layer.md` | เพิ่ม: `document_sets` + `files.set_id` (FK **SET NULL** ไม่ใช่ CASCADE), actions ของ sets **ไม่ publish SSE** (ไม่แตะข้อมูลรายไฟล์) แต่ `revalidatePath("/")` (+`/sets/[id]` ตอน rename) เสมอ | + +- `docs/requirement/gap-analysis.md` — ปรับ 5.3.10 ⚠️ → ✊ **หลัง implement เสร็จเท่านั้น** (task ปิดงาน) ไม่ใช่ตอนนี้ +- `.claude/rules/realtime.md`, `pdf-viewer.md`, `coordinates.md` **ไม่แตะ** (ไม่มี SSE event ใหม่ ไม่แตะ viewer/พิกัด) + +## ลำดับ task + verify (ทุก task: `npx tsc --noEmit` ผ่าน) + +| # | งาน | ไฟล์หลัก | Verify เพิ่มเติม | +|---|---|---|---| +| 1 | Docs sync N8 | ตารางด้านบน | สเปก §7 ครบตาม format N5–N7; PLAN.md §3 มีตาราง/column ใหม่ | +| 2 | Schema + migrate | `db/schema.ts`, `drizzle/` | `/sync-db` + `drizzle-kit generate` → psql `\d files` เห็น `set_id` + `\d document_sets` มีตาราง; SQL generated idempotent (`IF NOT EXISTS`) | +| 3 | Server Actions | `app/actions/sets.ts` | ผ่าน UI ใน task 4–5; title ว่าง/ยาว = error; setId/fileId ไม่มีจริง = error ไม่ throw ระเบิดหน้า | +| 4 | หน้ารายการจัดกลุ่มชุด + ฟอร์มสร้าง + ย้ายไฟล์ | `app/page.tsx`, `components/CreateSetForm.tsx`, `components/SetFileMenu.tsx` | Playwright: สร้างชุุด → การ์ดจัดกลุ่มถูก → ย้ายไฟล์เข้า/ออกชุด → แก้ชื่อ/ลบชุดแล้วไฟล์ไม่หาย (กลับ "ไม่อยู่ชุด"); **regression: อัปโหลด N4 + Annotate ยังทำงาน** | +| 5 | หน้า `/sets/[id]` | `app/sets/[id]/page.tsx` | Playwright: bookmark หลายไฟล์แสดงครบ เรียง ไฟล์ → N5 ต้นไม้ indent ถูก; คลิกกระโดด `/annotate/{fileId}?page=` แล้ว viewer ไปถูกหน้า; ชุดว่าง / ไฟล์ไม่มี bookmark / ชุดที่ถูกลบ = 404 | +| 6 | ปิดงาน | — | plan-reviewer + code-reviewer + vitest + `tsc` + gap-analysis 5.3.10 → ✅ | + +## Edge cases (ออกแบบไว้ล่วงหน้า) + +1. **ลบชุดที่มีไฟล์** → FK `ON DELETE SET NULL` — ไฟล์ตกกลับ "ไม่อยู่ชุด" ไม่ถูกลบ (มี confirm dialog กันมือเพื่อน) +2. **เข้า `/sets/[id]` ชุดที่ถูกลบ** → 404 +3. **ชื่อชุดซ้ำ** → อนุญาต (ไม่ unique — คดีต่างงวดชื่อคล้ายกันได้ ไม่มีเหตุผลบังคับ) +4. **ชื่อชุดว่าง/whitespace/ยาวเกิน 200** → action validate (trim → ว่าง = error, clamp 200) +5. **ชุดว่าง (ไม่มีไฟล์)** → หน้า set แสดง "ยังไม่มีเอกสารในชุดนี้" — ชุดว่างยังโชว์หน้า `/` ได้ (สร้างชุดรอไว้ก่อนแล้วค่อยย้ายไฟล์เข้า) +6. **ไฟล์ในชุดไม่มี bookmark** → หัวไฟล์ + "ไม่มีที่คั่นหน้าในไฟล์นี้" (เห็นว่าไฟล์อยู่ชุดแต่ว่าง — ไม่หายเงียบ) +7. **cycle ใน bookmark tree** (แก้ตรง DB หลุด guard ของ action) → เหมือน N7: DFS seen ชุุดเดียวร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1 +8. **`?page=` format ของ F12** → เช็คจากโค้ดจริง (1-based/0-based) ให้ตรงกับปุ่มกระโดดหน้าสรุป F15 — อย่าเดา +9. **Dev DB แชร์กับการทดสอบของผู้ใช้** → เทส Playwright สร้างชุด/ย้ายไฟล์ด้วย id ที่จับเป๊ะ ไม่ลบ/รีเซ็ตข้อมูลทั้งหมด +10. **ไฟล์ถูก "อยู่ชุด" แล้วเลือกย้ายเข้าชุดเดิม** → update ค่าเดิม idempotent ไม่ error + +## Out of scope (ไม่ทำในแผนนี้) + +- ไฟล์อยู่หลายชุด (many-to-many) — ตัดสินใจแล้ว: ชุุดเดียว +- export/JSON bookmark **ตามชุด** (ขยาย N7 รับ `setId`) — N7 ยังรับ `fileId` เดียว +- roles/สิทธิ์การจัดการชุด (gap 5.3.23) +- reorder ไฟล์ในชุด / reorder ชุด / ค้นหาข้ามชุด (ขยาย N6 เป็นหลายไฟล์) +- หน้ารวมทุกชุด `/sets` (ทางเข้าอยู่หน้ารายการแล้ว) +- seed ตัวอย่างชุดใน `npm run seed-samples` (สร้างผ่าน UI ได้ — ไม่บังคับ) +- annotation รวมชุด (bookmark พอตาม requirement 5.3.10) + +## Verification (ภาพรวม end-to-end) + +1. `npm run dev` → login → หน้า `/`: สร้างชุด "คดีตัวอย่าง" → ย้ายไฟล์ 2 ไฟล์เข้าชุุด → การ์ดจัดกลุ่มใต้หัวชุด +2. หัวชุด → "bookmark ของชุด" → `/sets/[id]`: เห็น bookmark ทั้งสองไฟล์ จัดกลุ่มตามไฟล์ เรียง N5 ต้นไม้ indent ถูก +3. คลิกรายการ → กระโดดเข้า viewer ไฟล์นั้น**ถูกหน้า** +4. ลบชุด → ไฟล์ทั้งสองกลับมาอยู่ "ไม่อยู่ชุด" ครบ ไม่มีอะไรหาย +5. Regression: annotate / ค้นหา N6 / export JSON N7 / อัปโหลด N4 ยังทำงานเหมือนเดิม