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
เตรียม 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>
334 lines
16 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|