diff --git a/apps/cs/app/(with-base-layout)/user-statistics/page.tsx b/apps/cs/app/(with-base-layout)/user-statistics/page.tsx new file mode 100644 index 000000000..125900022 --- /dev/null +++ b/apps/cs/app/(with-base-layout)/user-statistics/page.tsx @@ -0,0 +1,2 @@ +import { UserStatisticsPage } from "@/pages/user-statistics"; +export default UserStatisticsPage; diff --git a/apps/cs/src/pages/user-statistics/api/index.ts b/apps/cs/src/pages/user-statistics/api/index.ts new file mode 100644 index 000000000..802b91e30 --- /dev/null +++ b/apps/cs/src/pages/user-statistics/api/index.ts @@ -0,0 +1 @@ +export { UserStatisticsApi } from "./user-statistics-service"; diff --git a/apps/cs/src/pages/user-statistics/api/user-statistics-service.ts b/apps/cs/src/pages/user-statistics/api/user-statistics-service.ts new file mode 100644 index 000000000..10c90c024 --- /dev/null +++ b/apps/cs/src/pages/user-statistics/api/user-statistics-service.ts @@ -0,0 +1,138 @@ +import { + type AccessLog, + type AccessLogParams, + type AccessLogService, + type PageableList, +} from "../model/user-statistics-interface"; + +// 목 데이터 +const mockAccessLogs: AccessLog[] = [ + { + no: 1, + accessTime: "2024-01-15 14:30:25", + accessIp: "192.168.1.100", + accessRegion: "서울특별시", + urlPath: "/index", + browser: "Chrome", + site: "SITE1", + accessType: "메인", + }, + { + no: 2, + accessTime: "2024-01-15 14:28:15", + accessIp: "203.241.50.123", + accessRegion: "부산광역시", + urlPath: "/board/list", + browser: "Edge", + site: "SITE2", + accessType: "게시판", + }, + { + no: 3, + accessTime: "2024-01-15 14:25:42", + accessIp: "175.223.45.67", + accessRegion: "대구광역시", + urlPath: "/statistics", + browser: "Safari", + site: "SITE1", + accessType: "통계", + }, + { + no: 4, + accessTime: "2024-01-15 14:22:18", + accessIp: "211.252.87.45", + accessRegion: "인천광역시", + urlPath: "/contents/view", + browser: "Firefox", + site: "SITE3", + accessType: "컨텐츠", + }, + { + no: 5, + accessTime: "2024-01-15 14:20:33", + accessIp: "121.254.12.89", + accessRegion: "광주광역시", + urlPath: "/search", + browser: "Chrome", + site: "SITE1", + accessType: "검색", + }, +]; + +// 목 API 서비스 +class UserStatisticsApiImpl implements AccessLogService { + async getAccessLogList( + params: AccessLogParams, + ): Promise> { + // 실제 API 호출 대신 목 데이터 반환 + const { page = 0, size = 10 } = params; + + // 필터링 로직 (실제로는 서버에서 처리) + let filteredLogs = [...mockAccessLogs]; + + if (params.urlPath) { + filteredLogs = filteredLogs.filter((log) => + log.urlPath.toLowerCase().includes(params.urlPath!.toLowerCase()), + ); + } + + if (params.accessIp) { + filteredLogs = filteredLogs.filter((log) => + log.accessIp.includes(params.accessIp!), + ); + } + + if (params.accessRegion) { + filteredLogs = filteredLogs.filter((log) => + log.accessRegion.includes(params.accessRegion!), + ); + } + + if (params.browser) { + filteredLogs = filteredLogs.filter( + (log) => log.browser.toLowerCase() === params.browser!.toLowerCase(), + ); + } + + if (params.siteId) { + filteredLogs = filteredLogs.filter((log) => log.site === params.siteId); + } + + // 페이지네이션 + const start = page * size; + const end = start + size; + const paginatedLogs = filteredLogs.slice(start, end); + + // 목 응답 생성 + return { + content: paginatedLogs, + pageable: { + pageNumber: page, + pageSize: size, + sort: { + empty: true, + sorted: false, + unsorted: true, + }, + offset: page * size, + paged: true, + unpaged: false, + }, + last: (page + 1) * size >= filteredLogs.length, + totalPages: Math.ceil(filteredLogs.length / size), + totalElements: filteredLogs.length, + size, + number: page, + sort: { + empty: true, + sorted: false, + unsorted: true, + }, + first: page === 0, + numberOfElements: paginatedLogs.length, + empty: paginatedLogs.length === 0, + }; + } +} + +export const UserStatisticsApi = new UserStatisticsApiImpl(); diff --git a/apps/cs/src/pages/user-statistics/index.ts b/apps/cs/src/pages/user-statistics/index.ts new file mode 100644 index 000000000..6d4fc3a25 --- /dev/null +++ b/apps/cs/src/pages/user-statistics/index.ts @@ -0,0 +1 @@ +export { UserStatisticsPage } from "./ui/user-statistics-page"; diff --git a/apps/cs/src/pages/user-statistics/model/chart-data.ts b/apps/cs/src/pages/user-statistics/model/chart-data.ts new file mode 100644 index 000000000..c836a4e5d --- /dev/null +++ b/apps/cs/src/pages/user-statistics/model/chart-data.ts @@ -0,0 +1,148 @@ +import { type ChartData, type ChartOptions } from "chart.js"; + +// 일별 접속 현황 차트 데이터 +export const monthlyStatisticsData = { + labels: [ + "1일", + "2일", + "3일", + "4일", + "5일", + "6일", + "7일", + "8일", + "9일", + "10일", + "11일", + "12일", + "13일", + "14일", + "15일", + ], + datasets: [ + { + type: "bar" as const, + label: "페이지뷰", + data: [ + 850, 1000, 750, 750, 700, 750, 780, 800, 850, 900, 1200, 1700, 2300, + 2100, 2600, + ], + backgroundColor: "rgba(54, 162, 235, 0.8)", + order: 2, + }, + { + type: "line" as const, + label: "신규 접속자", + data: [ + 700, 600, 500, 550, 500, 550, 500, 550, 600, 850, 1200, 1500, 1700, + 2300, 2200, + ], + borderColor: "rgba(255, 99, 132, 0.8)", + borderWidth: 2, + tension: 0.4, + pointRadius: 3, + order: 0, + }, + { + type: "line" as const, + label: "총 접속자", + data: [ + 200, 300, 400, 500, 600, 500, 550, 500, 400, 500, 350, 800, 900, 1200, + 1300, + ], + borderColor: "rgba(75, 192, 192, 0.8)", + borderWidth: 2, + tension: 0.4, + pointRadius: 3, + order: 1, + }, + ], +}; + +export const monthlyStatisticsOptions: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: "top" as const, + }, + tooltip: { + mode: "index" as const, + intersect: false, + }, + }, +}; + +// 사이트별 접속자 차트 데이터 +export const siteVisitorsData: ChartData<"bar"> = { + labels: ["SITE1", "SITE2", "SITE3", "SITE4", "SITE5"], + datasets: [ + { + label: "사이트별 접속자", + data: [2500, 2000, 1500, 1200, 800], + backgroundColor: [ + "rgba(54, 162, 235, 0.8)", + "rgba(153, 102, 255, 0.8)", + "rgba(255, 99, 132, 0.8)", + "rgba(255, 159, 64, 0.8)", + "rgba(255, 205, 86, 0.8)", + ], + borderWidth: 1, + }, + ], +}; + +export const siteVisitorsOptions: ChartOptions<"bar"> = { + responsive: true, + maintainAspectRatio: false, + indexAxis: "y" as const, + plugins: { + legend: { + display: false, + }, + }, +}; + +// 브라우저별 접속자 차트 데이터 +export const browserStatisticsData: ChartData<"doughnut"> = { + labels: ["Chrome", "Edge", "Safari", "Firefox", "Internet Explorer"], + datasets: [ + { + data: [45, 25, 15, 10, 5], + backgroundColor: [ + "rgba(54, 162, 235, 0.8)", + "rgba(153, 102, 255, 0.8)", + "rgba(255, 99, 132, 0.8)", + "rgba(255, 159, 64, 0.8)", + "rgba(255, 205, 86, 0.8)", + ], + borderColor: [ + "rgba(54, 162, 235, 1)", + "rgba(153, 102, 255, 1)", + "rgba(255, 99, 132, 1)", + "rgba(255, 159, 64, 1)", + "rgba(255, 205, 86, 1)", + ], + borderWidth: 1, + }, + ], +}; + +export const browserStatisticsOptions: ChartOptions<"doughnut"> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + position: "bottom" as const, + }, + }, +}; + +export const chartData = { + monthlyStatisticsData, + monthlyStatisticsOptions, + siteVisitorsData, + siteVisitorsOptions, + browserStatisticsData, + browserStatisticsOptions, +}; diff --git a/apps/cs/src/pages/user-statistics/model/table-columns.ts b/apps/cs/src/pages/user-statistics/model/table-columns.ts new file mode 100644 index 000000000..c0d53380e --- /dev/null +++ b/apps/cs/src/pages/user-statistics/model/table-columns.ts @@ -0,0 +1,39 @@ +import { createColumnHelper } from "@tanstack/react-table"; +import { type AccessLog } from "./user-statistics-interface"; + +const columnHelper = createColumnHelper(); + +export const columns = [ + columnHelper.accessor("no", { + header: "No", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("accessTime", { + header: "접속일시", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("accessIp", { + header: "접속IP", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("accessRegion", { + header: "접속지역", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("urlPath", { + header: "URL경로", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("browser", { + header: "브라우저", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("site", { + header: "사이트", + cell: (info) => info.getValue(), + }), + columnHelper.accessor("accessType", { + header: "메뉴명", + cell: (info) => info.getValue(), + }), +]; diff --git a/apps/cs/src/pages/user-statistics/model/user-statistics-interface.ts b/apps/cs/src/pages/user-statistics/model/user-statistics-interface.ts new file mode 100644 index 000000000..124d91044 --- /dev/null +++ b/apps/cs/src/pages/user-statistics/model/user-statistics-interface.ts @@ -0,0 +1,56 @@ +export interface AccessLog { + no: number; + accessTime: string; + accessIp: string; + accessRegion: string; + urlPath: string; + browser: string; + site: string; + accessType: string; +} + +export interface AccessLogParams { + page?: number; + size?: number; + urlPath?: string; + accessIp?: string; + accessRegion?: string; + browser?: string; + accessDateStart?: string; + accessDateEnd?: string; + siteId?: string; + sort?: string; +} + +export interface AccessLogService { + getAccessLogList(params: AccessLogParams): Promise>; +} + +export interface PageableList { + content: T[]; + pageable: { + pageNumber: number; + pageSize: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + offset: number; + paged: boolean; + unpaged: boolean; + }; + last: boolean; + totalPages: number; + totalElements: number; + size: number; + number: number; + sort: { + empty: boolean; + sorted: boolean; + unsorted: boolean; + }; + first: boolean; + numberOfElements: number; + empty: boolean; +} diff --git a/apps/cs/src/pages/user-statistics/ui/user-statistics-page.tsx b/apps/cs/src/pages/user-statistics/ui/user-statistics-page.tsx new file mode 100644 index 000000000..4c45e04e2 --- /dev/null +++ b/apps/cs/src/pages/user-statistics/ui/user-statistics-page.tsx @@ -0,0 +1,330 @@ +"use client"; + +import dayjs from "dayjs"; +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { getCoreRowModel, type PaginationState } from "@tanstack/react-table"; +import { + Button, + Input, + InputDatePicker, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { SiteSelect } from "@/entities/site"; +import { useDataTable } from "@/shared/lib"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Filler, + Legend, + Tooltip, + type ChartData, +} from "chart.js"; +import { Bar, Doughnut } from "react-chartjs-2"; +import { UserStatisticsApi } from "../api"; +import { chartData } from "../model/chart-data"; +import { columns } from "../model/table-columns"; +import { type AccessLogParams } from "../model/user-statistics-interface"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Filler, + Legend, + Tooltip, +); + +type DateRange = { + from?: Date | undefined; + to?: Date | undefined; +}; + +function UserStatisticsPage() { + const defaultDateRange: DateRange = { + from: dayjs().subtract(7, "day").toDate(), + to: dayjs().toDate(), + }; + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [keywordType, setKeywordType] = useState(""); + const [keyword, setKeyword] = useState(""); + const [presetDateRange, setPresetDateRange] = useState("1W"); + const [dateRange, setDateRange] = useState(defaultDateRange); + const [searchParams, setSearchParams] = useState({ + urlPath: "", + accessIp: "", + accessRegion: "", + browser: "", + accessDateStart: dayjs().subtract(7, "day").format("YYYY-MM-DD"), + accessDateEnd: dayjs().format("YYYY-MM-DD"), + sort: "accessTime,desc", + }); + + const query = useQuery({ + queryKey: ["user-statistics", searchParams, pagination], + queryFn: () => + UserStatisticsApi.getAccessLogList({ + ...searchParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const { tableComponent, tableInstance: table } = useDataTable({ + query, + tableOptions: { + columns, + state: { + pagination, + }, + manualPagination: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + }, + dataTableOptions: { + viewPagination: true, + }, + }); + + const handlePresetDateRange = (value: string) => { + let presetDateRange: DateRange | undefined; + switch (value) { + case "1W": + presetDateRange = { + from: dayjs().subtract(7, "day").toDate(), + to: dayjs().toDate(), + }; + break; + case "1M": + presetDateRange = { + from: dayjs().subtract(30, "day").toDate(), + to: dayjs().toDate(), + }; + break; + case "3M": + presetDateRange = { + from: dayjs().subtract(90, "day").toDate(), + to: dayjs().toDate(), + }; + break; + default: + presetDateRange = undefined; + } + setPresetDateRange(value); + setDateRange(presetDateRange || defaultDateRange); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setPagination({ + ...pagination, + pageIndex: 0, + }); + + // 검색 조건에 따라 검색 파라미터 설정 + const params: AccessLogParams = { + sort: "accessTime,desc", + accessDateStart: dateRange.from + ? dayjs(dateRange.from).format("YYYY-MM-DD") + : "", + accessDateEnd: dateRange.to + ? dayjs(dateRange.to).format("YYYY-MM-DD") + : "", + }; + + // 검색어 타입에 따라 파라미터 설정 + switch (keywordType) { + case "urlPath": + params.urlPath = keyword; + break; + case "ip": + params.accessIp = keyword; + break; + case "region": + params.accessRegion = keyword; + break; + case "browser": + params.browser = keyword; + break; + } + + setSearchParams(params); + }; + + const handleReset = () => { + setKeywordType(""); + setKeyword(""); + setDateRange(defaultDateRange); + setPresetDateRange("1W"); + setSearchParams({ + urlPath: "", + accessIp: "", + accessRegion: "", + browser: "", + accessDateStart: dayjs().subtract(7, "day").format("YYYY-MM-DD"), + accessDateEnd: dayjs().format("YYYY-MM-DD"), + sort: "accessTime,desc", + }); + }; + + return ( +
+
+
+ + + { + setDateRange({ ...dateRange, from: date }); + setPresetDateRange(""); + }} + /> + ~ + { + setDateRange({ ...dateRange, to: date }); + setPresetDateRange(""); + }} + /> + + + + + setKeyword(e.target.value)} + className="h-[3.2rem] w-[24rem] rounded-sm !text-[1.3rem]" + /> + + + +
+ + {/* 차트 영역 */} +
+ {/* 일별 접속 현황 */} +
+
+

일별 접속 현황

+
+
+ } + options={chartData.monthlyStatisticsOptions} + /> +
+
+ +
+ {/* 사이트별 접속자 */} +
+
+

사이트별 접속자

+
+
+ +
+
+ + {/* 브라우저별 접속자 */} +
+
+

브라우저별 접속자

+
+
+ +
+
+
+
+ + {/* 접속 로그 상세 */} +
+
+
+

접속 로그 상세

+
+ +
+
+
+
{tableComponent}
+
+
+ ); +} + +export { UserStatisticsPage };