diff --git a/CLAUDE.md b/CLAUDE.md index 3eac683..37fd3d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,7 @@ lib/external-import.ts ← N8 registerExternalFile — ลงทะเบีย lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/pdf กับ export ใช้ร่วม) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) -utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/CSV ใช้ร่วม) +utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) @@ -64,11 +64,12 @@ app/api/annotations/export-csv/route.ts ← export สรุป CSV พร้ app/api/bookmarks/export/route.ts ← ส่งออกรายการ bookmark เป็น JSON (N7 — ข้อมูลล้วนจาก DB ไม่โหลดไฟล์ต้นฉบับ; เดิมเป็นรายงาน PDF เปลี่ยนตามคำขอผู้ใช้ 2026-09-17) app/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) -app/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404, หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" + การ์ดแต่ละไฟล์
"Annotations & Bookmarks" หุบ/กางดูรายการ bookmark+annotation ทั้งไฟล์ deep link เหมือนหน้าสรุป F12) +app/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) app/login/page.tsx ← หน้า login (มี session แล้ว redirect /) app/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer -app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark จัดกลุ่มตามหน้า (design "แฟ้มสำนวน" 2026-09-17: แถบหัวน้ำเงิน + รายการตามหน้าจุดไข่ปลา + การ์ดรายหน้า 1 หน้า 1 การ์ด ขอบ folio — คำขอผู้ใช้ 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น; ต่อหน้า = บรรทัดสรุปหน้า (ไม่มีหัวข้อ) + Bookmark/Annotation หุบ-กางได้ผ่าน CollapsibleBlock; รายการตามหน้า desktop = การ์ด sticky มี max-h, มือถือ < lg =
การ์ด ไม่มี JS; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + ซ่อนแถบ action/รายการตามหน้ามือถือ — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) -components/summary/CollapsibleBlock.tsx ← client component เดียวของหน้าสรุป (state หุบ/กางกลุ่ม Bookmark/Annotation — children ส่งจาก server ได้ตรง ๆ) +app/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน/ส่งออก CSV F16/ส่งออก Bookmark JSON N7) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) +components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) +components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) @@ -77,14 +78,17 @@ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบา components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4) components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /?fileUrl=&fileId=&typeId= ให้หน้า / ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court) +components/groups/GroupExplorer.tsx ← N9 client root ของหน้า /groups/[typeId] (redesign 2026-09-24) — state ตัวกรองก้อนเดียว `FacetFilterState` (first=fileId/`kinds: Set<"bm"|"ann">` ชนิดรายการ + `types: Set` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
` แบบหน้าสรุป, desktop aside sticky 15rem scroll ภายใน) — render `FacetFilterPanel` สองที่แชร์ state เดียว (`firstGroup` = ไฟล์) + re-export `GroupListItem` = `ExplorerItem` +components/explorer/FacetFilterPanel.tsx ← แผงตัวกรอง facet **กลางใช้ร่วม 2 หน้า** — ย้ายมาจาก FilterControls ของหน้ากลุ่ม 2026-09-24 ให้หน้าสรุปใช้ร่วม (มิติแรกส่งเข้าทาง prop `firstGroup`: groups = "ไฟล์", สรุป = "หน้า" พร้อม `optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ; อีก 3 กลุ่ม ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก เหมือนกันทุกหน้า) — **tree menu 2 ระดับสไตล์ Quasar docs** (q-tree--dense no-connectors — design รอบสอง 2026-09-24 ตามคำขอผู้ใช้, ธีม Genesis พื้นสว่าง) — **ชนิดรายการ (Bookmark/Annotation) เป็น checkbox คู่ที่ไม่เลือก = แสดงทั้งหมด (default) ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** (direct selection ตามคำขอผู้ใช้ 2026-09-24 รอบสอง — แบบเดิมเปิดครบทั้งคู่ต้องปิดเอง = สับสน; เคยย้ายไป segmented บนหัว content แล้วย้ายกลับตามคำขอผู้ใช้) = parent `
` หุบ/กางอิสระ + chevron หมุน 90°, leaf = `
- {pages.length === 0 ? ( + {items.length === 0 ? ( + /* ไฟล์ว่าง = ไม่ mount explorer เลย — แผงกรองของข้อมูลว่างไม่มีความหมาย + (minimize choices) ทางเข้าเอกสารคือปุ่ม "เปิดหน้าอ่าน" */

@@ -236,235 +179,7 @@ export default async function SummaryPage({

) : ( -
- {/* รายการตามหน้ามือถือ (จอ < lg) — design review 2026-09-17 H2: คืน navigation ที่หาย - ไปบนมือถือ (aside ด้านล่างแสดงเฉพาะ lg) ใช้
ได้หุบ/กาง + keyboard - โดยไม่มี JS เพิ่ม — จุดไข่ปลา + ผู้ร่วมทำ ภาษาเดียวกับ desktop (จอ lg กับ - ตอนพิมพ์ซ่อน — display:none ไม่กินช่องของกริด) */} -
- - รายการตามหน้าและผู้ร่วมทำ - - - -

- ผู้ร่วมทำ: {allParticipants.join(" · ")} -

-
- - {/* รายการตามหน้า (จอ ≥ lg) — จุดไข่ปลาแบบสารบัญของเอกสารราชการ anchor ล้วนไม่มี JS - เป็นการ์ดชุดเดียวกับ
มือถือและการ์ดรายหน้า (แสดงเฉพาะหน้าที่มี - รายการ ≥ 1 ตามหลัก minimize choices; max-h กันเอกสารหลายสิบหน้าสูงเกินจอ - — ครึ่งล่างของ sticky กดไม่ได้ตลอดกาล) */} - - - {/* การ์ดรายหน้า — 1 หน้า 1 การ์ด เรียงลงมาแยกด้วยช่องว่าง (คำขอผู้ใช้ - 2026-09-17 แทนแผ่นกระดาษแผ่นเดียวคั่นเส้น); แต่ละการ์ดมีขอบ folio: - เลขหน้าใหญ่ + เส้นดิ่ง แบบเลขหน้าบนขอบกระดาษ (mt-5 lg:mt-0 — ระยะจาก - รายการตามหน้า
บนมือถือ; จอ lg กริดจัดให้) */} -
- {pages.map(([pi, b]) => { - return ( -
- {/* ขอบ folio — "หน้า" เล็ก + เลขใหญ่ชิดเส้นดิ่ง */} -
-

- - หน้า - - {/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ - interactive" (ตัดสินใจพร้อม exception list ใน DESIGN.md - 2026-09-23) ความเด่นมาจากขนาด 2xl semibold tabular-nums */} - - {pi + 1} - -

-
- - {/* เนื้อหาของหน้า — บรรทัดสรุปหน้า (ไม่มีหัวข้อคำอธิบาย) / - Bookmark / Annotation (หุบ-กางได้, รายการเยื้องจากหัวข้อ) */} -
-
-
-

- {pageSummaryLine(b)} -

-
- - เปิดหน้านี้ - - -
- - {/* ที่คั่นหน้าของหน้านี้ทั้งหมด (root + sub) เรียงตาม yOffset */} - {b.bms.length > 0 && ( -
- -
    - {b.bms.map((bm) => { - const parent = bm.parentId - ? bookmarkById.get(bm.parentId) - : undefined; - return ( -
  • - - - - - {bm.title} - - - โดย {bm.userName || "ไม่ทราบชื่อ"} - {parent ? ` · ใน: ${parent.title}` : ""} - - {bm.note && ( - - - - {bm.note} - - - )} - - - -
  • - ); - })} -
-
-
- )} - - {/* หมายเหตุของหน้านี้ — แถวคลิกได้ทั้งแถว (เป้าใหญ่บนจอสัมผัส) */} - {b.anns.length > 0 && ( -
0 ? "mt-3" : "mt-2"}> - -
    - {b.anns.map((a) => ( -
  • - - - - - {annotationPreviewText(a)} - {a.type === "COMMENT" && a.resolved && ( - - จัดการแล้ว - - )} - - - {annotationTypeLabel(a)} · โดย {a.userName} - - {annotationNote(a) && ( - - - - {annotationNote(a)} - - - )} - - - -
  • - ))} -
-
-
- )} -
-
- ); - })} -
-
+ )}
); diff --git a/app/globals.css b/app/globals.css index 1457539..e490e48 100644 --- a/app/globals.css +++ b/app/globals.css @@ -241,9 +241,10 @@ align-items: center; } - /* design review 2026-09-17 H1: ปุ่มหุบ/กางของ CollapsibleBlock เป็น + ); +} + +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() : 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) => ( + } + label={o.label} + count={o.count} + onClick={() => onChange({ ...state, first: o.key })} + /> + )); + + return ( +
+ {/* มิติแรก (ไฟล์/หน้า) — ตัวเลือก + count ส่งมาจากหน้าผ่าน firstGroup; + optionsScroll ครอบด้วยกล่อง scroll เฉพาะกรณีตัวเลือกเยอะ */} + + } + label={firstGroup.allLabel} + count={counts.total} + onClick={() => onChange({ ...state, first: null })} + /> + {firstGroup.optionsScroll ? ( +
+ {firstOptions} +
+ ) : ( + firstOptions + )} +
+ + {/* ไม่เลือก = แสดงทั้งหมด (default) — ติ๊กอะไรเห็นเฉพาะสิ่งนั้น */} + } + label="ชนิดรายการ" + > + + } + label="Bookmark" + count={counts.bm} + onClick={() => toggleKind("bm", !state.kinds.has("bm"))} + /> + } + label="Annotation" + count={counts.ann} + onClick={() => toggleKind("ann", !state.kinds.has("ann"))} + /> + + + {/* กติกาเดียวกับชนิดรายการ: ไม่เลือก = ทุกชนิด (default) ติ๊กอะไรเห็นเฉพาะ + ชนิดนั้น + แสดงเฉพาะชนิดที่มี ≥1 รายการ (กดแล้วได้ list ว่าง = ไม่เสนอ) — + **ไม่ได้ติ๊ก Annotation = กลุ่มนี้ disabled + หุบ** (คำขอผู้ใช้ 2026-09-24: + กรองชนิดมีความหมายเฉพาะเมื่อกำลังดู annotation — ติ๊ก Annotation แล้ว + กลุ่มกลับมาใช้ได้ + กางให้อัตโนมัติ) */} + {counts.ann > 0 && ( + } + label="ชนิดของ Annotation" + open={state.kinds.has("ann")} + disabled={!state.kinds.has("ann")} + > + {TYPE_ORDER.filter((t) => (counts.type[t] ?? 0) > 0).map((t) => ( + + } + label={TYPE_SHORT[t]} + count={counts.type[t] ?? 0} + onClick={() => toggleType(t, !state.types.has(t))} + /> + ))} + + )} + + } + label="ผู้บันทึก" + > + {/* ไอคอนคนซ้ำ N แถวเป็น noise — spacer คง indentation แทน (pattern เดียวกับ + leaf ของต้นไม้ bookmark ใน sidebar) */} + } + label="ทุกคน" + count={counts.total} + onClick={() => onChange({ ...state, userId: null })} + /> + {users.map((u) => ( + } + label={u.label} + count={counts.user[u.userId] ?? 0} + onClick={() => onChange({ ...state, userId: u.userId })} + /> + ))} + +
+ ); +} diff --git a/components/groups/GroupExplorer.tsx b/components/groups/GroupExplorer.tsx new file mode 100644 index 0000000..c6b53cb --- /dev/null +++ b/components/groups/GroupExplorer.tsx @@ -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(createDefaultFacets); + + // facet count เป็น static จากข้อมูลทั้งกลุ่ม (ไม่หดตามตัวกรองอื่น — กันเข้าใจ + // ผิดว่าตัวเลือกหายไปตอนไล่กรอง) + const counts = useMemo(() => { + let bm = 0; + let ann = 0; + const file: Record = {}; + const type: Partial> = {}; + const user: Record = {}; + 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(); + 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: , + allLabel: "ทุกไฟล์", + options: files.map((f) => ({ + key: f.id, + label: f.title, + count: counts.file[f.id] ?? 0, + icon: f.isImage ? ( + + ) : ( + + ), + })), + }; + + 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 ( +
+ {/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น
แบบเดียวกับหน้าสรุป — + จอ lg ถูก display:none เอาออกจาก grid — ใช้ named group `group/filters` + ไม่ใช่ `group` เปล่า: `group-open:` ของ parent tree ข้างใน match ทุก + ancestor ที่มี .group[open] ใช้ชื่อซ้ำแล้ว chevron ของทุกกลุ่มหมุนตลอด + ตอนกางแผงนี้ (responsive review 2026-09-24) */} +
+ + ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""} + + + {/* cap 70dvh + scroll ภายใน: กลุ่ม "ไฟล์"/"ผู้บันทึก" เยอะ ๆ ต้องไม่ดัน + "พบ N รายการ"/list ลงไกลจนมองไม่เห็น (responsive review 2026-09-24) */} +
+ +
+
+ + {/* 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) */} + + +
+ +
+
+ ); +} diff --git a/components/groups/GroupList.tsx b/components/groups/GroupList.tsx new file mode 100644 index 0000000..e1097ad --- /dev/null +++ b/components/groups/GroupList.tsx @@ -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 ซ้อน
เดิม และกฎ coarse ของ listing-page ที่ +// display:inline-flex ชนะ utility flex ก็ไม่กระทบแถวอีกต่อไป — เหลือแค่ปุ่ม) + +/** จุดคั่น "·" ของ meta line */ +function Sep() { + return ·; +} + +/** 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 ( + + + {file.isImage ? ( + + ) : ( + + )} + + {file.title} + + + + หน้า {pageIndex + 1} + {middle && ( + <> + + + {middle} + + + )} + + โดย {user} + + {/* formatThaiDate แปลงตาม timezone เครื่องที่ render — SSR/server กับ + hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention + (เดียวกับ AnnotationSidebar/PDFViewer) */} + + {date} + + + ); +} + +/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */ +function RowNote({ text }: { text: string }) { + return ( + + + {text} + + ); +} + +/** ปุ่ม secondary "เปิดเอกสารนี้" — ทางเข้าไฟล์ที่ยังไม่มีรายการใด ๆ + * (หน้าเดิมมีปุ่ม Annotate ทุกการ์ด — เก็บไว้ตามคำตัดสินใจผู้ใช้ 2026-09-24) */ +function OpenFileButton({ file }: { file: GroupFileSummary }) { + return ( + + {file.isImage ? ( + + ) : ( + + )} + เปิดเอกสารนี้ + + ); +} + +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 (ที่เห็นผลของตัวกรอง) ไม่ใช่ในแผง */} +
+

+ พบ{" "} + + {items.length} + {" "} + รายการ +

+
+ {selectedFile && } + {activeCount > 0 && ( + + )} +
+
+ + {items.length > 0 ? ( +
+
    + {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 ที่แถว +
  • + {isBm ? ( + <> + + + + {item.dto.title} + + + {item.dto.note && } + + + ) : ( + <> + + + + {annotationPreviewText(item.dto)} + {item.dto.type === "COMMENT" && item.dto.resolved && ( + + จัดการแล้ว + + )} + + + {annotationNote(item.dto) && ( + + )} + + + )} + + View + +
  • + ); + })} +
+
+ ) : totalCount === 0 ? ( +
+ +

+ ยังไม่มี Annotation หรือ Bookmark ในกลุ่มนี้ +

+

+ เปิดเอกสารในกลุ่มเพื่อมาร์กข้อความ วาดเส้น ใส่คอมเมนต์ หรือคั่นหน้า +

+
+ ) : selectedFile && selectedFileTotal === 0 ? ( +
+ +

+ ไฟล์นี้ยังไม่มี Annotation หรือ Bookmark +

+
+ +
+
+ ) : ( +
+ +

+ ไม่มีรายการตรงตามตัวกรอง +

+ +
+ )} + + ); +} diff --git a/components/summary/CollapsibleBlock.tsx b/components/summary/CollapsibleBlock.tsx deleted file mode 100644 index d7e7904..0000000 --- a/components/summary/CollapsibleBlock.tsx +++ /dev/null @@ -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 ( -
- {/* h3 ครอบ button = disclosure pattern มาตรฐาน (heading level คงเดิม) */} -

- -

- {open && ( -
- {children} -
- )} -
- ); -} diff --git a/components/summary/SummaryExplorer.tsx b/components/summary/SummaryExplorer.tsx new file mode 100644 index 0000000..36494ff --- /dev/null +++ b/components/summary/SummaryExplorer.tsx @@ -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(createDefaultFacets); + + // facet count เป็น static จากข้อมูลทั้งไฟล์ (ไม่หดตามตัวกรองอื่น — เหตุผลเดียว + // กับหน้ากลุ่ม) + const counts = useMemo(() => { + let bm = 0; + let ann = 0; + const type: Partial> = {}; + const user: Record = {}; + 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(); + 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(); + 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: , + })); + }, [items]); + + const firstGroup = { + label: "หน้า", + parentIcon: ( + + ), + 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 ( +
+ {/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น
— named group `group/filters` + กัน `group-open:` match ancestor ซ้ำชื่อ (pattern ของหน้ากลุ่ม); ตอนพิมพ์ + ซ่อนทั้งสอง shell — กระดาษได้ฉบับเต็มจาก SummaryList เสมอ */} +
+ + ตัวกรอง{activeCount > 0 ? ` (${activeCount})` : ""} + + +
+ +
+
+ + {/* desktop: แผงตัวกรอง sticky — หน้าไม่มี navbar จึง top-6 พอ (เหมือนหน้ากลุ่ม) */} + + + {/* div ไม่ใช่ section — กฎ print `main.summary-page section` (break-inside + avoid-page) มุ่งที่การ์ดรายหน้าข้างใน ห้ามครอบทั้ง list กลายเป็นก้อนเดียว */} +
+ +
+
+ ); +} diff --git a/components/summary/SummaryList.tsx b/components/summary/SummaryList.tsx new file mode 100644 index 0000000..5828e7b --- /dev/null +++ b/components/summary/SummaryList.tsx @@ -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> = {}; + let resolvedCount = 0; + const participants = new Set(); + 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 ·; +} + +/** meta line ของแถวสรุป: บริบทกลาง ("ใน: …" ของ sub bookmark / ชื่อชนิดของ + * annotation) → ผู้บันทึก → วันที่ — ต่างจาก GroupList ตรงไม่มี segment ไฟล์ + * (ไฟล์เดียว ชื่ออยู่แถบหัวของหน้าเองแล้ว) */ +function RowMeta({ + middle, + user, + date, +}: { + middle?: string; + user: string; + date: string; +}) { + return ( + + {middle && ( + <> + + {middle} + + + + )} + โดย {user} + + {/* formatThaiDate แปลงตาม timezone เคื่องที่ render — SSR/server กับ + hydration/ผู้ใช้อาจต่างกัน ใส่ suppressHydrationWarning ตาม convention + (เดียวกับ GroupList/AnnotationSidebar) */} + + {date} + + + ); +} + +/** โน้ตคำอธิบาย (N1) / โน้ตย่อยของ bookmark (N2) — 2 บรรทัดพอ */ +function RowNote({ text }: { text: string }) { + return ( + + + {text} + + ); +} + +/** การ์ดรายหน้าตามจำนวนที่ได้เห็น — 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(); + 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 ( +
+ {pages.map(([pi, pageItems]) => ( + // ห้ามใส่ overflow-hidden — ฆ่า position:sticky ของหัวการ์ด (sticky ไม่ + // ทำงานใน ancestor ที่ overflow:hidden; มุมโค้งไม่มีสีหลุดมุมเพราะหัวการ์ด + // ขาวบนการ์ดขาว) — id page-N คงจากหน้าเดิมให้ลิงก์เก่ายังใช้ได้ +
+ {/* หัวการ์ด sticky ระดับ viewport — ไต่ list ยาวแล้วยังรู้ว่าอยู่หน้าไหน + (รับช่วง orientation จากรายการตามหน้าจุดไข่ปลาที่ถอดออก; บนกระดาษ + กลับเป็นตำแหน่งปกติผ่านกฎ .page-head ใน globals.css) */} +
+

+ หน้า + {/* เลขหน้าเป็น ink ไม่ใช่ primary — Genesis "น้ำเงินเฉพาะของ + interactive" ความเด่นมาจาก font-display semibold */} + + {pi + 1} + +

+

+ {pageSummaryLine(pageItems)} +

+ + เปิดหน้านี้ + + +
+ +
    + {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 ที่แถว +
  • + {isBm ? ( + <> + + + + {item.dto.title} + + + {item.dto.note && } + + + ) : ( + <> + + + + {annotationPreviewText(item.dto)} + {item.dto.type === "COMMENT" && item.dto.resolved && ( + + จัดการแล้ว + + )} + + + {annotationNote(item.dto) && ( + + )} + + + )} + {/* scroll-mt-12: โฟกัสผ่าน Tab แล้วปุ่มไม่จมใต้หัวการ์ด sticky + (~41px) — WCAG 2.4.11 Focus not obscured */} + + View + +
  • + ); + })} +
+
+ ))} +
+ ); +} + +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 */} +
+

+ พบ{" "} + + {items.length} + {" "} + รายการ +

+ {activeCount > 0 && ( + + )} +
+ + {items.length > 0 && ( +
0 ? "print:hidden" : undefined}> + +
+ )} + + {items.length === 0 && ( +
+ +

+ ไม่มีรายการตรงตามตัวกรอง +

+ +
+ )} + + {/* ตอนพิมพ์แสดงฉบับเต็มเสมอ — ตัวกรองที่ตั้งค้างไว้ต้องไม่ตัดรายการออกเงียบ ๆ + บนกระดาษ (หน้าสรุปมีโอกาสถูกพิมพ์แนบสำนวน ความครบถ้วนสำคัญกว่า subset — + ต้องการ subset มี export CSV อยู่แล้ว); ไม่กรอง = DOM ชุดเดียวไม่ซ้ำ */} + {activeCount > 0 && ( +
+ +
+ )} + + ); +} diff --git a/docs/PLAN-DOCSET.md b/docs/PLAN-DOCSET.md index fd5883d..ef7b632 100644 --- a/docs/PLAN-DOCSET.md +++ b/docs/PLAN-DOCSET.md @@ -2,6 +2,8 @@ > สถานะ: **implement แล้ว (2026-09-23)** — `tsc` + vitest 142/142 + Playwright ผ่านทุกข้อ verify ของ task 3–4 + plan-reviewer/code-reviewer ไม่พบ violation (คงเหลือ e2e ลิงก์ e-Court จริงตามแผน ตอนปิดงาน N8) — เขียนใหม่ 2026-09-22 แทนร่างเดิม "Document Set จัดชุดมือใน UI" (2026-09-17, commit 6940b38) ตามคำขอผู้ใช้: ให้ e-Court บอกกลุ่มผ่านลิงก์แทนการจัดชุดด้วยมือ > เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): การ์ดไฟล์หน้า `/groups/[typeId]` หุบ/กางดู bookmark+annotation ทั้งไฟล์ (ปิด gap 5.3.10 ครึ่งหลังที่เคยเป็น out of scope) + ย่อข้อความหมายเหตุเหลือ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" +> +> Redesign (2026-09-24 ตามคำขอผู้ใช้): หน้า `/groups/[typeId]` เปลี่ยนจากการ์ดต่อไฟล์เป็น **explorer 2 คอลัมน์ ไม่มี navbar** — แผงตัวกรองฝั่งซ้าย (ไฟล์ / ชนิดรายการ bookmark|annotation / ชนิดของ Annotation / ผู้บันทึก) + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์ (รายละเอียด D3) — ผ่าน client components `components/groups/` 3 ตัว > เพิ่มเติม (2026-09-23 ตามคำขอผู้ใช้): ฟอร์มอัปโหลด N4 เพิ่มช่อง **"กลุ่ม typeId"** ทางเลือก (ไม่กรอก = ไม่มีกลุ่ม) — `uploadFile` กลายเป็นจุดเขียน `type_id` ที่สองนอกจาก N8 (insert ตอนสร้าง row ใหม่เท่านั้น normalize เหมือน N8: trim → ว่าง = NULL, cap 200) — ยังไม่มี UI แก้กลุ่มของไฟล์ที่มีอยู่เดิม > เลขฟีเจอร์: ร่างเดิม (2026-09-17) เคยใช้เลข N8 — ยกให้แผนลิงก์ e-Court (`docs/PLAN-ECOURT.md`) ซึ่งเป็น dependency ต้นทาง แผนนี้จึงเป็น **N9** > Dependency: **N8** — การรับ/refresh `typeId` ของเอกสารจากลิงก์เกิดที่ `registerExternalFile` ของ N8 ทั้งหมด (เดิมแผนนี้ไม่มี code path ที่เขียน `type_id` เอง — ต่อมาเพิ่มช่องกลุ่มของฟอร์มอัปโหลด N4 ตามคำขอผู้ใช้ 2026-09-23) — **N8 implement ครบแล้ว (2026-09-22 รวม D5)**: schema `type_id` + index มากับ migration `drizzle/0002` ด้วย @@ -11,13 +13,13 @@ - กรณีจริงของศาล: คดีหนึ่งงวด = หลายเอกสาร (คำฟ้อง / คำพิพากษา / คำสั่งศาล) — ผู้ใช้อยากเห็นเอกสารที่เกี่ยวเนื่องกันจัดกลุ่มมาแสดง และมีหน้ารวมทุกเอกสารของกลุ่ม - ร่างเดิม (2026-09-17) ออกแบบเป็นตาราง `document_sets` + ย้ายไฟล์เข้า/ออกด้วยมือใน UI — ผู้ใช้ตัดสินใจใหม่ (2026-09-22): ให้ e-Court ส่งรหัสกลุ่มมากับลิงก์ (`?typeId=` — เปลี่ยนชื่อจาก caseId ตามคำขอ) จึง**ไม่ต้องมีตารางแยก / Server Actions / UI จัดการกลุ่มเลย** เหลือแค่ column + การแสดงผล -- ข้อจำกัดที่ยอมรับ (ผู้ใช้ยืนยัน 2026-09-22): ระบบเรารู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court (row เกิดตอน `registerExternalFile`) — หน้ากลุ่มจึงแสดง**เฉพาะเอกสารที่รู้จักแล้ว** พร้อมหมายเหตุกำกับว่า "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบเรา — ข้อความเดิมยาวกว่า ย่อตามคำขอผู้ใช้ 2026-09-23) +- ข้อจำกัดที่ยอมรับ (ผู้ใช้ยืนยัน 2026-09-22): ระบบเรารู้จักเฉพาะเอกสารที่เคยเปิดผ่านลิงก์ e-Court (row เกิดตอน `registerExternalFile`) — หน้ากลุ่มจึงแสดง**เฉพาะเอกสารที่รู้จักแล้ว** (เคยมีหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" กำกับ — ถอดออกตามคำขอผู้ใช้ 2026-09-24; รายการครบทั้งคดีอยู่ใน e-Court ไม่ใช่ระบบเรา) ## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22) 1. **กลุ่มระดับเดียว กำหนดด้วย `typeId` จากลิงก์ e-Court** — เพิ่ม column บนตาราง `files` ค่าเดียว (`type_id` — ถอด `type_name` ตามคำขอผู้ใช้ 2026-09-22, แสดงผลด้วยรหัสกลุ่มโดยตรง) — ไม่มีตารางกลุ่มแยก ไม่มี many-to-many ไม่มีหลายระดับ (เอกสารหนึ่งอยู่กลุ่มเดียวตามธรรมชาติของงาน) — ความหมายของ id เป็นของ e-Court ฝั่งเราถือว่า "ส่งมาเหมือนกัน = กลุ่มเดียวกัน" 2. **เข้ามาอัตโนมัติ ไม่มี UI จัดการกลุ่ม** — ไฟล์ได้กลุ่มเมื่อเปิดผ่านลิงก์ที่มี `typeId` (เขียนที่ N8) หรือกรอกช่อง "กลุ่ม typeId" ตอนอัปโหลด N4 (เพิ่ม 2026-09-23) — กลุ่มเกิด/หายตามข้อมูลจริงใน `files` ไม่มีสถานะกลุ่มค้างในตารางอื่น ไม่มีหน้าทางย้ายไฟล์ที่มีอยู่เดิมเข้า/ออกกลุ่ม -3. **หน้ารายการ `/` จัดกลุ่มตาม `typeId`** (section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด) + **หน้าใหม่ `/groups/[typeId]`** แสดงเอกสารทั้งกลุ่มที่รู้จัก พร้อมหมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" และการ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้นได้ (2026-09-23) +3. **หน้ารายการ `/` จัดกลุ่มตาม `typeId`** (section ต่อกลุ่ม + ส่วน "ไม่มีกลุ่ม" ท้ายสุด) + **หน้าใหม่ `/groups/[typeId]`** แสดงเอกสารทั้งกลุ่มที่รู้จัก พร้อมหมายเหตุกำกับ (ถอดออก 2026-09-24 ตามคำขอผู้ใช้) และการ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ของไฟล์นั้นได้ (2026-09-23 — รูปแบบเปลี่ยนเป็น explorer ใน redesign 2026-09-24) ## Design @@ -47,9 +49,14 @@ Server component `force-dynamic` (login ถูกบังคับโดย `mi - โหลด: `files where type_id = params.typeId` `orderBy createdAt` (Next decode dynamic param ให้อัตโนมัติ — **อย่าเรียก `decodeURIComponent` ซ้ำ** ตาม edge case #5) — **ไม่มี = 404** (กลุ่มที่ระบบไม่รู้จัก/ย้ายไฟล์ออกหมดแล้ว) - แสดง: หัวกลุ่ม (`type_id` + "N เอกสาร") + ลิงก์กลับ `/` -- **หมายเหตุกำกับทุกครั้ง** (ผู้ใช้ยืนยัน): "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้" (กล่อง info ใต้หัวกลุ่ม — ย่อจากข้อความเดิม "… — อาจไม่ครบทุกฉบับของกลุ่ม" ตามคำขอผู้ใช้ 2026-09-23) +- ~~**หมายเหตุกำกับทุกครั้ง**: "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ — **ถอดออกตามคำขอผู้ใช้ 2026-09-24** (เดิมเป็นกล่อง info ใต้หัวกลุ่ม ย่อจากข้อความ "… — อาจไม่ครบทุกฉบับของกลุ่ม" 2026-09-23) - รายการเอกสาร: ชื่อไฟล์ + วันที่นำเข้า (`formatThaiDate`) + จำนวน annotation/bookmark + ปุ่ม/ลิงก์ **Annotate → `/annotate/{fileId}`** (ไม่ต้อง deep link หน้า — เข้าไปเหมือนปกติ) -- **การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์** (คำขอผู้ใช้ 2026-09-23 — แถบ summary เป็น en: "Annotations & Bookmarks (N)"): select รายการเต็ม 2 query (`inArray fileId` + `orderBy` หน้า — เดิมนับอย่างเดียวด้วย `groupBy`) แล้วจัดตามไฟล์ใน JS; รวมเป็นรายการเดียวเรียงตามหน้า (หน้าเดียวกันให้ที่คั่นหน้าก่อนหมายเหตุ — ไล่ตามตำแหน่งในเอกสาร); แต่ละแถว deep link เดียวกับหน้าสรุป F12 (`?ann=` / `?page=N`) + รูปแบบแถว/ไอคอน/โน้ต/ป้าย "จัดการแล้ว"/"ใน: …" ของ sub bookmark ตามหน้าสรุป F15 + วันเวลาที่สร้าง (`formatThaiDate` withTime — คำขอผู้ใช้ 2026-09-23) ในบรรทัด meta ก่อน "โดย"; ใช้ `
` ล้วน ไม่มี client component; ไฟล์ที่ไม่มีรายการเลยไม่มีแถบหุบ/กาง +- ~~**การ์ดแต่ละไฟล์หุบ/กางดู bookmark+annotation ทั้งไฟล์** (คำขอผู้ใช้ 2026-09-23 — แถบ summary เป็น en: "Annotations & Bookmarks (N)")~~ → **แทนด้วย explorer 2 คอลัมน์ (redesign 2026-09-24 ตามคำขอผู้ใช้): ไม่มี navbar** (ถอด `AppNav` — เหลือ back link + หัวกลุ่ม + หมายเหตุ): + - **ซ้าย = แผงตัวกรองเป็น tree menu 2 ระดับสไตล์ Quasar docs** (`q-tree--dense q-tree--no-connectors` — รอบสอง 2026-09-24 ตามคำขอผู้ใช้, ธีม Genesis พื้นสว่างตามคำยืนยัน) — desktop aside sticky 15rem scroll ภายใน; มือถือ `` "ตัวกรอง (N)" pattern เดียวกับหน้าสรุป (nested details ทำงานได้): 4 กลุ่ม (**ไฟล์** — "ทุกไฟล์" + รายไฟล์พร้อมไอคอนชนิดไฟล์ + จำนวน แม้ 0 เพื่อกด "เปิดเอกสารนี้" ได้ / **ชนิดรายการ** Bookmark+Annotation — checkbox คู่ **ไม่เลือก = แสดงทั้งหมด (default) ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** (direct selection ตามคำขอผู้ใช้ 2026-09-24 รอบสอง — แบบแรกเปิดครบทั้งคู่ต้องปิดเอง = สับสน; เคยย้ายไป segmented บนหัว content แล้วย้ายกลับมาอยู่ในแผงตามคำขอผู้ใช้) / **ชนิดของ Annotation** 7 ค่า label TYPE_SHORT — กติกาเดียวกับชนิดรายการ: **ไม่เลือก = ทุกชนิด (default) ติ๊กอะไรเห็นเฉพาะชนิดนั้น** (คำขอผู้ใช้ 2026-09-24), **ติ๊กชนิดแล้วเลือก Annotation ในชนิดรายการให้อัตโนมัติ / เลิกเลือก Annotation แล้วล้างชนิดที่ติ๊กค้างไว้เริ่มใหม่ตาม default (คำขอผู้ใช้ 2026-09-24)**, แสดงเฉพาะชนิดที่มี ≥1 รายการ, HIGHLIGHT รวม 4 สไตล์เป็นค่าเดียว / **ผู้บันทึก** — "ทุกคน" + รายคน label จาก `userName || "ไม่ทราบชื่อ"` เรียง localeCompare th) = parent `
` หุบ/กางอิสระ + chevron หมุน 90° (pattern `bm-toggle` ของต้นไม้ bookmark N2), leaf = `