diff options
Diffstat (limited to 'lib/page-visits')
| -rw-r--r-- | lib/page-visits/service.ts | 169 | ||||
| -rw-r--r-- | lib/page-visits/table/page-visits-table-columns.tsx | 309 | ||||
| -rw-r--r-- | lib/page-visits/table/page-visits-table-toolbar-actions.tsx | 112 | ||||
| -rw-r--r-- | lib/page-visits/table/page-visits-table.tsx | 146 | ||||
| -rw-r--r-- | lib/page-visits/validation.ts | 44 |
5 files changed, 780 insertions, 0 deletions
diff --git a/lib/page-visits/service.ts b/lib/page-visits/service.ts new file mode 100644 index 00000000..66c57eaa --- /dev/null +++ b/lib/page-visits/service.ts @@ -0,0 +1,169 @@ +import db from "@/db/db" +import { loginSessions, pageVisits, users } from "@/db/schema" +import { and, or, ilike, eq, desc, asc, count, sql, isNull } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns"; +import type { GetPageVisitsSchema, ExtendedPageVisit } from "./validation" + +export async function getPageVisits(input: GetPageVisitsSchema) { + try { + const offset = (input.page - 1) * input.perPage; + const advancedTable = true; + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: pageVisits, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + // 전역 검색 + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(pageVisits.route, s), + ilike(pageVisits.pageTitle, s), + ilike(pageVisits.referrer, s), + ilike(pageVisits.deviceType, s), + ilike(pageVisits.browserName, s), + ilike(pageVisits.osName, s), + ilike(users.email, s), + ilike(users.name, s) + ); + } + + // 조건 결합 + const conditions = []; + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + let finalWhere; + if (conditions.length > 0) { + finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + } + + // 정렬 처리 + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => { + // 사용자 관련 필드 정렬 + if (item.id === 'userEmail') { + return item.desc ? desc(users.email) : asc(users.email); + } else if (item.id === 'userName') { + return item.desc ? desc(users.name) : asc(users.name); + } else { + // 페이지 방문 필드 정렬 + return item.desc + ? desc(pageVisits[item.id as keyof typeof pageVisits.$inferSelect]) + : asc(pageVisits[item.id as keyof typeof pageVisits.$inferSelect]); + } + }) + : [desc(pageVisits.visitedAt)]; + + // 데이터 조회 + const data = await db + .select({ + id: pageVisits.id, + userId: pageVisits.userId, + sessionId: pageVisits.sessionId, + route: pageVisits.route, + pageTitle: pageVisits.pageTitle, + referrer: pageVisits.referrer, + ipAddress: pageVisits.ipAddress, + userAgent: pageVisits.userAgent, + visitedAt: pageVisits.visitedAt, + duration: pageVisits.duration, + queryParams: pageVisits.queryParams, + deviceType: pageVisits.deviceType, + browserName: pageVisits.browserName, + osName: pageVisits.osName, + userEmail: users.email, + userName: users.name, + // 지속 시간 포맷팅 + durationFormatted: sql<string>` + CASE + WHEN ${pageVisits.duration} IS NULL THEN NULL + WHEN ${pageVisits.duration} < 60 THEN CONCAT(${pageVisits.duration}, '초') + WHEN ${pageVisits.duration} < 3600 THEN CONCAT(FLOOR(${pageVisits.duration} / 60), '분 ', ${pageVisits.duration} % 60, '초') + ELSE CONCAT(FLOOR(${pageVisits.duration} / 3600), '시간 ', FLOOR((${pageVisits.duration} % 3600) / 60), '분') + END + `, + // 장기 체류 여부 (5분 이상) + isLongVisit: sql<boolean>`${pageVisits.duration} >= 300` + }) + .from(pageVisits) + .leftJoin(users, eq(pageVisits.userId, users.id)) + .where(finalWhere) + .orderBy(...orderBy) + .limit(input.perPage) + .offset(offset); + + // 총 개수 조회 + const totalResult = await db + .select({ count: count() }) + .from(pageVisits) + .leftJoin(users, eq(pageVisits.userId, users.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + const pageCount = Math.ceil(total / input.perPage); + + return { data: data as ExtendedPageVisit[], pageCount }; + } catch (err) { + console.error("Failed to fetch page visits:", err); + return { data: [], pageCount: 0 }; + } +} + + +export async function getUserActivitySummary(userId: string, startDate: Date, endDate: Date) { + try { + // 페이지 방문 통계 + const pageStats = await db + .select({ + route: pageVisits.route, + visitCount: count(), + totalDuration: sql<number>`SUM(${pageVisits.duration})`, + avgDuration: sql<number>`AVG(${pageVisits.duration})`, + }) + .from(pageVisits) + .where( + and( + eq(pageVisits.userId, userId), + between(pageVisits.visitedAt, startDate, endDate) + ) + ) + .groupBy(pageVisits.route) + .orderBy(desc(count())); + + // 세션 통계 + const sessionStats = await db + .select({ + sessionCount: count(), + totalSessionTime: sql<number>` + SUM(EXTRACT(EPOCH FROM ( + COALESCE(${loginSessions.logoutAt}, ${loginSessions.lastActivityAt}) + - ${loginSessions.loginAt} + )) / 60) + `, + }) + .from(loginSessions) + .where( + and( + eq(loginSessions.userId, userId), + between(loginSessions.loginAt, startDate, endDate) + ) + ); + + return { + pageStats, + sessionStats: sessionStats[0] || { sessionCount: 0, totalSessionTime: 0 }, + }; + } catch (error) { + console.error("Failed to get user activity summary:", error); + return { + pageStats: [], + sessionStats: { sessionCount: 0, totalSessionTime: 0 }, + }; + } +}
\ No newline at end of file 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<React.SetStateAction<DataTableRowAction<ExtendedPageVisit> | null>> +} + +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedPageVisit>[] { + return [ + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + id: "사용자", + header: "사용자", + columns: [ + { + accessorKey: "userEmail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="사용자" /> + ), + cell: ({ row }) => { + const userEmail = row.getValue("userEmail") as string | null + const userName = row.original.userName + + if (!userEmail) { + return ( + <div className="flex items-center gap-2"> + <User className="size-3 text-muted-foreground" /> + <span className="text-muted-foreground text-xs">익명</span> + </div> + ) + } + + return ( + <div className="flex flex-col"> + <span className="font-medium text-sm">{userEmail}</span> + {userName && ( + <span className="text-xs text-muted-foreground">{userName}</span> + )} + </div> + ) + }, + }, + ], + }, + { + id: "페이지 정보", + header: "페이지 정보", + columns: [ + { + accessorKey: "route", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="경로" /> + ), + cell: ({ row }) => { + const route = row.getValue("route") as string + const pageTitle = row.original.pageTitle + + return ( + <div className="flex flex-col max-w-[200px]"> + <code className="text-xs bg-muted px-1 py-0.5 rounded font-mono"> + {route} + </code> + {pageTitle && ( + <span className="text-xs text-muted-foreground mt-1 truncate"> + {pageTitle} + </span> + )} + </div> + ) + }, + }, + { + accessorKey: "visitedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="방문 시간" /> + ), + cell: ({ row }) => { + const date = row.getValue("visitedAt") as Date + return ( + <Tooltip> + <TooltipTrigger> + <div className="text-sm"> + {formatDate(date, 'KR')} + </div> + </TooltipTrigger> + <TooltipContent> + {formatDate(date)} + </TooltipContent> + </Tooltip> + ) + }, + }, + { + accessorKey: "duration", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="체류 시간" /> + ), + cell: ({ row }) => { + const duration = row.getValue("duration") as number | null + const isLongVisit = row.original.isLongVisit + + if (!duration) { + return <span className="text-muted-foreground">-</span> + } + + const minutes = Math.floor(duration / 60) + const seconds = duration % 60 + + let displayText = "" + if (minutes > 0) { + displayText = `${minutes}분 ${seconds}초` + } else { + displayText = `${seconds}초` + } + + return ( + <div className="flex items-center gap-2"> + {isLongVisit && <Clock className="size-3 text-orange-500" />} + <span className={isLongVisit ? "font-medium" : ""}> + {displayText} + </span> + </div> + ) + }, + }, + ], + }, + { + id: "환경 정보", + header: "환경 정보", + columns: [ + { + accessorKey: "deviceType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="디바이스" /> + ), + cell: ({ row }) => { + const deviceType = row.getValue("deviceType") as string + const variants = { + desktop: "default", + mobile: "secondary", + tablet: "outline", + } as const + + return ( + <Badge variant={variants[deviceType as keyof typeof variants] || "default"} className="text-xs"> + {deviceType} + </Badge> + ) + }, + }, + { + accessorKey: "browserName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="브라우저" /> + ), + cell: ({ row }) => { + const browserName = row.getValue("browserName") as string | null + const osName = row.original.osName + + return ( + <div className="flex flex-col"> + <span className="text-sm">{browserName || "Unknown"}</span> + {osName && ( + <span className="text-xs text-muted-foreground">{osName}</span> + )} + </div> + ) + }, + }, + { + accessorKey: "ipAddress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="IP" /> + ), + cell: ({ row }) => ( + <code className="text-xs bg-muted px-2 py-1 rounded"> + {row.getValue("ipAddress")} + </code> + ), + }, + ], + }, + { + id: "추가 정보", + header: "추가 정보", + columns: [ + { + accessorKey: "referrer", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="리퍼러" /> + ), + cell: ({ row }) => { + const referrer = row.getValue("referrer") as string | null + + if (!referrer) { + return <span className="text-muted-foreground text-xs">직접 접근</span> + } + + try { + const url = new URL(referrer) + return ( + <div className="flex items-center gap-1"> + <ExternalLink className="size-3" /> + <span className="text-xs truncate max-w-[100px]"> + {url.hostname} + </span> + </div> + ) + } catch { + return ( + <span className="text-xs truncate max-w-[100px]"> + {referrer} + </span> + ) + } + }, + }, + ], + }, + // { + // id: "actions", + // cell: function Cell({ row }) { + // const visit = row.original + + // return ( + // <DropdownMenu> + // <DropdownMenuTrigger asChild> + // <Button + // aria-label="Open menu" + // variant="ghost" + // className="flex size-8 p-0 data-[state=open]:bg-muted" + // > + // <Ellipsis className="size-4" aria-hidden="true" /> + // </Button> + // </DropdownMenuTrigger> + // <DropdownMenuContent align="end" className="w-40"> + // <DropdownMenuItem + // onSelect={() => setRowAction({ type: "view", row })} + // > + // <Eye className="mr-2 size-4" aria-hidden="true" /> + // 상세 보기 + // </DropdownMenuItem> + // {visit.userEmail && ( + // <DropdownMenuItem + // onSelect={() => setRowAction({ type: "viewUserActivity", row })} + // > + // <User className="mr-2 size-4" aria-hidden="true" /> + // 사용자 활동 + // </DropdownMenuItem> + // )} + // {visit.route && ( + // <DropdownMenuItem + // onSelect={() => setRowAction({ type: "viewPageStats", row })} + // > + // <ExternalLink className="mr-2 size-4" aria-hidden="true" /> + // 페이지 통계 + // </DropdownMenuItem> + // )} + // </DropdownMenuContent> + // </DropdownMenu> + // ) + // }, + // 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<ExtendedPageVisit> +} + +export function PageVisitsTableToolbarActions({ + table, +}: PageVisitsTableToolbarActionsProps) { + const router = useRouter() + const [isPending, startTransition] = useTransition() + + const handleRefresh = () => { + startTransition(() => { + router.refresh() // ✅ 서버 컴포넌트만 새로고침 (더 빠르고 부드러움) + }) + } + + return ( + <div className="flex items-center gap-2"> + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "page-visits", + excludeColumns: ["select", "actions"], + }) + } + > + <Download className="mr-2 size-4" aria-hidden="true" /> + Export + </Button> + </TooltipTrigger> + <TooltipContent> + <p>페이지 방문 데이터를 엑셀로 내보내기</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={handleRefresh} + disabled={isPending} // 로딩 중 비활성화 + > + <RotateCcw + className={`mr-2 size-4 ${isPending ? 'animate-spin' : ''}`} + aria-hidden="true" + /> + {isPending ? '새로고침 중...' : '새로고침'} + </Button> + </TooltipTrigger> + <TooltipContent> + <p>데이터 새로고침</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + // 페이지 통계 분석 기능 + console.log("Generate page analytics") + }} + > + <BarChart3 className="mr-2 size-4" aria-hidden="true" /> + 페이지 분석 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>페이지별 방문 통계 분석</p> + </TooltipContent> + </Tooltip> + + <Tooltip> + <TooltipTrigger asChild> + <Button + variant="outline" + size="sm" + onClick={() => { + // 고급 필터링 옵션 + console.log("Advanced filtering options") + }} + > + <Filter className="mr-2 size-4" aria-hidden="true" /> + 고급 필터 + </Button> + </TooltipTrigger> + <TooltipContent> + <p>고급 필터링 옵션</p> + </TooltipContent> + </Tooltip> + </div> + ) +} 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<ReturnType<typeof getPageVisits>>, + ] + > +} + +export function PageVisitsTable({ promises }: PageVisitsTableProps) { + + const [{ data, pageCount }] = React.use(promises) + + const [rowAction, setRowAction] = + React.useState<DataTableRowAction<ExtendedPageVisit> | null>(null) + + const columns = React.useMemo( + () => getColumns({ setRowAction }), + [setRowAction] + ) + + // 기본 필터 필드 + const filterFields: DataTableFilterField<ExtendedPageVisit>[] = [ + { + 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<ExtendedPageVisit>[] = [ + { + 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 ( + <> + <DataTable table={table}> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <PageVisitsTableToolbarActions table={table} /> + </DataTableAdvancedToolbar> + </DataTable> + </> + ) +}
\ No newline at end of file diff --git a/lib/page-visits/validation.ts b/lib/page-visits/validation.ts new file mode 100644 index 00000000..5364505a --- /dev/null +++ b/lib/page-visits/validation.ts @@ -0,0 +1,44 @@ +import { + createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum, + } from "nuqs/server" + import * as z from "zod" + + import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + import { pageVisits, users } from "@/db/schema" + + // 조인된 데이터 타입 정의 + export type ExtendedPageVisit = typeof pageVisits.$inferSelect & { + userEmail?: string | null; + userName?: string | null; + durationFormatted?: string; // 계산된 필드 + isLongVisit?: boolean; // 5분 이상 체류 + }; + + // 검색 파라미터 캐시 정의 + export const searchParamsCache = createSearchParamsCache({ + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<ExtendedPageVisit>().withDefault([ + { id: "visitedAt", desc: true }, + ]), + + // 기본 필터 + route: parseAsString.withDefault(""), + deviceType: parseAsString.withDefault(""), + browserName: parseAsString.withDefault(""), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + search: parseAsString.withDefault(""), + }); + + // 타입 내보내기 + export type GetPageVisitsSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
\ No newline at end of file |
