import type { Metadata } from "next"; import Link from "next/link"; import { asc, eq } from "drizzle-orm"; import { notFound } from "next/navigation"; 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, 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 (ทุกผู้ใช้) + 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 ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px * + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ * `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ
) */ const RING_CLS = "focus-ring"; 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, }: { params: { id: string }; }) { const { id } = params; if (!UUID_RE.test(id)) notFound(); const [file] = await db.select().from(files).where(eq(files.id, id)); if (!file) notFound(); const annRows = await db .select() .from(annotations) .where(eq(annotations.fileId, id)) .orderBy(asc(annotations.pageIndex), asc(annotations.createdAt)); const dtos = annRows.map(toAnnotationDTO); // 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 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); } 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 (
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก 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) */} ส่งออกสรุป (CSV) {/* N7: ส่งออก Bookmark ทั้งหมดเป็น JSON (label บอกขอบเขตเอง — คู่กับ "ส่งออกสรุป (CSV)" ข้างบน ไม่ต้องพึ่ง title tooltip ที่จอสัมผัสใช้ไม่ได้) */} ส่งออก Bookmark (JSON)
{pages.length === 0 ? (

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

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

เปิดหน้าอ่าน
) : (
{/* รายการตามหน้ามือถือ (จอ < lg) — design review 2026-09-17 H2: คืน navigation ที่หาย ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้
ได้หุบ/กาง + keyboard โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */}
รายการตามหน้าและผู้ร่วมทำ

ผู้ร่วมทำ: {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 — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */}

หน้า {/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ interactive" (ตัดสินใจพร้อม exception list ใน DESIGN.md 2026-09-23) ความเด่นมาจากขนาด 2xl semibold tabular-nums */} {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)} )}
  • ))}
)}
); })}
)}
); }