diff --git a/apps/cm/.env.local b/apps/cm/.env.local index a544bdf7e..1ef6a3045 100644 --- a/apps/cm/.env.local +++ b/apps/cm/.env.local @@ -5,4 +5,6 @@ NEXT_PUBLIC_SITE_ID=CS NEXT_PUBLIC_SUB_DOMAIN=http://localhost:13000 NEXT_PUBLIC_SERVER_API_URL=http://dev.onasys.co.kr:8000 NEXT_PUBLIC_LINK_BACKEND_API_HOST=http://rsone.iptime.org:10014 -NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 \ No newline at end of file +NEXT_PUBLIC_TINY_API_KEY=t9nc4z6inquvvd5pq1vea8wcp7lb01qmqseh6egms9vpaju2 + +NEXT_PUBLIC_SERVER_API_URL2=http://con.mind-one.co.kr:11083/ \ No newline at end of file diff --git a/apps/cm/middleware.ts b/apps/cm/middleware.ts index e0316fa9f..ac1e5464b 100644 --- a/apps/cm/middleware.ts +++ b/apps/cm/middleware.ts @@ -11,6 +11,18 @@ import { const serverApiUrl = process.env.NEXT_PUBLIC_SERVER_API_URL ?? "http://localhost:3000"; + const serverApiUrl2 = + process.env.NEXT_PUBLIC_SERVER_API_URL2 ?? serverApiUrl; + + const serverApiUrl3 = + process.env.NEXT_PUBLIC_SERVER_API_URL3 ?? serverApiUrl; + + const serverApiUrl4 = + process.env.NEXT_PUBLIC_SERVER_API_URL4 ?? serverApiUrl; + + const serverApiUrl5 = + process.env.NEXT_PUBLIC_SERVER_API_URL5 ?? serverApiUrl; + export function middleware(req: NextRequest) { const pathname = req.nextUrl.pathname; const searchParams = req.nextUrl.searchParams; @@ -52,6 +64,97 @@ export function middleware(req: NextRequest) { }); } + + if (pathname.startsWith("/sewerage-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl2}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + + if (pathname.startsWith("/reuse-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl3}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + + if (pathname.startsWith("/operation-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl4}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + + if (pathname.startsWith("/ic-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl5}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + + if (pathname.startsWith("/dc-service")) { + const requestHeaders = new Headers(req.headers); + const token = req.cookies.get(CLAIM_NAME)?.value; + const userId = req.cookies.get(AUTH_USER_ID)?.value; + const newUrl = `${serverApiUrl5}${pathname}`; + + if (token) requestHeaders.set(CLAIM_NAME, token); + if (userId) requestHeaders.set(AUTH_USER_ID, userId); + + logMessages.push(` Rewritten URL: ${newUrl}`); + logMessages.push("\n--------------------------------\n"); + console.log(logMessages.join("\n")); + + return NextResponse.rewrite(new URL(newUrl), { + headers: requestHeaders, + }); + } + if (pathname.startsWith("/user-service")) { const queryParams = searchParams.toString(); const newUrl = `${serverApiUrl}${pathname}${ diff --git a/apps/cm/public/images/region_map.svg b/apps/cm/public/images/region_map.svg new file mode 100644 index 000000000..f327bb9fc --- /dev/null +++ b/apps/cm/public/images/region_map.svg @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/cm/public/images/region_map_bg.png b/apps/cm/public/images/region_map_bg.png new file mode 100644 index 000000000..6f59f58cc Binary files /dev/null and b/apps/cm/public/images/region_map_bg.png differ diff --git a/apps/cm/src/entities/board/@x/post.ts b/apps/cm/src/entities/board/@x/post.ts new file mode 100644 index 000000000..9d2b29076 --- /dev/null +++ b/apps/cm/src/entities/board/@x/post.ts @@ -0,0 +1 @@ +export { BoardApi } from "../api/board-service"; diff --git a/apps/cm/src/entities/board/api/board-service.ts b/apps/cm/src/entities/board/api/board-service.ts new file mode 100644 index 000000000..dc474b3ad --- /dev/null +++ b/apps/cm/src/entities/board/api/board-service.ts @@ -0,0 +1,60 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + Board, + BoardFormData, + BoardList, + BoardParams, + BoardPopupList, + BoardPopupParams, + BoardService, +} from "../model/board-interface"; + +export class BoardApiService implements BoardService { + public async getBoardList(params?: BoardParams): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + { params }, + ); + return response.data; + } + + public async getBoardPopupList( + params: BoardPopupParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + { params }, + ); + return response.data; + } + + public async getBoardInfo(bbsId: string): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`, + ); + return response.data; + } + + public async createBoard(data: BoardFormData): Promise { + const response = await client.post( + `${ENDPOINT.BOARD_SERVICE.BBS}`, + data, + ); + return response.data; + } + + public async updateBoard(bbsId: string, data: BoardFormData): Promise { + const response = await client.put( + `${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`, + data, + ); + return response.data; + } + + public async deleteBoard(bbsId: string): Promise { + await client.delete(`${ENDPOINT.BOARD_SERVICE.BBS}/${bbsId}`); + } +} + +export const BoardApi = new BoardApiService(); diff --git a/apps/cm/src/entities/board/api/board-template-service.ts b/apps/cm/src/entities/board/api/board-template-service.ts new file mode 100644 index 000000000..be3efde75 --- /dev/null +++ b/apps/cm/src/entities/board/api/board-template-service.ts @@ -0,0 +1,17 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import type { + BoardTemplate, + BoardTemplateService, +} from "../model/board-template-interface"; + +export class BoardTemplateApiService implements BoardTemplateService { + public async getBoardTemplate(bbsTypeCd: string): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.BBS_TEMPLATES}/${bbsTypeCd}`, + ); + return response.data; + } +} + +export const BoardTemplateApi = new BoardTemplateApiService(); diff --git a/apps/cm/src/entities/board/index.ts b/apps/cm/src/entities/board/index.ts new file mode 100644 index 000000000..ddd10f382 --- /dev/null +++ b/apps/cm/src/entities/board/index.ts @@ -0,0 +1,11 @@ +export { BoardApi } from "./api/board-service"; +export { + boardFormSchema, + BOARD_POSTING_CODE, + type Board, + type BoardFormData, + type BoardParams, + type BoardPopup, + type BoardPostingCode, +} from "./model/board-interface"; +export { BoardTemplateApi } from "./api/board-template-service"; diff --git a/apps/cm/src/entities/board/model/board-interface.ts b/apps/cm/src/entities/board/model/board-interface.ts new file mode 100644 index 000000000..8b7859223 --- /dev/null +++ b/apps/cm/src/entities/board/model/board-interface.ts @@ -0,0 +1,99 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; +import type { BbsType } from "@/shared/store"; + +export const BOARD_POSTING_CODE = { + 팝업게시: "01", + 상단게시: "02", + 기간게시: "03", +} as const; + +export type BoardPostingCode = + (typeof BOARD_POSTING_CODE)[keyof typeof BOARD_POSTING_CODE]; + +export interface BoardParams extends PageableParams { + keywordType?: string; + keyword?: string; + bbsId?: number; + bbsTypeCd?: string; + bbsNm?: string; + bbsExpln?: string; + rfrncUseYn?: string; + atflUseYn?: string; + atflMaxCnt?: number; + atflMaxFileSz?: number; + ansUseYn?: string; + cmntUseYn?: string; + dgstfnEvlUseYn?: string; + smsUseYn?: string; + upndFixUseYn?: string; + pstgPrdUseYn?: string; + popupUseYn?: string; + delYn?: string; +} + +export interface BoardPopupParams extends PageableParams { + bbsNm?: string; +} + +export interface Board { + bbsId: number; + bbsTypeCd: BbsType; + bbsTypeNm: string; + bbsNm: string; + bbsExpln: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + ansUseAuthrt: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + delYn: string; + useYn: string; + prvcIndctYn: string; + mdfcnDt: string; + mdfrNm: string; +} + +export interface BoardPopup extends Board { + pstCnt: number; +} + +export const boardFormSchema = z.object({ + bbsTypeCd: z.string(), + bbsNm: z.string(), + bbsExpln: z.string(), + rfrncUseYn: z.string(), + atflUseYn: z.string(), + atflMaxCnt: z.number().nullish(), + atflMaxFileSz: z.number().nullish(), + ansUseYn: z.string(), + ansUseAuthrt: z.string().nullish(), + cmntUseYn: z.string(), + dgstfnEvlUseYn: z.string(), + smsUseYn: z.string(), + upndFixUseYn: z.string(), + pstgPrdUseYn: z.string(), + popupUseYn: z.string(), + useYn: z.string(), + prvcIndctYn: z.string(), +}); + +export type BoardList = PageableList; +export type BoardPopupList = PageableList; +export type BoardFormData = z.infer; + +export interface BoardService { + getBoardList: (params: BoardParams) => Promise; + getBoardPopupList: (params: BoardPopupParams) => Promise; + getBoardInfo: (bbsId: string) => Promise; + createBoard: (data: BoardFormData) => Promise; + updateBoard: (bbsId: string, data: BoardFormData) => Promise; + deleteBoard: (bbsId: string) => Promise; +} diff --git a/apps/cm/src/entities/board/model/board-template-interface.ts b/apps/cm/src/entities/board/model/board-template-interface.ts new file mode 100644 index 000000000..6a7f84fef --- /dev/null +++ b/apps/cm/src/entities/board/model/board-template-interface.ts @@ -0,0 +1,24 @@ +export interface BoardTemplate { + bbsTypeCd: string; + rfrncUseYn: string; + atflUseYn: string; + atflMaxCnt: number; + atflMaxFileSz: number; + ansUseYn: string; + cmntUseYn: string; + dgstfnEvlUseYn: string; + smsUseYn: string; + upndFixUseYn: string; + pstgPrdUseYn: string; + popupUseYn: string; + regDt: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; + rgtrNm: string; + mdfrNm: string; +} + +export interface BoardTemplateService { + getBoardTemplate: (bbsTypeCd: string) => Promise; +} diff --git a/apps/cm/src/entities/post/api/post-service.ts b/apps/cm/src/entities/post/api/post-service.ts new file mode 100644 index 000000000..b7016b780 --- /dev/null +++ b/apps/cm/src/entities/post/api/post-service.ts @@ -0,0 +1,97 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + postFormSchema, + postUpdateFormSchema, + type Post, + type MovePost, + type DeletePost, + type PostInfo, + type PostList, + type MyPostList, + type PostParams, + type MyPostParams, + type PostFormData, + type PostUpdateFormData, + type PostService, +} from "../model/post-interface"; + +export class PostApiService implements PostService { + public async getPostList( + bbsId: number, + params: PostParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}`, + { params }, + ); + return response.data; + } + + public async getMyPostList( + params?: MyPostParams, + ): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS}/mypost`, + { params }, + ); + return response.data; + } + + public async getPostInfo(bbsId: number, pstNo: number): Promise { + const response = await client.get( + `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, + ); + return response.data; + } + + public async createPost(data: PostFormData): Promise { + const validatedData = postFormSchema.parse(data); + const response = await client.post( + `${ENDPOINT.BOARD_SERVICE.POSTS}`, + validatedData, + ); + return response.data; + } + + public async updatePost( + bbsId: number, + pstNo: number, + data: PostUpdateFormData, + ): Promise { + const validatedData = postUpdateFormSchema.parse(data); + const response = await client.put( + `${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, + validatedData, + ); + return response.data; + } + + public async deletePost( + bbsId: number, + pstNo: number, + delRsn?: string, + ): Promise { + await client.delete(`${ENDPOINT.BOARD_SERVICE.POSTS}/${bbsId}/${pstNo}`, { + params: { delRsn }, + }); + } + + public async softDeletePost(data: DeletePost[]): Promise { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/softDeleteSels`, data); + } + + public async hardDeletePost(data: DeletePost[]): Promise { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/hardDeleteSels`, data); + } + + public async movePost(data: MovePost[]): Promise { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/movePstByBbsId`, data); + } + + public async restorePost(data: DeletePost[]): Promise { + await client.post(`${ENDPOINT.BOARD_SERVICE.POSTS}/restoreSels`, data); + } +} + +export const PostApi = new PostApiService(); diff --git a/apps/cm/src/entities/post/index.ts b/apps/cm/src/entities/post/index.ts new file mode 100644 index 000000000..e0241e2f4 --- /dev/null +++ b/apps/cm/src/entities/post/index.ts @@ -0,0 +1,17 @@ +export { PostApi } from "./api/post-service"; +export { + postFormSchema, + postUpdateFormSchema, + type Post, + type MyPost, + type MovePost, + type DeletePost, + type PostList, + type PostParams, + type MyPostParams, + type PostFormData, + type PostUpdateFormData, + type PostService, +} from "./model/post-interface"; +export { deleteReasonMessages } from "./model/delete-reason-messages"; +export { PostPageGuard } from "./lib/post-page-guard"; diff --git a/apps/cm/src/entities/post/lib/post-page-guard.tsx b/apps/cm/src/entities/post/lib/post-page-guard.tsx new file mode 100644 index 000000000..46875f1fc --- /dev/null +++ b/apps/cm/src/entities/post/lib/post-page-guard.tsx @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { usePathname, useRouter } from "next/navigation"; +import { BoardApi } from "@/entities/board/@x/post"; +import { useAlertStore, useMenuStore } from "@/shared/lib"; + +function PostPageGuard({ + bbsId, + children, +}: { + bbsId: string; + children: React.ReactNode; +}) { + const router = useRouter(); + const pathname = usePathname(); + const alert = useAlertStore((state) => state.setMessage); + const setBbsInfo = useMenuStore((state) => state.setBbsInfo); + + const { data: boardInfo, isLoading } = useQuery({ + queryKey: [bbsId], + queryFn: () => BoardApi.getBoardInfo(bbsId), + }); + + useEffect(() => { + if (!boardInfo) return; + setBbsInfo(boardInfo); + }, [boardInfo]); + + if (!isLoading && pathname && boardInfo) { + if (!pathname.includes(boardInfo.bbsTypeCd.toLowerCase())) { + alert("잘못된 경로로 접속하였습니다.", router.back); + return null; + } + } + + return children; +} + +export { PostPageGuard }; diff --git a/apps/cm/src/entities/post/model/delete-reason-messages.ts b/apps/cm/src/entities/post/model/delete-reason-messages.ts new file mode 100644 index 000000000..3f1587472 --- /dev/null +++ b/apps/cm/src/entities/post/model/delete-reason-messages.ts @@ -0,0 +1,11 @@ +export const deleteReasonMessages = { + "저작권 침해": + "타인의 저작물을 허락 없이 게시하는 경우 저작권 침해로 간주되어 삭제될 수 있습니다.", + 명예훼손: "타인의 명예를 훼손하는 내용의 게시물은 삭제될 수 있습니다.", + "개인정보 노출": "타인의 개인정보를 노출하는 게시물도 삭제될 수 있습니다.", + "불법 정보 유통": + "불법적인 상품이나 서비스를 판매하거나 홍보하는 게시물은 삭제될 수 있습니다.", + "욕설 및 비방": + "심한 욕설이나 비방, 모욕적인 내용이 포함된 게시물은 삭제될 수 있습니다.", + "광고성 정보": "지나치게 광고성이 짙은 게시물은 삭제될 수 있습니다.", +}; diff --git a/apps/cm/src/entities/post/model/post-interface.ts b/apps/cm/src/entities/post/model/post-interface.ts new file mode 100644 index 000000000..1be87dadd --- /dev/null +++ b/apps/cm/src/entities/post/model/post-interface.ts @@ -0,0 +1,119 @@ +import { z } from "zod"; +import type { PageableList, PageableParams } from "@/shared/model"; + +export interface PostParams extends PageableParams { + keywordType?: string; + keyword?: string; + popupYn?: string; + upndFixYn?: string; + prdPstgYn?: string; + siteId?: string; + bbsTypeCd?: string; + ansUseYn?: string; +} + +export interface MyPostParams extends PageableParams { + keywordType?: string; + keyword?: string; + bbsId?: number | null; + delYn?: string; + frDateYmd?: string; + toDateYmd?: string; +} + +export interface Post { + rowNo: number; + bbsId: number; + pstNo: number; + pstTtl: string; + pstCn: string; + inqNocs: number; + rfrncUrl: string; + popupYn: string; + upndFixYn: string; + prdPstgYn: string; + rootPath: string; + pstPath: string; + ansPstn: number; + wrtrNm: string; + wrtrCplc: string; + atflCd: string; + regDt: string; + rgtrNm: string; + cmntCnt: number; +} + +export interface MyPost extends Post { + bbsNm: string; + bbsTypeCd: string; +} + +export interface PostInfo extends Post { + pstCn: string; + rgtr: string; + mdfcnDt: string; + mdfr: string; +} + +export interface MovePost { + fromBbsId: number; + toBbsId?: number; + pstNo: number; +} + +export interface DeletePost { + bbsId: number; + pstNo: number; + delRsn?: string; +} + +const postEtcSchema = z.object({ + bbsPstgTypeCd: z.string(), + pstgPrdSeCd: z.string(), + pstgBgngDt: z.string().nullish(), + pstgEndDt: z.string().nullish(), +}); + +export const postFormSchema = z.object({ + bbsId: z.number(), + pstTtl: z.string().nonempty(), + pstCn: z.string().nullish(), + rfrncUrl: z.string().nullish(), + popupYn: z.string().nullish(), + popupYnEtc: z.array(postEtcSchema).nullish(), + upndFixYn: z.string().nullish(), + upndFixYnEtc: z.array(postEtcSchema).nullish(), + prdPstgYn: z.string().nullish(), + prdPstgYnEtc: z.array(postEtcSchema).nullish(), + pstPswd: z.string(), + wrtrNm: z.string().nullish(), + wrtrCplc: z.string().nullish(), + atflCd: z.string().nullish(), + parentPstNo: z.number().nullish(), +}); + +export const postUpdateFormSchema = postFormSchema.omit({ + bbsId: true, + parentPstNo: true, +}); + +export type PostList = PageableList; +export type MyPostList = PageableList; +export type PostFormData = z.infer; +export type PostUpdateFormData = z.infer; + +export interface PostService { + getPostList(bbsId: number, params: PostParams): Promise; + getMyPostList(params: MyPostParams): Promise; + getPostInfo(bbsId: number, pstNo: number): Promise; + createPost(data: PostFormData): Promise; + updatePost( + bbsId: number, + pstNo: number, + data: PostUpdateFormData, + ): Promise; + deletePost(bbsId: number, pstNo: number, delRsn?: string): Promise; + softDeletePost(data: DeletePost[]): Promise; + hardDeletePost(data: DeletePost[]): Promise; + movePost(data: MovePost[]): Promise; +} diff --git a/apps/cm/src/pages/dashboard/api/dashboard-service.ts b/apps/cm/src/pages/dashboard/api/dashboard-service.ts new file mode 100644 index 000000000..94bf807a1 --- /dev/null +++ b/apps/cm/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/cm/src/pages/dashboard/lib/KoreaBasinMap.module.css b/apps/cm/src/pages/dashboard/lib/KoreaBasinMap.module.css new file mode 100644 index 000000000..0af00a62c --- /dev/null +++ b/apps/cm/src/pages/dashboard/lib/KoreaBasinMap.module.css @@ -0,0 +1,212 @@ +#mapArea { + display: flex; + align-items: center; + justify-content: center; +} + +.stage { + --map-inset: 7.5%; + aspect-ratio: 0.72/1; + width: 100%; + max-height: 100%; + position: relative; + margin: auto; +} + +.bgLayer { + position: absolute; + inset: var(--map-inset); + display: flex; + align-items: center; + justify-content: center; + z-index: 1; + width: calc(100% - var(--map-inset) * 2); + height: calc(100% - var(--map-inset) * 2); + background-image: url("/images/region_map_bg.png"); + background-repeat: no-repeat; + background-size: contain; + background-position: center center; + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + image-rendering: pixelated; + opacity: 0.7; + filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.02)); + pointer-events: none; + min-height: 200px; +} + +.svgLayer { + position: absolute; + inset: var(--map-inset); + display: flex; + align-items: center; + justify-content: center; + z-index: 2; + width: calc(100% - var(--map-inset) * 2); + height: calc(100% - var(--map-inset) * 2); +} + +.svgLayer > svg { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + image-rendering: -webkit-optimize-contrast; + image-rendering: crisp-edges; + shape-rendering: geometricPrecision; +} + +/* SVG 스타일을 위한 컨테이너 */ +.svgContainer :global(svg) :global(path) { + fill: rgba(0, 150, 255, 0.2) !important; + stroke: #0066cc; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.svgContainer :global(svg) :global(.cls-1) { + fill: rgba(0, 150, 255, 0.2) !important; + stroke: #0066cc; + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.svgContainer :global(svg) :global(.basin) { + opacity: 0; + pointer-events: all; + transition: opacity 0.15s ease; + cursor: pointer; +} + +.svgContainer :global(svg) :global(.basin.active), +.svgContainer :global(svg) :global(.basin:hover) { + opacity: 1; +} + +.svgContainer :global(svg) :global(.basin) :global(path), +.svgContainer :global(svg) :global(.basin) :global(polygon), +.svgContainer :global(svg) :global(.basin) :global(polyline) { + fill: rgba(0, 209, 209, 0.5); + stroke: #399AC3; + stroke-width: 1.3; + vector-effect: non-scaling-stroke; +} + +/* 팝업 + 배지 */ +.popup { + position: absolute; + width: 300px; + height: 150px; + min-width: 220px; + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: + 0 12px 24px rgba(0, 0, 0, 0.18), + 0 2px 0 rgba(0, 0, 0, 0.05); + opacity: 0; + visibility: hidden; + transform: translate(-50%, -8px) scale(0.98); + transition: + opacity 0.12s, + visibility 0.12s, + transform 0.12s; + pointer-events: none; + z-index: 10; +} +.popup.show { + opacity: 1 !important; + visibility: visible !important; + transform: translate(-50%, -12px) scale(1) !important; +} + +.show { + opacity: 1; + visibility: visible; +} +.popup .head { + display: grid; + grid-template-columns: 1fr 1fr; + background: #d1ecfa; + border-bottom: 1px solid #c8e4f3; + font-weight: 700; + font-size: 16px; +} +.popup .head > div { + padding: 10px 12px; + text-align: center; +} +.popup .body { + padding: 6px 10px 8px; +} +.popup td { + padding: 8px 10px; + border-bottom: 1px solid #eef2f7; + font-size: 14px; +} +.popup .arrow { + position: absolute; + left: 50%; + bottom: -8px; + transform: translateX(-50%); + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-top: 10px solid #fff; + filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.1)); +} +.badge { + position: absolute; + z-index: 9; + padding: 6px 10px; + border-radius: 8px; + background: #1e40af; + color: #fff; + font-weight: 700; + transform: translate(-50%, -50%); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.22); + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity 0.12s, visibility 0.12s; +} +.badge.show { + opacity: 1; + visibility: visible; +} + +/* 권역 라벨 */ +.labelsLayer { + position: absolute; + inset: var(--map-inset); + pointer-events: none; + z-index: 3; + width: 100%; + height: 100%; +} +.basinLabel { + position: absolute; + transform: translate(-50%, -50%); + pointer-events: none; + padding: 6px 12px; + border-radius: 6px; + font-weight: 700; + font-size: 16px; + background: #ffffffee; + border: 2px solid #d0d0d0; + white-space: nowrap; + min-width: 60px; + text-align: center; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); +} +.basinLabel.active { + background: #006bf8; + color: #fff; + border-color: #96c3ff; + box-shadow: 0 4px 12px rgba(0, 107, 248, 0.3); +} diff --git a/apps/cm/src/pages/dashboard/lib/KoreaBasinMap.tsx b/apps/cm/src/pages/dashboard/lib/KoreaBasinMap.tsx new file mode 100644 index 000000000..139f9f872 --- /dev/null +++ b/apps/cm/src/pages/dashboard/lib/KoreaBasinMap.tsx @@ -0,0 +1,425 @@ +import React, { useEffect, useRef } from "react"; +import styles from "./KoreaBasinMap.module.css"; // 추가 + +const SVG_URL = "/images/region_map.svg"; // public 폴더에 region_map.svg 위치 필요 + +const MANUAL_LABEL_POS = { + han: { x: 0.52, y: 0.14 }, + geum: { x: 0.28, y: 0.47 }, + nakdong: { x: 0.64, y: 0.47 }, + yeongsan: { x: 0.18, y: 0.77 }, +}; + +const BASIN_DATA = { + han: { + name: "한강", + colTitle: "시설수", + rows: [ + ["500m³ 미만", 100000000], + ["500m³ 이상", 100000000], + ], + }, + geum: { + name: "금강", + colTitle: "시설수", + rows: [ + ["500m³ 미만", 100000000], + ["500m³ 이상", 100000000], + ], + }, + nakdong: { + name: "낙동강", + colTitle: "시설수", + rows: [ + ["500m³ 미만", 100000000], + ["500m³ 이상", 100000000], + ], + }, + yeongsan: { + name: "영산강", + colTitle: "시설수", + rows: [ + ["500m³ 미만", 100000000], + ["500m³ 이상", 100000000], + ], + }, +}; + +const BASIN_IDS = [ + { key: "han", labels: ["한강", "han"] }, + { key: "geum", labels: ["금강", "geum"] }, + { key: "nakdong", labels: ["낙동강", "nakdong"] }, + { key: "yeongsan", labels: ["영산강", "yeongsan"] }, +]; + +function fmt(n: number) { + return new Intl.NumberFormat("ko-KR").format(n); +} + +export function KoreaBasinMap() { + const stageRef = useRef(null); + const svgLayerRef = useRef(null); + const labelsLayerRef = useRef(null); + const popupRef = useRef(null); + const badgeRef = useRef(null); + const rowsRef = useRef(null); + const colTitleRef = useRef(null); + + useEffect(() => { + let svg: SVGSVGElement | null = null; + let gs: SVGGElement[] = []; + let labels: HTMLDivElement[] = []; + + console.log("Loading SVG from:", SVG_URL); + fetch(SVG_URL) + .then((r) => { + console.log("SVG fetch response:", r.status, r.statusText); + if (!r.ok) throw new Error(`HTTP error! status: ${r.status}`); + return r.text(); + }) + .then((txt) => { + console.log("SVG content loaded, length:", txt.length); + if (!svgLayerRef.current) { + console.error("svgLayerRef.current is null"); + return; + } + svgLayerRef.current.innerHTML = txt; + svg = svgLayerRef.current.querySelector("svg"); + if (!svg) { + console.error("SVG element not found in loaded content"); + return; + } + console.log("SVG element found:", svg); + + // viewBox/크기 세팅 + if (!svg.hasAttribute("viewBox")) { + const bb = svg.getBBox(); + svg.setAttribute( + "viewBox", + [bb.x, bb.y, bb.width, bb.height].join(" "), + ); + } + const vb = (svg.getAttribute("viewBox") || "").split(/\s+/).map(Number); + if (vb.length === 4 && vb[3]! > 0 && stageRef.current) { + stageRef.current!.style.aspectRatio = String(vb[2]! / vb[3]!); + } + svg!.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.style.width = "100%"; + svg.style.height = "100%"; + + // 1) 지도 그룹 수집 + gs = []; + console.log("Looking for basin groups in SVG..."); + BASIN_IDS.forEach((item) => { + let g: SVGGElement | null = null; + for (const id of item.labels) { + g = svg!.getElementById(id) as SVGGElement | null; + if (g) { + console.log(`Found group for ${item.key} with id '${id}':`, g); + break; + } + } + if (!g) { + console.warn(`No group found for ${item.key} with labels:`, item.labels); + return; + } + g.classList.add("basin"); + (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key = item.key; + (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.label = item.labels[0] || ''; + + // 패스에 기본 스타일 적용 + const paths = g.querySelectorAll('path'); + paths.forEach((path) => { + path.style.fill = 'rgba(0, 150, 255, 0.2)'; + path.style.stroke = '#0066cc'; + path.style.strokeWidth = '1'; + path.style.vectorEffect = 'non-scaling-stroke'; + }); + + gs.push(g); + }); + console.log(`Found ${gs.length} basin groups`); + + // 각 유역 그룹의 패스 정보 확인 + gs.forEach((g, index) => { + const paths = g.querySelectorAll('path'); + console.log(`Group ${index} (${(g as SVGGElement & { dataset: { key: string } }).dataset.key}):`, { + element: g, + pathCount: paths.length, + paths: Array.from(paths).map(p => ({ + className: p.className.baseVal, + d: p.getAttribute('d')?.substring(0, 50) + '...' + })) + }); + }); + + // 2) 함수들 먼저 정의 + const clear = () => { + gs.forEach((el) => el.classList.remove("active")); + if (popupRef.current) { + if (styles.show) { + popupRef.current.classList.remove(styles.show); + } + popupRef.current.style.opacity = "0"; + popupRef.current.style.visibility = "hidden"; + } + if (badgeRef.current) { + if (styles.show) { + badgeRef.current.classList.remove(styles.show); + } + badgeRef.current.style.opacity = "0"; + badgeRef.current.style.visibility = "hidden"; + } + labels.forEach((l) => l.classList.remove("active")); + }; + + function showFor(g: SVGGElement) { + console.log("showFor called for:", g); + const key = (g as SVGGElement & { dataset: { key: string; label: string } }).dataset.key; + const data = (BASIN_DATA as Record)[key] || { + name: (g as SVGGElement & { dataset: { label: string } }).dataset.label, + colTitle: "", + rows: [], + }; + console.log("Data for popup:", data); + + if (badgeRef.current) badgeRef.current.textContent = data.name; + if (colTitleRef.current) + colTitleRef.current.textContent = data.colTitle || "???"; + if (rowsRef.current) + rowsRef.current.innerHTML = data.rows + .map( + (r: (string | number)[]) => + `${r[0]}${fmt(Number(r[1]) || 0)}`, + ) + .join(""); + + if (!stageRef.current) return; + const sRect = stageRef.current.getBoundingClientRect(), + r = g.getBoundingClientRect(); + const cx = (r.left + r.right) / 2 - sRect.left; + const ty = r.top - sRect.top; + + console.log("Popup position:", { cx, ty }); + + if (badgeRef.current) { + badgeRef.current.style.left = cx + "px"; + badgeRef.current.style.top = ty + 24 + "px"; + } + if (popupRef.current) { + popupRef.current.style.left = cx + "px"; + popupRef.current.style.top = ty + 6 + "px"; + popupRef.current.style.opacity = "1"; + popupRef.current.style.visibility = "visible"; + if (styles.show) { + popupRef.current.classList.add(styles.show); + } + console.log("Added show class to popup:", popupRef.current.classList.toString()); + } + if (badgeRef.current && styles.show) { + badgeRef.current.classList.add(styles.show); + } + + if (popupRef.current) { + const pRect = popupRef.current.getBoundingClientRect(); + const x = Math.max( + 12 + pRect.width / 2, + Math.min(cx, sRect.width - 12 - pRect.width / 2), + ); + popupRef.current.style.left = x + "px"; + if (badgeRef.current) badgeRef.current.style.left = x + "px"; + } + } + + function showForWithLabel(g: SVGGElement) { + console.log("showForWithLabel called for:", g); + showFor(g); + const idx = gs.indexOf(g); + labels.forEach((l) => l.classList.remove("active")); + if (idx >= 0) labels[idx]!.classList.add("active"); + } + + // 3) 라벨 DOM 생성 + labels = []; + if (labelsLayerRef.current) { + labelsLayerRef.current.innerHTML = ""; + gs.forEach((g) => { + const el = document.createElement("div"); + el.className = styles.basinLabel || ''; + el.textContent = (g as SVGGElement & { dataset: { label: string } }).dataset.label || ''; + labelsLayerRef.current!.appendChild(el); + labels.push(el); + }); + } + + // 3) 라벨 위치 + function positionLabels() { + if (!stageRef.current || !svg) return; + const sRect = stageRef.current.getBoundingClientRect(); + const inset = + parseFloat( + getComputedStyle(stageRef.current).getPropertyValue( + "--map-inset", + ) || "0", + ) / 100; + const left = sRect.width * inset, + top = sRect.height * inset; + const w = sRect.width * (1 - inset * 2), + h = sRect.height * (1 - inset * 2); + const [vx, vy, vw, vh] = ( + svg!.getAttribute("viewBox") || "0 0 100 100" + ) + .split(/\s+/) + .map(Number); + + const svgRect = svg.getBoundingClientRect(); + const stageRect = stageRef.current.getBoundingClientRect(); + const offsetX = svgRect.left - stageRect.left - left; + const offsetY = svgRect.top - stageRect.top - top; + + gs.forEach((g, i) => { + const key = (g as SVGGElement & { dataset: { key: string } }).dataset.key; + const manual = (MANUAL_LABEL_POS as Record)[key]; + let px, py; + if ( + manual && + (("x" in manual && "y" in manual) || + ("px" in manual && "py" in manual)) + ) { + if ("px" in manual) { + px = left + (manual.px || 0) + offsetX; + py = top + (manual.py || 0) + offsetY; + } else { + px = left + w * (manual.x || 0) + offsetX; + py = top + h * (manual.y || 0) + offsetY; + } + } else { + const b = g.getBBox(); + const cx = b.x + b.width / 2, + cy = b.y + b.height / 2; + px = ((cx - vx!) / vw!) * w + left + offsetX; + py = ((cy - vy!) / vh!) * h + top + offsetY; + } + labels[i]!.style.left = `${px}px`; + labels[i]!.style.top = `${py + 18}px`; + }); + } + + // 4) 상호작용 (함수들은 이미 위에서 정의됨) + gs.forEach((g) => { + g.addEventListener("mouseenter", () => { + clear(); + g.classList.add("active"); + showForWithLabel(g); + }); + g.addEventListener("mousemove", () => { + if (g.classList.contains("active")) showForWithLabel(g); + }); + g.addEventListener("mouseleave", clear); + g.addEventListener("click", (e) => { + e.stopPropagation(); + clear(); + g.classList.add("active"); + showForWithLabel(g); + }); + }); + if (stageRef.current) { + stageRef.current.addEventListener("mouseleave", clear); + stageRef.current.addEventListener("click", () => clear()); + } + + // 5) 실행 + positionLabels(); + window.addEventListener("resize", positionLabels); + new window.ResizeObserver(positionLabels).observe(stageRef.current!); + }) + .catch((error) => { + console.error("Error loading SVG:", error); + if (svgLayerRef.current) + svgLayerRef.current.innerHTML = + `
지도를 불러올 수 없습니다. (${error.message})
`; + }); + + // cleanup + return () => { + window.removeEventListener("resize", () => {}); + }; + }, []); + + // 스타일은 필요에 따라 tailwind, css 등으로 커스터마이즈하세요. + return ( +
+
+
+
+
+ +
+ ); +} diff --git a/apps/cm/src/pages/dashboard/model/chart-data.ts b/apps/cm/src/pages/dashboard/model/chart-data.ts new file mode 100644 index 000000000..0d55f9bc7 --- /dev/null +++ b/apps/cm/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/cm/src/pages/dashboard/model/dashboard-interface.ts b/apps/cm/src/pages/dashboard/model/dashboard-interface.ts new file mode 100644 index 000000000..9e038eef7 --- /dev/null +++ b/apps/cm/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/cm/src/pages/dashboard/model/table-columns.tsx b/apps/cm/src/pages/dashboard/model/table-columns.tsx new file mode 100644 index 000000000..aac2b2868 --- /dev/null +++ b/apps/cm/src/pages/dashboard/model/table-columns.tsx @@ -0,0 +1,75 @@ +import dayjs from "dayjs"; +import type { ColumnDef } from "@tanstack/react-table"; +import { Badge } from "@common/components"; +import type { Post, MyPost } 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 myPostTableColumns: 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, myPostTableColumns }; diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx index 075efac41..76b6876fb 100644 --- a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -1,9 +1,36 @@ "use client"; -import { useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; -import { tokenManager } from '@/entities/auth/lib/token-manager'; -import { client } from '@/shared/api'; -import { ENDPOINT } from '@/shared/config'; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { tokenManager } from "@/entities/auth/lib/token-manager"; +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { KoreaBasinMap } from "../lib/KoreaBasinMap"; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Filler, + Legend, + Tooltip, + ArcElement, +} from "chart.js"; +import { Doughnut } from "react-chartjs-2"; + +// Chart.js 등록 +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + Filler, + Legend, + Tooltip, + ArcElement +); interface UserInfo { email?: string; @@ -14,124 +41,258 @@ interface UserInfo { function DashboardPage() { const [userInfo, setUserInfo] = useState(null); - const [error, setError] = useState(''); + const [error, setError] = useState(""); const router = useRouter(); useEffect(() => { - const fetchUserInfo = async () => { - console.log('fetchUserInfo called'); - + const fetchUserInfo = async () => { + console.log("fetchUserInfo called"); + try { // tokenManager를 통해 토큰 확인 - const accessToken = tokenManager.getToken('Authorization'); - const userId = tokenManager.getToken('token-id'); - + const accessToken = tokenManager.getToken("Authorization"); + const userId = tokenManager.getToken("token-id"); + if (!accessToken || !userId) { - console.log('No tokens found, redirecting to login'); - setError('로그인이 필요합니다.'); - router.push('/auth/login'); + console.log("No tokens found, redirecting to login"); + setError("로그인이 필요합니다."); + router.push("/auth/login"); return; } - console.log('Fetching user info with userId:', userId); - + console.log("Fetching user info with userId:", userId); + // client를 사용하여 API 호출 (자동 토큰 처리) - const response = await client.get(`${ENDPOINT.USER_SERVICE.USERS}/${userId}`); - + const response = await client.get( + `${ENDPOINT.USER_SERVICE.USERS}/${userId}`, + ); + if (response.data) { - console.log('User info received:', response.data); + console.log("User info received:", response.data); setUserInfo(response.data); } else { - setError('사용자 정보를 불러올 수 없습니다.'); + setError("사용자 정보를 불러올 수 없습니다."); } } catch (error) { - console.error('Error fetching user info:', error); - if (error instanceof Error && error.message.includes('401')) { - setError('인증이 만료되었습니다. 다시 로그인해주세요.'); + console.error("Error fetching user info:", error); + if (error instanceof Error && error.message.includes("401")) { + setError("인증이 만료되었습니다. 다시 로그인해주세요."); tokenManager.clearAuthToken(); - router.push('/auth/login'); + router.push("/auth/login"); } else { - setError('네트워크 오류가 발생했습니다.'); + setError("네트워크 오류가 발생했습니다."); } } }; - + fetchUserInfo(); }, [router]); const handleLogout = () => { - console.log('Logging out...'); + console.log("Logging out..."); // localStorage 정리 - localStorage.removeItem('tempToken'); + localStorage.removeItem("tempToken"); // tokenManager를 통해 쿠키 정리 tokenManager.clearAuthToken(); - router.push('/auth/logout'); + router.push("/auth/logout"); }; - const handleChangePassword = () => { - router.push('/otp?next=change-password'); + const handleChangePassword = () => { + router.push("/otp?next=change-password"); }; return (
{/* 첫번째 라인 */} -
- {/* 총 회원수 카드 */} -
- - +
+ {/* 첫번쨰 열 */} +
+ {/* 전국 하수처리시설 가동률 */} +
+
+

전국 하수처리시설 가동률

+
+
+ { + const ctx = chart.ctx; + const centerX = chart.width / 2; + const centerY = chart.height / 2; + + // 가동률 텍스트 + ctx.save(); + ctx.font = 'bold 24px Arial'; + ctx.fillStyle = '#00DFE5'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('75%', centerX, centerY + 10); + + // 라벨 텍스트 + ctx.font = '12px Arial'; + ctx.fillStyle = '#666'; + ctx.fillText('가동률', centerX, centerY + 30); + ctx.restore(); + } + }]} + /> +
+
+ + {/* 오수관로 및 우수관로 현황 */} +
+
+

오수관로 및 우수관로 현황

+
+
+
+ + {/* 하수처리수 재이용 */} +
+
+

하수처리수 재이용

+
+
+
- {/* 전체 게시물 카드 */} -
+ + {/* 두번쨰 열 */} +
+ {/* 하수처리시설(개소) */} +
+
+

하수처리시설(개소)

+
+
+
+
+

500㎥ 미만(개소)

+
+
3,567 개소
+
+
+
+

500㎥ 이상(개소)

+
+
767 개소
+
+
기준년월 : 2025.09
+
+
+ {/* 노후하수관로현황 */} +
+
+

노후하수관로현황

+
+
+
+
+ + {/* 재이용시설(개소) */} +
+
+

재이용시설(개소)

+
+
+
+
- {/* 오늘 접속자 수 카드 */} -
- + + {/* 지도영역 */} +
+
+
+

유역별 정보

+
+ +
{/* 사용자 정보 카드 */} -
-

사용자 정보

- {error &&
{error}
} - {userInfo ? ( -
-
아이디: {userInfo.userId}
-
이메일: {userInfo.email || userInfo.userId}
-
이름: {userInfo.userName || '-'}
-
2FA 활성화: {userInfo.twoFactorEnabled ? '예' : '아니오'}
+
+

사용자 정보

+ {error &&
{error}
} + {userInfo ? ( +
+
+ 아이디: {userInfo.userId}
- ) : ( - !error &&
사용자 정보를 불러오는 중...
- )} - - -
+
+ 이메일: {userInfo.email || userInfo.userId} +
+
+ 이름: {userInfo.userName || "-"} +
+
+ 2FA 활성화:{" "} + {userInfo.twoFactorEnabled ? "예" : "아니오"} +
+
+ ) : ( + !error &&
사용자 정보를 불러오는 중...
+ )} + + +
{/* 두번째 라인 */} -
-
+
+
+
+

지원현황

+
+
+ +
+
+
+
+

자료관 자료현황

+
+
+
+
+
+
+

공지사항

+
+
+
-
-
-
+ {/* 배너 영역 */} +
- {/* 세번째 라인 */} -
-
-
-
-
-
- - {/* 네번째 라인 */} -
-
-
-
@@ -139,4 +300,4 @@ function DashboardPage() { ); } -export { DashboardPage }; \ No newline at end of file +export { DashboardPage }; diff --git a/apps/cm/src/shared/config/api.ts b/apps/cm/src/shared/config/api.ts index 36dfc69a3..25c8b88c9 100644 --- a/apps/cm/src/shared/config/api.ts +++ b/apps/cm/src/shared/config/api.ts @@ -69,6 +69,17 @@ export const ENDPOINT = { POSTS_COMMENTS: "/board-service/api/v1/posts-cmnts", POST_EVALUATION: "/board-service/api/v1/pstEvl", }, + SEWERAGE_SERVICE: { + PPL_COUNTS: "/sewerage-service/api/v1/ms/pplCounts", + OLD_PPL_COUNTS: "/sewerage-service/api/v1/ms/oldPplCounts", + }, + IC_SERVICE: { + GROUPING_STATISTICS: "/ic-service/api/v1/nti/grouping-statistics", + }, + DC_SERVICE: { + DATA_STATUS: "/dc-service/api/v1/dc/dataStatus", + }, + }; export const JWT_EXPIRATION_TIME = 1800000;