diff --git a/CLAUDE.md b/CLAUDE.md index 56919df..f2eeafd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ - pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/pdf.worker.min.mjs` (postinstall คัดลอกจาก node_modules) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) - Tailwind CSS -- mock login e-Court — หน้า `/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) +- ไม่มีระบบ auth — anonymous identity จาก localStorage (`utils/user.ts`) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) @@ -34,30 +34,26 @@ ``` db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) -middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login (ยกเว้น /login, /api/*, /uploads/*, static ใน public/) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) lib/server-pdf.ts ← path guard อ่าน PDF จาก public/ (export ใช้) -lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP (sidebar/สรุป/CSV ใช้ร่วม) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) -utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) +utils/user.ts ← anonymous identity (localStorage) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route) hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event -app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie) app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/uploads/ (N4) app/api/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้อม BOM (F16) -app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) +app/api/bookmarks/export/route.ts ← รายงานรายการ bookmark เป็น PDF (N7 — สร้าง PDF ใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ) app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/page.tsx ← รายการไฟล์ (server component) -app/login/page.tsx ← หน้า login (มี session แล้ว redirect /) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer app/annotate/[id]/summary/page.tsx ← หน้าสรุปหมายเหตุจัดกลุ่มตามหน้า (F15) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) @@ -66,15 +62,13 @@ components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) -components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/")) -components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม) ``` ## ขอบเขต — ห้ามทำเกิน (Out of Scope) -> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md` — **2026-09-17 เปลี่ยน deliverable เป็นส่งออก JSON** แทนรายงาน PDF ตามคำขอผู้ใช้: flat array `BookmarkDTO` เรียง N5 + envelope fileId/title/exportedAt ผ่าน route เดิม) +> ขอบเขตด้านล่างเป็นของ**เฟสแรก** — ฟีเจอร์ต่อยอด (edit/ย้าย, undo/redo, export, reply, resolve ฯลฯ) อนุญาต**เฉพาะตาม task F1–F19 ใน `docs/PLAN-FEAT.md`** และทำผ่าน `/implement-feat` เท่านั้น ห้ามทำเกินสเปกของแผนนั้น — **ปัจจุบันทำครบตามแผนแล้ว ยกเว้น F11 (export/import JSON) ที่ถูกถอดออกจากโค้ดแล้ว**: แก้ไข/ย้าย/ปรับขนาด (F1), undo/redo (F2), คีย์ลัด (F3), กรองรายคน (F4), รูปทรง (F5), eraser (F6), สไตลัส/pressure (F7), redaction + export PDF (F8), reply เธรด (F9), resolve (F10), deep link (F12), แก้ไข comment (F13), ค้นหา (F14), หน้าสรุป (F15), export CSV (F16), text box (F17), stamp (F18), version history + กู้คืน (F19) — ฟีเจอร์เสริมหลังแผน (อนุญาตตาม §7 ของ `docs/PLAN-FEAT.md`): โน้ตคำอธิบาย `note?` ฝังใน annotation ทุกชนิดยกเว้น COMMENT แก้ไขใน sidebar + hover tooltip (N1), bookmark โครงสร้าง 3 ระดับ `parent_id` + โน้ต + popup สร้าง/แก้ไข/ย้ายหัวข้อ + ปุ่ม bookmark ในแผง selection + ชื่อ default จากข้อความที่คลุม (N2), รองรับไฟล์รูปภาพ jpg/jpeg/png/tif เป็นเอกสาร annotate ได้ครบทุกชนิด + TIFF หลายเฟรม = หลายหน้า + export PDF ฝังรูป (N3 — แผน `docs/PLAN-IMAGE.md`), อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการแบบง่าย (N4), bookmark เรียงตามลำดับหน้า (N5), ค้นหา bookmark พร้อมเงื่อนไข คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ + `bookmarks.user_name` (N6 — แผน `docs/PLAN-BOOKMARK.md`), รายงานรายการ bookmark เป็น PDF ผ่าน route แยก (สร้างเอกสารใหม่ล้วน ไม่โหลดไฟล์ต้นฉบับ — N7 แผน `docs/PLAN-BOOKMARK-PDF.md`) -- ไม่มี auth จริง/roles (login เป็น mock หน้า `/login` ตั้ง cookie `oacCase` — ไม่ได้ตรวจกับระบบ e-Court จริง), ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` +- ไม่มี login/roles, ไม่มี presence — undo/redo เป็น history ฝั่ง client ต่อ session ต่อผู้ใช้ ไม่เก็บใน DB (D10) ส่วน version history/กู้คืน (F19) เก็บในตาราง `annotation_versions` - Annotation: สร้าง + ลบ + แก้ไข/ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา/แก้ไขข้อความ **ของตัวเอง ในหน้าเดียวกัน** — resolve เป็นสิ่งเดียวที่ใครก็ toggle ได้ (D6) - ปุ่มลบ/แก้ไข/ประวัติแสดงเฉพาะรายการของตัวเอง (`userId` ตรงกัน) เท่านั้น - หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียวแบบง่าย (N4 — PDF/jpg/jpeg/png/tif/tiff ≤ 100MB เขียนไฟล์ใหม่ที่ `public/uploads/` เท่านั้น; ไม่มีหน้าจัดการ/ลบไฟล์ใน UI; sample data sync ด้วย `npm run seed-samples`) diff --git a/README.md b/README.md index ae13d78..ba8a69b 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - **Bookmark** — จดตำแหน่ง (หน้า + % ความสูง) กดแล้ว smooth-scroll กลับตำแหน่งเดิม - **Realtime sync** — เปิด 2 แท็บ วาดในแท็บ A แท็บ B เห็นทันทีผ่าน SSE (`EventSource` ต่อใหม่อัตโนมัติเมื่อขาด) - **Page Virtualization** — render เฉพาะหน้าใน viewport ± 1 หน้า ไฟล์หลายสิบหน้าเลื่อนลื่น -- หน้า login (mock e-Court — dropdown `user1`–`user5` รหัสผ่านกลาง `Demo1234`) — identity จาก cookie `oacCase` บังคับทุกหน้า (ปุ่มลบ/แก้ไขแสดงเฉพาะรายการของตัวเอง) +- ไม่มีระบบ login — anonymous identity เก็บใน `localStorage` (ปุ่มลบแสดงเฉพาะรายการของตัวเอง) ## สิ่งที่ต้องมี (Prerequisites) @@ -85,7 +85,7 @@ npm run dev | bookmark+ | เพิ่ม bookmark ที่ตำแหน่งกึ่งกลางจอปัจจุบัน | | zoom − / + | สเกล 50%–300% (annotation ทั้งหมด scale ตามเพราะเก็บเป็น %) | -Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (identity จากหน้า login) +Sidebar ซ้าย: แท็บ Bookmarks / Annotations — คลิกรายการเพื่อเลื่อนกลับไปตำแหน่งนั้น และมีปุ่มลบเฉพาะรายการของตัวเอง (anonymous identity จาก `localStorage`) ทดสอบ realtime: เปิดหน้า annotate ไฟล์เดียวกันใน 2 แท็บ แล้ววาด/comment จากแท็บหนึ่ง — อีกแท็บเห็นทันทีโดยไม่ต้อง refresh @@ -112,7 +112,7 @@ db/schema.ts ตาราง + JSONB strict union + DTO + toDTO db/index.ts postgres.js + drizzle (prepare: false) lib/annotate-events.ts pub/sub in-process (actions → SSE route) utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine -utils/user.ts parse/build cookie oacCase (mock login e-Court) +utils/user.ts anonymous identity (localStorage) hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว) app/api/annotations/stream/route.ts SSE endpoint (GET ?fileId=) diff --git a/app/actions/auth.ts b/app/actions/auth.ts deleted file mode 100644 index 79499a9..0000000 --- a/app/actions/auth.ts +++ /dev/null @@ -1,41 +0,0 @@ -"use server"; - -// mock login e-Court: รับเฉพาะผู้ใช้ใน MOCK_USERS (user1–user5) + Demo1234 -// → ตั้ง cookie oacCase ตาม format จริง (identity ของแอปอ่านผ่าน lib/session.ts -// — บังคับด้วย middleware.ts) -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; - -import { buildOacCaseCookie, MOCK_USERS, OAC_CASE_COOKIE } from "@/utils/user"; - -const MOCK_PASSWORD = "Demo1234"; - -export async function login( - username: string, - password: string, -): Promise<{ ok: true } | { ok: false; error: string }> { - // mock ตอบเร็วกว่าตา — หน่วงเล็กน้อยให้เห็นสถานะ "กำลังตรวจสอบ…" - await new Promise((resolve) => setTimeout(resolve, 500)); - const name = username.trim(); - // username มาจาก dropdown เลือกผิดไม่ได้ — เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft - if (!MOCK_USERS.includes(name) || password !== MOCK_PASSWORD) { - return { ok: false, error: "รหัสผ่านไม่ถูกต้อง" }; - } - // u_userid ต่างกันต่อคน (= u_userlogin) — สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย - // userId) ต้องแยกกันจริงทั้ง 5 คน ห้ามแชร์ค่าเดียว - cookies().set(OAC_CASE_COOKIE, buildOacCaseCookie(name, name), { - httpOnly: true, - sameSite: "lax", - path: "/", - maxAge: 8 * 60 * 60, - }); - return { ok: true }; -} - -export async function logout(): Promise { - // ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้ - cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 }); - // redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว - // (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้า "/" ที่เพิ่งเสีย session จน navigation ไม่เกิด) - redirect("/login"); -} diff --git a/app/annotate/[id]/page.tsx b/app/annotate/[id]/page.tsx index 50c1b4c..6d1e4a3 100644 --- a/app/annotate/[id]/page.tsx +++ b/app/annotate/[id]/page.tsx @@ -1,5 +1,5 @@ import { asc, eq, inArray } from "drizzle-orm"; -import { notFound, redirect } from "next/navigation"; +import { notFound } from "next/navigation"; import { db } from "@/db"; import { @@ -13,7 +13,6 @@ import { } from "@/db/schema"; import PDFViewer from "@/components/pdf/PDFViewer"; import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์) -import { getSessionUser } from "@/lib/session"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -28,10 +27,6 @@ export default async function AnnotatePage({ const { id } = params; if (!UUID_RE.test(id)) notFound(); - // defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint - const user = getSessionUser(); - if (!user) redirect("/login"); - const [file] = await db.select().from(files).where(eq(files.id, id)); if (!file) notFound(); @@ -67,7 +62,6 @@ export default async function AnnotatePage({ initialAnnotations={annotationRows.map(toAnnotationDTO)} initialBookmarks={bookmarkRows.map(toBookmarkDTO)} initialReplies={replyRows.map(toReplyDTO)} - user={user} /> ); diff --git a/app/api/bookmarks/export/route.ts b/app/api/bookmarks/export/route.ts index 675d470..c3b6aab 100644 --- a/app/api/bookmarks/export/route.ts +++ b/app/api/bookmarks/export/route.ts @@ -1,19 +1,38 @@ +import fs from "node:fs"; +import path from "node:path"; + +import fontkit from "@pdf-lib/fontkit"; +import { PDFDocument, rgb, type PDFFont, type PDFPage } from "pdf-lib"; import { asc, eq } from "drizzle-orm"; import { db } from "@/db"; -import { bookmarks, files, toBookmarkDTO } from "@/db/schema"; -import { encodeRFC5987 } from "@/utils/pdf-export"; +import { bookmarks, files, type BookmarkRow } from "@/db/schema"; +import { encodeRFC5987, wrapTextByWidth } from "@/utils/pdf-export"; -// ส่งออกรายการ bookmark เป็น JSON (N7 — เดิมเป็นรายงาน PDF ตาม requirement 5.3.15 -// เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) -// - ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย (invariant #1) -// - array flat เก็บ parentId ครบ (lossless) เรียงตามลำดับ N5 เหมือน sidebar -// ต้นไม้ reconstruct จาก parentId ได้เอง จึงไม่ต้อง build tree/cycle guard +// รายงานรายการ bookmark เป็น PDF (N7 — PLAN-BOOKMARK-PDF.md / PLAN-FEAT.md §7, requirement 5.3.15) +// - สร้างเอกสาร PDF ใหม่ล้วน (PDFDocument.create) — ไม่โหลดไฟล์เอกสารต้นฉบับเลย +// (invariant #1 โดยการสร้าง ไม่ใช่การก๊อป จึงไม่เกี่ยว readPublicPdf) +// - ต้นไม้ indent 3 ระดับ เรียงตามลำดับหน้า (N5) เหมือน sidebar — แต่ละรายการ: +// ชื่อ + "หน้า N · ผู้บันทึก · วันที่บันทึก" + โน้ตใต้ชื่อ // - read-only: ไม่ publish event ไม่ revalidatePath (ไม่เขียน DB) const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const SARABUN_REL = path.join("fonts", "Sarabun-Regular.ttf"); + +// ─── layout คงที่ของรายงาน (A4 portrait) ─── +const PAGE_W = 595.28; +const PAGE_H = 841.89; +const MARGIN = 48; +const INDENT_STEP = 14; // indent ต่อระดับของต้นไม้ +const META_SIZE = 9; +const LINE_H = 1.35; // พื้นที่เผื่อสระบน–ล่างของไทย (ไ ใ / ุ ึ) +// slate-900 / slate-500 / slate-200 — โทนเดียวกับ sidebar +const INK = rgb(15 / 255, 23 / 255, 42 / 255); +const MUTED = rgb(100 / 255, 116 / 255, 139 / 255); +const RULE = rgb(226 / 255, 232 / 255, 240 / 255); + export async function GET(req: Request) { const fileId = new URL(req.url).searchParams.get("fileId") ?? ""; if (!UUID_RE.test(fileId)) { @@ -33,20 +52,173 @@ export async function GET(req: Request) { asc(bookmarks.createdAt), ); - // item เป็น BookmarkDTO ตรง ๆ (camelCase + ISO string — invariant #5) - const payload = { - fileId: file.id, - title: file.title, - exportedAt: new Date().toISOString(), - bookmarks: rows.map(toBookmarkDTO), + // ฟอนต์จำเป็น — รายงานเป็นข้อความล้วน ไม่มี fallback ที่มีความหมาย + // (ต่างจาก D5 ของ F8 ที่ annotation ยังวาดรูปได้จึง optional) + const fontPath = path.join(process.cwd(), "public", SARABUN_REL); + if (!fs.existsSync(fontPath)) { + console.error("ไม่พบ public/fonts/Sarabun-Regular.ttf"); + return new Response("ขาดไฟล์ฟอนต์ Sarabun-Regular.ttf", { status: 500 }); + } + const doc = await PDFDocument.create(); + doc.registerFontkit(fontkit); + let font: PDFFont; + try { + font = await doc.embedFont(fs.readFileSync(fontPath), { subset: true }); + } catch (err) { + console.error("embed Sarabun ไม่สำเร็จ (รายงาน bookmark)", err); + return new Response("ฝังฟอนต์ไม่สำเร็จ สร้างรายงานไม่ได้", { status: 500 }); + } + const widthOf = (s: string, size: number) => font.widthOfTextAtSize(s, size); + + // ต้นไม้ตาม parentId — เดินตามลำดับ array ที่ query เรียงตาม N5 มาแล้ว + // (ลูกในแต่ละแม่เรียงตาม หน้า → yOffset → เวลา อัตโนมัติ) + // orphan (parentId ไม่อยู่ในชุด) → เลื่อนขึ้น root เหมือน sidebar/buildOutline + const ids = new Set(rows.map((b) => b.id)); + const childrenOf = new Map(); + const roots: BookmarkRow[] = []; + for (const b of rows) { + if (b.parentId && ids.has(b.parentId)) { + const list = childrenOf.get(b.parentId) ?? []; + list.push(b); + childrenOf.set(b.parentId, list); + } else { + roots.push(b); + } + } + type Item = { row: BookmarkRow; level: number }; + const items: Item[] = []; + // visited กัน DFS วนไม่รู้จบเมื่อข้อมูลเป็นวงจร (defensive — action กันมาแล้ว) + // ใช้ seen ชุดเดียวร่วมทุก root: แชร์ subtree ข้าม root ไม่เกิด (ลูกมีแม่เดียว) + // เกิดจริงเฉพาะวงจร ซึ่งต้องกันไม่ให้เดินซ้ำ + const seen = new Set(); + const walk = (b: BookmarkRow, level: number): void => { + if (seen.has(b.id)) return; + seen.add(b.id); + items.push({ row: b, level }); + for (const k of childrenOf.get(b.id) ?? []) walk(k, level + 1); + }; + for (const r of roots) walk(r, 1); + // cycle (ข้อมูลถูกแก้ตรง DB หลุด guard ของ action) → โหนดในวงจรไม่มี root เข้าถึง + // เติมเป็นรายการระดับ 1 ให้ครบทุกแถว "ข้อมูลไม่หาย" (ต่างจาก buildOutline ที่ drop + // เงียบ ๆ — รายงานหายไปเฉย ๆ ยิ่งงงกว่า) — เคส roots ว่างทั้งไฟล์ (วงจรล้วน) ก็ + // ออกมาแบนทั้งหมดระดับ 1 จากกลไกนี้เอง + for (const row of rows) { + if (!seen.has(row.id)) items.push({ row, level: 1 }); + } + + // ─── วาด — y คือ "ขอบล่างของกล่องบรรทัดถัดไป" (baseline = y + ส่วนต่างของ line height) + let page: PDFPage = doc.addPage([PAGE_W, PAGE_H]); + let y = PAGE_H - MARGIN; + + // เช็คพื้นที่*ต่อบรรทัด* ไม่ใช่ต่อรายการ — บรรทัดเดียวพอกับหน้าเปล่าเสมอ จึงไม่มี + // เคสแถวสูงเกินหน้าที่ต้องจัดการพิเศษ (โน้ตยาว ๆ ก็ไหลข้ามหน้าเอง) + const advanceLine = (size: number): number => { + const lh = size * LINE_H; + if (y - lh < MARGIN) { + page = doc.addPage([PAGE_W, PAGE_H]); + y = PAGE_H - MARGIN; + } + y -= lh; + return y + (lh - size); // baseline ชิดล่างของกล่องบรรทัด (เผื่อ descender ไทย) + }; + const drawTextLine = ( + text: string, + x: number, + size: number, + color = INK, + ): void => { + // ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง — ถ้าฝัง advanceLine() ใน argument + // base ของ method call (`page`) ถูก resolve ก่อน arguments ตาม spec + // ทำให้บรรทัดแรกของหน้าใหม่ (ที่ advanceLine เพิ่ง reassign page) ถูกวาด + // กลับลงหน้าเก่าซ้อนบรรทัดบนสุด + const ty = advanceLine(size); + page.drawText(text, { x, y: ty, size, font, color }); }; - const filename = encodeRFC5987(`${file.title}-bookmarks.json`); - return new Response(JSON.stringify(payload, null, 2) + "\n", { + // หัวรายงาน (หน้าแรกหน้าเดียว) — ชื่อไฟล์ wrap เหมือนรายการ (title ใน DB ยาวได้ถึง 200) + drawTextLine("รายการที่คั่นหน้า", MARGIN, 16); + for (const line of wrapTextByWidth(file.title, PAGE_W - MARGIN * 2, (s) => + widthOf(s, 12), + )) { + drawTextLine(line, MARGIN, 12); + } + const created = new Date().toLocaleDateString("th-TH", { + timeZone: "Asia/Bangkok", + }); + drawTextLine( + `สร้างเมื่อ ${created} · ${rows.length} รายการ`, + MARGIN, + META_SIZE, + MUTED, + ); + y -= 8; + page.drawLine({ + start: { x: MARGIN, y }, + end: { x: PAGE_W - MARGIN, y }, + thickness: 1, + color: RULE, + }); + y -= 18; + + if (items.length === 0) { + drawTextLine("ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้", MARGIN, 11, MUTED); + } + + let first = true; + for (const { row, level } of items) { + // เว้นช่องว่างก่อนรายการ — หัวข้อระดับ 1 เว้นกว้างกว่าให้เห็นกลุ่ม + // (y ตกหลุดขอบล่างจากการเว้น = advanceLine บรรทัดแรกเปิดหน้าใหม่เอง) + y -= first ? 0 : level === 1 ? 12 : 8; + first = false; + + const indent = (level - 1) * INDENT_STEP; + const x = MARGIN + indent; + const maxW = PAGE_W - MARGIN * 2 - indent; + const titleSize = level === 1 ? 13 : 11; + + for (const line of wrapTextByWidth(row.title, maxW, (s) => + widthOf(s, titleSize), + )) { + drawTextLine(line, x, titleSize); + } + const date = row.createdAt.toLocaleDateString("th-TH", { + timeZone: "Asia/Bangkok", + }); + drawTextLine( + `หน้า ${row.pageIndex + 1} · ${row.userName || "ไม่ทราบชื่อ"} · ${date}`, + x, + META_SIZE, + MUTED, + ); + if (row.note) { + for (const line of wrapTextByWidth(`โน้ต: ${row.note}`, maxW, (s) => + widthOf(s, META_SIZE), + )) { + drawTextLine(line, x, META_SIZE, MUTED); + } + } + } + + // footer เลขหน้า — วาดท้ายเมื่อรู้จำนวนหน้าทั้งหมด (เนื้อหาหยุดที่ ≥ MARGIN ไม่ชน 24pt) + const pages = doc.getPages(); + pages.forEach((p, i) => { + const label = `หน้า ${i + 1} จาก ${pages.length}`; + p.drawText(label, { + x: (PAGE_W - widthOf(label, META_SIZE)) / 2, + y: 24, + size: META_SIZE, + font, + color: MUTED, + }); + }); + + const out = await doc.save(); + const filename = encodeRFC5987(`${file.title}-bookmarks.pdf`); + return new Response(new Uint8Array(out), { headers: { - "content-type": "application/json; charset=utf-8", + "content-type": "application/pdf", // filename เป็น ASCII fallback + filename* UTF-8 สำหรับชื่อไทย (pattern F8/F16) - "content-disposition": `attachment; filename="bookmarks.json"; filename*=UTF-8''${filename}`, + "content-disposition": `attachment; filename="bookmarks.pdf"; filename*=UTF-8''${filename}`, }, }); } diff --git a/app/login/page.tsx b/app/login/page.tsx deleted file mode 100644 index 51bf203..0000000 --- a/app/login/page.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import type { Metadata } from "next"; -import { redirect } from "next/navigation"; - -import LoginForm from "@/components/LoginForm"; -import { getSessionUser } from "@/lib/session"; - -export const dynamic = "force-dynamic"; - -export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" }; - -export default function LoginPage() { - // เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก - const user = getSessionUser(); - if (user) redirect("/"); - - return ( -
- -
- ); -} diff --git a/app/page.tsx b/app/page.tsx index 7ed5772..64f2e83 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,14 +1,12 @@ import Link from "next/link"; -import { redirect } from "next/navigation"; import { asc } from "drizzle-orm"; import { FileImage, FileText } from "lucide-react"; import { db } from "@/db"; import { files } from "@/db/schema"; -import SessionBar from "@/components/SessionBar"; +import UserPicker from "@/components/UserPicker"; import UploadFileForm from "@/components/UploadFileForm"; import { imageFormatFromUrl } from "@/utils/file-kind"; -import { getSessionUser } from "@/lib/session"; export const dynamic = "force-dynamic"; @@ -27,10 +25,6 @@ function kindBadge(pdfUrl: string): string { } export default async function HomePage() { - // defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session - const user = getSessionUser(); - if (!user) redirect("/login"); - const rows = await db.select().from(files).orderBy(asc(files.createdAt)); return ( @@ -49,7 +43,7 @@ export default async function HomePage() { - +

เพิ่มเอกสาร

diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx deleted file mode 100644 index 08e4365..0000000 --- a/components/LoginForm.tsx +++ /dev/null @@ -1,132 +0,0 @@ -"use client"; - -// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace("/") -// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail) -import { useRef, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; - -import { login } from "@/app/actions/auth"; -import Select from "@/components/Select"; -import { MOCK_USERS } from "@/utils/user"; - -// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad -const INPUT_CLASS = - "mt-1 block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 placeholder:text-slate-400 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50 aria-invalid:border-red-600 aria-invalid:focus:ring-red-600/30"; -// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน) -const SELECT_CLASS = - "block w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-base text-slate-900 transition focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:bg-slate-50"; - -export default function LoginForm() { - const router = useRouter(); - const passwordRef = useRef(null); - const [error, setError] = useState(null); - const [pending, startTransition] = useTransition(); - - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - setError(null); - const usernameInput = form.elements.namedItem("username") as HTMLSelectElement; - const passwordInput = form.elements.namedItem("password") as HTMLInputElement; - startTransition(async () => { - try { - const result = await login(usernameInput.value, passwordInput.value); - if (result.ok) { - router.replace("/"); - } else { - setError(result.error); - // คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที - passwordInput.value = ""; - passwordRef.current?.focus(); - passwordRef.current?.select(); - } - } catch { - setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง"); - } - }); - }; - - return ( -
- {/* brand lockup เดียวกับ header หน้ารายการ (ลดขนาดตัวอักษรให้พอการ์ดแคบ) */} -
-
- JN -
-
-

Judge's Note

-

ระบบ PDF Annotation สำหรับ e-Court

-
-
- -

เข้าสู่ระบบ

- -
-
- -
- -
-
- -
- - -
- - - - {/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) */} -

