diff --git a/CLAUDE.md b/CLAUDE.md index 1681511..aa5449f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,12 +62,14 @@ app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัป 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) +app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark จัดกลุ่มตามหน้า (design "แฟ้มสำนวน" 2026-09-17: แถบหัวน้ำเงิน + สารบัญจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด ขอบ folio — คำขอผู้ใช้ 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น; ต่อหน้า = บรรทัดสรุปหน้า (ไม่มีหัวข้อ) + Bookmark/Annotation หุบ-กางได้ผ่าน CollapsibleBlock; สารบัญ desktop = การ์ด sticky มี max-h, มือถือ < lg =
การ์ด ไม่มี JS; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + ซ่อนแถบ action/สารบัญมือถือ — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) +components/summary/CollapsibleBlock.tsx ← client component เดียวของหน้าสรุป (state หุบ/กางกลุ่ม Bookmark/Annotation — children ส่งจาก server ได้ตรง ๆ) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction 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/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ (N4) components/LoginForm.tsx ← ฟอร์ม login (client — เรียก action login แล้ว router.replace("/")) components/SessionBar.tsx ← แถบแสดงผู้ใช้ที่ login + ปุ่มออกจากระบบบนหน้ารายการ (แทน UserPicker เดิม) diff --git a/app/annotate/[id]/summary/page.tsx b/app/annotate/[id]/summary/page.tsx index 91fef30..af65e2a 100644 --- a/app/annotate/[id]/summary/page.tsx +++ b/app/annotate/[id]/summary/page.tsx @@ -1,21 +1,115 @@ +import type { Metadata } from "next"; import Link from "next/link"; import { asc, eq } from "drizzle-orm"; import { notFound } from "next/navigation"; -import { ArrowLeft, MapPin } from "lucide-react"; +import { + ArrowLeft, + Bookmark, + Braces, + ChevronDown, + ChevronRight, + ClipboardList, + FileDown, + StickyNote, +} from "lucide-react"; +import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon"; +import CollapsibleBlock from "@/components/summary/CollapsibleBlock"; import { db } from "@/db"; -import { annotations, files, toAnnotationDTO, type AnnotationDTO } from "@/db/schema"; -import { annotationNote, annotationPreviewText, annotationTypeLabel } from "@/utils/annotation-display"; +import { + annotations, + bookmarks, + files, + toAnnotationDTO, + toBookmarkDTO, + type AnnotationDTO, + type AnnotationType, + type BookmarkDTO, +} from "@/db/schema"; +import { + annotationNote, + annotationPreviewText, + annotationTypeLabel, +} from "@/utils/annotation-display"; import { withBasePath } from "@/utils/base-path"; -// หน้าสรุปหมายเหตุ (F15/D2) — server component รวมทุก annotation (ทุกผู้ใช้) -// จัดกลุ่มตามหน้า พร้อมปุ่มกระโดดเป็น deep link `/annotate/?ann=` (F12) +// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark +// จัดกลุ่มตามหน้า แต่ละแถวกระโดดเป็น deep link `/annotate/?ann=` / `?page=` (F12) +// — design "แฟ้มสำนวน" 2026-09-17: แถบหัวกระดาษน้ำเงิน (สืบต่อจาก tile JN ของแบรนด์) + +// สารบัญจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด (คำขอผู้ใช้ 2026-09-17 แทนแผ่น +// กระดาษแผ่นเดียวคั่นเส้น) แต่ละการ์ดมีขอบ folio เลขหน้าใหญ่; สารบัญเป็น anchor +// link (#page-N) ล้วน — desktop เป็น sticky aside แบบการ์ด ส่วนมือถือ (< lg) เป็น +//
แบบการ์ด ไม่มี JS / client component เดียวของหน้าคือ CollapsibleBlock +// (หุบ/กางกลุ่ม Bookmark/Annotation) const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (ชื่อเต็มอยู่ที่ annotationTypeLabel) */ +const TYPE_SHORT: Record = { + HIGHLIGHT: "ไฮไลต์", + DRAW: "เส้นวาด", + COMMENT: "คอมเมนต์", + SHAPE: "รูปทรง", + REDACTION: "ปิดพาง", + TEXTBOX: "กล่องข้อความ", + STAMP: "ตรายาง", +}; + +/** ลำดับชนิดในบรรทัดสรุป — ตามลำดับ enum ให้ deterministic ไม่ขึ้นกับลำดับสร้างรายการ */ +const TYPE_ORDER = Object.keys(TYPE_SHORT) as AnnotationType[]; + +type PageBucket = { + anns: AnnotationDTO[]; + bms: BookmarkDTO[]; + typeCounts: Partial>; + resolvedCount: number; + participants: Set; +}; + +/** บรรทัดสรุปของหน้า: "ไฮไลต์ 1 · คอมเมนต์ 1 (จัดการแล้ว 1) · โดย ผู้พิพากษา ก., เลขานุการ ข." + * (หน้าที่มีแต่ที่คั่นหน้า = "โดย …" อย่างเดียว) */ +function pageSummaryLine(b: PageBucket): string { + const parts: string[] = []; + for (const t of TYPE_ORDER) { + const n = b.typeCounts[t]; + if (!n) continue; + if (t === "COMMENT" && b.resolvedCount > 0) + parts.push(`คอมเมนต์ ${n} (จัดการแล้ว ${b.resolvedCount})`); + else parts.push(`${TYPE_SHORT[t]} ${n}`); + } + const who = Array.from(b.participants).sort((x, y) => x.localeCompare(y, "th")); + const shown = who.slice(0, 3).join(", "); + const whoLabel = who.length > 3 ? `${shown} และอีก ${who.length - 3} คน` : shown; + return parts.length > 0 ? `${parts.join(" · ")} · โดย ${whoLabel}` : `โดย ${whoLabel}`; +} + +/** focus ring ร่วมของทุกลิงก์ในหน้า — จอสัมผัสได้ 44px อัตโนมัติจากกฎ + * `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page + * ของ
) */ +const RING_CLS = + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1"; + export const dynamic = "force-dynamic"; +// design review 2026-09-17 M5: ชื่อแท็บ browser ระบุคดี — เลขานุการเปิดหลายคดี +// หลายแท็บพร้อมกัน (เดิมทุกแท็บชื่อ "Judge's Note" เหมือนกันหมดแยกไม่ออก) +export async function generateMetadata({ + params, +}: { + params: { id: string }; +}): Promise { + const { id } = params; + if (!UUID_RE.test(id)) return { title: "สรุปหมายเหตุ | Judge's Note" }; + const [file] = await db + .select({ title: files.title }) + .from(files) + .where(eq(files.id, id)); + return { + title: `สรุปหมายเหตุ — ${file?.title ?? "ไม่พบไฟล์"} | Judge's Note`, + }; +} + export default async function SummaryPage({ params, }: { @@ -27,92 +121,348 @@ export default async function SummaryPage({ const [file] = await db.select().from(files).where(eq(files.id, id)); if (!file) notFound(); - const rows = await db + const annRows = await db .select() .from(annotations) .where(eq(annotations.fileId, id)) .orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)); - const dtos = rows.map(toAnnotationDTO); + const dtos = annRows.map(toAnnotationDTO); - // จัดกลุ่มตามหน้า (เรียงหน้ามากไปน้อยไม่เกี่ยว — เดินตามลำดับหน้าเสมอ) - const byPage = new Map(); + // bookmark เรียงตามหน้า → ตำแหน่งบนหน้า → เวลาสร้าง (N5 เหมือน sidebar) + const bmRows = await db + .select() + .from(bookmarks) + .where(eq(bookmarks.fileId, id)) + .orderBy( + asc(bookmarks.pageIndex), + asc(bookmarks.yOffset), + asc(bookmarks.createdAt), + ); + const bmDtos = bmRows.map(toBookmarkDTO); + const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const)); + + // จัดกลุ่มตามหน้าแบบ union — หน้าปรากฏเมื่อมี annotation หรือ bookmark ≥ 1 รายการ + const pageMap = new Map(); + const bucketOf = (pageIndex: number): PageBucket => { + let b = pageMap.get(pageIndex); + if (!b) { + b = { anns: [], bms: [], typeCounts: {}, resolvedCount: 0, participants: new Set() }; + pageMap.set(pageIndex, b); + } + return b; + }; for (const a of dtos) { - const list = byPage.get(a.pageIndex); - if (list) list.push(a); - else byPage.set(a.pageIndex, [a]); + const b = bucketOf(a.pageIndex); + b.anns.push(a); + b.typeCounts[a.type] = (b.typeCounts[a.type] ?? 0) + 1; + if (a.type === "COMMENT" && a.resolved) b.resolvedCount += 1; + b.participants.add(a.userName); } - const pages = Array.from(byPage.entries()).sort((x, y) => x[0] - y[0]); + for (const b of bmDtos) { + const bucket = bucketOf(b.pageIndex); + bucket.bms.push(b); + bucket.participants.add(b.userName || "ไม่ทราบชื่อ"); + } + const pages = Array.from(pageMap.entries()).sort((x, y) => x[0] - y[0]); + + // ภาพรวมระดับไฟล์สำหรับสารบัญ desktop (sticky aside) + สารบัญมือถือ (
) + const allParticipants = Array.from( + new Set([ + ...dtos.map((a) => a.userName), + ...bmDtos.map((b) => b.userName || "ไม่ทราบชื่อ"), + ]), + ).sort((x, y) => x.localeCompare(y, "th")); return ( -
-
-

- สรุปหมายเหตุ — {file.title} -

+
+ {/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login): + ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */} +
+

