[apps/cs] design: 게시판 통계 레이아웃 작업
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user