- รหัสผ่านทดสอบ:{" "} - Demo1234 -

- - {error && ( -

- {error} -

- )} -
-
- ); -} diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx deleted file mode 100644 index c95704d..0000000 --- a/components/LogoutButton.tsx +++ /dev/null @@ -1,29 +0,0 @@ -"use client"; - -// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง -import { useTransition } from "react"; -import { LogOut } from "lucide-react"; - -import { logout } from "@/app/actions/auth"; - -export default function LogoutButton() { - const [pending, startTransition] = useTransition(); - - const onLogout = () => { - startTransition(async () => { - await logout(); - }); - }; - - return ( - - ); -} diff --git a/components/SessionBar.tsx b/components/SessionBar.tsx deleted file mode 100644 index 360d950..0000000 --- a/components/SessionBar.tsx +++ /dev/null @@ -1,15 +0,0 @@ -// แถบแสดงผู้ใช้ที่เข้าสู่ระบบบนหน้ารายการ (แทน UserPicker เดิม) — server component -// รับค่าจาก cookie ผ่าน props จากหน้า (middleware การันตีว่ามี session ก่อนถึงหน้านี้) -import LogoutButton from "./LogoutButton"; - -export default function SessionBar({ userName }: { userName: string }) { - return ( -
-

- เข้าสู่ระบบเป็น{" "} - {userName} -

