groups+summary: redesign หน้า /groups/[typeId] + หน้าสรุปเป็น explorer 2 คอลัมน์ (ตัวกรองซ้าย + list เดียว) — หน้าสรุปใช้มิติแรก "หน้า" ตามแบบหน้ากลุ่มที่ใช้ "ไฟล์" — (1) หน้า /groups/[typeId]: ถอด navbar (เหลือ back link — AppNav เหลือใช้ที่หน้า / อย่างเดียว) + ถอดหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้", query ทั้งหมดฝั่ง server ส่ง GroupFileSummary[]+GroupListItem[] ให้ GroupExplorer กรองใน memory — ตัวกรอง tree menu 2 ระดับสไตล์ Quasar docs 4 กลุ่ม (ไฟล์/ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก — ชนิดรายการเป็น checkbox คู่ ไม่เลือก = แสดงทั้งหมด, ติ๊กชนิด → เลือก Annotation อัตโนมัติ, เลิกเลือก Annotation → ล้างชนิดค้าง) + GroupList แถว passive จุดคลิกเดียวคือปุ่ม "View" (2) แยกแผงตัวกรองเป็น component กลาง components/explorer/FacetFilterPanel.tsx — มิติแรกส่งเข้าทาง prop firstGroup (groups = ไฟล์ / สรุป = หน้า พร้อม optionsScroll ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ) + TYPE_SHORT/TYPE_ORDER รวมจาก 2 ที่ที่เคย define ซ้ำมาอยู่ utils/annotation-display.ts จุดเดียว (3) หน้าสรุป /annotate/[id]/summary: server ส่ง ExplorerItem[] (หน้า → bookmark ก่อน annotation → ในหน้าเดียวกัน bookmark เรียง yOffset ตาม N5/annotation ตาม createdAt) ให้ SummaryExplorer + SummaryList การ์ดรายหน้าหัวการ์ด sticky (เลขหน้า + บรรทัดสรุปหน้าคำนวณจากรายการที่เห็นจริงหลังกรอง + ปุ่มเปิดหน้านี้) + แถว passive ปุ่ม View deep link ?ann=/?page= เหมือนหน้ากลุ่ม — ถอดรายการตามหน้าจุดไข่ปลา + การ์ด folio + CollapsibleBlock ออก (anchor #page-N ของ section คงไว้ให้ลิงก์เก่ายังใช้ได้) (4) ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ (ตอนกรองอยู่ render ชุดบนจอ print:hidden + ชุดเต็ม hidden print:block, prop anchorIds กัน duplicate id) + กฎ print ใหม่ใน globals.css (section>ul>li break-inside:avoid-page + .page-head position:static) (5) ผ่าน code review 2026-09-24 (tsc/build/vitest 144 tests): คืน N5 yOffset ให้ bookmark ต่อหน้าที่ sort createdAt ทับไปโดยไม่ตั้งใจ + จดรับความเสี่ยง localeCompare "th" รันทั้ง SSR/hydration ไว้ที่ comment พร้อมทางแก้ + docs sync ครบ (CLAUDE.md โครงสร้างไฟล์ + PLAN-FEAT ถอดหมายเหตุ + PLAN-DOCSET อ้าง FacetFilterPanel) (คำขอผู้ใช้ 2026-09-24)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-24 17:34:23 +07:00
parent c5c6b65bd2
commit df502088b7
14 changed files with 1522 additions and 616 deletions

File diff suppressed because one or more lines are too long

View file

@ -2,19 +2,10 @@ 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 { ArrowLeft, Braces, ClipboardList, FileDown } from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import CollapsibleBlock from "@/components/summary/CollapsibleBlock";
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
import { SummaryExplorer } from "@/components/summary/SummaryExplorer";
import { db } from "@/db";
import {
annotations,
@ -22,68 +13,21 @@ import {
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/<id>?ann=` / `?page=` (F12)
// — design "แฟ้มสำนวน" 2026-09-17: แถบหัวกระดาษน้ำเงิน (สืบต่อจาก tile JN ของแบรนด์) +
// รายการตามหน้าจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด (คำขอผู้ใช้ 2026-09-17 แทนแผ่น
// กระดาษแผ่นเดียวคั่นเส้น) แต่ละการ์ดมีขอบ folio เลขหน้าใหญ่; รายการตามหน้าเป็น anchor
// link (#page-N) ล้วน — desktop เป็น sticky aside แบบการ์ด ส่วนมือถือ (< lg) เป็น
// <details> แบบการ์ด ไม่มี JS / client component เดียวของหน้าคือ CollapsibleBlock
// (หุบ/กางกลุ่ม Bookmark/Annotation)
// แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
// Annotation/ผู้บันทึก) + การ์ดรายหน้าหัว sticky — แถบหัวน้ำเงิน + action bar +
// empty state คงเดิม; query ทั้งหมดทำที่นี่แล้วส่ง `ExplorerItem[]` ให้
// SummaryExplorer กรองใน memory ฝั่ง client (read-only snapshot ไม่มี SSE)
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<AnnotationType, string> = {
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<Record<AnnotationType, number>>;
resolvedCount: number;
participants: Set<string>;
};
/** บรรทัดสรุปของหน้า: "ไฮไลต์ 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 ของ <main>) */
@ -140,49 +84,46 @@ export default async function SummaryPage({
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<number, PageBucket>();
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]);
// list เดียวรวมทั้งไฟล์ไล่ตามลำดับอ่าน: หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อน
// หมายเหตุ → ในหน้าเดียวกัน bookmark เรียงตามตำแหน่งบนหน้า (N5 เหมือน query/
// sidebar — code review 2026-09-24 คืนจาก createdAt ที่เคยแทนที่โดยไม่ตั้งใจ)
// ส่วน annotation ตาม createdAt (sort ครั้งเดียวบน server — ฝั่ง client กรอง
// เป็น pure predicate ไม่ sort ซ้ำ)
const items: ExplorerItem[] = [
...bmDtos.map((dto) => ({
kind: "bm" as const,
pageIndex: dto.pageIndex,
dto,
parentTitle: dto.parentId
? bookmarkById.get(dto.parentId)?.title ?? null
: null,
})),
...dtos.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })),
].sort(
(a, x) =>
a.pageIndex - x.pageIndex ||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
(a.kind === "bm" && x.kind === "bm"
? a.dto.yOffset - x.dto.yOffset
: new Date(a.dto.createdAt).getTime() -
new Date(x.dto.createdAt).getTime()),
);
// ภาพรวมระดับไฟล์สำหรับรายการตามหน้า desktop (sticky aside) + มือถือ (<details>)
const allParticipants = Array.from(
new Set([
...dtos.map((a) => a.userName),
...bmDtos.map((b) => b.userName || "ไม่ทราบชื่อ"),
]),
).sort((x, y) => x.localeCompare(y, "th"));
// จำนวนหน้าที่มีรายการ ≥1 สำหรับสถิติแถบหัว
const pageCount = new Set(items.map((it) => it.pageIndex)).size;
return (
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */}
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง
(ตอนพิมพ์เป็นกรอบเส้นน้ำเงินตามกฎ @media print ของ summary-page) */}
<header className="rounded-xl bg-primary px-5 py-4 text-white sm:px-6">
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
<p className="mt-0.5 break-words text-sm text-white/85">{file.title}</p>
{pages.length > 0 && (
{items.length > 0 && (
<p className="mt-2 text-xs tabular-nums text-white/70">
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
{pages.length} หน้า
{pageCount} หน้า
</p>
)}
</header>
@ -219,7 +160,9 @@ export default async function SummaryPage({
</div>
</div>
{pages.length === 0 ? (
{items.length === 0 ? (
/* ไฟล์ว่าง = ไม่ mount explorer เลย — แผงกรองของข้อมูลว่างไม่มีความหมาย
(minimize choices) ทางเข้าเอกสารคือปุ่ม "เปิดหน้าอ่าน" */
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
@ -236,235 +179,7 @@ export default async function SummaryPage({
</Link>
</div>
) : (
<div className="mt-5 lg:grid lg:grid-cols-[220px_minmax(0,1fr)] lg:items-start lg:gap-8">
{/* รายการตามหน้ามือถือ (จอ < lg) — design review 2026-09-17 H2: คืน navigation ที่หาย
ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้ <details> ได้หุบ/กาง + keyboard
โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ
ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */}
<details className="group rounded-xl border border-line bg-white lg:hidden print:hidden">
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink hover:text-primary [&::-webkit-details-marker]:hidden">
รายการตามหน้าและผู้ร่วมทำ
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<nav
aria-label="รายการตามหน้า"
className="flex flex-col gap-0.5 border-t border-line/70 px-2 pb-2 pt-2"
>
{pages.map(([pi, b]) => (
<a
key={pi}
href={`#page-${pi + 1}`}
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
className={`flex items-end gap-1.5 rounded-md px-2 py-2 text-sm text-ink-soft hover:text-primary ${RING_CLS}`}
>
<span className="leading-5">หน้า {pi + 1}</span>
<span
aria-hidden
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-ink-faint"
/>
<span className="leading-5 tabular-nums text-ink-soft">
{b.anns.length + b.bms.length}
</span>
</a>
))}
</nav>
<p className="border-t border-line/70 px-4 py-3 text-xs leading-relaxed text-ink-soft">
ผู้ร่วมทำ: {allParticipants.join(" · ")}
</p>
</details>
{/* รายการตามหน้า (จอ ≥ lg) — จุดไข่ปลาแบบสารบัญของเอกสารราชการ anchor ล้วนไม่มี JS
เป็นการ์ดชุดเดียวกับ <details> มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี
รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ
— ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */}
<aside className="hidden rounded-xl border border-line bg-white p-4 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100vh-3rem)] lg:overflow-y-auto">
<h2 className="text-sm font-semibold text-ink">รายการตามหน้า</h2>
<nav aria-label="รายการตามหน้า" className="mt-2 flex flex-col gap-1">
{pages.map(([pi, b]) => (
<a
key={pi}
href={`#page-${pi + 1}`}
aria-label={`หน้า ${pi + 1}, ${b.anns.length + b.bms.length} รายการ`}
className={`flex items-end gap-1.5 rounded-md px-1 text-sm text-ink-soft hover:text-primary ${RING_CLS}`}
>
<span className="leading-5">หน้า {pi + 1}</span>
<span
aria-hidden
className="mb-1.5 min-w-4 flex-1 border-b border-dotted border-ink-faint"
/>
<span className="leading-5 tabular-nums text-ink-soft">
{b.anns.length + b.bms.length}
</span>
</a>
))}
</nav>
<h2 className="mt-6 border-t border-line pt-4 text-sm font-semibold text-ink">
ผู้ร่วมทำ
</h2>
<p className="mt-1.5 px-1 text-xs leading-relaxed text-ink-soft">
{allParticipants.join(" · ")}
</p>
</aside>
{/* การ์ดรายหน้า — 1 หน้า 1 การ์ด เรียงลงมาแยกด้วยช่องว่าง (คำขอผู้ใช้
2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น); แต่ละการ์ดมีขอบ folio:
เลขหน้าใหญ่ + เส้นดิ่ง แบบเลขหน้าบนขอบกระดาษ (mt-5 lg:mt-0 — ระยะจาก
รายการตามหน้า <details> บนมือถือ; จอ lg กริดจัดให้) */}
<div className="mt-5 flex flex-col gap-4 lg:mt-0">
{pages.map(([pi, b]) => {
return (
<section
key={pi}
id={`page-${pi + 1}`}
aria-labelledby={`page-${pi + 1}-title`}
className="scroll-mt-6 grid grid-cols-[2.75rem_1fr] rounded-xl border border-line bg-white sm:grid-cols-[3.75rem_1fr]"
>
{/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */}
<div className="border-r border-line/60 py-4 pl-3 pr-2 text-right sm:pl-4">
<h2 id={`page-${pi + 1}-title`} className="tabular-nums">
<span className="block text-[11px] leading-none text-ink-soft">
หน้า
</span>
{/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ
interactive" (ตัดสินใจพร้อม exception list ใน DESIGN.md
2026-09-23) ความเด่นมาจากขนาด 2xl semibold tabular-nums */}
<span className="mt-1 block text-2xl font-semibold leading-none text-ink">
{pi + 1}
</span>
</h2>
</div>
{/* เนื้อหาของหน้า — บรรทัดสรุปหน้า (ไม่มีหัวข้อคำอธิบาย) /
Bookmark / Annotation (หุบ-กางได้, รายการเยื้องจากหัวข้อ) */}
<div className="min-w-0 py-3 pl-3 pr-3 sm:pl-4 sm:pr-5 sm:py-4">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm leading-relaxed text-ink-soft">
{pageSummaryLine(b)}
</p>
</div>
<Link
href={`/annotate/${id}?page=${pi + 1}`}
className={`inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline ${RING_CLS}`}
>
เปิดหน้านี้
<ChevronRight aria-hidden className="h-3 w-3" />
</Link>
</div>
{/* ที่คั่นหน้าของหน้านี้ทั้งหมด (root + sub) เรียงตาม yOffset */}
{b.bms.length > 0 && (
<div className="mt-2">
<CollapsibleBlock kind="bookmark" count={b.bms.length}>
<ul className="divide-y divide-line/70">
{b.bms.map((bm) => {
const parent = bm.parentId
? bookmarkById.get(bm.parentId)
: undefined;
return (
<li key={bm.id} className="ml-4">
<Link
href={`/annotate/${id}?page=${pi + 1}`}
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
>
<Bookmark
aria-hidden
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
{bm.title}
</span>
<span className="mt-0.5 block truncate text-xs text-ink-soft">
โดย {bm.userName || "ไม่ทราบชื่อ"}
{parent ? ` · ใน: ${parent.title}` : ""}
</span>
{bm.note && (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote
aria-hidden
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
/>
<span className="line-clamp-2 whitespace-pre-wrap break-words">
{bm.note}
</span>
</span>
)}
</span>
<ChevronRight
aria-hidden
className="mt-1.5 h-4 w-4 shrink-0 text-ink-faint group-hover:text-ink"
/>
</Link>
</li>
);
})}
</ul>
</CollapsibleBlock>
</div>
)}
{/* หมายเหตุของหน้านี้ — แถวคลิกได้ทั้งแถว (เป้าใหญ่บนจอสัมผัส) */}
{b.anns.length > 0 && (
<div className={b.bms.length > 0 ? "mt-3" : "mt-2"}>
<CollapsibleBlock kind="annotation" count={b.anns.length}>
<ul className="divide-y divide-line/70">
{b.anns.map((a) => (
<li key={a.id} className="ml-4">
<Link
href={`/annotate/${id}?ann=${a.id}`}
className={`group -mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
>
<AnnotationTypeIcon
type={a.type}
data={a.data}
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
{annotationPreviewText(a)}
{a.type === "COMMENT" && a.resolved && (
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
จัดการแล้ว
</span>
)}
</span>
<span className="mt-0.5 block text-xs text-ink-soft">
{annotationTypeLabel(a)} · โดย {a.userName}
</span>
{annotationNote(a) && (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote
aria-hidden
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
/>
<span className="line-clamp-2 whitespace-pre-wrap break-words">
{annotationNote(a)}
</span>
</span>
)}
</span>
<ChevronRight
aria-hidden
className="mt-1.5 h-4 w-4 shrink-0 text-ink-faint group-hover:text-ink"
/>
</Link>
</li>
))}
</ul>
</CollapsibleBlock>
</div>
)}
</div>
</section>
);
})}
</div>
</div>
<SummaryExplorer fileId={id} items={items} />
)}
</main>
);

View file

@ -241,9 +241,10 @@
align-items: center;
}
/* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น <button>
ไม่ใช่ <a> จึงหลุดจากกฎข้างบน (สูงจริง ~29px) — ปุ่มเป็น flex w-full
text-left อยู่แล้ว ใส่แค่ min-height (ไม่ต้อง display/justify แบบกฎ <a>) */
/* ปุ่มที่ไม่ใช่ <a> (แถวตัวกรองในแผง explorer ของหน้าสรุป, ปุ่มล้างตัวกรอง)
หลุดจากกฎข้างบน — ใส่แค่ min-height (ปุ่มเป็น flex w-full text-left อยู่แล้ว
ไม่ต้อง display/justify แบบกฎ <a>; เดิมเป็นปุ่มหุบ/กางของ CollapsibleBlock
design review 2026-09-17 H1 ก่อนหน้าถูกแทนด้วยตัวกรอง explorer 2026-09-24) */
main.summary-page button {
min-height: 2.75rem;
}
@ -308,7 +309,10 @@
แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ
ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน
กระดาษเดียว browser เลื่อนทั้งก้อนขึ้นหน้าถัดไปเอง — break-inside เป็นคำ
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับรายการตามหน้ามือถือซ่อนด้วย print:hidden ใน JSX */
แนะนำ ไม่ใช่กฎเหล็ก) / แถบ action กับแผงตัวกรอง (ทั้ง <details> มือถือและ
aside desktop) ซ่อนด้วย print:hidden ใน JSX — รายการบนกระดาษเป็นฉบับเต็ม
เสมอไม่สนตัวกรองที่ตั้งค้างไว้ (redesign explorer 2026-09-24: SummaryList
render ชุดเต็มแยกไว้เป็น hidden print:block) */
@media print {
main.summary-page header {
background: #fff;
@ -321,6 +325,14 @@
main.summary-page section {
break-inside: avoid-page;
}
/* แถวรายการของหน้าเดียวกันไม่ถูกผ่ากลางระหว่างหน้ากระดาษ */
main.summary-page section > ul > li {
break-inside: avoid-page;
}
/* หัวการ์ดรายหน้าเป็น sticky บนจอ — กลับเป็นตำแหน่งปกติบนกระดาษ */
main.summary-page .page-head {
position: static;
}
}
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B) →

View file

@ -1,19 +1,14 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { notFound } from "next/navigation";
import { asc, eq, inArray } from "drizzle-orm";
import {
ArrowLeft,
Bookmark,
ChevronDown,
FileImage,
FileText,
Folder,
StickyNote,
} from "lucide-react";
import { ArrowLeft, Folder } from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import AppNav from "@/components/AppNav";
import {
GroupExplorer,
type GroupFileSummary,
type GroupListItem,
} from "@/components/groups/GroupExplorer";
import { db } from "@/db";
import {
annotations,
@ -24,41 +19,20 @@ import {
type AnnotationDTO,
type BookmarkDTO,
} from "@/db/schema";
import {
annotationNote,
annotationPreviewText,
annotationTypeLabel,
} from "@/utils/annotation-display";
import { formatThaiDate } from "@/utils/date-format";
import { imageFormatFromUrl } from "@/utils/file-kind";
import { getSessionUser } from "@/lib/session";
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
// การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้น (คำขอผู้ใช้ 2026-09-23)
// รวมเป็นรายการเดียวเรียงตามหน้า — แถวคลิกไป deep link เดียวกับหน้าสรุป (F12)
//
// redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar — explorer 2 คอลัมน์ ตัวกรอง
// ฝั่งซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ (เคยเป็นการ์ด
// ต่อไฟล์ + <details>) — query ทั้งหมดทำที่นี่แล้วส่งข้อมูลพร้อมใช้ให้ GroupExplorer
// กรองใน memory ฝั่ง client
type FileBucket = { anns: AnnotationDTO[]; bms: BookmarkDTO[] };
/** bookmark+annotation ของไฟล์รวมเป็นรายการเดียวเรียงตามหน้า (หน้าเดียวกัน
* ให้ที่คั่นหน้าก่อนหมายเหตุ — ไล่ตามตำแหน่งในเอกสาร) */
function mergedItems(b: FileBucket) {
return [
...b.bms.map((dto) => ({ kind: "bm" as const, pageIndex: dto.pageIndex, dto })),
...b.anns.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })),
].sort(
(a, x) =>
a.pageIndex - x.pageIndex ||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1),
);
}
/** focus ring ร่วมของลิงก์รายการในการ์ด — Genesis .focus-ring (outline น้ำเงินทึบ
* 2px + offset ตาม spec Elevation) เดียวกับหน้าสรุป */
const RING_CLS = "focus-ring";
export const dynamic = "force-dynamic";
export async function generateMetadata({
@ -76,10 +50,6 @@ export default async function GroupPage({
}) {
const { typeId } = params;
// AppNav ต้องการชื่อผู้ใช้ — defense ซ้อน middleware กัน render หน้าให้คนไม่มี session
const user = getSessionUser();
if (!user) redirect("/login");
const rows = await db
.select()
.from(files)
@ -87,8 +57,7 @@ export default async function GroupPage({
.orderBy(asc(files.createdAt));
if (rows.length === 0) notFound();
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS —
// ได้ทั้งจำนวนสำหรับบรรทัด meta และรายการเต็มสำหรับกางดูในการ์ด
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS
const ids = rows.map((f) => f.id);
const [annRows, bmRows] = await Promise.all([
db
@ -123,11 +92,43 @@ export default async function GroupPage({
// หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
const fileSummaries: GroupFileSummary[] = rows.map((f) => ({
id: f.id,
title: f.title,
isImage: Boolean(imageFormatFromUrl(f.pdfUrl)),
}));
// list เดียวรวมทั้งกลุ่มไล่ตามลำดับอ่านสำนวน: ไฟล์ตาม createdAt (rows เรียงแล้ว)
// → หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อนหมายเหตุ → createdAt เป็น tiebreak
// (sort ครั้งเดียวบน server — ฝั่ง client กรองเป็น pure predicate ไม่ sort ซ้ำ)
const items: GroupListItem[] = rows.flatMap((f) => {
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
return [
...bucket.bms.map((dto) => {
const parent = dto.parentId ? bookmarkById.get(dto.parentId) : undefined;
return {
kind: "bm" as const,
pageIndex: dto.pageIndex,
dto,
parentTitle: parent?.title ?? null,
};
}),
...bucket.anns.map((dto) => ({
kind: "ann" as const,
pageIndex: dto.pageIndex,
dto,
})),
].sort(
(a, x) =>
a.pageIndex - x.pageIndex ||
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
new Date(a.dto.createdAt).getTime() - new Date(x.dto.createdAt).getTime(),
);
});
return (
<>
<AppNav userName={user.userName} />
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด) */}
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6">
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
<Link
href="/"
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
@ -144,153 +145,17 @@ export default async function GroupPage({
<h1 className="font-display break-all text-section-title text-ink">
{typeId}
</h1>
<p className="mt-0.5 text-sm text-ink-soft">{rows.length} เอกสาร</p>
<p className="mt-0.5 text-sm text-ink-soft">
{rows.length} เอกสาร · {items.length} รายการ
</p>
</div>
</header>
{/* หมายเหตุแสดงทุกครั้ง (แผน N9 D3): ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ — Genesis: แผงข้อมูลกลาง 8px radius
(น้ำเงินสงวนให้ของ interactive จึงเป็นเทาโปร่ง ไม่ใช่แบนเนอร์ฟ้า) */}
<div className="mt-4 rounded-lg border border-line bg-ink/[0.03] px-4 py-3 text-sm text-ink-soft">
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
</div>
{/* หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (แผน N9 D3 เดิม) ถูกถอดออก
ตามคำขอผู้ใช้ 2026-09-24 — ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
<ul className="mt-6 space-y-4">
{rows.map((f) => {
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
const total = bucket.anns.length + bucket.bms.length;
return (
<li
key={f.id}
className="rounded-xl border border-line bg-white p-5"
>
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
{imageFormatFromUrl(f.pdfUrl) ? (
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
) : (
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
)}
</div>
<span className="min-w-0 flex-1">
<span className="block break-all line-clamp-2 text-body font-medium text-ink">
{f.title}
</span>
{/* วันที่/จำนวนเป็นข้อมูลที่อ่านจริง ใช้ ink-soft (ผ่าน 4.5:1) —
ink-faint เก็บไว้ให้ placeholder/disabled เท่านั้น (UX review M2) */}
<span className="mt-0.5 block text-caption text-ink-soft">
นำเข้า {formatThaiDate(f.createdAt, true)} · Annotations{" "}
{bucket.anns.length} · Bookmarks {bucket.bms.length}
</span>
</span>
<Link
href={`/annotate/${f.id}`}
className="focus-ring inline-flex h-8 shrink-0 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
Annotate
</Link>
</div>
{total > 0 && (
<details className="group mt-3 border-t border-line/70">
<summary className="flex cursor-pointer list-none items-center justify-between rounded-md px-1 py-2.5 text-sm font-medium text-ink-soft transition hover:text-primary [&::-webkit-details-marker]:hidden">
Annotations &amp; Bookmarks ({total})
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open:rotate-180 motion-reduce:transition-none"
/>
</summary>
<ul className="divide-y divide-line/70">
{mergedItems(bucket).map((item) => {
const parent =
item.kind === "bm" && item.dto.parentId
? bookmarkById.get(item.dto.parentId)
: undefined;
return item.kind === "bm" ? (
<li key={item.dto.id}>
<Link
href={`/annotate/${f.id}?page=${item.pageIndex + 1}`}
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
>
<Bookmark
aria-hidden
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
{item.dto.title}
</span>
<span className="mt-0.5 block truncate text-xs text-ink-soft">
หน้า {item.pageIndex + 1} ·{" "}
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
{item.dto.userName || "ไม่ทราบชื่อ"}
{parent ? ` · ใน: ${parent.title}` : ""}
</span>
{item.dto.note && (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote
aria-hidden
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
/>
<span className="line-clamp-2 whitespace-pre-wrap break-words">
{item.dto.note}
</span>
</span>
)}
</span>
</Link>
</li>
) : (
<li key={item.dto.id}>
<Link
href={`/annotate/${f.id}?ann=${item.dto.id}`}
className={`-mx-2 flex items-start gap-2 rounded-lg px-2 py-2.5 transition hover:bg-ink/[0.03] ${RING_CLS}`}
>
<AnnotationTypeIcon
type={item.dto.type}
data={item.dto.data}
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
{annotationPreviewText(item.dto)}
{item.dto.type === "COMMENT" &&
item.dto.resolved && (
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
จัดการแล้ว
</span>
)}
</span>
<span className="mt-0.5 block text-xs text-ink-soft">
หน้า {item.pageIndex + 1} ·{" "}
{annotationTypeLabel(item.dto)} ·{" "}
{formatThaiDate(item.dto.createdAt, true)} · โดย{" "}
{item.dto.userName}
</span>
{annotationNote(item.dto) && (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote
aria-hidden
className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink"
/>
<span className="line-clamp-2 whitespace-pre-wrap break-words">
{annotationNote(item.dto)}
</span>
</span>
)}
</span>
</Link>
</li>
);
})}
</ul>
</details>
)}
</li>
);
})}
</ul>
<GroupExplorer files={fileSummaries} items={items} />
</main>
</>
);
}

View file

@ -1,7 +1,8 @@
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
// ลิงก์กลางจอ) ใช้ร่วมทุกหน้าเนื้อหา (หน้ารายการ /, กลุ่ม /groups/[typeId]) —
// แทน SessionBar เดิมที่แยกเป็นแถบของตัวเองกลางหน้า
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
// เป็นแถบของตัวเองกลางหน้า
import Link from "next/link";
import LogoutButton from "./LogoutButton";

View file

@ -0,0 +1,354 @@
"use client";
import type { ReactNode } from "react";
import {
Bookmark,
Check,
ChevronRight,
FileText,
Layers,
PenLine,
Shapes,
Users,
} from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import type {
AnnotationData,
AnnotationDTO,
AnnotationType,
BookmarkDTO,
} from "@/db/schema";
import { TYPE_ORDER, TYPE_SHORT } from "@/utils/annotation-display";
// แผงตัวกรอง facet กลาง — tree menu 2 ระดับสไตล์ Quasar docs q-tree--dense
// no-connectors (design 2026-09-24 ตามคำขอผู้ใช้: ธีม Genesis พื้นสว่าง + dense
// บน desktop แต่เป้าแตะ ≥44px บนจอสัมผัส) — ย้ายมาจาก FilterControls ของหน้า
// กลุ่ม (2026-09-24) เพื่อใช้ร่วมกับหน้าสรุป: มิติแรก (ไฟล์/หน้า) ส่งเข้ามาทาง
// prop `firstGroup` ส่วนอีก 3 กลุ่ม (ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก)
// เหมือนกันทุกหน้า — parent = <details> หุบ/กางอิสระ (chevron หมุน 90°),
// leaf = <button> dense ไอคอน + ชื่อ + badge pill เลือก button แทน label+input
// เพราะโดนกฎ coarse 44px ของ `main.listing-page`/`main.summary-page` ใน
// globals.css พอดี (สรุปเป็น desktop-dense / touch-44 โดยไม่ต้องเพิ่ม CSS)
/** รายการรวมของ explorer — server เรียงตามลำดับอ่านเอกสารให้แล้ว
* (หน้า → หน้าเดียวกัน bookmark ก่อน annotation → createdAt) */
export type ExplorerItem =
| { kind: "bm"; pageIndex: number; dto: BookmarkDTO; parentTitle: string | null }
| { kind: "ann"; pageIndex: number; dto: AnnotationDTO };
/** state ตัวกรองกลาง — มิติแรก (`first`) เป็นไฟล์ id (หน้ากลุ่ม) หรือเลขหน้า
* (หน้าสรุป); null/Set ว่าง = default "แสดงทั้งหมด" ทุกช่อง */
export type FacetFilterState = {
first: string | number | null;
/** ชนิดรายการที่สนใจ — **ว่าง = แสดงทั้งหมด** (default), ติ๊กอะไรเห็นเฉพาะ
* สิ่งนั้น (direct selection — คำขอผู้ใช้ 2026-09-24 แทนแบบเดิมที่เปิดครบ
* ทั้งคู่ไว้แล้วต้องปิดเอง) */
kinds: Set<"bm" | "ann">;
/** ชนิดของ Annotation ที่สนใจ — กติกาเดียวกับ kinds: **ว่าง = ทุกชนิด** */
types: Set<AnnotationType>;
userId: string | null;
};
/** สร้างใหม่ทุกครั้ง — ห้ามแชร์ object default เดียว (Set ถูก mutate ข้าม
* หน้า/ข้าม render ได้) */
export function createDefaultFacets(): FacetFilterState {
return { first: null, kinds: new Set(), types: new Set(), userId: null };
}
export type UserOption = { userId: string; label: string };
/** facet count แบบ static จากข้อมูลทั้งหมด (ไม่หดตามตัวกรองอื่น — กันเข้าใจ
* ผิดว่าตัวเลือกหายไปตอนไล่กรอง); มิติแรกไม่มีในนี้ — นับไว้ใน options ของ
* แต่ละตัวเลือกเอง */
export type FacetCounts = {
total: number;
bm: number;
ann: number;
type: Partial<Record<AnnotationType, number>>;
user: Record<string, number>;
};
/** ตัวเลือกของมิติแรก (ไฟล์/หน้า) — icon ส่งเข้ามาเอง (ไฟล์ = FileText/
* FileImage, หน้า = spacer ให้เลขชิดซ้ายตรงแนวกลุ่มผู้บันทึก) */
export type FirstDimensionOption = {
key: string | number;
label: string;
icon?: ReactNode;
count: number;
};
export type FirstDimensionGroup = {
label: string;
parentIcon: ReactNode;
allLabel: string;
allIcon?: ReactNode;
options: FirstDimensionOption[];
/** true = ตัวเลือกเยอะ (เช่น "หน้า" หลายสิบแถว) — ครอบด้วย max-h + scroll
* ภายในกลุ่มกันกลุ่มอื่นถูกดันออกนอกจอ (facet หลักต้องเห็นใน first
* viewport ของแผง) */
optionsScroll?: boolean;
};
/** data ตัวแทน (ค่าว่างทั้งหมด — เอาไว้เลือกไอคอนเท่านั้น ไม่เคยเข้าถึงจริง)
* เพื่อ reuse AnnotationTypeIcon ในแถวกรองโดยไม่ต้องคอป map ไอคอนซ้ำ */
const TYPE_ICON_DATA: Record<AnnotationType, AnnotationData> = {
HIGHLIGHT: { text: "", color: "", rects: [], style: "highlight" },
DRAW: { strokeColor: "", strokeWidth: 0, paths: [], pen: "pencil" },
COMMENT: { position: { x: 0, y: 0 }, message: "" },
SHAPE: {
shape: "rect",
rect: { x: 0, y: 0, width: 0, height: 0 },
strokeColor: "",
strokeWidth: 0,
},
REDACTION: { rect: { x: 0, y: 0, width: 0, height: 0 } },
TEXTBOX: { position: { x: 0, y: 0 }, text: "", color: "", fontSize: 0 },
STAMP: { label: "", position: { x: 0, y: 0 }, scale: 0 },
};
const PARENT_ICON_CLS = "h-4 w-4 shrink-0 text-ink-soft";
const LEAF_ICON_CLS = "h-4 w-4 shrink-0 text-ink-soft";
/** parent node — หุบ/กางอิสระ (default กาง) pattern bm-toggle ของต้นไม้ bookmark N2
* disabled = กลุ่มใช้ไม่ได้: หรี่ + หุบ + แถวข้างในล็อก (สรุปยังกางดูได้ —
* open เป็น prop เฉพาะ transition false↔true เท่านั้นที่เขียน DOM การหุบ/กาง
* ด้วยมือของผู้ใช้จึงไม่ถูก render ถัดไปเขียนทับ) */
function FilterGroup({
icon,
label,
open = true,
disabled = false,
children,
}: {
icon: ReactNode;
label: string;
open?: boolean;
disabled?: boolean;
children: ReactNode;
}) {
return (
<details open={open} className="group">
<summary
aria-disabled={disabled}
className={`focus-ring flex cursor-pointer list-none items-center gap-1.5 rounded px-1.5 py-1.5 text-sm font-semibold text-ink transition hover:bg-ink/[0.03] hover:text-primary [&::-webkit-details-marker]:hidden ${
disabled ? "opacity-50" : ""
}`}
>
<ChevronRight
aria-hidden
className="h-3.5 w-3.5 shrink-0 text-ink-faint transition-transform group-open:rotate-90 motion-reduce:transition-none"
/>
{icon}
<span className="min-w-0 flex-1 truncate">{label}</span>
</summary>
<div className="mt-0.5">{children}</div>
</details>
);
}
/** leaf row — single-select มี highlight แถวเดียว, multi-select (multi) เพิ่ม ✓
* border-l-2 โปร่งคงไว้ทุกแถว เปลี่ยนเป็นน้ำเงินเฉพาะ active กัน layout shift
* ไอคอนแรกต่อแนวใต้ chevron ของ parent พอดี 26px = border-l-2 (2) + pl-6 (24)
* = px-1.5 (6) + chevron 14 + gap-1.5 (6) — ต้องนับ border เข้าไปด้วย อย่าแยกออก
* (responsive review 2026-09-24 เคยอ่านเป็น 24px แล้วเข้าใจผิดว่าเพี้ยน) */
function LeafRow({
active,
multi = false,
disabled = false,
icon,
label,
count,
onClick,
}: {
active: boolean;
multi?: boolean;
disabled?: boolean;
icon: ReactNode;
label: string;
count: number;
onClick: () => void;
}) {
return (
<button
type="button"
aria-pressed={active}
disabled={disabled}
onClick={onClick}
title={label}
className="focus-ring flex w-full items-center gap-1.5 rounded border-l-2 border-transparent py-1 pl-6 pr-1.5 text-left text-sm text-ink transition hover:bg-ink/[0.03] aria-pressed:border-l-primary aria-pressed:bg-primary/[0.08] aria-pressed:text-primary disabled:pointer-events-none disabled:opacity-50"
>
{/* กลุ่ม multi มีกล่อง checkbox visual หน้าไอคอน (facet-filter convention —
คำขอผู้ใช้ 2026-09-24): ปกติกรอบเทาโปร่งใน, เลือก = พื้นน้ำเงิน + ✓ ขาว
เป็น visual ของ button เดิม ไม่ใช่ native input (กฎ coarse 44px ยังครบ) */}
{multi && (
<span
aria-hidden
className={`flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors ${
active ? "border-primary bg-primary text-white" : "border-ink-faint"
}`}
>
{active && <Check className="h-3 w-3" />}
</span>
)}
{icon}
<span className="min-w-0 flex-1 truncate">{label}</span>
<span className="shrink-0 rounded-full bg-ink/[0.05] px-1.5 text-[11px] leading-4 tabular-nums text-ink-soft">
{count}
</span>
</button>
);
}
export function FacetFilterPanel({
state,
onChange,
firstGroup,
users,
counts,
}: {
state: FacetFilterState;
onChange: (next: FacetFilterState) => void;
firstGroup: FirstDimensionGroup;
users: UserOption[];
counts: FacetCounts;
}) {
const toggleKind = (k: "bm" | "ann", on: boolean) => {
const kinds = new Set(state.kinds);
if (on) kinds.add(k);
else kinds.delete(k);
// เลิกเลือก Annotation = ไม่ได้ดู annotation แล้ว → ล้างชนิดที่ติ๊กค้างไว้
// ให้เริ่มใหม่ตาม default ตอนกลับมาติ๊ก Annotation (คำขอผู้ใช้ 2026-09-24)
const types = !on && k === "ann" ? new Set<AnnotationType>() : state.types;
onChange({ ...state, kinds, types });
};
const toggleType = (t: AnnotationType, on: boolean) => {
const types = new Set(state.types);
if (on) types.add(t);
else types.delete(t);
// ติ๊กชนิดของ Annotation = ตั้งใจดู annotation → เลือก Annotation ให้อัตโนมัติ
// (คำขอผู้ใช้ 2026-09-24 — ก่อนหน้านี้ bookmark ยังปนอยู่ใน list) — ไม่ถอด
// อัตโนมัติตอนเลิกติ๊ก เพราะผู้ใช้อาจตั้งใจอยู่มุมมอง annotation แล้ว
const kinds = new Set(state.kinds);
if (on) kinds.add("ann");
onChange({ ...state, kinds, types });
};
const firstOptions = firstGroup.options.map((o) => (
<LeafRow
key={o.key}
active={state.first === o.key}
icon={o.icon ?? <FileText aria-hidden className={LEAF_ICON_CLS} />}
label={o.label}
count={o.count}
onClick={() => onChange({ ...state, first: o.key })}
/>
));
return (
<div className="space-y-2">
{/* มิติแรก (ไฟล์/หน้า) — ตัวเลือก + count ส่งมาจากหน้าผ่าน firstGroup;
optionsScroll ครอบด้วยกล่อง scroll เฉพาะกรณีตัวเลือกเยอะ */}
<FilterGroup icon={firstGroup.parentIcon} label={firstGroup.label}>
<LeafRow
active={state.first === null}
icon={firstGroup.allIcon ?? <FileText aria-hidden className={LEAF_ICON_CLS} />}
label={firstGroup.allLabel}
count={counts.total}
onClick={() => onChange({ ...state, first: null })}
/>
{firstGroup.optionsScroll ? (
<div className="max-h-64 overflow-y-auto overscroll-contain scroll-pb-1 scroll-pt-1">
{firstOptions}
</div>
) : (
firstOptions
)}
</FilterGroup>
{/* ไม่เลือก = แสดงทั้งหมด (default) — ติ๊กอะไรเห็นเฉพาะสิ่งนั้น */}
<FilterGroup
icon={<Layers aria-hidden className={PARENT_ICON_CLS} />}
label="ชนิดรายการ"
>
<LeafRow
multi
active={state.kinds.has("bm")}
icon={
<Bookmark aria-hidden className="h-4 w-4 shrink-0 text-warning-ink" />
}
label="Bookmark"
count={counts.bm}
onClick={() => toggleKind("bm", !state.kinds.has("bm"))}
/>
<LeafRow
multi
active={state.kinds.has("ann")}
icon={<PenLine aria-hidden className={LEAF_ICON_CLS} />}
label="Annotation"
count={counts.ann}
onClick={() => toggleKind("ann", !state.kinds.has("ann"))}
/>
</FilterGroup>
{/* กติกาเดียวกับชนิดรายการ: ไม่เลือก = ทุกชนิด (default) ติ๊กอะไรเห็นเฉพาะ
ชนิดนั้น + แสดงเฉพาะชนิดที่มี ≥1 รายการ (กดแล้วได้ list ว่าง = ไม่เสนอ) —
**ไม่ได้ติ๊ก Annotation = กลุ่มนี้ disabled + หุบ** (คำขอผู้ใช้ 2026-09-24:
กรองชนิดมีความหมายเฉพาะเมื่อกำลังดู annotation — ติ๊ก Annotation แล้ว
กลุ่มกลับมาใช้ได้ + กางให้อัตโนมัติ) */}
{counts.ann > 0 && (
<FilterGroup
icon={<Shapes aria-hidden className={PARENT_ICON_CLS} />}
label="ชนิดของ Annotation"
open={state.kinds.has("ann")}
disabled={!state.kinds.has("ann")}
>
{TYPE_ORDER.filter((t) => (counts.type[t] ?? 0) > 0).map((t) => (
<LeafRow
key={t}
multi
disabled={!state.kinds.has("ann")}
active={state.types.has(t)}
icon={
<AnnotationTypeIcon
type={t}
data={TYPE_ICON_DATA[t]}
className={LEAF_ICON_CLS}
/>
}
label={TYPE_SHORT[t]}
count={counts.type[t] ?? 0}
onClick={() => toggleType(t, !state.types.has(t))}
/>
))}
</FilterGroup>
)}
<FilterGroup
icon={<Users aria-hidden className={PARENT_ICON_CLS} />}
label="ผู้บันทึก"
>
{/* ไอคอนคนซ้ำ N แถวเป็น noise — spacer คง indentation แทน (pattern เดียวกับ
leaf ของต้นไม้ bookmark ใน sidebar) */}
<LeafRow
active={state.userId === null}
icon={<span aria-hidden className="w-4 shrink-0" />}
label="ทุกคน"
count={counts.total}
onClick={() => onChange({ ...state, userId: null })}
/>
{users.map((u) => (
<LeafRow
key={u.userId}
active={state.userId === u.userId}
icon={<span aria-hidden className="w-4 shrink-0" />}
label={u.label}
count={counts.user[u.userId] ?? 0}
onClick={() => onChange({ ...state, userId: u.userId })}
/>
))}
</FilterGroup>
</div>
);
}

View file

@ -0,0 +1,186 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, FileImage, FileText, Folder } from "lucide-react";
import {
FacetFilterPanel,
createDefaultFacets,
type ExplorerItem,
type FacetFilterState,
} from "@/components/explorer/FacetFilterPanel";
import type { AnnotationType } from "@/db/schema";
import { GroupList } from "./GroupList";
// N9 หน้ากลุ่ม (redesign 2026-09-24 ตามคำขอผู้ใช้): explorer 2 คอลัมน์ — ซ้ายเป็น
// ตัวกรอง (ไฟล์/ชนิดรายการ/ชนิด annotation/ผู้บันทึก) ขวาเป็น list เดียวรวม
// bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ — ข้อมูลทั้งหมด query ฝั่ง server ส่งมา
// ครั้งเดียวแล้วกรองใน memory (read-only snapshot เหมือนเดิม ไม่มี SSE)
// แผงตัวกรองเป็น component กลาง `components/explorer/FacetFilterPanel` (2026-09-24
// — เดิม FilterControls เป็นของหน้านี้) ใช้ร่วมกับหน้าสรุปที่มิติแรกเป็น "หน้า"
/** ไฟล์ในกลุ่มแบบย่อ — เอาเฉพาะ field ที่ filter/list ใช้ (ไม่ส่ง row เต็มข้าม wire) */
export type GroupFileSummary = {
id: string;
title: string;
isImage: boolean;
};
/** รายการเดียวรวมทั้งกลุ่ม — server เรียงตามลำดับอ่านเอกสารให้แล้ว (ไฟล์ตาม
* createdAt → หน้า → หน้าเดียวกัน bookmark ก่อน annotation → createdAt) */
export type GroupListItem = ExplorerItem;
export function GroupExplorer({
files,
items,
}: {
files: GroupFileSummary[];
items: GroupListItem[];
}) {
const [filters, setFilters] = useState<FacetFilterState>(createDefaultFacets);
// facet count เป็น static จากข้อมูลทั้งกลุ่ม (ไม่หดตามตัวกรองอื่น — กันเข้าใจ
// ผิดว่าตัวเลือกหายไปตอนไล่กรอง)
const counts = useMemo(() => {
let bm = 0;
let ann = 0;
const file: Record<string, number> = {};
const type: Partial<Record<AnnotationType, number>> = {};
const user: Record<string, number> = {};
for (const it of items) {
file[it.dto.fileId] = (file[it.dto.fileId] ?? 0) + 1;
user[it.dto.userId] = (user[it.dto.userId] ?? 0) + 1;
if (it.kind === "bm") bm++;
else {
ann++;
type[it.dto.type] = (type[it.dto.type] ?? 0) + 1;
}
}
return { total: items.length, bm, ann, file, type, user };
}, [items]);
// รายชื่อผู้บันทึกที่มีรายการจริง เรียงชื่อไทย (คนละไฟล์รวมเป็นคนเดียว)
const users = useMemo(() => {
const labelById = new Map<string, string>();
for (const it of items) {
if (!labelById.has(it.dto.userId)) {
labelById.set(it.dto.userId, it.dto.userName || "ไม่ทราบชื่อ");
}
}
const out: { userId: string; label: string }[] = [];
labelById.forEach((label, userId) => out.push({ userId, label }));
return out.sort((a, b) => a.label.localeCompare(b.label, "th"));
}, [items]);
// มิติแรกของหน้ากลุ่ม = ไฟล์ (count รายไฟล์ฝังใน options ของ firstGroup)
const firstGroup = {
label: "ไฟล์",
parentIcon: <Folder aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />,
allLabel: "ทุกไฟล์",
options: files.map((f) => ({
key: f.id,
label: f.title,
count: counts.file[f.id] ?? 0,
icon: f.isImage ? (
<FileImage aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
) : (
<FileText aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
),
})),
};
const filtered = useMemo(
() =>
items.filter(
(it) =>
(!filters.first || it.dto.fileId === filters.first) &&
(filters.kinds.size === 0 || filters.kinds.has(it.kind)) &&
(it.kind === "ann"
? filters.types.size === 0 || filters.types.has(it.dto.type)
: true) &&
(!filters.userId || it.dto.userId === filters.userId),
),
[items, filters],
);
const selectedFile = filters.first
? files.find((f) => f.id === filters.first) ?? null
: null;
// จำนวนตัวกรองที่ ≠ default — โชว์ที่ summary มือถือ (กัน "กรองเก่ากรองเงียบ")
// และเป็นเงื่อนไขปุ่ม "ล้างตัวกรอง"
const activeCount =
(filters.first !== null ? 1 : 0) +
(filters.kinds.size > 0 ? 1 : 0) +
(filters.types.size > 0 ? 1 : 0) +
(filters.userId !== null ? 1 : 0);
const resetFilters = () => setFilters(createDefaultFacets());
return (
<div className="mt-6 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start lg:gap-8">
{/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น <details> แบบเดียวกับหน้าสรุป —
จอ lg ถูก display:none เอาออกจาก grid — ใช้ named group `group/filters`
ไม่ใช่ `group` เปล่า: `group-open:` ของ parent tree ข้างใน match ทุก
ancestor ที่มี .group[open] ใช้ชื่อซ้ำแล้ว chevron ของทุกกลุ่มหมุนตลอด
ตอนกางแผงนี้ (responsive review 2026-09-24) */}
<details className="group/filters rounded-xl border border-line bg-white lg:hidden">
<summary className="focus-ring flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink transition hover:text-primary [&::-webkit-details-marker]:hidden">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open/filters:rotate-180 motion-reduce:transition-none"
/>
</summary>
{/* cap 70dvh + scroll ภายใน: กลุ่ม "ไฟล์"/"ผู้บันทึก" เยอะ ๆ ต้องไม่ดัน
"พบ N รายการ"/list ลงไกลจนมองไม่เห็น (responsive review 2026-09-24) */}
<div className="max-h-[70dvh] overscroll-contain overflow-y-auto border-t border-line/70 px-2.5 pb-3 pt-1">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</details>
{/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ ไฟล์เยอะ scroll ภายในแผงเอง
(p-3 กระชับตามความ dense ของ tree — design 2026-09-24) — h2 "ตัวกรอง (N)"
ให้ parity กับ summary มือถือ, scroll-pt/pb กัน focus ring โดนขอบ scroll
ตัด, max-h เป็น dvh ตาม pattern h-dvh ของ viewer (responsive review 2026-09-24) */}
<aside
aria-label="ตัวกรองรายการ"
className="hidden rounded-xl border border-line bg-white p-3 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100dvh-3rem)] lg:scroll-pb-1 lg:scroll-pt-1 lg:overflow-y-auto"
>
<h2 className="px-1.5 text-sm font-semibold text-ink">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
</h2>
<div className="mt-3">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</aside>
<section className="mt-5 lg:mt-0">
<GroupList
items={filtered}
files={files}
totalCount={counts.total}
selectedFile={selectedFile}
selectedFileTotal={
selectedFile ? counts.file[selectedFile.id] ?? 0 : null
}
activeCount={activeCount}
onReset={resetFilters}
/>
</section>
</div>
);
}

View file

@ -0,0 +1,293 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import {
Bookmark,
ClipboardList,
FileImage,
FileText,
ListFilter,
StickyNote,
} from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import {
annotationNote,
annotationPreviewText,
annotationTypeLabel,
} from "@/utils/annotation-display";
import { formatThaiDate } from "@/utils/date-format";
import type { GroupFileSummary, GroupListItem } from "./GroupExplorer";
// คอลัมน์ content ของหน้ากลุ่ม (design 2026-09-24) — list เดียวรวมทั้งกลุ่ม
// ไม่แยกไฟล์ (ตัวกรองฝั่งซ้ายทำหน้าที่แยกให้แล้ว) แต่ละแถวบอกไฟล์ที่มาที่
// ต้น meta line — แถวเป็น passive surface จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (deep link ?ann= / ?page= เดียวกับหน้าสรุป — คำขอผู้ใช้ 2026-09-24 ไม่กดทั้งแถว
// ทำให้พ้นข้อจำกัด interactive ซ้อน <a> เดิม และกฎ coarse ของ listing-page ที่
// display:inline-flex ชนะ utility flex ก็ไม่กระทบแถวอีกต่อไป — เหลือแค่ปุ่ม)
/** จุดคั่น "·" ของ meta line */
function Sep() {
return <span aria-hidden className="text-ink-faint">·</span>;
}
/** meta line: ไฟล์ที่มา (truncate เฉพาะ span ของตัวเอง — ไม่งั้นชื่อยาวกินบรรทัด
* จนตัด หน้า/ผู้บันทึก/วันที่ ทิ้ง) → หน้า → บริบทกลาง → ผู้บันทึก → วันที่
* flex-wrap ให้วันที่เต็มรูปแบบไทยตกบรรทัดใหม่บนมือถืออย่างสง่า — บริบทกลาง
* ("ใน: …" ของ sub bookmark — ชื่อ parent มาจากข้อความที่คลุม ยาวไม่มีขอบเขต)
* กับ "โดย …" ต้อง min-w-0+truncate ด้วย: whitespace-nowrap เปล่า ๆ หดไม่ได้
* ล้นการ์ดแล้วโดน overflow-hidden ตัดดื้อ ๆ ไม่มี ellipsis (review 2026-09-24) */
function RowMeta({
file,
pageIndex,
middle,
user,
date,
}: {
file: GroupFileSummary;
pageIndex: number;
/** ชื่อชนิดของ annotation หรือ "ใน: …" ของ sub bookmark */
middle?: string;
user: string;
date: string;
}) {
return (
<span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-ink-soft">
<span className="inline-flex min-w-0 max-w-[12rem] items-center gap-1 sm:max-w-[16rem]">
{file.isImage ? (
<FileImage aria-hidden className="h-3 w-3 shrink-0" />
) : (
<FileText aria-hidden className="h-3 w-3 shrink-0" />
)}
<span className="truncate" title={file.title}>
{file.title}
</span>
</span>
<Sep />
<span className="whitespace-nowrap">หน้า {pageIndex + 1}</span>
{middle && (
<>
<Sep />
<span className="min-w-0 max-w-full truncate" title={middle}>
{middle}
</span>
</>
)}
<Sep />
<span className="min-w-0 max-w-full truncate">โดย {user}</span>
<Sep />
{/* formatThaiDate แปลงตาม timezone เครื่องที่ render — SSR/server กับ
hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention
(เดียวกับ AnnotationSidebar/PDFViewer) */}
<span className="whitespace-nowrap" suppressHydrationWarning>
{date}
</span>
</span>
);
}
/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */
function RowNote({ text }: { text: string }) {
return (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote aria-hidden className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{text}</span>
</span>
);
}
/** ปุ่ม secondary "เปิดเอกสารนี้" — ทางเข้าไฟล์ที่ยังไม่มีรายการใด ๆ
* (หน้าเดิมมีปุ่ม Annotate ทุกการ์ด — เก็บไว้ตามคำตัดสินใจผู้ใช้ 2026-09-24) */
function OpenFileButton({ file }: { file: GroupFileSummary }) {
return (
<Link
href={`/annotate/${file.id}`}
className="focus-ring inline-flex items-center gap-1.5 rounded-md border border-line bg-white px-3 py-1.5 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
{file.isImage ? (
<FileImage aria-hidden className="h-3.5 w-3.5" />
) : (
<FileText aria-hidden className="h-3.5 w-3.5" />
)}
เปิดเอกสารนี้
</Link>
);
}
export function GroupList({
items,
files,
totalCount,
selectedFile,
selectedFileTotal,
activeCount,
onReset,
}: {
/** ผ่านตัวกรองแล้ว (เรียงลำดับจาก server คงเดิม) */
items: GroupListItem[];
files: GroupFileSummary[];
totalCount: number;
selectedFile: GroupFileSummary | null;
/** จำนวนรายการทั้งหมดของไฟล์ที่เลือก (ก่อนกรอง) — null = ไม่ได้เลือกไฟล์เดี่ยว */
selectedFileTotal: number | null;
activeCount: number;
onReset: () => void;
}) {
const fileById = useMemo(
() => new Map(files.map((f) => [f.id, f] as const)),
[files],
);
return (
<>
{/* จำนวนผลลัพธ์อยู่ต้นคอลัมน์ content (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 px-1 pb-2.5">
<p className="text-sm text-ink-soft">
พบ{" "}
<span className="font-semibold tabular-nums text-ink">
{items.length}
</span>{" "}
รายการ
</p>
<div className="flex items-center gap-2.5">
{selectedFile && <OpenFileButton file={selectedFile} />}
{activeCount > 0 && (
<button
type="button"
onClick={onReset}
className="focus-ring rounded-md px-1.5 text-sm text-primary transition hover:underline"
>
ล้างตัวกรอง
</button>
)}
</div>
</div>
{items.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-line bg-white">
<ul className="divide-y divide-line/70">
{items.map((item) => {
// items สร้างจาก files ชุดเดียวกันตอน build ที่ server — หาได้เสมอ
const file = fileById.get(item.dto.fileId)!;
// ปลายทาง + คำบอกตำแหน่งต่อชนิดรายการ (title tooltip + aria-label
// ของปุ่ม View — แถวเดียวกันหลายสิบปุ่มชื่อเหมือนกัน ต้องรู้จักแยก)
const isBm = item.kind === "bm";
const where = isBm
? `ไปที่หน้า ${item.pageIndex + 1}`
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
const href = isBm
? `/annotate/${item.dto.fileId}?page=${item.pageIndex + 1}`
: `/annotate/${item.dto.fileId}?ann=${item.dto.id}`;
return (
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (คำขอผู้ใช้ 2026-09-24: ไม่ต้องกดทั้งแถว) จึงไม่มี hover bg ที่แถว
<li
key={item.dto.id}
className="flex items-start gap-2.5 px-3 py-3 sm:px-4"
>
{isBm ? (
<>
<Bookmark
aria-hidden
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
{item.dto.title}
</span>
<RowMeta
file={file}
pageIndex={item.pageIndex}
middle={
item.parentTitle
? `ใน: ${item.parentTitle}`
: undefined
}
user={item.dto.userName || "ไม่ทราบชื่อ"}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{item.dto.note && <RowNote text={item.dto.note} />}
</span>
</>
) : (
<>
<AnnotationTypeIcon
type={item.dto.type}
data={item.dto.data}
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
{annotationPreviewText(item.dto)}
{item.dto.type === "COMMENT" && item.dto.resolved && (
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
จัดการแล้ว
</span>
)}
</span>
<RowMeta
file={file}
pageIndex={item.pageIndex}
middle={annotationTypeLabel(item.dto)}
user={item.dto.userName}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{annotationNote(item.dto) && (
<RowNote text={annotationNote(item.dto)} />
)}
</span>
</>
)}
<Link
href={href}
title={`${where} ใน "${file.title}"`}
aria-label={`View — ${where} ใน "${file.title}"`}
className="focus-ring mt-0.5 inline-flex h-8 shrink-0 items-center rounded-md border border-line bg-white px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-primary hover:text-primary"
>
View
</Link>
</li>
);
})}
</ul>
</div>
) : totalCount === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ยังไม่มี Annotation หรือ Bookmark ในกลุ่มนี้
</p>
<p className="mt-1 text-xs text-ink-soft">
เปิดเอกสารในกลุ่มเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า
</p>
</div>
) : selectedFile && selectedFileTotal === 0 ? (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ไฟล์นี้ยังไม่มี Annotation หรือ Bookmark
</p>
<div className="mt-4">
<OpenFileButton file={selectedFile} />
</div>
</div>
) : (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center">
<ListFilter aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ไม่มีรายการตรงตามตัวกรอง
</p>
<button
type="button"
onClick={onReset}
className="focus-ring mt-4 inline-flex rounded-md border border-line bg-white px-4 py-2 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
ล้างตัวกรองทั้งหมด
</button>
</div>
)}
</>
);
}

View file

@ -1,58 +0,0 @@
"use client";
import { useId, useState, type ReactNode } from "react";
import { Bookmark, ChevronDown, ChevronRight, PenLine } from "lucide-react";
/** หุบ/กางกลุ่มรายการ (Bookmark/Annotation) ในหน้าสรุป — client component ตัวเดียว
* ของหน้า: เก็บแค่ state หุบ/กางต่อบล็อก เริ่มกางไว้เสมอ (เปิดหน้าสรุปครั้งแรกเห็น
* ครบ ผู้ใช้พับเฉพาะกลุ่มที่ไม่สนใจ) — รายการ (children) ส่งมาจาก server component
* ได้ตรง ๆ และเยื้องเข้า (pl-5) ให้หัวข้อกับรายการไม่ชิดเส้นซ้ายเดียวกัน */
const HEADS = {
bookmark: { label: "Bookmark", Icon: Bookmark, iconCls: "text-warning-ink" },
annotation: { label: "Annotation", Icon: PenLine, iconCls: "text-ink-faint" },
} as const;
export default function CollapsibleBlock({
kind,
count,
children,
}: {
kind: keyof typeof HEADS;
count: number;
children: ReactNode;
}) {
const [open, setOpen] = useState(true);
// design review 2026-09-17 L2: aria-controls ชี้ id ของก้อนเนื้อหา (useId
// กันชนกันระหว่างหลายหน้าเอกสารที่ render บล็อกนี้ซ้ำ)
const contentId = useId();
const { label, Icon, iconCls } = HEADS[kind];
return (
<div>
{/* h3 ครอบ button = disclosure pattern มาตรฐาน (heading level คงเดิม) */}
<h3>
<button
type="button"
aria-expanded={open}
aria-controls={contentId}
onClick={() => setOpen((v) => !v)}
className="focus-ring flex w-full items-center gap-1.5 rounded-md py-1.5 text-left text-xs font-semibold text-ink-soft hover:text-ink"
>
{open ? (
<ChevronDown aria-hidden className="h-3.5 w-3.5 shrink-0 text-ink-faint" />
) : (
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-ink-faint" />
)}
<Icon aria-hidden className={`h-3.5 w-3.5 shrink-0 ${iconCls}`} />
{label}
<span className="font-normal tabular-nums">({count})</span>
<span aria-hidden className="ml-1 h-px min-w-4 flex-1 bg-line" />
</button>
</h3>
{open && (
<div id={contentId} className="pl-5">
{children}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,176 @@
"use client";
import { useMemo, useState } from "react";
import { ChevronDown, FileStack } from "lucide-react";
import {
FacetFilterPanel,
createDefaultFacets,
type ExplorerItem,
type FacetFilterState,
} from "@/components/explorer/FacetFilterPanel";
import type { AnnotationType } from "@/db/schema";
import { SummaryList } from "./SummaryList";
// หน้าสรุปหมายเหตุ (F15/D2) redesign 2026-09-24 ตามคำขอผู้ใช้ — explorer 2 คอลัมน์
// แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์" (ไฟล์เดียวทั้งหน้า):
// ซ้าย = แผงตัวกรองกลาง FacetFilterPanel (หน้า/ชนิดรายการ/ชนิดของ Annotation/
// ผู้บันทึก) ขวา = SummaryList การ์ดรายหน้า — ข้อมูล query ฝั่ง server ส่งมา
// ครั้งเดียวแล้วกรองใน memory (read-only snapshot ไม่มี SSE เหมือนเดิม)
export function SummaryExplorer({
fileId,
items,
}: {
fileId: string;
items: ExplorerItem[];
}) {
const [filters, setFilters] = useState<FacetFilterState>(createDefaultFacets);
// facet count เป็น static จากข้อมูลทั้งไฟล์ (ไม่หดตามตัวกรองอื่น — เหตุผลเดียว
// กับหน้ากลุ่ม)
const counts = useMemo(() => {
let bm = 0;
let ann = 0;
const type: Partial<Record<AnnotationType, number>> = {};
const user: Record<string, number> = {};
for (const it of items) {
user[it.dto.userId] = (user[it.dto.userId] ?? 0) + 1;
if (it.kind === "bm") bm++;
else {
ann++;
type[it.dto.type] = (type[it.dto.type] ?? 0) + 1;
}
}
return { total: items.length, bm, ann, type, user };
}, [items]);
// รายชื่อผู้บันทึกที่มีรายการจริง เรียงชื่อไทย — แทนบล็อก "ผู้ร่วมทำ" แบบอ่านอย่าง
// เดียวของหน้าเดิม (กรองตามคนได้ + เห็น count) — localeCompare "th" รันทั้ง
// SSR/hydration หลังย้ายมา client: CLDR สมัยใหม่ของ Node/browser ใกล้กันพอ
// รับความเสี่ยง mismatch ต่ำไว้ (code review 2026-09-24 — ถ้าเจอ hydration
// warning จริง ให้ย้าย sort นี้ไปฝั่ง server แล้วส่งเป็น prop)
const users = useMemo(() => {
const labelById = new Map<string, string>();
for (const it of items) {
if (!labelById.has(it.dto.userId)) {
labelById.set(it.dto.userId, it.dto.userName || "ไม่ทราบชื่อ");
}
}
const out: { userId: string; label: string }[] = [];
labelById.forEach((label, userId) => out.push({ userId, label }));
return out.sort((a, b) => a.label.localeCompare(b.label, "th"));
}, [items]);
// มิติ "หน้า": เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (ทางเข้าหน้าแทนรายการตาม
// หน้าจุดไข่ปลาของหน้าเดิม — เห็น count ก่อนกระโดดดีกว่า) — ไอคอนเป็น spacer
// ให้เลขชิดซ้ายตรงแนวกลุ่มผู้บันทึก; หน้าเยอะเป็นสิบ ๆ รายการใช้ optionsScroll
// ให้ scroll ภายในกลุ่ม (max-h-64) กันกลุ่มอื่นถูกดันออกนอกจอ
const pageOptions = useMemo(() => {
const perPage = new Map<number, number>();
for (const it of items) {
perPage.set(it.pageIndex, (perPage.get(it.pageIndex) ?? 0) + 1);
}
return Array.from(perPage.entries())
.sort((a, b) => a[0] - b[0])
.map(([pi, n]) => ({
key: pi,
label: `หน้า ${pi + 1}`,
count: n,
icon: <span aria-hidden className="w-4 shrink-0" />,
}));
}, [items]);
const firstGroup = {
label: "หน้า",
parentIcon: (
<FileStack aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
),
allLabel: "ทุกหน้า",
options: pageOptions,
optionsScroll: true,
};
// pure predicate — ห้าม sort ซ้ำ (server เรียงลำดับอ่านให้แล้ว)
const filtered = useMemo(
() =>
items.filter(
(it) =>
(filters.first === null || it.pageIndex === filters.first) &&
(filters.kinds.size === 0 || filters.kinds.has(it.kind)) &&
(it.kind === "ann"
? filters.types.size === 0 || filters.types.has(it.dto.type)
: true) &&
(filters.userId === null || it.dto.userId === filters.userId),
),
[items, filters],
);
// จำนวนตัวกรองที่ ≠ default — โชว์ที่ summary มือถือ (กัน "กรองเก่ากรองเงียบ")
// และเป็นเงื่อนไขปุ่ม "ล้างตัวกรอง"/ชุดพิมพ์ฉบับเต็ม
const activeCount =
(filters.first !== null ? 1 : 0) +
(filters.kinds.size > 0 ? 1 : 0) +
(filters.types.size > 0 ? 1 : 0) +
(filters.userId !== null ? 1 : 0);
const resetFilters = () => setFilters(createDefaultFacets());
return (
<div className="mt-6 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start lg:gap-8">
{/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น <details> — named group `group/filters`
กัน `group-open:` match ancestor ซ้ำชื่อ (pattern ของหน้ากลุ่ม); ตอนพิมพ์
ซ่อนทั้งสอง shell — กระดาษได้ฉบับเต็มจาก SummaryList เสมอ */}
<details className="group/filters rounded-xl border border-line bg-white lg:hidden print:hidden">
<summary className="focus-ring flex cursor-pointer list-none items-center justify-between rounded-md px-4 py-3 text-sm font-semibold text-ink transition hover:text-primary [&::-webkit-details-marker]:hidden">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
<ChevronDown
aria-hidden
className="h-4 w-4 shrink-0 text-ink-faint transition-transform group-open/filters:rotate-180 motion-reduce:transition-none"
/>
</summary>
<div className="max-h-[70dvh] overscroll-contain overflow-y-auto border-t border-line/70 px-2.5 pb-3 pt-1">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</details>
{/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ (เหมือนหน้ากลุ่ม) */}
<aside
aria-label="ตัวกรองรายการ"
className="hidden rounded-xl border border-line bg-white p-3 lg:sticky lg:top-6 lg:block lg:max-h-[calc(100dvh-3rem)] lg:scroll-pb-1 lg:scroll-pt-1 lg:overflow-y-auto print:hidden"
>
<h2 className="px-1.5 text-sm font-semibold text-ink">
ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""}
</h2>
<div className="mt-3">
<FacetFilterPanel
state={filters}
onChange={setFilters}
firstGroup={firstGroup}
users={users}
counts={counts}
/>
</div>
</aside>
{/* div ไม่ใช่ section — กฎ print `main.summary-page section` (break-inside
avoid-page) มุ่งที่การ์ดรายหน้าข้างใน ห้ามครอบทั้ง list กลายเป็นก้อนเดียว */}
<div className="mt-5 lg:mt-0">
<SummaryList
fileId={fileId}
items={filtered}
allItems={items}
activeCount={activeCount}
onReset={resetFilters}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,334 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import {
Bookmark,
ChevronRight,
ListFilter,
StickyNote,
} from "lucide-react";
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
import type { AnnotationType } from "@/db/schema";
import {
TYPE_ORDER,
TYPE_SHORT,
annotationNote,
annotationPreviewText,
annotationTypeLabel,
} from "@/utils/annotation-display";
import { formatThaiDate } from "@/utils/date-format";
// คอลัมน์ content ของหน้าสรุป (redesign 2026-09-24) — คงหลัก "1 หน้า 1 การ์ด"
// ของหน้าเดิมแต่ผิวบางลง: หัวการ์ด sticky (เลขหน้า + บรรทัดสรุป + เปิดหน้านี้)
// แทนขอบ folio เต็ม ส่วนแถวรายการเป็น passive surface จุดคลิกเดียวคือปุ่ม
// "View" ท้ายแถวเหมือน GroupList ของหน้ากลุ่ม (คำขอผู้ใช้ 2026-09-24) — deep
// link ?ann= / ?page= เดิม; meta line ไม่มี segment ไฟล์ (ไฟล์เดียวทั้งหน้า)
/** บรรทัดสรุปของหน้า จากรายการที่มองเห็นจริงหลังกรอง (ย้ายจาก server 2026-09-24 —
* กรองเหลือแต่เส้นวาดของใครคนหนึ่ง บรรทัดสรุปต้องเล่าเรื่องนั้น ไม่ใช่ยอดดิบ):
* "ไฮไลต์ 1 · คอมเมนต์ 1 (จัดการแล้ว 1) · โดย ผู้พิพากษา ก., เลขานุการ ข."
* (หน้าที่เหลือแต่ที่คั่นหน้า = "โดย …" อย่างเดียว) */
function pageSummaryLine(items: ExplorerItem[]): string {
const typeCounts: Partial<Record<AnnotationType, number>> = {};
let resolvedCount = 0;
const participants = new Set<string>();
for (const it of items) {
if (it.kind === "ann") {
typeCounts[it.dto.type] = (typeCounts[it.dto.type] ?? 0) + 1;
if (it.dto.type === "COMMENT" && it.dto.resolved) resolvedCount += 1;
participants.add(it.dto.userName);
} else {
participants.add(it.dto.userName || "ไม่ทราบชื่อ");
}
}
const parts: string[] = [];
for (const t of TYPE_ORDER) {
const n = typeCounts[t];
if (!n) continue;
if (t === "COMMENT" && resolvedCount > 0)
parts.push(`คอมเมนต์ ${n} (จัดการแล้ว ${resolvedCount})`);
else parts.push(`${TYPE_SHORT[t]} ${n}`);
}
// localeCompare "th" ทั้ง SSR/hydration — รับความเสี่ยง mismatch ต่ำไว้เหมือน
// users ของ SummaryExplorer (code review 2026-09-24)
const who = Array.from(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}`;
}
/** จุดคั่น "·" ของ meta line */
function Sep() {
return <span aria-hidden className="text-ink-faint">·</span>;
}
/** meta line ของแถวสรุป: บริบทกลาง ("ใน: …" ของ sub bookmark / ชื่อชนิดของ
* annotation) → ผู้บันทึก → วันที่ — ต่างจาก GroupList ตรงไม่มี segment ไฟล์
* (ไฟล์เดียว ชื่ออยู่แถบหัวของหน้าเองแล้ว) */
function RowMeta({
middle,
user,
date,
}: {
middle?: string;
user: string;
date: string;
}) {
return (
<span className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-ink-soft">
{middle && (
<>
<span className="min-w-0 max-w-full truncate" title={middle}>
{middle}
</span>
<Sep />
</>
)}
<span className="min-w-0 max-w-full truncate">โดย {user}</span>
<Sep />
{/* formatThaiDate แปลงตาม timezone เคื่องที่ render — SSR/server กับ
hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention
(เดียวกับ GroupList/AnnotationSidebar) */}
<span className="whitespace-nowrap" suppressHydrationWarning>
{date}
</span>
</span>
);
}
/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */
function RowNote({ text }: { text: string }) {
return (
<span className="mt-0.5 flex items-start gap-1 text-xs leading-relaxed text-ink-soft">
<StickyNote aria-hidden className="mt-0.5 h-3 w-3 shrink-0 text-warning-ink" />
<span className="line-clamp-2 whitespace-pre-wrap break-words">{text}</span>
</span>
);
}
/** การ์ดรายหน้าตามจำนวนที่ได้เห็น — anchorIds เฉพาะชุดบนจอ (ชุดพิมพ์ฉบับเต็ม
* ที่ render คู่กันตอนกรองอยู่ต้องไม่มี id ซ้ำ) */
function PageSections({
fileId,
items,
anchorIds,
}: {
fileId: string;
items: ExplorerItem[];
anchorIds: boolean;
}) {
// จัดกลุ่มตามหน้า — Map เรียงตาม insertion = หน้าน้อย→มาก (items เรียงจาก server)
const pages = useMemo(() => {
const byPage = new Map<number, ExplorerItem[]>();
for (const it of items) {
let arr = byPage.get(it.pageIndex);
if (!arr) {
arr = [];
byPage.set(it.pageIndex, arr);
}
arr.push(it);
}
return Array.from(byPage.entries());
}, [items]);
return (
<div className="flex flex-col gap-4">
{pages.map(([pi, pageItems]) => (
// ห้ามใส่ overflow-hidden — ฆ่า position:sticky ของหัวการ์ด (sticky ไม่
// ทำงานใน ancestor ที่ overflow:hidden; มุมโค้งไม่มีสีหลุดมุมเพราะหัวการ์ด
// ขาวบนการ์ดขาว) — id page-N คงจากหน้าเดิมให้ลิงก์เก่ายังใช้ได้
<section
key={pi}
id={anchorIds ? `page-${pi + 1}` : undefined}
aria-labelledby={anchorIds ? `page-${pi + 1}-title` : undefined}
className="scroll-mt-6 rounded-xl border border-line bg-white"
>
{/* หัวการ์ด sticky ระดับ viewport — ไต่ list ยาวแล้วยังรู้ว่าอยู่หน้าไหน
(รับช่วง orientation จากรายการตามหน้าจุดไข่ปลาที่ถอดออก; บนกระดาษ
กลับเป็นตำแหน่งปกติผ่านกฎ .page-head ใน globals.css) */}
<div className="page-head sticky top-0 z-10 flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-line bg-white px-3 py-2.5 sm:px-4">
<h2
id={anchorIds ? `page-${pi + 1}-title` : undefined}
className="flex items-baseline gap-1 tabular-nums"
>
<span className="text-[11px] font-medium text-ink-soft">หน้า</span>
{/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ
interactive" ความเด่นมาจาก font-display semibold */}
<span className="font-display text-lg font-semibold leading-none text-ink">
{pi + 1}
</span>
</h2>
<p className="min-w-0 flex-1 text-xs leading-relaxed text-ink-soft">
{pageSummaryLine(pageItems)}
</p>
<Link
href={`/annotate/${fileId}?page=${pi + 1}`}
className="focus-ring inline-flex shrink-0 items-center gap-0.5 text-xs text-primary hover:underline"
>
เปิดหน้านี้
<ChevronRight aria-hidden className="h-3 w-3" />
</Link>
</div>
<ul className="divide-y divide-line/70">
{pageItems.map((item) => {
const isBm = item.kind === "bm";
// ปลายทาง + คำบอกตำแหน่งต่อชนิดรายการ (title tooltip + aria-label
// ของปุ่ม View — ปุ่มชื่อเหมือนกันทั้ง list ต้องรู้จักแยก) ไม่ต่อ
// ชื่อไฟล์เหมือนหน้ากลุ่ม (ไฟล์เดียว ชื่ออยู่แถบหัวแล้ว)
const where = isBm
? `ไปที่หน้า ${item.pageIndex + 1}`
: `ดู${annotationTypeLabel(item.dto)}ที่หน้า ${item.pageIndex + 1}`;
const href = isBm
? `/annotate/${fileId}?page=${item.pageIndex + 1}`
: `/annotate/${fileId}?ann=${item.dto.id}`;
return (
// แถวเป็น passive surface — จุดคลิกเดียวคือปุ่ม "View" ท้ายแถว
// (คำขอผู้ใช้ 2026-09-24 เหมือนหน้ากลุ่ม) จึงไม่มี hover bg ที่แถว
<li
key={item.dto.id}
className="flex items-start gap-2.5 px-3 py-3 sm:px-4"
>
{isBm ? (
<>
<Bookmark
aria-hidden
className="mt-1 h-4 w-4 shrink-0 text-warning-ink"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm font-medium leading-snug text-ink">
{item.dto.title}
</span>
<RowMeta
middle={
item.parentTitle
? `ใน: ${item.parentTitle}`
: undefined
}
user={item.dto.userName || "ไม่ทราบชื่อ"}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{item.dto.note && <RowNote text={item.dto.note} />}
</span>
</>
) : (
<>
<AnnotationTypeIcon
type={item.dto.type}
data={item.dto.data}
className="mt-1 h-4 w-4 shrink-0 text-ink-soft"
/>
<span className="min-w-0 flex-1">
<span className="block whitespace-pre-wrap break-words text-sm leading-snug text-ink">
{annotationPreviewText(item.dto)}
{item.dto.type === "COMMENT" && item.dto.resolved && (
<span className="ml-1.5 whitespace-nowrap rounded border border-success/40 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success-ink">
จัดการแล้ว
</span>
)}
</span>
<RowMeta
middle={annotationTypeLabel(item.dto)}
user={item.dto.userName}
date={formatThaiDate(item.dto.createdAt, true)}
/>
{annotationNote(item.dto) && (
<RowNote text={annotationNote(item.dto)} />
)}
</span>
</>
)}
{/* scroll-mt-12: โฟกัสผ่าน Tab แล้วปุ่มไม่จมใต้หัวการ์ด sticky
(~41px) — WCAG 2.4.11 Focus not obscured */}
<Link
href={href}
title={where}
aria-label={`View — ${where}`}
className="focus-ring mt-0.5 inline-flex h-8 shrink-0 scroll-mt-12 items-center rounded-md border border-line bg-white px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-primary hover:text-primary"
>
View
</Link>
</li>
);
})}
</ul>
</section>
))}
</div>
);
}
export function SummaryList({
fileId,
items,
allItems,
activeCount,
onReset,
}: {
fileId: string;
/** ผ่านตัวกรองแล้ว (เรียงลำดับจาก server คงเดิม) */
items: ExplorerItem[];
/** ข้อมูลทั้งหมดก่อนกรอง — ใช้เป็นชุดพิมพ์ฉบับเต็มตอนกรองอยู่ */
allItems: ExplorerItem[];
activeCount: number;
onReset: () => void;
}) {
return (
<>
{/* จำนวนผลลัพธ์อยู่ต้นคอลัมน์ content (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง —
aria-live ให้ screen reader ได้รับแจ้งผลกรองทันทีที่ toggle */}
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 px-1 pb-2.5 print:hidden">
<p className="text-sm text-ink-soft" aria-live="polite">
พบ{" "}
<span className="font-semibold tabular-nums text-ink">
{items.length}
</span>{" "}
รายการ
</p>
{activeCount > 0 && (
<button
type="button"
onClick={onReset}
className="focus-ring rounded-md px-1.5 text-sm text-primary transition hover:underline"
>
ล้างตัวกรอง
</button>
)}
</div>
{items.length > 0 && (
<div className={activeCount > 0 ? "print:hidden" : undefined}>
<PageSections fileId={fileId} items={items} anchorIds />
</div>
)}
{items.length === 0 && (
<div className="rounded-xl border border-dashed border-line bg-white/70 p-10 text-center print:hidden">
<ListFilter aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
<p className="mt-3 text-sm font-medium text-ink-soft">
ไม่มีรายการตรงตามตัวกรอง
</p>
<button
type="button"
onClick={onReset}
className="focus-ring mt-4 inline-flex rounded-md border border-line bg-white px-4 py-2 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
>
ล้างตัวกรองทั้งหมด
</button>
</div>
)}
{/* ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างไว้ต้องไม่ตัดรายการออกเงียบ ๆ
บนกระดาษ (หน้าสรุปมีโอกาสถูกพิมพ์แนบสำนวน ความครบถ้วนสำคัญกว่า subset —
ต้องการ subset มี export CSV อยู่แล้ว); ไม่กรอง = DOM ชุดเดียวไม่ซ้ำ */}
{activeCount > 0 && (
<div className="hidden print:block">
<PageSections fileId={fileId} items={allItems} anchorIds={false} />
</div>
)}
</>
);
}

File diff suppressed because one or more lines are too long

View file

@ -360,6 +360,6 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
- **กลุ่มระดับเดียว กำหนดด้วย `files.type_id`** (จากลิงก์ e-Court `?typeId=` — เขียนที่ `registerExternalFile` ของ N8 เท่านั้น: insert ครั้งแรก + ย้ายกลุ่มเมื่อลิงก์รอบใหม่ส่ง `typeId` คนละค่า, ไม่ส่ง = คงเดิม ห้ามเคลียร์) — ไม่มีตารางกลุ่ม/many-to-many/หลายระดับ: กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query, แสดงผลด้วยรหัสกลุ่มโดยตรง (ไม่มีคอลัมน์ชื่อกลุ่ม — ถอด `type_name` ตามคำขอผู้ใช้) — ไฟล์ seed/อัปโหลด N4/ลิงก์ไม่มี typeId = NULL เสมอ
- **หน้ารายการ `/` จัดกลุ่ม:** section ต่อกลุ่ม (`type_id` ไม่ NULL) เรียงตาม `createdAt` ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน) — การ์ดไฟล์ในกลุ่มเรียง `createdAt` asc เหมือนเดิม + ปุ่ม Annotate เดิม; หัวกลุ่ม = รหัสกลุ่ม + จำนวนไฟล์ + ลิงก์ "เอกสารในกลุ่ม" → `/groups/{encodeURIComponent(typeId)}`; ไฟล์ `type_id` NULL → section **"ไม่มีกลุ่ม"** ท้ายสุด (ใกล้ฟอร์มเพิ่มเอกสาร — แสดงเสมอถ้ามีไฟล์) — จัดกลุ่มเป็นการแสดงผลล้วนบน server component เดิม ไม่มี client component/action ใหม่
- **หน้า `/groups/[typeId]`** (server component `force-dynamic` — login ถูกบังคับโดย `middleware.ts` อยู่แล้ว): query `files where type_id` `orderBy createdAt` — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ไฟล์ย้ายออกหมดแล้ว — ไม่มีตารางกลุ่มค้าง); หัวกลุ่ม (รหัส + "N เอกสาร") + ลิงก์กลับ `/` + กล่อง info **"แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"** แสดงทุกครั้ง (ย่อจากข้อความเดิม "… — อาจไม่ครบทุกฉบับของกลุ่ม" ตามคำขอผู้ใช้ 2026-09-23); รายการ = ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark + ปุ่ม Annotate → `/annotate/{fileId}`; **การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์** (คำขอผู้ใช้ 2026-09-23 — select รายการเต็ม 2 query แทนการ `groupBy` นับอย่างเดียว, รวมเป็นรายการเดียวเรียงตามหน้า หน้าเดียวกันให้ที่คั่นหน้าก่อน, แต่ละแถว = deep link `/annotate/{fileId}?ann=…` / `?page=…` เดียวกับหน้าสรุป F12 + วันเวลาที่สร้าง (`formatThaiDate` withTime) ในบรรทัด meta, ใช้ `<details>` ไม่มี client component — ปิด gap 5.3.10 ครึ่งหลัง "bookmark รวมทั้งกลุ่ม"); `generateMetadata` ตั้งชื่อแท็บ browser ตามรหัสกลุ่ม
- **หน้า `/groups/[typeId]`** (server component `force-dynamic` — login ถูกบังคับโดย `middleware.ts` อยู่แล้ว): query `files where type_id` `orderBy createdAt` — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ไฟล์ย้ายออกหมดแล้ว — ไม่มีตารางกลุ่มค้าง); หัวกลุ่ม (รหัส + "N เอกสาร") + ลิงก์กลับ `/` + กล่อง info **"แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"** (ถอดออกตามคำขอผู้ใช้ 2026-09-24); รายการ = ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark + ปุ่ม Annotate → `/annotate/{fileId}`; **การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์** (คำขอผู้ใช้ 2026-09-23 — select รายการเต็ม 2 query แทนการ `groupBy` นับอย่างเดียว, รวมเป็นรายการเดียวเรียงตามหน้า หน้าเดียวกันให้ที่คั่นหน้าก่อน, แต่ละแถว = deep link `/annotate/{fileId}?ann=…` / `?page=…` เดียวกับหน้าสรุป F12 + วันเวลาที่สร้าง (`formatThaiDate` withTime) ในบรรทัด meta, ใช้ `<details>` ไม่มี client component — ปิด gap 5.3.10 ครึ่งหลัง "bookmark รวมทั้งกลุ่ม"); `generateMetadata` ตั้งชื่อแท็บ browser ตามรหัสกลุ่ม
- **ไม่มี Server Action/SSE/migration ใหม่** (schema `type_id` + index มากับ N8 ใน migration เดียวกัน) — การเปลี่ยนกลุ่มเกิดที่ N8 ตอน register แล้ว redirect ออกจาก `/` ทันที ไม่มี Router Cache ค้าง
- **Out of scope ของ N9:** จัดกลุ่ม/แก้ชื่อ/ลบกลุ่มด้วยมือใน UI, ไฟล์อยู่หลายกลุ่ม (many-to-many) / กลุ่มซ้อนหลายระดับ, export JSON ตามกลุ่ม (N7 ยังรับ `fileId` เดียว), รายการเอกสารครบทุกฉบับของคดีรวมฉบับที่ยังไม่เคยเปิด (ข้อมูลอยู่ที่ e-Court), API ให้ระบบภายนอก (**N10** `docs/PLAN-API.md`)

View file

@ -114,6 +114,23 @@ export function sanitizePdfText(s: string): string {
.trim();
}
/** ชื่อย่อต่อชนิดสำหรับบรรทัดสรุปของแต่ละหน้า (หน้าสรุป F15) + แผงตัวกรอง
* explorer — ชื่อเต็มอยู่ที่ annotationTypeLabel (เดิม define ซ้ำ 2 ที่: หน้าสรุป
* กับ FilterControls ของหน้ากลุ่ม รวมมาที่นี่ตอนแยกแผงเป็นกลาง 2026-09-24) */
export const TYPE_SHORT: Record<AnnotationType, string> = {
HIGHLIGHT: "ไฮไลต์",
DRAW: "เส้นวาด",
COMMENT: "คอมเมนต์",
SHAPE: "รูปทรง",
REDACTION: "ปิดพราง",
TEXTBOX: "กล่องข้อความ",
STAMP: "ตรายาง",
};
/** ลำดับชนิดในบรรทัดสรุป/แผงตัวกรอง — ตามตำแหน่ง key ของ TYPE_SHORT (เรียงให้ตรง
* ลำดับ enum) ให้ deterministic ไม่ขึ้นกับลำดับสร้างรายการ */
export const TYPE_ORDER = Object.keys(TYPE_SHORT) as AnnotationType[];
/** AnnotationDTO เป็น flat type (type/data แยกกัน) → แยกตาม type ก่อนแคสต์ data */
export function annotationTypeLabel(a: AnnotationDTO): string {
switch (a.type) {