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
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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue