diff --git a/apps/cs/app/(with-base-layout)/board-statistics/page.tsx b/apps/cs/app/(with-base-layout)/board-statistics/page.tsx new file mode 100644 index 000000000..02821377f --- /dev/null +++ b/apps/cs/app/(with-base-layout)/board-statistics/page.tsx @@ -0,0 +1,2 @@ +import { BoardStatisticsPage } from "@/pages/board-statistics"; +export default BoardStatisticsPage; diff --git a/apps/cs/src/pages/board-statistics/api/board-statistics-service.ts b/apps/cs/src/pages/board-statistics/api/board-statistics-service.ts new file mode 100644 index 000000000..1897e39ba --- /dev/null +++ b/apps/cs/src/pages/board-statistics/api/board-statistics-service.ts @@ -0,0 +1,57 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + type BoardStatisticsParams, + type BoardStatistics, + type BoardStatisticsService, +} from "../model/board-statistics-interface"; + +// 임시 모의 데이터 +const mockBoardStatisticsData: BoardStatistics = { + totalPosts: 2456, + totalComments: 156, + averageRating: 4.2, + totalViews: 45678, + dayList: [ + { date: "1월", posts: 10, comments: 5, views: 800 }, + { date: "2월", posts: 15, comments: 8, views: 1200 }, + { date: "3월", posts: 12, comments: 10, views: 1000 }, + { date: "4월", posts: 18, comments: 15, views: 1500 }, + { date: "5월", posts: 22, comments: 20, views: 1800 }, + { date: "6월", posts: 20, comments: 18, views: 1600 }, + { date: "7월", posts: 25, comments: 22, views: 2000 }, + ], + boardList: [ + { boardType: "notice", boardName: "공지사항", count: 45 }, + { boardType: "faq", boardName: "FAQ", count: 120 }, + { boardType: "qna", boardName: "Q&A", count: 180 }, + { boardType: "free", boardName: "자유게시판", count: 95 }, + ], + statusList: [ + { status: "completed", statusName: "답변완료", count: 75 }, + { status: "waiting", statusName: "답변대기", count: 25 }, + ], +}; + +class BoardStatisticsApiService implements BoardStatisticsService { + async getBoardStatistics( + params: BoardStatisticsParams, + ): Promise { + try { + // 실제 API 연동 시 아래 코드 사용 + // const response = await client.get( + // `${ENDPOINT.CMS_SERVICE.BOARD_STATISTICS}`, + // { params }, + // ); + // return response.data; + + // 임시 모의 데이터 반환 + return Promise.resolve(mockBoardStatisticsData); + } catch (error) { + console.error("게시판 통계 데이터 조회 실패:", error); + throw error; + } + } +} + +export const BoardStatisticsApi = new BoardStatisticsApiService(); diff --git a/apps/cs/src/pages/board-statistics/index.ts b/apps/cs/src/pages/board-statistics/index.ts new file mode 100644 index 000000000..8ed0e1033 --- /dev/null +++ b/apps/cs/src/pages/board-statistics/index.ts @@ -0,0 +1 @@ +export { BoardStatisticsPage } from "./ui/board-statistics-page"; diff --git a/apps/cs/src/pages/board-statistics/model/board-statistics-interface.ts b/apps/cs/src/pages/board-statistics/model/board-statistics-interface.ts new file mode 100644 index 000000000..8ec52dabf --- /dev/null +++ b/apps/cs/src/pages/board-statistics/model/board-statistics-interface.ts @@ -0,0 +1,42 @@ +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface BoardStatisticsParams extends PageableParams { + keywordType?: string; + keyword?: string; + siteId?: string; + startDate?: string; + endDate?: string; +} + +export interface BoardStatistics { + totalPosts: number; + totalComments: number; + averageRating: number; + totalViews: number; + dayList: DayStatistics[]; + boardList: BoardTypeStatistics[]; + statusList: StatusStatistics[]; +} + +interface DayStatistics { + date: string; + posts: number; + comments: number; + views: number; +} + +interface BoardTypeStatistics { + boardType: string; + boardName: string; + count: number; +} + +interface StatusStatistics { + status: string; + statusName: string; + count: number; +} + +export interface BoardStatisticsService { + getBoardStatistics(params: BoardStatisticsParams): Promise; +} diff --git a/apps/cs/src/pages/board-statistics/model/chart-data.ts b/apps/cs/src/pages/board-statistics/model/chart-data.ts new file mode 100644 index 000000000..2a9b6671f --- /dev/null +++ b/apps/cs/src/pages/board-statistics/model/chart-data.ts @@ -0,0 +1,130 @@ +import type { ChartData, ChartOptions } from "chart.js"; +import { getCssVariableValue } from "@common/components"; +import type { BoardStatistics } from "./board-statistics-interface"; + +export const chartData = { + day: ( + data: BoardStatistics, + ): { + data: ChartData<"line">; + options: ChartOptions<"line">; + } => { + return { + data: { + labels: data.dayList.map((item) => item.date), + datasets: [ + { + type: "line", + label: "신규게시물", + data: data.dayList.map((item) => item.posts), + borderColor: getCssVariableValue("--chart-1"), + backgroundColor: "rgba(59, 130, 246, 0.1)", + tension: 0.4, + }, + { + type: "line", + label: "답변대기", + data: data.dayList.map((item) => item.comments), + borderColor: getCssVariableValue("--chart-2"), + backgroundColor: "rgba(236, 72, 153, 0.1)", + tension: 0.4, + }, + { + type: "line", + label: "조회수", + data: data.dayList.map((item) => item.views), + borderColor: getCssVariableValue("--chart-3"), + backgroundColor: "rgba(168, 85, 247, 0.1)", + tension: 0.4, + hidden: true, + }, + ], + }, + options: { + maintainAspectRatio: false, + responsive: true, + plugins: { + legend: { + position: "bottom", + }, + }, + scales: { + y: { + ticks: { + precision: 0, + }, + }, + }, + }, + }; + }, + + board: ( + data: BoardStatistics, + ): { + data: ChartData<"bar">; + options: ChartOptions<"bar">; + } => { + return { + data: { + labels: data.boardList.map((item) => item.boardName), + datasets: [ + { + type: "bar", + label: "게시판별 분포", + data: data.boardList.map((item) => item.count), + backgroundColor: data.boardList.map((_, index) => + getCssVariableValue(`--chart-${(index % 5) + 1}`), + ), + }, + ], + }, + options: { + maintainAspectRatio: false, + indexAxis: "y", + scales: { + x: { + ticks: { + precision: 0, + }, + }, + }, + plugins: { + legend: { + display: false, + }, + }, + }, + }; + }, + + status: ( + data: BoardStatistics, + ): { + data: ChartData<"doughnut">; + options: ChartOptions<"doughnut">; + } => { + return { + data: { + labels: data.statusList.map((item) => item.statusName), + datasets: [ + { + type: "doughnut", + data: data.statusList.map((item) => item.count), + backgroundColor: data.statusList.map((_, index) => + getCssVariableValue(`--chart-${(index % 5) + 1}`), + ), + }, + ], + }, + options: { + maintainAspectRatio: false, + plugins: { + legend: { + position: "bottom", + }, + }, + }, + }; + }, +}; diff --git a/apps/cs/src/pages/board-statistics/ui/board-statistics-page.tsx b/apps/cs/src/pages/board-statistics/ui/board-statistics-page.tsx new file mode 100644 index 000000000..d879c34d6 --- /dev/null +++ b/apps/cs/src/pages/board-statistics/ui/board-statistics-page.tsx @@ -0,0 +1,358 @@ +"use client"; + +import dayjs from "dayjs"; +import { useState } from "react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Filler, + Legend, + Tooltip, +} from "chart.js"; +import { Bar, Doughnut, Line } from "react-chartjs-2"; +import { + Button, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { SiteSelect } from "@/entities/site"; +import { ENDPOINT } from "@/shared/config"; +import { NoData } from "@/shared/ui/no-data"; +import { InputDateRangePicker } from "@/shared/ui/input-date-range-picker"; +import { BoardStatisticsApi } from "../api/board-statistics-service"; +import { chartData } from "../model/chart-data"; +import type { BoardStatisticsParams } from "../model/board-statistics-interface"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + ArcElement, + Filler, + Legend, + Tooltip, +); + +type DateRange = { + from?: Date | undefined; + to?: Date | undefined; +}; + +function BoardStatisticsPage() { + const defaultDateRange: DateRange = { + from: dayjs().subtract(7, "day").toDate(), + to: dayjs().toDate(), + }; + + const [siteId, setSiteId] = useState(""); + const [presetDateRange, setPresetDateRange] = useState("1W"); + const [dateRange, setDateRange] = useState(defaultDateRange); + const [searchParams, setSearchParams] = useState({ + siteId: "", + startDate: dayjs(defaultDateRange.from).format("YYYY-MM-DD"), + endDate: dayjs(defaultDateRange.to).format("YYYY-MM-DD"), + }); + + const { data: boardStatisticsData } = useQuery({ + queryKey: ["board-statistics", searchParams], + queryFn: () => + BoardStatisticsApi.getBoardStatistics({ + ...searchParams, + }), + placeholderData: keepPreviousData, + }); + + const handlePresetDateRange = (value: string) => { + let presetDateRange: DateRange; + 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 = defaultDateRange; + } + setPresetDateRange(value); + setDateRange(presetDateRange); + }; + + const handleDateRangeChange = (dateRange: DateRange) => { + setDateRange(dateRange); + setPresetDateRange(""); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const params: BoardStatisticsParams = { + siteId, + startDate: dateRange.from + ? dayjs(dateRange.from).format("YYYY-MM-DD") + : "", + endDate: dateRange.to ? dayjs(dateRange.to).format("YYYY-MM-DD") : "", + }; + setSearchParams(params); + }; + + const handleReset = () => { + setSiteId(""); + setDateRange(defaultDateRange); + setPresetDateRange("1W"); + setSearchParams({ + siteId: "", + startDate: dayjs(defaultDateRange.from).format("YYYY-MM-DD"), + endDate: dayjs(defaultDateRange.to).format("YYYY-MM-DD"), + }); + }; + + return ( +
+
+
+ + + + + setSiteId(value)} + /> + + + +
+ + {/* 주요 지표 카드 */} +
+
+
+

총 게시물

+
+
+ {boardStatisticsData?.totalPosts.toLocaleString() || 0} +
+
+ +
+
+
+
+ +
+
+

답변대기

+
+
+ {boardStatisticsData?.totalComments.toLocaleString() || 0} +
+
+ +
+
+
+
+ +
+
+

평균평점

+
+
+ {boardStatisticsData?.averageRating.toFixed(1) || 0} +
+
+ +
+
+
+
+ +
+
+

총 조회수

+
+
+ {boardStatisticsData?.totalViews.toLocaleString() || 0} +
+
+ +
+
+
+
+
+ + {/* 차트 영역 */} +
+
+
+

일별 게시물 현황

+
+
+ {boardStatisticsData?.dayList?.length ? ( + + ) : ( + + )} +
+
+ +
+
+
+

답변상태

+
+
+ {boardStatisticsData?.statusList?.length ? ( + + ) : ( + + )} +
+
+ +
+
+

게시판별 분포

+
+
+ {boardStatisticsData?.boardList?.length ? ( + + ) : ( + + )} +
+
+
+
+
+ ); +} + +export { BoardStatisticsPage };