สรุปหมายเหตุ

+

{file.title}

+ {pages.length > 0 && ( +

+ Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "} + {pages.length} หน้า +

+ )} +
+ + {/* แถบ action — ghost button ขาวมีขอบ ภาษาเดียวกับการ์ดของหน้า (คำขอผู้ใช้ + 2026-09-17 แทนลิงก์ข้อความเปล่า — เห็นเป็นปุ่มชัดขึ้น; จอสัมผัสได้ 44px + จากกฎ coarse; ตอนพิมพ์ซ่อนทิ้ง — ปุ่มกลับ/export ไม่มีความหมายบนกระดาษ) */} +
- + กลับหน้าอ่าน - {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} - - Export CSV - +
+ {/* F16/D3: export สรุปเป็น CSV (UTF-8 BOM) */} + + + ส่งออกสรุป (CSV) + + {/* N7: ส่งออก Bookmark ทั้งหมดเป็น JSON (label บอกขอบเขตเอง — คู่กับ + "ส่งออกสรุป (CSV)" ข้างบน ไม่ต้องพึ่ง title tooltip ที่จอสัมผัสใช้ไม่ได้) */} + + + ส่งออก Bookmark (JSON) + +
{pages.length === 0 ? ( -

- ยังไม่มี annotation ในไฟล์นี้ -

+
+ +

+ ยังไม่มี Annotation หรือ Bookmark ในไฟล์นี้ +

+

+ เปิดหน้าอ่านเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า +

+ + เปิดหน้าอ่าน + +
) : ( - pages.map(([pageIndex, items]) => ( -
-

- หน้า {pageIndex + 1} - - {items.length} รายการ - -

- -
- )) + +

+ ผู้ร่วมทำ: {allParticipants.join(" · ")} +

+
+ + {/* สารบัญ (จอ ≥ lg) — สารบัญแบบจุดไข่ปลาของเอกสารราชการ anchor ล้วนไม่มี JS + เป็นการ์ดชุดเดียวกับ
มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี + รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ + — ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */} + + + {/* การ์ดรายหน้า — 1 หน้า 1 การ์ด เรียงลงมาแยกด้วยช่องว่าง (คำขอผู้ใช้ + 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น); แต่ละการ์ดมีขอบ folio: + เลขหน้าใหญ่ + เส้นดิ่ง แบบเลขหน้าบนขอบกระดาษ (mt-5 lg:mt-0 — ระยะจาก + สารบัญ
บนมือถือ; จอ lg กริดจัดให้) */} +
+ {pages.map(([pi, b]) => { + return ( +
+ {/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */} +
+

+ + หน้า + + + {pi + 1} + +

+
+ + {/* เนื้อหาของหน้า — บรรทัดสรุปหน้า (ไม่มีหัวข้อคำอธิบาย) / + Bookmark / Annotation (หุบ-กางได้, รายการเยื้องจากหัวข้อ) */} +
+
+
+

+ {pageSummaryLine(b)} +

+
+ + เปิดหน้านี้ + + +
+ + {/* ที่คั่นหน้าของหน้านี้ทั้งหมด (root + sub) เรียงตาม yOffset */} + {b.bms.length > 0 && ( +
+ +
    + {b.bms.map((bm) => { + const parent = bm.parentId + ? bookmarkById.get(bm.parentId) + : undefined; + return ( +
  • + + + + + {bm.title} + + + โดย {bm.userName || "ไม่ทราบชื่อ"} + {parent ? ` · ใน: ${parent.title}` : ""} + + {bm.note && ( + + + + {bm.note} + + + )} + + + +
  • + ); + })} +
+
+
+ )} + + {/* หมายเหตุของหน้านี้ — แถวคลิกได้ทั้งแถว (เป้าใหญ่บนจอสัมผัส) */} + {b.anns.length > 0 && ( +
0 ? "mt-3" : "mt-2"}> + +
    + {b.anns.map((a) => ( +
  • + + + + + {annotationPreviewText(a)} + {a.type === "COMMENT" && a.resolved && ( + + จัดการแล้ว + + )} + + + {annotationTypeLabel(a)} · โดย {a.userName} + + {annotationNote(a) && ( + + + + {annotationNote(a)} + + + )} + + + +
  • + ))} +
+
+
+ )} +
+
+ ); + })} +
+ )}
); diff --git a/app/globals.css b/app/globals.css index 9862123..9a446d9 100644 --- a/app/globals.css +++ b/app/globals.css @@ -197,6 +197,34 @@ display: inline-flex; align-items: center; } + + /* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น + + {open && ( +
+ {children} +
+ )} + + ); +}