- -
- ); -} diff --git a/components/UserPicker.tsx b/components/UserPicker.tsx new file mode 100644 index 0000000..1717fa7 --- /dev/null +++ b/components/UserPicker.tsx @@ -0,0 +1,66 @@ +"use client"; + +// เลือก mock user ที่หน้าแรกก่อนเข้า annotate — เขียนลง localStorage key เดิม +// ที่ getLocalUser() อ่าน (PDFViewer ไม่ต้องแก้อะไร) +import { useEffect, useState } from "react"; + +import Select from "./Select"; +import { + MOCK_USERS, + getStoredLocalUser, + setLocalUser, + type LocalUser, +} from "@/utils/user"; + +export default function UserPicker() { + const [current, setCurrent] = useState(null); + + // ต้องรันใน effect เพราะ SSR ไม่มี localStorage — ก่อน mount แสดงคนแรกไว้ก่อน + useEffect(() => { + const stored = getStoredLocalUser(); + if (!stored) { + // ยังไม่เคยมี identity → เริ่มด้วย mock user คนแรก + setLocalUser(MOCK_USERS[0]); + setCurrent(MOCK_USERS[0]); + return; + } + setCurrent(stored); + }, []); + + // identity สุ่มเดิมไม่อยู่ในรายการ mock → แสดงเป็น option ชั่วคราวให้เห็นว่าปัจจุบันคือใคร + const isMock = current !== null && MOCK_USERS.some((u) => u.userId === current.userId); + + const select = (userId: string) => { + const user = MOCK_USERS.find((u) => u.userId === userId); + if (!user) return; + setLocalUser(user); + setCurrent(user); + }; + + return ( +
+

เลือกผู้ใช้ (mock)

+

+ annotation ทั้งหมดจะบันทึกในชื่อผู้ใช้ที่เลือก +

