177 lines
8.6 KiB
TypeScript
177 lines
8.6 KiB
TypeScript
|
|
"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<FacetFilterState>(createDefaultFacets);
|
||
|
|
|
||
|
|
// facet count เป็น static จากข้อมูลทั้งไฟล์ (ไม่หดตามตัวกรองอื่น — เหตุผลเดียว
|
||
|
|
// กับหน้ากลุ่ม)
|
||
|
|
const counts = useMemo(() => {
|
||
|
|
let bm = 0;
|
||
|
|
let ann = 0;
|
||
|
|
const type: Partial<Record<AnnotationType, number>> = {};
|
||
|
|
const user: Record<string, number> = {};
|
||
|
|
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<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]);
|
||
|
|
|
||
|
|
// มิติ "หน้า": เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (ทางเข้าหน้าแทนรายการตาม
|
||
|
|
// หน้าจุดไข่ปลาของหน้าเดิม — เห็น count ก่อนกระโดดดีกว่า) — ไอคอนเป็น spacer
|
||
|
|
// ให้เลขชิดซ้ายตรงแนวกลุ่มผู้บันทึก; หน้าเยอะเป็นสิบ ๆ รายการใช้ optionsScroll
|
||
|
|
// ให้ scroll ภายในกลุ่ม (max-h-64) กันกลุ่มอื่นถูกดันออกนอกจอ
|
||
|
|
const pageOptions = useMemo(() => {
|
||
|
|
const perPage = new Map<number, number>();
|
||
|
|
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: <span aria-hidden className="w-4 shrink-0" />,
|
||
|
|
}));
|
||
|
|
}, [items]);
|
||
|
|
|
||
|
|
const firstGroup = {
|
||
|
|
label: "หน้า",
|
||
|
|
parentIcon: (
|
||
|
|
<FileStack aria-hidden className="h-4 w-4 shrink-0 text-ink-soft" />
|
||
|
|
),
|
||
|
|
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 (
|
||
|
|
<div className="mt-6 lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:items-start lg:gap-8">
|
||
|
|
{/* มือถือ (< lg): ตัวกรองหุบ/กางเป็น <details> — named group `group/filters`
|
||
|
|
กัน `group-open:` match ancestor ซ้ำชื่อ (pattern ของหน้ากลุ่ม); ตอนพิมพ์
|
||
|
|
ซ่อนทั้งสอง shell — กระดาษได้ฉบับเต็มจาก SummaryList เสมอ */}
|
||
|
|
<details className="group/filters rounded-xl border border-line bg-white lg:hidden print: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>
|
||
|
|
<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 พอ (เหมือนหน้ากลุ่ม) */}
|
||
|
|
<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 print:hidden"
|
||
|
|
>
|
||
|
|
<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>
|
||
|
|
|
||
|
|
{/* div ไม่ใช่ section — กฎ print `main.summary-page section` (break-inside
|
||
|
|
avoid-page) มุ่งที่การ์ดรายหน้าข้างใน ห้ามครอบทั้ง list กลายเป็นก้อนเดียว */}
|
||
|
|
<div className="mt-5 lg:mt-0">
|
||
|
|
<SummaryList
|
||
|
|
fileId={fileId}
|
||
|
|
items={filtered}
|
||
|
|
allItems={items}
|
||
|
|
activeCount={activeCount}
|
||
|
|
onReset={resetFilters}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|