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

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

View file

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