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

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>
)}
</>
);
}