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 Link from "next/link";
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { asc, eq } from "drizzle-orm";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import {
|
import { ArrowLeft, Braces, ClipboardList, FileDown } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
Bookmark,
|
|
||||||
Braces,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
ClipboardList,
|
|
||||||
FileDown,
|
|
||||||
StickyNote,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
import type { ExplorerItem } from "@/components/explorer/FacetFilterPanel";
|
||||||
import CollapsibleBlock from "@/components/summary/CollapsibleBlock";
|
import { SummaryExplorer } from "@/components/summary/SummaryExplorer";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import {
|
import {
|
||||||
annotations,
|
annotations,
|
||||||
|
|
@ -22,68 +13,21 @@ import {
|
||||||
files,
|
files,
|
||||||
toAnnotationDTO,
|
toAnnotationDTO,
|
||||||
toBookmarkDTO,
|
toBookmarkDTO,
|
||||||
type AnnotationDTO,
|
|
||||||
type AnnotationType,
|
|
||||||
type BookmarkDTO,
|
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import {
|
|
||||||
annotationNote,
|
|
||||||
annotationPreviewText,
|
|
||||||
annotationTypeLabel,
|
|
||||||
} from "@/utils/annotation-display";
|
|
||||||
import { withBasePath } from "@/utils/base-path";
|
import { withBasePath } from "@/utils/base-path";
|
||||||
|
|
||||||
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
// หน้าสรุปหมายเหตุ (F15/D2) — server component รวม annotation (ทุกผู้ใช้) + bookmark
|
||||||
// จัดกลุ่มตามหน้า แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
// แต่ละแถวกระโดดเป็น deep link `/annotate/<id>?ann=` / `?page=` (F12)
|
||||||
// — design "แฟ้มสำนวน" 2026-09-17: แถบหัวกระดาษน้ำเงิน (สืบต่อจาก tile JN ของแบรนด์) +
|
// — design "แฟ้มสำนวน" 2026-09-17 (แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio +
|
||||||
// รายการตามหน้าจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด (คำขอผู้ใช้ 2026-09-17 แทนแผ่น
|
// รายการตามหน้าจุดไข่ปลา) → redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer
|
||||||
// กระดาษแผ่นเดียวคั่นเส้น) แต่ละการ์ดมีขอบ folio เลขหน้าใหญ่; รายการตามหน้าเป็น anchor
|
// 2 คอลัมน์แบบหน้ากลุ่ม N9: ตัวกรองฝั่งซ้าย (หน้า/ชนิดรายการ/ชนิดของ
|
||||||
// link (#page-N) ล้วน — desktop เป็น sticky aside แบบการ์ด ส่วนมือถือ (< lg) เป็น
|
// Annotation/ผู้บันทึก) + การ์ดรายหน้าหัว sticky — แถบหัวน้ำเงิน + action bar +
|
||||||
// <details> แบบการ์ด ไม่มี JS / client component เดียวของหน้าคือ CollapsibleBlock
|
// empty state คงเดิม; query ทั้งหมดทำที่นี่แล้วส่ง `ExplorerItem[]` ให้
|
||||||
// (หุบ/กางกลุ่ม Bookmark/Annotation)
|
// SummaryExplorer กรองใน memory ฝั่ง client (read-only snapshot ไม่มี SSE)
|
||||||
|
|
||||||
const UUID_RE =
|
const UUID_RE =
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
/^[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
|
/** focus ring ร่วมของทุกลิงก์ในหน้า — Genesis .focus-ring (outline น้ำเงินทึบ 2px
|
||||||
* + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ
|
* + offset ตาม spec Elevation); จอสัมผัสได้ 44px อัตโนมัติจากกฎ
|
||||||
* `main.summary-page a`/`button` ใน globals.css (ห้ามถอด class summary-page ของ <main>) */
|
* `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 bmDtos = bmRows.map(toBookmarkDTO);
|
||||||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
||||||
|
|
||||||
// จัดกลุ่มตามหน้าแบบ union — หน้าปรากฏเมื่อมี annotation หรือ bookmark ≥ 1 รายการ
|
// list เดียวรวมทั้งไฟล์ไล่ตามลำดับอ่าน: หน้า → หน้าเดียวกันให้ที่คั่นหน้าก่อน
|
||||||
const pageMap = new Map<number, PageBucket>();
|
// หมายเหตุ → ในหน้าเดียวกัน bookmark เรียงตามตำแหน่งบนหน้า (N5 เหมือน query/
|
||||||
const bucketOf = (pageIndex: number): PageBucket => {
|
// sidebar — code review 2026-09-24 คืนจาก createdAt ที่เคยแทนที่โดยไม่ตั้งใจ)
|
||||||
let b = pageMap.get(pageIndex);
|
// ส่วน annotation ตาม createdAt (sort ครั้งเดียวบน server — ฝั่ง client กรอง
|
||||||
if (!b) {
|
// เป็น pure predicate ไม่ sort ซ้ำ)
|
||||||
b = { anns: [], bms: [], typeCounts: {}, resolvedCount: 0, participants: new Set() };
|
const items: ExplorerItem[] = [
|
||||||
pageMap.set(pageIndex, b);
|
...bmDtos.map((dto) => ({
|
||||||
}
|
kind: "bm" as const,
|
||||||
return b;
|
pageIndex: dto.pageIndex,
|
||||||
};
|
dto,
|
||||||
for (const a of dtos) {
|
parentTitle: dto.parentId
|
||||||
const b = bucketOf(a.pageIndex);
|
? bookmarkById.get(dto.parentId)?.title ?? null
|
||||||
b.anns.push(a);
|
: null,
|
||||||
b.typeCounts[a.type] = (b.typeCounts[a.type] ?? 0) + 1;
|
})),
|
||||||
if (a.type === "COMMENT" && a.resolved) b.resolvedCount += 1;
|
...dtos.map((dto) => ({ kind: "ann" as const, pageIndex: dto.pageIndex, dto })),
|
||||||
b.participants.add(a.userName);
|
].sort(
|
||||||
}
|
(a, x) =>
|
||||||
for (const b of bmDtos) {
|
a.pageIndex - x.pageIndex ||
|
||||||
const bucket = bucketOf(b.pageIndex);
|
(a.kind === x.kind ? 0 : a.kind === "bm" ? -1 : 1) ||
|
||||||
bucket.bms.push(b);
|
(a.kind === "bm" && x.kind === "bm"
|
||||||
bucket.participants.add(b.userName || "ไม่ทราบชื่อ");
|
? a.dto.yOffset - x.dto.yOffset
|
||||||
}
|
: new Date(a.dto.createdAt).getTime() -
|
||||||
const pages = Array.from(pageMap.entries()).sort((x, y) => x[0] - y[0]);
|
new Date(x.dto.createdAt).getTime()),
|
||||||
|
);
|
||||||
|
|
||||||
// ภาพรวมระดับไฟล์สำหรับรายการตามหน้า desktop (sticky aside) + มือถือ (<details>)
|
// จำนวนหน้าที่มีรายการ ≥1 สำหรับสถิติแถบหัว
|
||||||
const allParticipants = Array.from(
|
const pageCount = new Set(items.map((it) => it.pageIndex)).size;
|
||||||
new Set([
|
|
||||||
...dtos.map((a) => a.userName),
|
|
||||||
...bmDtos.map((b) => b.userName || "ไม่ทราบชื่อ"),
|
|
||||||
]),
|
|
||||||
).sort((x, y) => x.localeCompare(y, "th"));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
|
<main className="summary-page mx-auto max-w-3xl px-4 py-6 lg:max-w-6xl lg:px-8">
|
||||||
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
|
{/* หัวกระดาษสำนวน — แถบน้ำเงินสืบต่อจาก tile JN ของแบรนด์ (หน้ารายการ/login):
|
||||||
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง */}
|
ชื่อรายงาน + ชื่อแฟ้ม + สถิติในที่เดียว แล้วปิดท้ายด้วยเส้นคั่นบาง
|
||||||
|
(ตอนพิมพ์เป็นกรอบเส้นน้ำเงินตามกฎ @media print ของ summary-page) */}
|
||||||
<header className="rounded-xl bg-primary px-5 py-4 text-white sm:px-6">
|
<header className="rounded-xl bg-primary px-5 py-4 text-white sm:px-6">
|
||||||
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
|
<h1 className="text-lg font-semibold leading-snug">สรุปหมายเหตุ</h1>
|
||||||
<p className="mt-0.5 break-words text-sm text-white/85">{file.title}</p>
|
<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">
|
<p className="mt-2 text-xs tabular-nums text-white/70">
|
||||||
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
|
Annotation {dtos.length} รายการ · Bookmark {bmDtos.length} รายการ ·{" "}
|
||||||
{pages.length} หน้า
|
{pageCount} หน้า
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -219,7 +160,9 @@ export default async function SummaryPage({
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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" />
|
<ClipboardList aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||||
|
|
@ -236,235 +179,7 @@ export default async function SummaryPage({
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-5 lg:grid lg:grid-cols-[220px_minmax(0,1fr)] lg:items-start lg:gap-8">
|
<SummaryExplorer fileId={id} items={items} />
|
||||||
{/* รายการตามหน้ามือถือ (จอ < 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>
|
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -241,9 +241,10 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น <button>
|
/* ปุ่มที่ไม่ใช่ <a> (แถวตัวกรองในแผง explorer ของหน้าสรุป, ปุ่มล้างตัวกรอง)
|
||||||
ไม่ใช่ <a> จึงหลุดจากกฎข้างบน (สูงจริง ~29px) — ปุ่มเป็น flex w-full
|
หลุดจากกฎข้างบน — ใส่แค่ min-height (ปุ่มเป็น flex w-full text-left อยู่แล้ว
|
||||||
text-left อยู่แล้ว ใส่แค่ min-height (ไม่ต้อง display/justify แบบกฎ <a>) */
|
ไม่ต้อง display/justify แบบกฎ <a>; เดิมเป็นปุ่มหุบ/กางของ CollapsibleBlock
|
||||||
|
design review 2026-09-17 H1 ก่อนหน้าถูกแทนด้วยตัวกรอง explorer 2026-09-24) */
|
||||||
main.summary-page button {
|
main.summary-page button {
|
||||||
min-height: 2.75rem;
|
min-height: 2.75rem;
|
||||||
}
|
}
|
||||||
|
|
@ -308,7 +309,10 @@
|
||||||
แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ
|
แปลงเป็นกรอบเส้นน้ำเงิน + ตัวอักษรน้ำเงินแทน (อ่านได้บนเครื่องพิมพ์ขาวดำ
|
||||||
ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน
|
ประหยัดหมึก) และไม่ผ่าหน้ากระดาษกลาง section ของหน้าเอกสาร (หน้าที่ยาวเกิน
|
||||||
กระดาษเดียว browser เลื่อนทั้งก้อนขึ้นหน้าถัดไปเอง — break-inside เป็นคำ
|
กระดาษเดียว 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 {
|
@media print {
|
||||||
main.summary-page header {
|
main.summary-page header {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
@ -321,6 +325,14 @@
|
||||||
main.summary-page section {
|
main.summary-page section {
|
||||||
break-inside: avoid-page;
|
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) →
|
/* UX review 2026-09-14 H2 → typography audit 2026-09-16 (T1 + Option B) →
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,14 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { asc, eq, inArray } from "drizzle-orm";
|
import { asc, eq, inArray } from "drizzle-orm";
|
||||||
import {
|
import { ArrowLeft, Folder } from "lucide-react";
|
||||||
ArrowLeft,
|
|
||||||
Bookmark,
|
|
||||||
ChevronDown,
|
|
||||||
FileImage,
|
|
||||||
FileText,
|
|
||||||
Folder,
|
|
||||||
StickyNote,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { AnnotationTypeIcon } from "@/components/AnnotationTypeIcon";
|
import {
|
||||||
import AppNav from "@/components/AppNav";
|
GroupExplorer,
|
||||||
|
type GroupFileSummary,
|
||||||
|
type GroupListItem,
|
||||||
|
} from "@/components/groups/GroupExplorer";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import {
|
import {
|
||||||
annotations,
|
annotations,
|
||||||
|
|
@ -24,41 +19,20 @@ import {
|
||||||
type AnnotationDTO,
|
type AnnotationDTO,
|
||||||
type BookmarkDTO,
|
type BookmarkDTO,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import {
|
|
||||||
annotationNote,
|
|
||||||
annotationPreviewText,
|
|
||||||
annotationTypeLabel,
|
|
||||||
} from "@/utils/annotation-display";
|
|
||||||
import { formatThaiDate } from "@/utils/date-format";
|
|
||||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||||
import { getSessionUser } from "@/lib/session";
|
|
||||||
|
|
||||||
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
|
// N9 (PLAN-DOCSET.md D3) — หน้ารวมเอกสารของกลุ่ม (type_id จากลิงก์ e-Court)
|
||||||
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
|
// server component force-dynamic — login ถูกบังคับโดย middleware.ts อยู่แล้ว
|
||||||
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
|
// snapshot ณ ตอนโหลดเหมือนหน้าสรุป F15 (ไม่มี SSE) — กลุ่มไม่มีไฟล์เลย = 404
|
||||||
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน files ณ ตอน query
|
// เพราะไม่มีตารางกลุ่ม: กลุ่มมีชีวิตตามข้อมูลจริงใน 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[] };
|
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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
|
|
@ -76,10 +50,6 @@ export default async function GroupPage({
|
||||||
}) {
|
}) {
|
||||||
const { typeId } = params;
|
const { typeId } = params;
|
||||||
|
|
||||||
// AppNav ต้องการชื่อผู้ใช้ — defense ซ้อน middleware กัน render หน้าให้คนไม่มี session
|
|
||||||
const user = getSessionUser();
|
|
||||||
if (!user) redirect("/login");
|
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(files)
|
.from(files)
|
||||||
|
|
@ -87,8 +57,7 @@ export default async function GroupPage({
|
||||||
.orderBy(asc(files.createdAt));
|
.orderBy(asc(files.createdAt));
|
||||||
if (rows.length === 0) notFound();
|
if (rows.length === 0) notFound();
|
||||||
|
|
||||||
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS —
|
// bookmark+annotation ทั้งกลุ่ม 2 query แยก (ไม่ join หนัก) แล้วจัดตามไฟล์ใน JS
|
||||||
// ได้ทั้งจำนวนสำหรับบรรทัด meta และรายการเต็มสำหรับกางดูในการ์ด
|
|
||||||
const ids = rows.map((f) => f.id);
|
const ids = rows.map((f) => f.id);
|
||||||
const [annRows, bmRows] = await Promise.all([
|
const [annRows, bmRows] = await Promise.all([
|
||||||
db
|
db
|
||||||
|
|
@ -123,174 +92,70 @@ export default async function GroupPage({
|
||||||
// หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม
|
// หา parent ของ sub bookmark แสดง "ใน: …" — id unique ทั้งตาราง ใช้ map เดียวร่วมทั้งกลุ่ม
|
||||||
const bookmarkById = new Map(bmDtos.map((b) => [b.id, b] as const));
|
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 (
|
return (
|
||||||
<>
|
// listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด)
|
||||||
<AppNav userName={user.userName} />
|
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:max-w-6xl lg:px-8">
|
||||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด) */}
|
<Link
|
||||||
<main className="listing-page mx-auto max-w-3xl px-4 py-8 sm:px-6">
|
href="/"
|
||||||
<Link
|
className="focus-ring inline-flex items-center gap-1.5 rounded-md text-sm text-ink-soft transition hover:text-ink"
|
||||||
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" />
|
||||||
>
|
หน้ารายการ
|
||||||
<ArrowLeft aria-hidden className="h-4 w-4" />
|
</Link>
|
||||||
หน้ารายการ
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<header className="mt-4 flex items-center gap-3">
|
<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]">
|
<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" />
|
<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">
|
|
||||||
แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้
|
|
||||||
</div>
|
</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">
|
{/* หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (แผน N9 D3 เดิม) ถูกถอดออก
|
||||||
{rows.map((f) => {
|
ตามคำขอผู้ใช้ 2026-09-24 — ระบบรู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court
|
||||||
const bucket = byFile.get(f.id) ?? { anns: [], bms: [] };
|
รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบนี้ */}
|
||||||
const total = bucket.anns.length + bucket.bms.length;
|
|
||||||
return (
|
|
||||||
<li
|
|
||||||
key={f.id}
|
|
||||||
className="rounded-xl border border-line bg-white p-5"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
|
||||||
{imageFormatFromUrl(f.pdfUrl) ? (
|
|
||||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
|
||||||
) : (
|
|
||||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<span className="min-w-0 flex-1">
|
|
||||||
<span className="block break-all line-clamp-2 text-body font-medium text-ink">
|
|
||||||
{f.title}
|
|
||||||
</span>
|
|
||||||
{/* วันที่/จำนวนเป็นข้อมูลที่อ่านจริง ใช้ ink-soft (ผ่าน 4.5:1) —
|
|
||||||
ink-faint เก็บไว้ให้ placeholder/disabled เท่านั้น (UX review M2) */}
|
|
||||||
<span className="mt-0.5 block text-caption text-ink-soft">
|
|
||||||
นำเข้า {formatThaiDate(f.createdAt, true)} · Annotations{" "}
|
|
||||||
{bucket.anns.length} · Bookmarks {bucket.bms.length}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<Link
|
|
||||||
href={`/annotate/${f.id}`}
|
|
||||||
className="focus-ring inline-flex h-8 shrink-0 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
|
||||||
>
|
|
||||||
Annotate
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{total > 0 && (
|
<GroupExplorer files={fileSummaries} items={items} />
|
||||||
<details className="group mt-3 border-t border-line/70">
|
</main>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
|
||||||
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี
|
||||||
// ลิงก์กลางจอ) ใช้ร่วมทุกหน้าเนื้อหา (หน้ารายการ /, กลุ่ม /groups/[typeId]) —
|
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ / — หน้ากลุ่ม /groups/[typeId] เลิกใช้ตาม
|
||||||
// แทน SessionBar เดิมที่แยกเป็นแถบของตัวเองกลางหน้า
|
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก
|
||||||
|
// เป็นแถบของตัวเองกลางหน้า
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import LogoutButton from "./LogoutButton";
|
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 เสมอ
|
- **กลุ่มระดับเดียว กำหนดด้วย `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 ใหม่
|
- **หน้ารายการ `/` จัดกลุ่ม:** 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 ค้าง
|
- **ไม่มี 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`)
|
- **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();
|
.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 */
|
/** AnnotationDTO เป็น flat type (type/data แยกกัน) → แยกตาม type ก่อนแคสต์ data */
|
||||||
export function annotationTypeLabel(a: AnnotationDTO): string {
|
export function annotationTypeLabel(a: AnnotationDTO): string {
|
||||||
switch (a.type) {
|
switch (a.type) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue