From 44bdb81a60d3a44ba7e379f3c20fe6d8fb284339 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 7 Jul 2025 08:24:16 +0000 Subject: (대표님) 변경사항 20250707 12시 30분 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../table/page-visits-table-columns.tsx | 309 +++++++++++++++++++++ .../table/page-visits-table-toolbar-actions.tsx | 112 ++++++++ lib/page-visits/table/page-visits-table.tsx | 146 ++++++++++ 3 files changed, 567 insertions(+) create mode 100644 lib/page-visits/table/page-visits-table-columns.tsx create mode 100644 lib/page-visits/table/page-visits-table-toolbar-actions.tsx create mode 100644 lib/page-visits/table/page-visits-table.tsx (limited to 'lib/page-visits/table') diff --git a/lib/page-visits/table/page-visits-table-columns.tsx b/lib/page-visits/table/page-visits-table-columns.tsx new file mode 100644 index 00000000..e1d2fed4 --- /dev/null +++ b/lib/page-visits/table/page-visits-table-columns.tsx @@ -0,0 +1,309 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { formatDate } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { ExtendedPageVisit } from "../validation" +import { Eye, ExternalLink, Clock, User, Ellipsis } from "lucide-react" + +interface GetColumnsProps { + setRowAction: React.Dispatch | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + id: "사용자", + header: "사용자", + columns: [ + { + accessorKey: "userEmail", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const userEmail = row.getValue("userEmail") as string | null + const userName = row.original.userName + + if (!userEmail) { + return ( +
+ + 익명 +
+ ) + } + + return ( +
+ {userEmail} + {userName && ( + {userName} + )} +
+ ) + }, + }, + ], + }, + { + id: "페이지 정보", + header: "페이지 정보", + columns: [ + { + accessorKey: "route", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const route = row.getValue("route") as string + const pageTitle = row.original.pageTitle + + return ( +
+ + {route} + + {pageTitle && ( + + {pageTitle} + + )} +
+ ) + }, + }, + { + accessorKey: "visitedAt", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = row.getValue("visitedAt") as Date + return ( + + +
+ {formatDate(date, 'KR')} +
+
+ + {formatDate(date)} + +
+ ) + }, + }, + { + accessorKey: "duration", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const duration = row.getValue("duration") as number | null + const isLongVisit = row.original.isLongVisit + + if (!duration) { + return - + } + + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + + let displayText = "" + if (minutes > 0) { + displayText = `${minutes}분 ${seconds}초` + } else { + displayText = `${seconds}초` + } + + return ( +
+ {isLongVisit && } + + {displayText} + +
+ ) + }, + }, + ], + }, + { + id: "환경 정보", + header: "환경 정보", + columns: [ + { + accessorKey: "deviceType", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const deviceType = row.getValue("deviceType") as string + const variants = { + desktop: "default", + mobile: "secondary", + tablet: "outline", + } as const + + return ( + + {deviceType} + + ) + }, + }, + { + accessorKey: "browserName", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const browserName = row.getValue("browserName") as string | null + const osName = row.original.osName + + return ( +
+ {browserName || "Unknown"} + {osName && ( + {osName} + )} +
+ ) + }, + }, + { + accessorKey: "ipAddress", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {row.getValue("ipAddress")} + + ), + }, + ], + }, + { + id: "추가 정보", + header: "추가 정보", + columns: [ + { + accessorKey: "referrer", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const referrer = row.getValue("referrer") as string | null + + if (!referrer) { + return 직접 접근 + } + + try { + const url = new URL(referrer) + return ( +
+ + + {url.hostname} + +
+ ) + } catch { + return ( + + {referrer} + + ) + } + }, + }, + ], + }, + // { + // id: "actions", + // cell: function Cell({ row }) { + // const visit = row.original + + // return ( + // + // + // + // + // + // setRowAction({ type: "view", row })} + // > + // + // {visit.userEmail && ( + // setRowAction({ type: "viewUserActivity", row })} + // > + // + // )} + // {visit.route && ( + // setRowAction({ type: "viewPageStats", row })} + // > + // + // )} + // + // + // ) + // }, + // enableSorting: false, + // enableHiding: false, + // }, + ] +} \ No newline at end of file diff --git a/lib/page-visits/table/page-visits-table-toolbar-actions.tsx b/lib/page-visits/table/page-visits-table-toolbar-actions.tsx new file mode 100644 index 00000000..5a74a765 --- /dev/null +++ b/lib/page-visits/table/page-visits-table-toolbar-actions.tsx @@ -0,0 +1,112 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import { Download, RotateCcw, BarChart3, Filter } from "lucide-react" +import { useRouter } from "next/navigation" +import { useTransition } from "react" + +import { Button } from "@/components/ui/button" +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip" + +import { ExtendedPageVisit } from "../validation" +import { exportTableToExcel } from "@/lib/export_all" + +interface PageVisitsTableToolbarActionsProps { + table: Table +} + +export function PageVisitsTableToolbarActions({ + table, +}: PageVisitsTableToolbarActionsProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const handleRefresh = () => { + startTransition(() => { + router.refresh() // ✅ 서버 컴포넌트만 새로고침 (더 빠르고 부드러움) + }) + } + + return ( +
+ + + + + +