+ +
+ ); +} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index e154660..aacf4ae 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -113,7 +113,7 @@ import { type Rect, type Size, } from "@/utils/coordinates"; -import type { OacUser } from "@/utils/user"; +import { getLocalUser, type LocalUser } from "@/utils/user"; import { fileKindFromUrl, imageFormatFromUrl } from "@/utils/file-kind"; import { decodeTiffPages } from "@/utils/tiff-client"; import { @@ -1138,14 +1138,11 @@ export default function PDFViewer({ initialAnnotations, initialBookmarks, initialReplies, - user, }: { file: { id: string; title: string; pdfUrl: string }; initialAnnotations: AnnotationDTO[]; initialBookmarks: BookmarkDTO[]; initialReplies: ReplyDTO[]; - /** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */ - user: OacUser; }) { const [pdfjs, setPdfjs] = useState(null); const [doc, setDoc] = useState(null); @@ -1197,6 +1194,7 @@ export default function PDFViewer({ const [menuPos, setMenuPos] = useState<{ left: number; top: number } | null>(null); const [bookmarkPos, setBookmarkPos] = useState<{ left: number; top: number } | null>(null); const [scale, setScale] = useState(1); + const [user, setUser] = useState(null); const [currentPage, setCurrentPage] = useState(0); // tool highlight — live preview มาร์กตามรูปแบบ/สีที่ตั้งไว้ ระหว่างลากคลุมข้อความ const [liveMark, setLiveMark] = useState<{ pageIndex: number; rects: Rect[] } | null>(null); @@ -1325,10 +1323,11 @@ export default function PDFViewer({ [replies, visibleAnnotations], ); - // UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount - // ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม) - // (identity มาจาก prop user แล้ว — ไม่ต้องอ่านใน effect) + // anonymous identity จาก localStorage — ต้องรันใน effect (SSR ไม่มี localStorage) useEffect(() => { + setUser(getLocalUser()); + // UX review 2026-09-14 H1: จอ < lg sidebar เป็น overlay บังเอกสาร — ปิดตอน mount + // ให้เห็นเอกสารทันที (คง initial true ไว้กัน hydration mismatch เหมือนเดิม) if (!window.matchMedia("(min-width: 1024px)").matches) setSidebarOpen(false); }, []); diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index 6e9e791..d8067a2 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -5,13 +5,13 @@ import { createPortal } from "react-dom"; import { ArrowRight, Bookmark, - Braces, Check, ChevronDown, ChevronRight, Circle, Clock, Copy, + Download, EyeOff, Highlighter, MessageSquare, @@ -1114,15 +1114,15 @@ export default function AnnotationSidebar({ )} - {/* N7: ส่งออกรายการ bookmark เป็น JSON — ลิงก์ธรรมดา pattern Export CSV (F16) + {/* N7: ส่งออกรายการ bookmark เป็น PDF — ลิงก์ธรรมดา pattern Export CSV (F16) ส่งออกทุกรายการของไฟล์เสมอ ไม่ผูกกับเงื่อนไขค้นหาที่ตั้งไว้ */} - + {bmShowConditions && ( diff --git a/docs/PLAN-BOOKMARK-PDF.md b/docs/PLAN-BOOKMARK-PDF.md index d5ac927..66c7e26 100644 --- a/docs/PLAN-BOOKMARK-PDF.md +++ b/docs/PLAN-BOOKMARK-PDF.md @@ -1,7 +1,5 @@ # แผน 5.3.15 — รายงานรายการ bookmark เป็น PDF (N7) -> **อัปเดต 2026-09-17:** ผู้ใช้ขอเปลี่ยน deliverable ของ N7 เป็น**ส่งออก JSON** แทนรายงาน PDF — route เดิม (`app/api/bookmarks/export/route.ts`) ถูกเขียนใหม่เป็น JSON (flat array `BookmarkDTO` เรียง N5 + envelope `{ fileId, title, exportedAt, bookmarks }`) โค้ด PDF ในแผนนี้จึงเป็น **historical record** — สเปกปัจจุบันอยู่ที่ `docs/PLAN-FEAT.md` §7 N7 (ถอด: build tree/cycle guard/ฟอนต์/layout ทั้งหมด) - > สถานะ: **implement เสร็จสมบูรณ์** (2026-09-16) — ครบ 4 task, `tsc` ผ่าน, vitest 120/120, plan-reviewer + code-reviewer ผ่าน (findings ทั้งหมดแก้แล้ว: **บรรทัดแรกของหน้าใหม่ถูกวาดกลับลงหน้าเก่า** — `page.drawText({ y: advanceLine(...) })` ประเมิน base ของ method call ก่อน arguments ตาม spec ทำให้ซ้อนบรรทัดบนสุดของหน้าเก่า → ต้อง advance ก่อนแล้วอ่าน `page` ทีหลัง, **cycle guard ซ้ำ/หาย** — seen set ต่อ root ทำให้วงจรล้วนซ้ำ 2 เท่าและวงจรปน root หายเงียบ → seen ร่วมทุก root + เติมโหนดที่ไม่ถูกเดินเป็นระดับ 1, **ชื่อไฟล์ในหัวรายงานไม่ wrap** — title ยาวถึง 200 ตัวล้นขอบขวา → wrap ด้วย `wrapTextByWidth` เหมือนรายการ) — re-verify หลังแก้ด้วยไฟล์ทดสอบ (40 รายการ + วงจรปน root + title ยาว 200): 4 หน้า **ไม่มีข้อความซ้อนทับเลย** ทุกหน้ามีเนื้อหาจริง วงจรโผล่ครบระดับ 1 · ไฟล์วงจรล้วน 2 รายการ = แบนระดับ 1 ทั้งคู่ · ทดสอบเดิมผ่านครบ: ไฟล์จริง 7 bookmarks ต้นไม้ 2 ระดับ indent x48↔x62 ลำดับ N5 ถูก, 40 รายการ = 5 หน้า + footer ทุกหน้า, 0 bookmark = "ยังไม่มีรายการ…", invalid id = 400, ไม่มีไฟล์ = 404, ฟอนต์ไทย render ถูก (ยืนยันสระผสม "คำสั่ง" ผ่าน render PNG + vision OCR — "คำา" ใน text extraction เป็น artifact ของ ToUnicode ตอน subset ไม่กระทบการแสดงผล), ปุ่มโผล่ใน SSR ของหน้า annotate พร้อม href/aria-label ถูก > อ้างอิง: requirement 5.3.15 (gap analysis `docs/requirement/gap-analysis.md` **กลุ่ม B** — ข้อสุดท้ายที่เหลือ: 5.3.11 = N5 ✅, 5.3.12 = N6 ✅) > ตาม governance ของโปรเจกต์: implement ต้องเขียนสเปกลง `docs/PLAN-FEAT.md` §7 (**N7** รายงานรายการ bookmark เป็น PDF) + ขยาย whitelist ใน `CLAUDE.md` ก่อน — เป็น task ที่ 1 ของแผนนี้ แล้วรันผ่าน `/implement-feat N7` diff --git a/docs/PLAN-DOCSET.md b/docs/PLAN-DOCSET.md deleted file mode 100644 index 63ece3c..0000000 --- a/docs/PLAN-DOCSET.md +++ /dev/null @@ -1,130 +0,0 @@ -# แผน 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 ยังทำงานเหมือนเดิม diff --git a/docs/PLAN-FEAT.md b/docs/PLAN-FEAT.md index 0877b8a..dbf1461 100644 --- a/docs/PLAN-FEAT.md +++ b/docs/PLAN-FEAT.md @@ -326,13 +326,15 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15 - **Server Action `searchBookmarks`** (read-only — **ไม่ publish event ไม่ `revalidatePath`** เพราะไม่เขียน DB): คำค้น ILIKE บน `title`+`note` (escape `%`/`_`/`\` เหมือน F14) AND ช่วงหน้า (input **1-based** ตรงที่ผู้ใช้เห็น) AND ผู้บันทึก (`user_id`) AND ช่วงวันที่บันทึก (`YYYY-MM-DD` ตีความเป็นเขตเวลา **+07:00 คงที่** — ไทยไม่มี DST) — ทุกเงื่อนไข optional แต่ต้องมีอย่างน้อยหนึ่งอย่าง; คู่ช่วงกลับด้าน (from > to) = swap เงียบ ๆ; `orderBy pageIndex, yOffset, createdAt` ตรงลำดับการแสดงผลของ N5 - **UI:** block ค้นหา**เฉพาะแท็บ "ที่คั่นหน้า"** ใน sidebar (แยกจากช่อง F14 ที่อยู่เหนือแท็บ — ช่องเดียวค้นคนละอย่างตามแท็บ = สับสน) — ช่องคำค้น + ปุ่มกางแถบเงื่อนไข (ช่วงหน้า from–to / ผู้บันทึก Select จาก unique `userId` ใน `bookmarks` prop — **ห้ามปนกับ dropdown กรอง F4** ที่ derive จาก annotations / วันที่ from–to แบบ `type="date"` + ปุ่มล้างเงื่อนไข) — q ว่างแต่มีเงื่อนไขอื่น**ค้นได้** (ต่างจาก F14); ผลลัพธ์เป็น list แบนแสดง**แทนต้นไม้ชั่วคราว** (แถว: title + "หน้า N · ผู้บันทึก" + "ใน: `<ชื่อแม่>`" ถ้าเป็น sub + โน้ต clamp, คลิก = `onGoToBookmark`) จนกว่าจะกดปิด — input ทุกตัว `onKeyDown` `stopPropagation` กันชนคีย์ลัด H/D/C/B ของ viewer - **Realtime:** ไม่มี event ใหม่ — `user_name` ไหลไปกับ DTO ของ create/update event เดิมอัตโนมัติ -- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนส่งออก bookmark ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search +- **Out of scope ของ N6:** bookmark ในหน้าสรุป F15/CSV (ส่วนรายงาน PDF ทำแล้วใน N7), ค้นข้ามหลายไฟล์ (gap 5.3.10), full-text search -### N7 · ส่งออกรายการ bookmark เป็น JSON (2026-09-16 · เปลี่ยนจากรายงาน PDF 2026-09-17) +### N7 · รายงานรายการ bookmark เป็น PDF (2026-09-16) -> แผนงานฉบับเต็ม (ตอนเป็น PDF): `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) · 2026-09-17 ผู้ใช้ขอเปลี่ยน deliverable เป็น JSON แทนรายงาน PDF +> แผนงานฉบับเต็ม: `docs/PLAN-BOOKMARK-PDF.md` — requirement 5.3.15 (gap analysis กลุ่ม B ข้อสุดท้าย) -- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — **ข้อมูลล้วนจาก DB ไม่โหลดไฟล์เอกสารต้นฉบับเลย** (invariant #1): validate `fileId` (UUID → 400) + หาไฟล์ไม่เจอ = 404 แล้ว query `bookmarks` ทั้งไฟล์ `orderBy asc(pageIndex), asc(yOffset), asc(createdAt)` (ลำดับเดียวกับ sidebar/N5) → response JSON `{ fileId, title, exportedAt (ISO), bookmarks: BookmarkDTO[] }` — item เป็น `toBookmarkDTO` ตรง ๆ (camelCase + ISO string, invariant #5) เก็บ `parentId` ครบ **lossless** reconstruct ต้นไม้ได้จากข้อมูลเอง จึงไม่ต้อง build tree/cycle guard/ฟอนต์/layout แบบตอนเป็น PDF; pretty-print 2 space; 0 bookmark = `"bookmarks": []` (ไม่ 404 — ปุ่มทำงานเสมอ); `content-type: application/json; charset=utf-8` + `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.json")` (pattern F8/F16) -- **UI:** ปุ่มไอคอน `Braces` ({ }) ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้) +- **Route:** `app/api/bookmarks/export/route.ts` (GET `?fileId=`) — สร้างเอกสาร PDF **ใหม่ล้วน** (`PDFDocument.create()` — ไม่โหลดไฟล์เอกสารต้นฉบับเลย: invariant #1 โดยการสร้าง ไม่ใช่การก๊อป ไม่เกี่ยว `readPublicPdf`): หัวรายงาน (หน้าแรกหน้าเดียว: "รายการที่คั่นหน้า" + ชื่อไฟล์ + "สร้างเมื่อ {วันที่} · N รายการ" + เส้นคั่น) + **ต้นไม้ indent 3 ระดับ** (indent 14pt/ระดับ; ระดับ 1 = 13pt, 2–3 = 11pt — Regular ตัวเดียว ลำดับชั้นด้วย indent+ขนาด ไม่ใช้ตัวหนา) เรียงตาม N5 (`pageIndex → yOffset → createdAt` — query เรียงมาเลย ลูกเรียงตามเกณฑ์เดียวกันอัตโนมัติ) แต่ละรายการ: ชื่อ (wrap `wrapTextByWidth`) + metadata 9pt เทา "หน้า N · {userName || "ไม่ทราบชื่อ"} · {วันที่บันทึก `toLocaleDateString("th-TH")`}" + โน้ต "โน้ต: …" ใต้ metadata; A4 + แบ่งหน้าต่อบรรทัด (guard แถวสูงเกินหน้า) + footer "หน้า n จาก N" ทุกหน้า; embed `Sarabun-Regular.ttf` **จำเป็น** (รายงานเป็นข้อความล้วน — ไม่มีไฟล์ = 500 ข้อความชัด ต่างจาก D5 ของ F8 ที่ optional); 0 bookmark = PDF หน้าเดียวข้อความ "ยังไม่มีรายการที่คั่นหน้าในไฟล์นี้" (ไม่ 404 — ปุ่มทำงานเสมอ); response `attachment` + `filename*` จาก `encodeRFC5987("{title}-bookmarks.pdf")` (pattern F8/F16) +- **ต้นไม้ defensive:** orphan `parentId` → เลื่อนขึ้น root เหมือน sidebar/buildOutline; cycle (ข้อมูลแก้ตรง DB หลุด guard ของ action) → DFS ด้วย **seen ชุดเดียวร่วมทุก root** กันเดินซ้ำ/วนไม่รู้จบ แล้ว**เติมโหนดที่ไม่มี root เข้าถึงเป็นรายการระดับ 1 ท้ายรายการ** (ข้อมูลไม่หาย — รวมเคสวงจรปน root ปกติ และเคสวงจรล้วนทั้งไฟล์ = ออกมาแบนทั้งหมดระดับ 1 จากกลไกเดียวกัน) +- **การวาด (ระวัง 2 จุด):** วาดบรรทัดต้อง **advanceLine ก่อนแล้วอ่าน `page` ทีหลัง** — ฝัง `advanceLine()` ใน argument ของ `page.drawText(...)` แล้ว base ของ method call ถูก resolve ก่อน arguments ตาม spec ทำให้บรรทัดแรกของหน้าใหม่ (ที่เพิ่ง reassign `page`) ถูกวาดกลับลง**หน้าเก่า**ซ้อนบรรทัดบนสุด; **ชื่อไฟล์ในหัวรายงาน wrap ด้วย `wrapTextByWidth`** เหมือนรายการ (title ใน DB ยาวได้ถึง 200 ตัวอักษร) +- **UI:** ปุ่มไอคอน `Download` ท้ายแถว toggle เงื่อนไขค้นหา N6 ในแท็บ "ที่คั่นหน้า" — `` ลิงก์ธรรมดา (pattern Export CSV F16 — ไม่มี JS/state) + `coarse-36` — ส่งออก**ทุก bookmark ของไฟล์เสมอ** (ไม่ผูกเงื่อนไขค้นหา N6 ที่ตั้งไว้) - **Read-only:** ไม่ publish event ไม่ `revalidatePath` (ไม่เขียน DB — เหมือน `searchBookmarks`/route export เดิม) -- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, กลับไปเป็นรายงาน PDF (ถอดแล้ว — จะเอาคืนดูแผนเดิม `docs/PLAN-BOOKMARK-PDF.md`), export ข้ามหลายไฟล์ (gap 5.3.10) +- **Out of scope ของ N7:** export ตามเงื่อนไขค้นหา N6, รวม annotation/เพิ่ม bookmark ลงหน้าสรุป F15 หรือ CSV, ตัวหนา (`Sarabun-Bold.ttf`), export ข้ามหลายไฟล์ (gap 5.3.10), ตัวเลือกหน้ากระดาษ/แนวนอน/config diff --git a/docs/PLAN.md b/docs/PLAN.md index fd65c3f..f2f6c2e 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -11,7 +11,7 @@ | A1 | **ตำแหน่งโปรเจกต์** | `D:\dms\judes-note\` — directory แยกต่างหาก (ไม่ใช่ subdirectory ของ dms-main) เพื่อไม่ชนกับโปรเจกต์ Nuxt ที่มีโฟลเดอร์ `app/` อยู่แล้ว | | A2 | **Framework** | Next.js 14 (App Router) + TypeScript strict | | A3 | **PDF Renderer** | `pdfjs-dist` เวอร์ชัน **v4** (`^4.10.38`) — ใช้ built-in `TextLayer` class; worker ตั้ง path ตรง ๆ: คัดลอก `pdf.worker.min.mjs` (จาก `node_modules/pdfjs-dist/build/`) มาไว้ที่ `public/` ด้วย **postinstall script** แล้วตั้ง `pdfjs.GlobalWorkerOptions.workerSrc = "/pdf.worker.min.mjs"` — **ไม่ใช้** `new URL(..., import.meta.url)` เพราะ v4 เป็นไฟล์ `.mjs` และมักชนกับ Turbopack/webpack ใน Next 14 | -| A4 | **Identity (ปรับปรุง 2026-09-17 — mock login)** | หน้า `/login` mock e-Court (dropdown ผู้ใช้ `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` เป็น query string ตาม format จริงของระบบศาล → identity อ่านจาก cookie: `userId` = `u_userid`, `userName` = `u_userlogin` (**ต่างกันต่อคน** = ชื่อผู้ใช้ เพื่อให้สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" แยกกันจริง) ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope; เดิมเป็น anonymous user ใน `localStorage` — annotation ที่สร้างสมัย `mock-user-01`/UUID จึงไม่ใช่ของตัวเองอีก) | +| A4 | **Identity** | สเปกไม่ระบุระบบ auth → ใช้ **anonymous user** สร้างครั้งเดียวเก็บใน `localStorage` (`userId` = UUID, `userName` = "ผู้ใช้ XXXX") เพื่อเติม field `userId`/`userName` ในตาราง | | A5 | **Upload แบบง่าย (ปรับปรุง 2026-09-15 — N4)** | หน้า `/` อ่านรายการจาก DB + ฟอร์มอัปโหลดไฟล์เดียว (PDF/jpg/jpeg/png/tif/tiff, ≤ 100MB) ผ่าน Server Action เขียนไฟล์**ใหม่**ที่ `public/uploads/.` เท่านั้น (ไม่แตะไฟล์เดิม) — sample เก็บใน `public/samples/` แล้ว sync ด้วย `npm run seed-samples` (รายละเอียดด้านล่าง) | | A6 | **ทุก annotation ลบได้** | แสดงปุ่มลบเฉพาะรายการของตัวเอง (`userId` ตรงกัน) — ไม่มี role/permission ในเฟสนี้ | | A7 | **Database & Realtime (ปรับปรุง 2026-09-08)** | PostgreSQL แบบ **plain container เดียว** (`judes-note-db`, Docker, image `postgres:17-alpine`, พอร์ต 54322) — **เลิกใช้ Supabase stack ทั้งหมด** (ไม่มี CLI/Kong/realtime container) เพราะโปรเจกต์ใช้จริงแค่ DB + sync; realtime sync ใช้ **SSE (Server-Sent Events) + pub/sub in-process** แทน Supabase Realtime | @@ -102,7 +102,7 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div | id | uuid PK | | | file_id | uuid FK → files ON DELETE CASCADE | | | page_index | integer NOT NULL | 0-based | -| user_id | text NOT NULL | จาก cookie oacCase (`u_userid`) | +| user_id | text NOT NULL | anonymous id จาก localStorage | | user_name | text NOT NULL | | | type | annotation_type NOT NULL | | | data | jsonb NOT NULL `$type()` | **strict typed union** (ด้านล่าง) | @@ -171,7 +171,7 @@ judes-note/ │ └── annotate-events.ts ← pub/sub in-process (Server Actions publish → SSE route subscribe) ├── utils/ │ ├── coordinates.ts ← pixel⇄% (point/rect/path) + getPointerPercent + clamp + mergeRectsByLine (รวบ rects บรรทัดเดียวกันเหลือ 1 rect/บรรทัดก่อนบันทึก) -│ └── user.ts ← parse/build ค่า cookie oacCase → identity (mock login e-Court) +│ └── user.ts ← anonymous user identity (localStorage) ├── hooks/ │ └── useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe + expose setters ├── app/ @@ -248,7 +248,7 @@ judes-note/ ## 9. ขอบเขตที่ **ไม่** ทำในเฟสนี้ (Out of Scope) -- auth จริงเทียบระบบ e-Court + roles/สิทธิ์ — login ปัจจุบันเป็น mock หน้า `/login` (ตั้ง cookie `oacCase` ตาม format จริง แต่ไม่ได้ตรวจกับระบบศาลจริง) +- ระบบ login/roles (ใช้ anonymous identity) - แก้ไข/ย้าย annotation ที่วาดแล้ว (มีเฉพาะสร้าง + ลบ) — **ข้อนี้ถูกขยายโดย `docs/PLAN-FEAT.md` F1**: อนุญาตแก้ไข `data` (ย้าย/ปรับขนาด/เปลี่ยนสี–ความหนา) ของตัวเอง ในหน้าเดียวกัน ผ่าน `updateAnnotation` - Undo/redo, export PDF ที่ฝัง annotation - แสดงรายชื่อ "ใครออนไลน์อยู่" (presence) diff --git a/docs/requirement/gap-analysis.md b/docs/requirement/gap-analysis.md index 716f74c..7b2b8b7 100644 --- a/docs/requirement/gap-analysis.md +++ b/docs/requirement/gap-analysis.md @@ -1,9 +1,9 @@ # รายงานเทียบ Requirement กับโปรเจกต์ (Gap Analysis) - **วันที่ตรวจสอบ:** 2026-09-14 -- **อัปเดตล่าสุด:** 2026-09-17 — ปิด gap 4 ข้อ: 5.3.11 ✅ (N5), 5.3.12 ✅ (N6), 5.3.14 ✅ (API ส่งออก bookmark เป็น JSON), 5.3.15 ✅ (export PDF ของ F8 มี bookmark ติดไป) · 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่ +- **อัปเดตล่าสุด:** 2026-09-15 — แก้ไขข้อความข้อ 5.3.18–5.3.25 ตามที่เจ้าของเอกสารยืนยันใหม่ - **เอกสาร requirement ต้นทาง:** `docs/requirement/1.png`, `docs/requirement/2.png` (ข้อกำหนดระบบบันทึกความเห็นของกรรมการ — Judges' Notes System) -- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` — ข้อ 5.3.11–5.3.15 อัปเดตตามโค้ดล่าสุด 2026-09-17 (รวม N5–N6 commit `a020e96` และ N7 → JSON export) · ตรวจครั้งแรก ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก) +- **โค้ดที่ใช้เทียบ:** branch `forgejo-dev` ณ commit `b3b884c` (implement ครบตาม PLAN.md + PLAN-FEAT.md แล้ว ยกเว้น F11 ที่ถูกถอดออก) - **ช่วงเลขข้อ (ยืนยันโดยเจ้าของเอกสาร):** ภาพที่ 1 = ข้อ **5.3.1–5.3.6** / ภาพที่ 2 = ข้อ **5.3.7–5.3.25** > ⚠️ **หมายเหตุการอ่าน requirement:** สำนวนภาษาไทยบางคำอาจคลาดเคลื่อนเล็กน้อยจากต้นฉบับ แต่สาระของทุกข้อชัดเจน — การอ่านครั้งแรกสับสนเพราะเลขข้อเป็นเลขอารบิก (5.3.X) ที่ OCR อ่านเป็นอักษรไทย ช่วงเลขข้อได้รับยืนยันจากเจ้าของเอกสารแล้ว และการตรวจซ้ำด้วยการสแกนตำแหน่งบรรทัด + อ่านเลขข้อรายบรรทัดยืนยันว่า **จำนวนข้อ map ตรง 1:1 ครบทั้ง 19 ข้อ** (10 ข้อครึ่งบน + 9 ข้อครึ่งล่าง รวม 5.3.7–5.3.25) — การจับคู่เลข ↔ เนื้อหาในตารางจึงเป็นที่ยืนยันแล้วทั้งหมด จนถึง**ฉบับแก้ไข 2026-09-15** ที่เจ้าของเอกสารให้ข้อความข้อ 5.3.18–5.3.25 ฉบับถูกต้อง: HTML5 แยกจากข้อ Tablet/iPad ออกเป็นข้อ 5.3.19 ของตัวเอง (ข้อถัดมาเลื่อนลง 1 ลำดับ), เพิ่มข้อสิทธิการใช้งานตามระบบปกครอง (5.3.23) และเชื่อมโยงกับระบบปกครอง (5.3.25) — ข้อเดิมเรื่อง API ระบบอื่น, มาตรฐานความมั่นคงปลอดภัย และ Availability 99.5% ไม่ปรากฏในฉบับแก้ไข @@ -12,7 +12,7 @@ **โปรเจกต์ judes-note ตอบแกน functional ของ requirement ได้ค่อนข้างเต็ม** — การ annotate บนเอกสาร PDF, bookmark ต่อหน้าเอกสาร, Text Box + Freehand Drawing, ลบ/แก้ไขความเห็น, แสดงผลรายการ, ใช้งานหลายคนพร้อมกันแบบ realtime, responsive และรองรับ tablet/iPad -**แต่ requirement ฉบับนี้เขียนสำหรับระบบ production เต็มรูปแบบ** ขณะที่ judes-note เป็น MVP เฟสแรกตามขอบเขตใน `CLAUDE.md` / `docs/PLAN.md` — ข้อที่ขาดส่วนใหญ่จึงแบ่งได้เป็น 2 กลุ่ม: (1) ของที่**ตั้งใจไม่ทำ**ในเฟสแรก (auth, integration) และ (2) ของที่**เกือบมีแต่ต่างรายละเอียด** (ลบ bookmark ทีละรายการ) รวมถึง (3) ของใหม่ที่ไม่เคยอยู่ในแผน (รองรับไฟล์รูปภาพ) +**แต่ requirement ฉบับนี้เขียนสำหรับระบบ production เต็มรูปแบบ** ขณะที่ judes-note เป็น MVP เฟสแรกตามขอบเขตใน `CLAUDE.md` / `docs/PLAN.md` — ข้อที่ขาดส่วนใหญ่จึงแบ่งได้เป็น 2 กลุ่ม: (1) ของที่**ตั้งใจไม่ทำ**ในเฟสแรก (auth, API, integration) และ (2) ของที่**เกือบมีแต่ต่างรายละเอียด** (ค้นหาไม่ครอบ bookmark, ลบ bookmark ทีละรายการ) รวมถึง (3) ของใหม่ที่ไม่เคยอยู่ในแผน (รองรับไฟล์รูปภาพ) | สถานะ | ความหมาย | |---|---| @@ -28,7 +28,7 @@ | Requirement | สถานะ | รายละเอียดในโปรเจกต์ | |---|---|---| -| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) — อัปโหลดไฟล์เองได้แล้ว (N4) แต่ไม่ใช่การดึงจากระบบคดีปกครอง — ผูกกับ gap 5.3.16 (integrate) และ 5.3.25 (เชื่อมโยงระบบปกครอง) | +| **5.3.1** เรียกดูเอกสารที่เกี่ยวข้องและตรวจสอบจากระบบงานคดีปกครองได้โดยตรง | ⚠️ | ตัว viewer แสดงไฟล์ PDF ที่ระบุใน DB ได้ แต่**ไม่มีการเชื่อมต่อระบบงานคดีปกครอง** (ไม่มี integration/API ดึงเอกสารโดยตรง) และไม่มีหน้า upload — หน้า `/` อ่านรายการจาก DB เท่านั้น (sample data seed ด้วย SQL) — ผูกกับ gap 5.3.14 (API) และ 5.3.16 (integrate) | | **5.3.2** ระบบต้องไม่สามารถแก้ไขไฟล์ต้นฉบับได้ (Non-Destructive) | ✅ | เป็น **invariant หลักของโปรเจกต์** (CLAUDE.md ข้อ 1): annotation เก็บใน DB เป็นพิกัด % เท่านั้น, ไฟล์ PDF ใน `public/` ถูกอ่านอย่างเดียวผ่าน path guard (`lib/server-pdf.ts`), export PDF (F8) สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ (pdf-lib load แล้ว save คืน bytes ใหม่) — ไม่เขียนทับต้นฉบับเด็ดขาด | | **5.3.3** จัดเก็บข้อมูล Bookmark แยกจากเอกสารโดยเก็บไว้ในฐานข้อมูล และสามารถนำข้อมูลดังกล่าวมาแสดงได้ | ✅ | เป็น**สถาปัตยกรรมแกนกลางของโปรเจกต์** (invariant #1–2): bookmark เก็บใน PostgreSQL ตาราง `bookmarks` เป็นพิกัด/ข้อมูลล้วน (pageIndex, ชื่อ, โครงสร้าง parent_id) แยกจากไฟล์ PDF ใน `public/` — นำกลับมาแสดงได้ทั้งแท็บ Bookmarks ใน sidebar (scroll ไปหน้านั้น), หน้าสรุป F15 และ export | | **5.3.4** รูปแบบเอกสารที่รองรับได้ — ไฟล์เอกสาร PDF และไฟล์รูปภาพ (jpg, jpeg, png, tif) เป็นอย่างน้อย | ✅ | รองรับครบทุกนามสกุลแล้ว (N3 — แผน `docs/PLAN-IMAGE.md`, 2026-09-15): รูปคือ "หน้าเอกสาร" แสดง `` แทน canvas + annotate ครบทุกชนิด + bookmark + realtime + export PDF ฝังรูป/CSV; TIFF รองรับทั้งหลายเฟรม (decode ด้วย utif2) และ EXIF orientation ตอน export; อัปโหลดไฟล์ PDF/รูปผ่านหน้ารายการได้ (N4) | @@ -45,21 +45,21 @@ | **5.3.8** ข้อมูลบันทึก bookmark ต้องอ้างถึงไฟล์เอกสาร หน้าเอกสาร วันที่บันทึก และผู้บันทึก | ✅ | ตาราง `bookmarks` มีครบทั้ง 4: `file_id` (FK → ไฟล์เอกสาร), `page_index` (หน้าเอกสาร), `created_at` (วันที่บันทึก), `user_id` (ผู้บันทึก) — นำมาแสดงในแท็บ Bookmarks, หน้าสรุป F15 และ export | | **5.3.9** กำหนดความเป็นส่วนตัวของ bookmark: ส่วนบุคคล (Private) / กลุ่ม (Group) | ❌ | ไม่มี Private/Group visibility — ทุกคนเห็นทุกรายการ (ไม่มี auth/roles โดยเจตนา) | | **5.3.10** แสดง bookmark ของเอกสารแต่ละไฟล์ หรือชุดของเอกสารได้ | ⚠️ | รายไฟล์ ✅ — แท็บ Bookmarks ใน sidebar แสดง bookmark ทั้งหมดของไฟล์ที่เปิด (ผูก `file_id`) + หน้าสรุป F15 / แต่**ชุดของเอกสาร** ❌ — ไม่มี concept การจัดกลุ่มไฟล์ (document set) และไม่มีหน้าแสดง bookmark รวมข้ามหลายไฟล์ | -| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ✅ | ทำแล้ว (2026-09-16, N5): แท็บ "ที่คั่นหน้า" เรียงตาม **หน้า → ตำแหน่งบนหน้า (yOffset) → เวลาสร้าง** ทั้ง root ของต้นไม้และลูกในแม่เดียวกัน — ลำดับเดียวกันใช้ทุกที่: PDF outline ตอน export (F8), ผลค้นหา (N6), ส่งออก JSON (N7) | -| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ✅ | ทำแล้ว (2026-09-16, N6): block ค้นหาเฉพาะแท็บ "ที่คั่นหน้า" — คำค้น (ชื่อ+โน้ต) + เงื่อนไข **ช่วงหน้า / ผู้บันทึก / ช่วงวันที่บันทึก** ผสมกันได้ + ปุ่มล้างเงื่อนไข (Server Action `searchBookmarks` — แผน `docs/PLAN-BOOKMARK.md`) | +| **5.3.11** แสดง bookmark ตามลำดับหน้าได้ | ⚠️ | แท็บ Bookmarks แสดงเลขหน้าต่อรายการ ("หน้า N") แต่เรียงตาม**เวลาสร้าง** ไม่ใช่ลำดับหน้า (`AnnotationSidebar.tsx:753` — root เรียงตามเวลาสร้าง) และหน้าสรุป F15 ไม่มี bookmark — แก้ง่าย: sort roots ของต้นไม้ตาม `pageIndex` | +| **5.3.12** สามารถกำหนดเงื่อนไขการค้นหา bookmarks ได้ | ❌ | ช่องค้นหา (F14) ครอบเฉพาะ annotations เท่านั้น (`searchAnnotations` query ตาราง `annotations`) — **ค้นหา bookmark ยังไม่ได้เลย** ยิ่งไม่มีการกำหนดเงื่อนไข (เช่น ตามหน้า/ผู้บันทึก/ช่วงวันที่) | | **5.3.13** แสดงผลรายการหน้าของเอกสารที่ bookmark ไว้ | ✅ | แท็บ Bookmarks ใน sidebar + หน้าสรุป (F15) | -| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ✅ | ทำแล้ว (2026-09-17): GET `/api/bookmarks/export?fileId=` คืน **JSON** ข้อมูล bookmark ทั้งไฟล์ (envelope `fileId`/`title`/`exportedAt` + `BookmarkDTO[]` ครบทุก field รวม `parentId` โครงสร้างต้นไม้ เรียงตามลำดับหน้า) — ระบบอื่นดึงข้อมูลไปใช้ต่อได้ทันที (read-only, ยังไม่มี auth ตามขอบเขตเฟสแรก) | -| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ✅ | export PDF ของ F8 (`app/api/annotations/export/route.ts`) ฝัง bookmark เป็น **PDF outline** ติดไปกับไฟล์ (ต้นไม้ 3 ระดับ เรียงตามหน้า) — เจ้าของเอกสารยืนยัน (2026-09-17) ถือว่าตอบข้อนี้แล้ว · หมายเหตุ: รายงาน PDF แยกต่างหากของ N7 เคยทำแล้วถูกเปลี่ยนเป็น**ส่งออก JSON** ตามคำขอผู้ใช้ (2026-09-17) | +| **5.3.14** จัดทำชุด API ดึงข้อมูล bookmark สำหรับเชื่อมต่อระบบอื่น | ❌ | ไม่มี REST API ภายนอก — มีเฉพาะ SSE stream ภายใน + export routes สำหรับผู้ใช้ (F11 export/import JSON ถูกถอดออกจากโค้ดแล้ว) | +| **5.3.15** ส่งออก (Export) รายการ bookmark เป็น PDF | ⚠️ | ใกล้เคียง: export PDF ฝัง annotation + bookmark เป็น **PDF outline** (F8 — `app/api/annotations/export/route.ts`) + export CSV สรุปรายการ (F16) — แต่ไม่ใช่ "รายงานรายการ bookmark" แบบเอกสาร PDF ตรง ๆ | | **5.3.16** หน้าจอเชื่อมต่อกับระบบอื่น (Integrate) เป็น modal หรือ iframe | ❌ | เป็น standalone Next.js app — ไม่มีโหมด embed | | **5.3.17** รองรับ Web Browser: Google Chrome, Microsoft Edge, Safari | ✅ | Web app มาตรฐาน — ทดสอบ desktop + iOS/Android แล้ว | | **5.3.18** ใช้งานบน Tablet, iPad | ✅ | Touch support เต็มรูปแบบ: ไฮไลต์ด้วย custom selection ลาก→ปล่อย=สร้างทันที (`hooks/useCustomSelection.ts` — แทนปุ่มลอยของ commit `b3b884c` ที่ถอดออกแล้ว), ลาก handles, วาดด้วยนิ้ว | -| **5.3.19** รองรับการพัฒนาโปรแกรมแบบ Web Application ที่สนับสนุนเทคโนโลยี HTML5 หรือดีกว่า | ✅ | เป็น Web Application (Next.js 14) สร้างบนเทคโนโลยี HTML5 ทั้งหมด: Canvas 2D (render PDF ผ่าน pdfjs), SVG (InkLayer), Server-Sent Events/EventSource (realtime), HTTP Cookie (mock login e-Court), Pointer Events + pressure (สไตลัส) — ไม่พึ่ง plugin เดิม เช่น Flash/Java Applet | +| **5.3.19** รองรับการพัฒนาโปรแกรมแบบ Web Application ที่สนับสนุนเทคโนโลยี HTML5 หรือดีกว่า | ✅ | เป็น Web Application (Next.js 14) สร้างบนเทคโนโลยี HTML5 ทั้งหมด: Canvas 2D (render PDF ผ่าน pdfjs), SVG (InkLayer), Server-Sent Events/EventSource (realtime), Web Storage (anonymous identity), Pointer Events + pressure (สไตลัส) — ไม่พึ่ง plugin เดิม เช่น Flash/Java Applet | | **5.3.20** Responsive รองรับแสดงผลได้ทุกความละเอียดหน้าจอ | ✅ | sidebar จอกลาง/จอเล็ก, PDF fit จอเล็ก, ปุ่มขนาด touch-44px (commit `1c2f4c8`) | | **5.3.21** ใช้งานหลายผู้ใช้พร้อมกัน (Multi-user) จากหลายเครื่อง | ✅ | Realtime sync ผ่าน SSE (`hooks/useAnnotationRealtime.ts`) — แก้ไข/ลบ ของใครของมันบนไฟล์เดียวกันได้พร้อมกัน | -| **5.3.22** การรับรองตัวผู้ใช้งาน (Authentication) | ✅ | ทำแล้วเป็น **mock** (2026-09-17): หน้า `/login` dropdown ผู้ใช้ `user1`–`user5` + รหัสผ่านกลาง `Demo1234` → ตั้ง cookie `oacCase` ตาม format จริงของ e-Court (userId = `u_userid` ต่างกันต่อคน, userName = `u_userlogin`) + `middleware.ts` บังคับทุกหน้า + ปุ่มออกจากระบบ — auth จริงเทียบระบบศาลยังไม่ทำ | -| **5.3.23** สิทธิการใช้งานเป็นไปตามระบบปกครอง | ❌ | ไม่มีระบบสิทธิ์ (roles/permissions) — login เป็น mock ยังไม่ผูกสิทธิ์ตามระบบปกครอง (ต้อง auth จริง 5.3.22 ก่อน) | +| **5.3.22** การรับรองตัวผู้ใช้งาน (Authentication) | ❌ | ไม่มีระบบ auth — anonymous identity จาก localStorage โดยเจตนา (out of scope ตามแผน) | +| **5.3.23** สิทธิการใช้งานเป็นไปตามระบบปกครอง | ❌ | ไม่มีระบบสิทธิ์ (roles/permissions) — ไม่มี login จึงกำหนดสิทธิ์ตามระบบปกครองไม่ได้ (ต้องทำ 5.3.22 ก่อน) | | **5.3.24** ติดตั้ง On-Premise บน Server ภายใน หรือ Cloud | ⚠️ | สถาปัตยกรรม Next.js + PostgreSQL deploy ได้ทั้งสองรูปแบบทางเทคนิค แต่ไม่มีข้อกำหนด/packaging พิเศษ (หมายเหตุ: SSE pub/sub เป็น in-memory — รันหลาย instance ต้องเพิ่ม Redis/LISTEN-NOTIFY ก่อน) | -| **5.3.25** ระบบที่พัฒนาสามารถเชื่อมโยงกับระบบปกครองได้ | ❌ | เป็น standalone Next.js app — ยังไม่มี integration เชื่อมกับระบบงานคดีปกครอง (ผูกกับ gap 5.3.1 เรียกเอกสารโดยตรง และ 5.3.16 โหมด integrate) — ด้านข้อมูลออกมี JSON export รอรับแล้ว (5.3.14) | +| **5.3.25** ระบบที่พัฒนาสามารถเชื่อมโยงกับระบบปกครองได้ | ❌ | เป็น standalone Next.js app — ยังไม่มี integration/API เชื่อมกับระบบงานคดีปกครอง (ผูกกับ gap 5.3.1 เรียกเอกสารโดยตรง และ 5.3.14 ชุด API) | --- @@ -70,16 +70,16 @@ - Authentication ผู้ใช้งาน + สิทธิการใช้งานตามระบบปกครอง (5.3.22, 5.3.23) - Private/Group visibility ของ bookmark (5.3.9) -- ~~ชุด API สำหรับระบบภายนอก (5.3.14)~~ — **ปิดแล้ว (2026-09-17)**: GET `/api/bookmarks/export` คืน JSON ข้อมูล bookmark ทั้งไฟล์ ระบบอื่นดึงไปใช้ได้ (ดูตาราง 5.3.14) +- ชุด API สำหรับระบบภายนอก (5.3.14) - โหมด Integrate แบบ modal/iframe (5.3.16) - เชื่อมโยงกับระบบงานคดีปกครอง — ทั้งเรียกเอกสารโดยตรงและเชื่อมต่อระบบ (5.3.1, 5.3.25) ### กลุ่ม B — ฟังก์ชันที่มีอยู่แล้ว ต่อยอดเล็กน้อยได้เลย -งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม — **ปิดครบทั้งกลุ่มแล้ว (2026-09-16–17)** เก็บรายการไว้เป็นประวัติ: +งานขนาดเล็ก–กลาง ไม่กระทบสถาปัตยกรรม: -- ~~**ค้นหา bookmark พร้อมเงื่อนไข** (5.3.12)~~ — **ทำแล้ว (2026-09-16, N6)**: คำค้น (ชื่อ/โน้ต) + ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ ในแท็บ "ที่คั่นหน้า" -- ~~**เรียง bookmark ตามลำดับหน้า** (5.3.11)~~ — **ทำแล้ว (2026-09-16, N5)**: เรียง หน้า → ตำแหน่งบนหน้า → เวลาสร้าง ทั้งต้นไม้ + ใช้ลำดับเดียวกันใน export PDF outline -- ~~**รายงานรายการ bookmark เป็น PDF** (5.3.15)~~ — **ปิดแล้ว (2026-09-17)**: เจ้าของเอกสารถือว่า export PDF ของ F8 ที่ฝัง bookmark เป็น outline ตอบข้อนี้แล้ว; งาน N7 ที่ทำไว้ (รายงาน PDF แยก) เปลี่ยนเป็นส่งออก JSON แทน (ซึ่งไปปิด 5.3.14 พอดี) +- **ค้นหา bookmark พร้อมเงื่อนไข** (5.3.12) — เพิ่มการค้นหาตาราง `bookmarks` (ชื่อ/โน้ต) + รองรับเงื่อนไขเช่นหน้าเอกสาร/ผู้บันทึก/ช่วงวันที่ +- **เรียง bookmark ตามลำดับหน้า** (5.3.11) — sort roots ของต้นไม้ bookmark ตาม `pageIndex` แทนเวลาสร้าง +- **รายงานรายการ bookmark เป็น PDF** (5.3.15) — ต่อจากหน้าสรุป F15 หรือ export route เดิม ### กลุ่ม C — ของใหม่ที่ไม่เคยอยู่ในแผน - ~~**รองรับไฟล์รูปภาพ jpg/jpeg/png/tif** (5.3.4)~~ — **ทำแล้ว (2026-09-15, N3 + N4 ตามแผน `docs/PLAN-IMAGE.md`)**: รูปคือ "หน้าเอกสาร" (layers เดิมซ้อนบน `` พิกัด % ใช้ของเดิมได้ทันที) + TIFF หลายเฟรม + EXIF orientation + export PDF ฝังรูป + อัปโหลดผ่านหน้ารายการ @@ -92,6 +92,6 @@ ## 4. ข้อเสนอแนะลำดับความสำคัญ -1. ~~**ทำกลุ่ม B ก่อน**~~ — **ทำเสร็จแล้ว (2026-09-16–17)**: N5+N6 ปิด 5.3.11/5.3.12, 5.3.15 ปิดด้วย export PDF ของ F8 (bookmark เป็น outline), 5.3.14 ปิดด้วย JSON export — สิ่งที่เหลืออยู่ในกลุ่ม A/C/D +1. **ทำกลุ่ม B ก่อน** — คุ้มค่าต่อแรง เห็นผลไว เสริมฟีเจอร์ bookmark ให้ครบ requirement โดยไม่แตะสถาปัตยกรรม 2. ~~**ตัดสินใจเรื่องรูปภาพ (กลุ่ม C)**~~ — **ตัดสินใจและทำเสร็จแล้ว (2026-09-15)**: 5.3.4 ครบทุกนามสกุลตาม N3 + อัปโหลดไฟล์ (N4) 3. **กลุ่ม A ตามด้วย D** — ต้องอัปเดต `docs/PLAN.md` ขยายขอบเขตอย่างเป็นทางการก่อน implement ตามกระบวนการของโปรเจกต์ diff --git a/lib/session.ts b/lib/session.ts deleted file mode 100644 index 90c2029..0000000 --- a/lib/session.ts +++ /dev/null @@ -1,8 +0,0 @@ -// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page -import { cookies } from "next/headers"; - -import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user"; - -export function getSessionUser(): OacUser | null { - return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value); -} diff --git a/middleware.ts b/middleware.ts deleted file mode 100644 index bf8b582..0000000 --- a/middleware.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextResponse, type NextRequest } from "next/server"; - -import { OAC_CASE_COOKIE, parseOacCase } from "@/utils/user"; - -// บังคับ login (mock e-Court) ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login -// (parse จริง ไม่ใช่แค่มี cookie — cookie ขยะ/ครึ่ง ๆ ถือว่ายังไม่เข้าสู่ระบบ) -export function middleware(req: NextRequest) { - const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value); - if (!user) { - const url = req.nextUrl.clone(); - url.pathname = "/login"; - url.search = ""; - return NextResponse.redirect(url); - } - return NextResponse.next(); -} - -// ยกเว้น: หน้า login เอง, API/SSE/export, ไฟล์อัปโหลด, Next internal, -// และ static ใน public/ (pdf.worker, samples, fonts, favicon) -export const config = { - matcher: [ - "/((?!login|api|uploads|_next|samples|fonts|pdf\\.worker\\.min\\.mjs|favicon\\.ico).*)", - ], -}; diff --git a/tests/utils/user.test.ts b/tests/utils/user.test.ts deleted file mode 100644 index 3da2828..0000000 --- a/tests/utils/user.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildOacCaseCookie, - OAC_CASE_COOKIE, - parseOacCase, -} from "@/utils/user"; - -/** ตัวอย่าง cookie จริงจากระบบ e-Court (เก็บใน test เพื่อล็อก format ของจริง) */ -const REAL_OAC_CASE = - "u_userid=235821&u_userlogin=tu_2&u_courtid=4&u_password=&u_AdminStatus=&u_perCardNo=5000000000006&u_perCardNoEncript=1800650010005002003&u_userType=4&u_CidAuthen=NNNENNNNNNNNNNNNNNNN&u_hSecId=NNNNNNNNNNNNNNNNNNNN&u_username=&u_courtName=&u_AuditID=1995227&u_PsGroupNum=1&u_PsGroup=11&u_PsGroupName="; - -describe("parseOacCase", () => { - it("cookie e-Court จริง → identity จาก u_userid/u_userlogin", () => { - expect(parseOacCase(REAL_OAC_CASE)).toEqual({ - userId: "235821", - userName: "tu_2", - }); - }); - - it("ค่าจาก buildOacCaseCookie อ่านกลับได้ (round-trip ค่า mock login จริง user1)", () => { - expect(parseOacCase(buildOacCaseCookie("user1", "user1"))).toEqual({ - userId: "user1", - userName: "user1", - }); - }); - - it("ค่าเสีย/ว่าง/ขาด field → null (ถือว่ายังไม่เข้าสู่ระบบ)", () => { - expect(parseOacCase(null)).toBeNull(); - expect(parseOacCase(undefined)).toBeNull(); - expect(parseOacCase("")).toBeNull(); - expect(parseOacCase("garbage")).toBeNull(); - expect(parseOacCase("u_userlogin=user_x")).toBeNull(); // ขาด u_userid - expect(parseOacCase("u_userid=123&u_userlogin=")).toBeNull(); // u_userlogin ว่าง - }); - - it("ค่ารอบด้วย space ถูก trim", () => { - expect(parseOacCase("u_userid= 123 &u_userlogin= tu_2 ")).toEqual({ - userId: "123", - userName: "tu_2", - }); - }); - - it("ชื่อ cookie ตรงกับระบบ e-Court", () => { - expect(OAC_CASE_COOKIE).toBe("oacCase"); - }); -}); - -describe("buildOacCaseCookie", () => { - it("แทนเฉพาะ u_userid/u_userlogin — ฟิลด์อื่นคงค่าตัวอย่างจริงครบทุก field", () => { - const fields = Object.fromEntries(new URLSearchParams(buildOacCaseCookie("xxxxxx", "user_x"))); - expect(fields["u_userid"]).toBe("xxxxxx"); - expect(fields["u_userlogin"]).toBe("user_x"); - expect(fields["u_courtid"]).toBe("4"); - expect(fields["u_perCardNo"]).toBe("5000000000006"); - expect(fields["u_AuditID"]).toBe("1995227"); - expect(Object.keys(fields)).toHaveLength(16); - }); -}); diff --git a/utils/user.ts b/utils/user.ts index a173b20..51dca51 100644 --- a/utils/user.ts +++ b/utils/user.ts @@ -1,53 +1,51 @@ -// identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase" -// (userId = u_userid, userName = u_userlogin) แทน localStorage เดิม -// ไฟล์นี้เป็น pure module — middleware (edge) และ client import ร่วมได้ ห้ามใส่ next/headers +// anonymous identity เก็บใน localStorage สร้างครั้งเดียวต่อ browser (PLAN.md §0 A4) +// ใช้เติม field userId/userName ในตาราง annotations/bookmarks — ไม่มีระบบ auth -export type OacUser = { userId: string; userName: string }; +export type LocalUser = { userId: string; userName: string }; -export const OAC_CASE_COOKIE = "oacCase"; +const STORAGE_KEY = "judes-note:user"; -/** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server) - * ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */ -export const MOCK_USERS: readonly string[] = [ - "user1", - "user2", - "user3", - "user4", - "user5", +// mock users สำหรับเลือกที่หน้าแรก (demo ไม่มีระบบ auth) — userId คงที่ +// เพื่อให้กรองรายคนและสิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" ทำงานสม่ำเสมอข้าม refresh +export const MOCK_USERS: LocalUser[] = [ + { userId: "mock-user-01", userName: "ผู้พิพากษา สมชาย" }, + { userId: "mock-user-02", userName: "ผู้พิพากษา สมหญิง" }, + { userId: "mock-user-03", userName: "อัยการ วิชัย" }, + { userId: "mock-user-04", userName: "เลขานุการศาล ปิยะ" }, + { userId: "mock-user-05", userName: "ทนายโจทก์ ณัฐ" }, + { userId: "mock-user-06", userName: "ทนายจำเลย อรุณี" }, + { userId: "mock-user-07", userName: "ผู้ช่วยผู้พิพากษา กันต์" }, + { userId: "mock-user-08", userName: "พนักงานสอบสวน เดชา" }, + { userId: "mock-user-09", userName: "ผู้เชี่ยวชาญ มานะ" }, + { userId: "mock-user-10", userName: "ผู้ช่วยเลขานุการ ฝ้าย" }, ]; -/** แยกค่า query string ของ cookie oacCase → identity - * ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null) - * หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว - * จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */ -export function parseOacCase(raw: string | null | undefined): OacUser | null { - if (!raw) return null; - const params = new URLSearchParams(raw); - const userId = params.get("u_userid")?.trim(); - const userName = params.get("u_userlogin")?.trim(); - if (!userId || !userName) return null; - return { userId, userName }; +export function setLocalUser(user: LocalUser): void { + localStorage.setItem(STORAGE_KEY, JSON.stringify(user)); } -/** ค่า cookie oacCase ตาม format จริงของ e-Court (ฟิลด์อื่นคงค่าจากตัวอย่างจริง) - * mock login แทนเฉพาะ u_userid/u_userlogin */ -export function buildOacCaseCookie(userId: string, userLogin: string): string { - return [ - `u_userid=${userId}`, - `u_userlogin=${userLogin}`, - "u_courtid=4", - "u_password=", - "u_AdminStatus=", - "u_perCardNo=5000000000006", - "u_perCardNoEncript=1800650010005002003", - "u_userType=4", - "u_CidAuthen=NNNENNNNNNNNNNNNNNNN", - "u_hSecId=NNNNNNNNNNNNNNNNNNNN", - "u_username=", - "u_courtName=", - "u_AuditID=1995227", - "u_PsGroupNum=1", - "u_PsGroup=11", - "u_PsGroupName=", - ].join("&"); +// อ่าน identity ที่เก็บไว้โดยไม่สร้างใหม่ — ใช้แสดงค่าปัจจุบันในหน้าเลือกผู้ใช้ +export function getStoredLocalUser(): LocalUser | null { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) { + try { + const parsed = JSON.parse(raw) as LocalUser; + if (typeof parsed.userId === "string" && typeof parsed.userName === "string") { + return parsed; + } + } catch { + // ข้อมูลเก่าเสียหาย → ถือว่าไม่มีข้อมูล + } + } + return null; +} + +export function getLocalUser(): LocalUser { + const stored = getStoredLocalUser(); + if (stored) return stored; + + const userId = crypto.randomUUID(); + const userName = `ผู้ใช้ ${userId.slice(0, 4).toUpperCase()}`; + localStorage.setItem(STORAGE_KEY, JSON.stringify({ userId, userName })); + return { userId, userName }; }