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:
parent
c5c6b65bd2
commit
df502088b7
14 changed files with 1522 additions and 616 deletions
16
CLAUDE.md
16
CLAUDE.md
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) →
|
||||
|
|
|
|||
|
|
@ -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,174 +92,70 @@ 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">
|
||||
<Link
|
||||
href="/"
|
||||
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
// 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"
|
||||
>
|
||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
||||
หน้ารายการ
|
||||
</Link>
|
||||
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
<Folder aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</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">
|
||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
|
||||
<header className="mt-4 flex items-center gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
<Folder aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<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} เอกสาร · {items.length} รายการ
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
{/* หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (แผน N9 D3 เดิม) ถูกถอดออก
|
||||
ตามคำขอผู้ใช้ 2026-09-24 — ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
|
||||
|
||||
{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 & 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>
|
||||
</main>
|
||||
</>
|
||||
<GroupExplorer files={fileSummaries} items={items} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
354
components/explorer/FacetFilterPanel.tsx
Normal file
354
components/explorer/FacetFilterPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
components/groups/GroupExplorer.tsx
Normal file
186
components/groups/GroupExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
components/groups/GroupList.tsx
Normal file
293
components/groups/GroupList.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
176
components/summary/SummaryExplorer.tsx
Normal file
176
components/summary/SummaryExplorer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
334
components/summary/SummaryList.tsx
Normal file
334
components/summary/SummaryList.tsx
Normal 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
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue