eCourt-note/components/summary/SummaryList.tsx
Your Name 8cc15ea75d
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 52s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 13s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
refactor(routes): move routes under /note, APIs under /api/note
เตรียม basePath /docedit เป็นพื้นที่กลางให้แอปพี่น้อง (wordaddin ใน
อนาคต) — เดิม basePath ครอบ /docedit/note ทั้งหมดจนแชร์ prefix ไม่ได้
(Next มี basePath ได้ทางเดียว) และจัด API แยกต้นไม้ตามที่ผู้ใช้ขอ

(1) โครงสร้างใหม่: basePath = /docedit + route หน้าทั้งหมดย้ายเข้า
app/note/** + API 4 เส้นย้ายเข้า app/api/note/** + statics ย้ายเข้า
public/note/** (samples, uploads, pdf.worker) — หน้า/statics URL ปลายทาง
คงเดิมเป๊ะ (/docedit/note/…) ส่วน URL API เปลี่ยนเป็น /docedit/api/note/…
ตามคำขอผู้ใช้ (N10 endpoint เปลี่ยน — ต้องแจ้ง backend e-Court ก่อนใช้)

(2) กลไก: withBasePath (utils/base-path.ts) เติม base + app path /note
ยกเว้น path ที่ขึ้นต้น /api/ เติมแค่ base (caller เขียน /api/note/… เต็ม);
resolvePublicPdfPath root ที่ public/note (traversal guard เดิม);
middleware matcher ยกเว้นใหม่ (note/login, api/note, note/uploads,
note/samples, note/pdf.worker, _next, fonts, favicon) + redirect
/note/login; next.config เพิ่ม redirects /docedit -> /docedit/note;
navigation ภายใน 19 จุด + revalidatePath 3 จุด ใส่ prefix /note;
pdf_url ใน DB เป็น logical path ไม่แก้แม้แต่ตัวเดียว (seed/upload
เขียนค่าเดิม, browser เติมผ่าน withBasePath, disk เติมผ่าน guard)

(3) ตามแก้: patch-pdf-worker (DEST public/note + mkdirSync ปลายทาง),
seed-samples (SAMPLES_DIR), uploadFile (UPLOAD_DIR), Dockerfile/
.dockerignore/.gitignore/docker-compose.test.yml, tests 2 ไฟล์ที่อ้าง
disk path จริง, sync เอกสารทั้งหมด (CLAUDE.md invariant #6/#12 + tree,
README, docs/API-DOCUMENTS.md, .claude/rules/*, sync-db skill)

(4) !! ตอน deploy ต้องย้าย volume ตาม: mount /app/public/uploads ->
/app/public/note/uploads + ย้ายไฟล์ใน volume ให้ตรงตำแหน่ง ไม่งั้นไฟล์
ที่เคยอัปโหลด (พร้อม annotation ที่ผูกอยู่) 404 ทันทีที่สลับ image

(5) verify: build ผ่าน (route table /api/note/** + /note/** ครบ), tsc,
vitest 215/215, curl ครบทุก endpoint (login/redirect chain, worker,
samples, uploads, SSE, api/pdf, export, N8 register, N10 bundle + deep
link), Playwright browser จริง (canvas + textLayer ไทย render, browser
ยิง URL ใหม่ทั้งหมดไม่มี request ไป URL เก่า), plan-reviewer +
code-reviewer 3 รายงาน 0 blocking

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-06 14:55:07 +07:00

334 lines
16 KiB
TypeScript

"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={`/note/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
? `/note/annotate/${fileId}?page=${item.pageIndex + 1}`
: `/note/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>
)}
</>
);
}