From af98524806be93a29102da5f45043a17656b1605 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 24 Jul 2025 10:53:48 +0900 Subject: [PATCH] =?UTF-8?q?[apps/cs]=20feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pages/dashboard/api/dashboard-service.ts | 14 + apps/cs/src/pages/dashboard/lib/utils.ts | 49 +++ .../src/pages/dashboard/model/chart-data.ts | 172 ++++++++ .../dashboard/model/dashboard-interface.ts | 32 ++ .../pages/dashboard/model/table-columns.tsx | 74 ++++ .../src/pages/dashboard/ui/dashboard-page.tsx | 406 +++++++++++++++++- apps/cs/src/shared/config/api.ts | 1 + apps/dm/next-env.d.ts | 5 + apps/fo/next-env.d.ts | 5 + package.json | 2 + pnpm-lock.yaml | 18 +- 11 files changed, 771 insertions(+), 7 deletions(-) create mode 100644 apps/cs/src/pages/dashboard/api/dashboard-service.ts create mode 100644 apps/cs/src/pages/dashboard/lib/utils.ts create mode 100644 apps/cs/src/pages/dashboard/model/chart-data.ts create mode 100644 apps/cs/src/pages/dashboard/model/dashboard-interface.ts create mode 100644 apps/cs/src/pages/dashboard/model/table-columns.tsx create mode 100644 apps/dm/next-env.d.ts create mode 100644 apps/fo/next-env.d.ts diff --git a/apps/cs/src/pages/dashboard/api/dashboard-service.ts b/apps/cs/src/pages/dashboard/api/dashboard-service.ts new file mode 100644 index 000000000..94bf807a1 --- /dev/null +++ b/apps/cs/src/pages/dashboard/api/dashboard-service.ts @@ -0,0 +1,14 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { Dashboard, DashboardService } from "../model/dashboard-interface"; + +export class DashboardApiService implements DashboardService { + public async getDashboard(): Promise { + const response = await client.get( + ENDPOINT.CMS_SERVICE.DASHBOARD, + ); + return response.data; + } +} + +export const DashboardApi = new DashboardApiService(); diff --git a/apps/cs/src/pages/dashboard/lib/utils.ts b/apps/cs/src/pages/dashboard/lib/utils.ts new file mode 100644 index 000000000..669402cb8 --- /dev/null +++ b/apps/cs/src/pages/dashboard/lib/utils.ts @@ -0,0 +1,49 @@ +import type { ChartData, ChartDataset } from "chart.js"; +import { getCssVariableValue } from "@common/components"; +import type { + MenuStatisticsContent, + SiteStatisticsContent, +} from "../model/dashboard-interface"; + +export function processSiteAccessData( + siteAccessList: SiteStatisticsContent[], +): ChartData<"line">["datasets"] { + const siteGroups: Record = siteAccessList.reduce( + (acc, item) => { + if (!acc[item.siteNm]) { + acc[item.siteNm] = { + label: item.siteNm, + data: Array(24).fill(0), // 24시간 데이터 초기화 + borderColor: getCssVariableValue( + `--chart-${Math.floor(Math.random() * 5) + 1}`, + ), + }; + } + + // 시간대별 접속 카운트 누적 + const hourIndex = parseInt(item.accessHour, 10); + if (!isNaN(hourIndex) && hourIndex >= 0 && hourIndex < 24) { + acc[item.siteNm]!.data[hourIndex] = item.accessCnt; + } + + return acc; + }, + {} as Record, + ); + + return Object.values(siteGroups) as ChartData<"line">["datasets"]; +} + +export function processMenuAccessData( + menuAccessList: MenuStatisticsContent[], +): ChartData<"bar">["datasets"] { + const data = menuAccessList.map((item) => item.accessCnt); + + return [ + { + label: "메뉴별 접속 횟수", + data, + backgroundColor: "rgb(32, 148, 250)", + }, + ] as ChartData<"bar">["datasets"]; +} diff --git a/apps/cs/src/pages/dashboard/model/chart-data.ts b/apps/cs/src/pages/dashboard/model/chart-data.ts new file mode 100644 index 000000000..0d55f9bc7 --- /dev/null +++ b/apps/cs/src/pages/dashboard/model/chart-data.ts @@ -0,0 +1,172 @@ +import { type ChartData, type ChartOptions } from "chart.js"; + +const commonData = { + labels: ["1", "2", "3", "4", "5", "6", "7"], + datasets: [ + { + fill: true, + pointRadius: 0, + tension: 0.5, + }, + ], +}; + +const commonOptions = { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + }, + scales: { + x: { + display: false, + }, + y: { + display: false, + }, + }, +}; + +export const chartData: Record< + string, + { data: ChartData<"line" | "bar">; options: ChartOptions<"line" | "bar"> } +> = { + memberChartData: { + data: { + ...commonData, + datasets: [ + { + ...commonData.datasets[0], + data: [8, 10, 7, 12, 9, 13, 12], + borderColor: "rgba(255, 255, 255, 0.6)", + backgroundColor: "rgba(255, 255, 255, 0.1)", + }, + ], + }, + options: { + ...commonOptions, + scales: { + ...commonOptions.scales, + y: { + ...commonOptions.scales.y, + min: 5, + max: 15, + }, + }, + }, + }, + postChartData: { + data: { + ...commonData, + datasets: [ + { + ...commonData.datasets[0], + data: [5200, 5350, 5450, 5520, 5600, 5650, 5678], + borderColor: "rgba(86, 138, 255, 1)", + backgroundColor: "rgba(86, 138, 255, 0.1)", + }, + ], + }, + options: { + ...commonOptions, + }, + }, + visitorChartData: { + data: { + ...commonData, + datasets: [ + { + ...commonData.datasets[0], + data: [1800, 2100, 1950, 2300, 2150, 2250, 2345], + borderColor: "rgba(149, 131, 252)", + backgroundColor: "rgba(149, 131, 252, 0.1)", + }, + ], + }, + options: { + ...commonOptions, + }, + }, + apiChartData: { + data: { + ...commonData, + datasets: [ + { + ...commonData.datasets[0], + data: [41000, 42500, 43200, 44100, 44800, 45200, 45678], + borderColor: "rgba(220, 103, 206)", + backgroundColor: "rgba(220, 103, 206, 0.1)", + }, + ], + }, + options: { + ...commonOptions, + }, + }, + siteAccessChartData: { + data: { + labels: Array.from({ length: 24 }, (_, i) => + i.toString().padStart(2, "0"), + ), + datasets: [], + }, + options: { + ...commonOptions, + plugins: { + legend: { + position: "top", + }, + tooltip: { + mode: "index", + intersect: false, + }, + }, + scales: { + x: { + title: { + text: "시간대", + }, + }, + y: { + title: { + text: "접속자 수", + }, + beginAtZero: true, + }, + }, + }, + }, + menuAccessChartData: { + data: { + labels: [], + datasets: [], + }, + options: { + ...commonOptions, + plugins: { + legend: { + position: "top", + }, + tooltip: { + mode: "index", + intersect: false, + }, + }, + scales: { + x: { + title: { + text: "메뉴명", + }, + }, + y: { + title: { + text: "접속 횟수", + }, + beginAtZero: true, + }, + }, + }, + }, +}; diff --git a/apps/cs/src/pages/dashboard/model/dashboard-interface.ts b/apps/cs/src/pages/dashboard/model/dashboard-interface.ts new file mode 100644 index 000000000..9e038eef7 --- /dev/null +++ b/apps/cs/src/pages/dashboard/model/dashboard-interface.ts @@ -0,0 +1,32 @@ +export interface SiteStatisticsContent { + siteId: string; + siteNm: string; + accessHour: string; + accessCnt: number; +} + +export interface MenuStatisticsContent { + menuCd: number; + menuNm: string; + accessCnt: number; +} + +export interface Dashboard { + memberTotCnt: number; + memberTodayCnt: number; + pstTotCnt: number; + pstTodayCnt: number; + accessTodayCnt: number; + accessBefDayPer: number; + apiTodayCnt: number; + waitMemberCnt: number; + waitCntnCnt: number; + notiList: []; + schdlList: []; + siteAccessList: SiteStatisticsContent[]; + menuAccessList: MenuStatisticsContent[]; +} + +export interface DashboardService { + getDashboard(): Promise; +} diff --git a/apps/cs/src/pages/dashboard/model/table-columns.tsx b/apps/cs/src/pages/dashboard/model/table-columns.tsx new file mode 100644 index 000000000..4d015e2dc --- /dev/null +++ b/apps/cs/src/pages/dashboard/model/table-columns.tsx @@ -0,0 +1,74 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@common/components"; +import type { RecentPost } from "@/entities/post"; + +const noticePostTableColumns: ColumnDef[] = [ + { + header: "번호", + cell: ({ row, table }) => { + if (row.original.upndFixYn === "Y") { + return ( + + + + ); + } + const totalRows = table.getRowCount(); + const currentPage = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + return totalRows - (currentPage * pageSize + row.index); + }, + }, + { + accessorKey: "pstTtl", + header: "제목", + }, + { + accessorKey: "wrtrNm", + header: "작성자", + }, + { + accessorKey: "regDt", + header: "작성일", + cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"), + }, + { + accessorKey: "inqNocs", + header: "조회수", + }, +]; + +const recentPostTableColumns: ColumnDef[] = [ + { + header: "번호", + cell: ({ row, table }) => { + const totalRows = table.getRowCount(); + const currentPage = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + return totalRows - (currentPage * pageSize + row.index); + }, + }, + { + accessorKey: "pstTtl", + header: "제목", + }, + { + accessorKey: "wrtrNm", + header: "작성자", + }, + { + accessorKey: "regDt", + header: "작성일", + cell: ({ row }) => dayjs(row.original.regDt).format("YYYY-MM-DD"), + }, + { + accessorKey: "inqNocs", + header: "조회수", + }, +]; + +export { noticePostTableColumns, recentPostTableColumns }; diff --git a/apps/cs/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cs/src/pages/dashboard/ui/dashboard-page.tsx index 4dc693361..4d657a5fc 100644 --- a/apps/cs/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cs/src/pages/dashboard/ui/dashboard-page.tsx @@ -1,5 +1,409 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useQuery } from "@tanstack/react-query"; +import { getCoreRowModel } from "@tanstack/react-table"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Filler, + Legend, + Tooltip, + type ChartData, +} from "chart.js"; +import { Bar, Line } from "react-chartjs-2"; +import { PostApi, type RecentPost } from "@/entities/post"; +import { ENDPOINT } from "@/shared/config"; +import { useDataTable } from "@/shared/lib"; +import { DashboardApi } from "../api/dashboard-service"; +import { chartData } from "../model/chart-data"; +import { + noticePostTableColumns, + recentPostTableColumns, +} from "../model/table-columns"; +import { processMenuAccessData, processSiteAccessData } from "../lib/utils"; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Filler, + Legend, + Tooltip, +); + function DashboardPage() { - return <>; + const router = useRouter(); + const { data } = useQuery({ + queryKey: [ENDPOINT.CMS_SERVICE.DASHBOARD], + queryFn: () => DashboardApi.getDashboard(), + }); + + const noticePostQuery = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS], + queryFn: () => + PostApi.getRecentPostList({ + page: 0, + size: 5, + keywordType: "bbsTypeCd", + keyword: "BBSNOTICE", + }), + }); + + const recentPostQuery = useQuery({ + queryKey: [ENDPOINT.BOARD_SERVICE.POSTS], + queryFn: () => + PostApi.getRecentPostList({ + page: 0, + size: 5, + }), + }); + + const handleNoticePostRowClick = (row: RecentPost) => { + router.push(`/bbs/bbsnotice/${row.bbsId}/${row.pstNo}`); + }; + + const handleRecentPostRowClick = (row: RecentPost) => { + router.push( + `/bbs/${row.bbsTypeCd.toLowerCase()}/${row.bbsId}/${row.pstNo}`, + ); + }; + + const { tableComponent: noticePostTableComponent } = useDataTable({ + query: noticePostQuery, + tableOptions: { + columns: noticePostTableColumns, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + variant: "line", + onRowClick: handleNoticePostRowClick, + }, + }); + + const { tableComponent: recentPostTableComponent } = useDataTable({ + query: recentPostQuery, + tableOptions: { + columns: recentPostTableColumns, + getCoreRowModel: getCoreRowModel(), + }, + dataTableOptions: { + variant: "line", + onRowClick: handleRecentPostRowClick, + }, + }); + + return ( +
+
+ {/* 첫번째 라인 */} +
+ {/* 총 회원수 카드 */} +
+
+
+

총 회원수

+
+ {data?.memberTotCnt ?? "-"} +
+

+ 금일 신규 가입자 {data?.memberTodayCnt ?? "-"}명 +

+
+ {/* 차트 영역 */} +
+ } + options={chartData.memberChartData!.options} + /> +
+
+
+ {/* 전체 게시물 카드 */} +
+
+
+

전체 게시물

+
+ {data?.pstTotCnt ?? "-"} +
+

+ 금일 +{data?.pstTodayCnt ?? "-"} 등록 +

+
+ {/* 차트 영역 */} +
+ } + options={chartData.postChartData!.options} + /> +
+
+
+ {/* 오늘 접속자 수 카드 */} +
+
+
+

오늘 접속자 수

+
+ {data?.accessTodayCnt ?? "-"} +
+

+ 전일 대비 +{data?.accessBefDayPer ?? "-"}% +

+
+ {/* 차트 영역 */} +
+ } + options={chartData.visitorChartData!.options} + /> +
+
+
+ {/* API 호출 건수 카드 */} +
+
+
+

API 호출 건수

+
+ {data?.apiTodayCnt ?? "-"} +
+

금일 누적

+
+ {/* 차트 영역 */} +
+ } + options={chartData.apiChartData!.options} + /> +
+
+
+
+ + {/* 두번째 라인 */} +
+ {/* 승인 대기 알림 */} +
+
+

승인 대기 알림

+
+
+
+ {/* 사용자 승인 */} +
+
+
+ + + + +
+ 사용자 승인 +
+
+
+ {data?.waitMemberCnt}건 +
+
+ 신규 가입 승인 대기 +
+
+
+ {/* 매뉴 승인 */} +
+
+
+ + + + + + +
+ 메뉴 승인 +
+
+
3건
+
+ 매뉴 수정 승인 대기 +
+
+
+ {/* 컨텐츠 승인 */} +
+
+
+ + + + +
+ 컨텐츠 승인 +
+
+
+ {data?.waitCntnCnt}건 +
+
+ 컨텐츠 수정 승인 대기 +
+
+
+
+
+
+
+ {/* 실시간 알림 */} +
+
+

실시간 알림

+
+
+
+
+ 신규 사용자 가입 + 1분 전 +
+
+ 메뉴 수정 요청 + 5분 전 +
+
+ 게시물 신고 + 10분 전 +
+
+ 컨텐츠 승인 요청 + 15분 전 +
+
+
+
+ {/* 일정 알림 */} +
+
+

일정 알림

+
+
+
+
+ 3월 정기 시스템 점검 + 내일 +
+
+ 서버 업데이트 예정 + 3일 후 +
+
+ 백업 스케줄 + 5일 후 +
+
+ 정기 보안 점검 + 7일 후 +
+
+
+
+
+
+ + {/* 세번째 라인 */} +
+ {/* 접속자 통계 */} +
+
+

접속자 통계

+
+
+ +
+
+ {/* 메뉴별 접속 통계 */} +
+
+

메뉴별 접속 통계

+
+
+ item.menuNm) || [], + datasets: processMenuAccessData(data?.menuAccessList || []), + }} + options={chartData.menuAccessChartData!.options} + /> +
+
+
+ + {/* 네번째 라인 */} +
+ {/* 공지사항 */} +
+
+