페이지 방문 데이터를 엑셀로 내보내기

+
+
+ + + + + + +

데이터 새로고침

+
+
+ + + + + + +

페이지별 방문 통계 분석

+
+
+ + + + + + +

고급 필터링 옵션

+
+
+
+ ) +} diff --git a/lib/page-visits/table/page-visits-table.tsx b/lib/page-visits/table/page-visits-table.tsx new file mode 100644 index 00000000..914b8180 --- /dev/null +++ b/lib/page-visits/table/page-visits-table.tsx @@ -0,0 +1,146 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" + +import { getPageVisits } from "../service" +import { PageVisitsTableToolbarActions } from "./page-visits-table-toolbar-actions" +import { getColumns } from "./page-visits-table-columns" +import { ExtendedPageVisit } from "../validation" + +interface PageVisitsTableProps { + promises: Promise< + [ + Awaited>, + ] + > +} + +export function PageVisitsTable({ promises }: PageVisitsTableProps) { + + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = + React.useState | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 기본 필터 필드 + const filterFields: DataTableFilterField[] = [ + { + id: "deviceType", + label: "디바이스", + options: [ + { label: "Desktop", value: "desktop" }, + { label: "Mobile", value: "mobile" }, + { label: "Tablet", value: "tablet" }, + ], + }, + { + id: "browserName", + label: "브라우저", + options: [ + { label: "Chrome", value: "Chrome" }, + { label: "Firefox", value: "Firefox" }, + { label: "Safari", value: "Safari" }, + { label: "Edge", value: "Edge" }, + ], + }, + ] + + // 고급 필터 필드 + const advancedFilterFields: DataTableAdvancedFilterField[] = [ + { + id: "userEmail", + label: "사용자 이메일", + type: "text", + }, + { + id: "route", + label: "페이지 경로", + type: "text", + }, + { + id: "pageTitle", + label: "페이지 제목", + type: "text", + }, + { + id: "deviceType", + label: "디바이스 타입", + type: "multi-select", + options: [ + { label: "Desktop", value: "desktop" }, + { label: "Mobile", value: "mobile" }, + { label: "Tablet", value: "tablet" }, + ], + }, + { + id: "browserName", + label: "브라우저", + type: "multi-select", + options: [ + { label: "Chrome", value: "Chrome" }, + { label: "Firefox", value: "Firefox" }, + { label: "Safari", value: "Safari" }, + { label: "Edge", value: "Edge" }, + ], + }, + { + id: "duration", + label: "체류 시간 (초)", + type: "number", + }, + { + id: "visitedAt", + label: "방문 시간", + type: "date", + }, + { + id: "ipAddress", + label: "IP 주소", + type: "text", + }, + ] + + const { table } = useDataTable({ + data, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "visitedAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + return ( + <> + + + + + + + ) +} \ No newline at end of file -- cgit v1.2.3