[apps/cs] design: 게시판 통계 레이아웃 작업

This commit is contained in:
2025-07-30 16:04:51 +09:00
parent 5a3eb041dc
commit 1f33f55079
6 changed files with 590 additions and 0 deletions
@@ -0,0 +1,2 @@
import { BoardStatisticsPage } from "@/pages/board-statistics";
export default BoardStatisticsPage;
@@ -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<BoardStatistics> {
try {
// 실제 API 연동 시 아래 코드 사용
// const response = await client.get<BoardStatistics>(
// `${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();
@@ -0,0 +1 @@
export { BoardStatisticsPage } from "./ui/board-statistics-page";
@@ -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<BoardStatistics>;
}
@@ -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",
},
},
},
};
},
};
@@ -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<DateRange>(defaultDateRange);
const [searchParams, setSearchParams] = useState<BoardStatisticsParams>({
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<HTMLFormElement>) => {
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 (
<div className="card card-border !mt-6">
<div className="card card-border">
<form onSubmit={handleSubmit} className="flex items-center gap-2 p-2">
<Label className="mr-2">기간</Label>
<Select value={presetDateRange} onValueChange={handlePresetDateRange}>
<SelectTrigger className="w-[12rem]">
<SelectValue placeholder="기간 선택" className="w-[24rem]" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1W">일주일</SelectItem>
<SelectItem value="1M">1개월</SelectItem>
<SelectItem value="3M">3개월</SelectItem>
</SelectContent>
</Select>
<InputDateRangePicker
dateRange={dateRange}
setDateRange={handleDateRangeChange}
/>
<Label className="ml-4 mr-2 text-label">사이트</Label>
<SiteSelect
className="w-[16rem]"
value={siteId}
onValueChange={(value) => setSiteId(value)}
/>
<Button className="h-[3.2rem]" type="submit">
검색
</Button>
<Button
type="button"
color="white"
className="h-[3.2rem]"
onClick={handleReset}
>
초기화
</Button>
</form>
</div>
{/* 주요 지표 카드 */}
<div className="mt-4 grid grid-cols-4 gap-4">
<div className="card card-border">
<div className="p-4">
<h3 className="text-lg font-medium text-gray-500">총 게시물</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.totalPosts.toLocaleString() || 0}
</div>
<div className="h-16 w-24">
<Line
data={{
labels: Array(7).fill(""),
datasets: [
{
data: [15, 10, 20, 25, 18, 22, 30],
borderColor: "rgb(59, 130, 246)",
backgroundColor: "rgba(59, 130, 246, 0.1)",
fill: true,
tension: 0.4,
pointRadius: 0,
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { display: false } },
}}
/>
</div>
</div>
</div>
</div>
<div className="card card-border">
<div className="p-4">
<h3 className="text-lg font-medium text-gray-500">답변대기</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.totalComments.toLocaleString() || 0}
</div>
<div className="h-16 w-24">
<Line
data={{
labels: Array(7).fill(""),
datasets: [
{
data: [5, 8, 12, 10, 15, 18, 14],
borderColor: "rgb(236, 72, 153)",
backgroundColor: "rgba(236, 72, 153, 0.1)",
fill: true,
tension: 0.4,
pointRadius: 0,
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { display: false } },
}}
/>
</div>
</div>
</div>
</div>
<div className="card card-border">
<div className="p-4">
<h3 className="text-lg font-medium text-gray-500">평균평점</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.averageRating.toFixed(1) || 0}
</div>
<div className="h-16 w-24">
<Line
data={{
labels: Array(7).fill(""),
datasets: [
{
data: [4.0, 4.2, 4.1, 4.3, 4.2, 4.4, 4.2],
borderColor: "rgb(168, 85, 247)",
backgroundColor: "rgba(168, 85, 247, 0.1)",
fill: true,
tension: 0.4,
pointRadius: 0,
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { display: false } },
}}
/>
</div>
</div>
</div>
</div>
<div className="card card-border">
<div className="p-4">
<h3 className="text-lg font-medium text-gray-500">총 조회수</h3>
<div className="flex items-center justify-between">
<div className="mt-2 text-4xl font-bold">
{boardStatisticsData?.totalViews.toLocaleString() || 0}
</div>
<div className="h-16 w-24">
<Line
data={{
labels: Array(7).fill(""),
datasets: [
{
data: [800, 1200, 1000, 1500, 1800, 1600, 2000],
borderColor: "rgb(234, 179, 8)",
backgroundColor: "rgba(234, 179, 8, 0.1)",
fill: true,
tension: 0.4,
pointRadius: 0,
},
],
}}
options={{
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: { x: { display: false }, y: { display: false } },
}}
/>
</div>
</div>
</div>
</div>
</div>
{/* 차트 영역 */}
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">일별 게시물 현황</h3>
</div>
<div className="h-96 p-4">
{boardStatisticsData?.dayList?.length ? (
<Line {...chartData.day(boardStatisticsData)} />
) : (
<NoData />
)}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">답변상태</h3>
</div>
<div className="h-96 p-4">
{boardStatisticsData?.statusList?.length ? (
<Doughnut {...chartData.status(boardStatisticsData)} />
) : (
<NoData />
)}
</div>
</div>
<div className="card card-border">
<div className="card-header">
<h3 className="text-2xl font-medium">게시판별 분포</h3>
</div>
<div className="h-96 p-4">
{boardStatisticsData?.boardList?.length ? (
<Bar {...chartData.board(boardStatisticsData)} />
) : (
<NoData />
)}
</div>
</div>
</div>
</div>
</div>
);
}
export { BoardStatisticsPage };