공지사항

+
+ {noticePostTableComponent} +
+ {/* 신규 게시글 */} +
+
+

신규 게시글

+
+ {recentPostTableComponent} +
+
+
+
+ ); } export { DashboardPage }; diff --git a/apps/cs/src/shared/config/api.ts b/apps/cs/src/shared/config/api.ts index 120a45b2e..6db62f1a0 100644 --- a/apps/cs/src/shared/config/api.ts +++ b/apps/cs/src/shared/config/api.ts @@ -23,6 +23,7 @@ export const ENDPOINT = { PRIVACYLOG: "/user-service//api/v1/prvc-inq-logs", }, CMS_SERVICE: { + DASHBOARD: "/cms-service/api/v1/dashboard", SITES: "/cms-service/api/v1/sites", IPS: "/cms-service/api/v1/site-ips", DOMAINS: "/cms-service/api/v1/site-dmns", diff --git a/apps/dm/next-env.d.ts b/apps/dm/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/apps/dm/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/fo/next-env.d.ts b/apps/fo/next-env.d.ts new file mode 100644 index 000000000..1b3be0840 --- /dev/null +++ b/apps/fo/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 818c7999d..038d81914 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,9 @@ "preinstall": "npx only-allow pnpm" }, "dependencies": { + "chart.js": "^4.4.8", "react": "^18.3.1", + "react-chartjs-2": "^5.3.0", "react-dom": "^18.3.1", "zustand": "^5.0.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d359a5e56..a64e257a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: .: dependencies: + chart.js: + specifier: ^4.4.8 + version: 4.4.8 react: specifier: ^18.3.1 version: 18.3.1 + react-chartjs-2: + specifier: ^5.3.0 + version: 5.3.0(chart.js@4.4.8)(react@18.3.1) react-dom: specifier: ^18.3.1 version: 18.3.1(react@18.3.1) @@ -2408,7 +2414,7 @@ packages: magic-string: 0.27.0 react-docgen-typescript: 2.2.2(typescript@5.6.3) typescript: 5.6.3 - vite: 6.2.0(@types/node@22.13.5) + vite: 6.2.0(@types/node@22.13.9) dev: true /@jridgewell/gen-mapping@0.3.8: @@ -2500,7 +2506,7 @@ packages: dnd-multi-backend: 8.1.2(dnd-core@16.0.1) framer-motion: 11.18.2(react-dom@18.3.1)(react@18.3.1) react: 18.3.1 - react-dnd: 16.0.1(@types/node@22.13.5)(@types/react@18.3.18)(react@18.3.1) + react-dnd: 16.0.1(@types/node@22.13.9)(@types/react@18.3.18)(react@18.3.1) react-dnd-html5-backend: 16.0.1 react-dnd-touch-backend: 16.0.1 react-dom: 18.3.1(react@18.3.1) @@ -4001,7 +4007,7 @@ packages: browser-assert: 1.2.1 storybook: 8.6.0(prettier@3.5.2) ts-dedent: 2.2.0 - vite: 6.2.0(@types/node@22.13.5) + vite: 6.2.0(@types/node@22.13.9) dev: true /@storybook/components@8.6.0(storybook@8.6.0): @@ -4133,7 +4139,7 @@ packages: resolve: 1.22.10 storybook: 8.6.0(prettier@3.5.2) tsconfig-paths: 4.2.0 - vite: 6.2.0(@types/node@22.13.5) + vite: 6.2.0(@types/node@22.13.9) transitivePeerDependencies: - rollup - supports-color @@ -4608,7 +4614,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.9) '@types/babel__core': 7.20.5 react-refresh: 0.14.2 - vite: 6.2.0(@types/node@22.13.5) + vite: 6.2.0(@types/node@22.13.9) transitivePeerDependencies: - supports-color dev: true @@ -9906,7 +9912,7 @@ packages: debug: 4.4.0 globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.6.3) - vite: 6.2.0(@types/node@22.13.5) + vite: 6.2.0(@types/node@22.13.9) transitivePeerDependencies: - supports-color - typescript