From 8491d0243a9d19ffe0e203ffc183fdead0105ec6 Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 4 Feb 2026 15:22:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[cm]=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=20dnd=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cm/package.json | 3 + .../dashboard/lib/hooks/use-dnd-sensors.ts | 16 ++ .../dashboard/lib/hooks/use-panel-order.ts | 56 ++++ .../pages/dashboard/model/panel-config.tsx | 71 ++++++ apps/cm/src/pages/dashboard/model/types.ts | 7 + .../src/pages/dashboard/ui/dashboard-page.tsx | 240 +++++++++++------- .../src/pages/dashboard/ui/sortable-panel.tsx | 38 +++ pnpm-lock.yaml | 61 ++++- 8 files changed, 394 insertions(+), 98 deletions(-) create mode 100644 apps/cm/src/pages/dashboard/lib/hooks/use-dnd-sensors.ts create mode 100644 apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts create mode 100644 apps/cm/src/pages/dashboard/model/panel-config.tsx create mode 100644 apps/cm/src/pages/dashboard/model/types.ts create mode 100644 apps/cm/src/pages/dashboard/ui/sortable-panel.tsx diff --git a/apps/cm/package.json b/apps/cm/package.json index 9d4e1da8f..1b40efc69 100644 --- a/apps/cm/package.json +++ b/apps/cm/package.json @@ -12,6 +12,9 @@ "@common/assets": "workspace:*", "@common/components": "workspace:*", "@common/config": "workspace:*", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/noto-sans-kr": "^5.2.5", "@frontend-opensource/use-react-hooks": "^1.1.4", "@fullcalendar/core": "^6.1.17", diff --git a/apps/cm/src/pages/dashboard/lib/hooks/use-dnd-sensors.ts b/apps/cm/src/pages/dashboard/lib/hooks/use-dnd-sensors.ts new file mode 100644 index 000000000..953bc874f --- /dev/null +++ b/apps/cm/src/pages/dashboard/lib/hooks/use-dnd-sensors.ts @@ -0,0 +1,16 @@ +import { + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { sortableKeyboardCoordinates } from "@dnd-kit/sortable"; + +export function useDndSensors() { + return useSensors( + useSensor(PointerSensor), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }), + ); +} diff --git a/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts b/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts new file mode 100644 index 000000000..8842e43db --- /dev/null +++ b/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts @@ -0,0 +1,56 @@ +import { useState } from "react"; +import { arrayMove } from "@dnd-kit/sortable"; +import type { DragEndEvent } from "@dnd-kit/core"; +import type { PanelItem } from "../../model/types"; + +interface UsePanelOrderProps { + initialPanels: PanelItem[]; + storageKey: string; +} + +export function usePanelOrder({ + initialPanels, + storageKey, +}: UsePanelOrderProps) { + const [panels, setPanels] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem(storageKey); + if (saved) { + try { + const savedIds = JSON.parse(saved); + const restoredPanels = savedIds + .map((id: string) => initialPanels.find((p) => p.id === id)) + .filter(Boolean); + if (restoredPanels.length === initialPanels.length) { + return restoredPanels; + } + } catch { + return initialPanels; + } + } + } + return initialPanels; + }); + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + if (over && active.id !== over.id) { + setPanels((items) => { + const oldIndex = items.findIndex((item) => item.id === active.id); + const newIndex = items.findIndex((item) => item.id === over.id); + const newItems = arrayMove(items, oldIndex, newIndex); + + // localStorage에 저장 + localStorage.setItem( + storageKey, + JSON.stringify(newItems.map((item) => item.id)), + ); + + return newItems; + }); + } + }; + + return { panels, handleDragEnd }; +} diff --git a/apps/cm/src/pages/dashboard/model/panel-config.tsx b/apps/cm/src/pages/dashboard/model/panel-config.tsx new file mode 100644 index 000000000..e995e41c6 --- /dev/null +++ b/apps/cm/src/pages/dashboard/model/panel-config.tsx @@ -0,0 +1,71 @@ +import FoFcltCntRt from "@/pages/dashboard/ui/fo-fclt-cnt-rt"; +import SnPplStatus from "@/pages/dashboard/ui/sn-ppl-status"; +import ReuseChart from "@/pages/dashboard/ui/reuse-chart"; +import FoWqltInflEffl from "@/pages/dashboard/ui/fo-wqlt-Infl-effl"; +import SnOldPpl from "@/pages/dashboard/ui/sn-old-ppl"; +import ReuseStatus from "@/pages/dashboard/ui/reuse-status"; +import SupportList from "@/pages/dashboard/ui/support-list"; +import DataStatusList from "@/pages/dashboard/ui/datastatus-list"; +import NoticeList from "@/pages/dashboard/ui/notice-list"; +import BannerList from "@/pages/dashboard/ui/banner-list"; +import type { PanelItem } from "./types"; + +export function getInitialTopPanels(): PanelItem[] { + return [ + { + id: "panel-1", + component: , + className: "", + }, + { + id: "panel-2", + component: , + className: "", + }, + { + id: "panel-3", + component: , + className: "", + }, + { + id: "panel-4", + component: , + className: "", + }, + { + id: "panel-5", + component: , + className: "", + }, + { + id: "panel-6", + component: , + className: "", + }, + ]; +} + +export function getInitialBottomPanels(): PanelItem[] { + return [ + { + id: "panel-7", + component: , + className: "", + }, + { + id: "panel-8", + component: , + className: "", + }, + { + id: "panel-9", + component: , + className: "", + }, + { + id: "panel-10", + component: , + className: "", + }, + ]; +} diff --git a/apps/cm/src/pages/dashboard/model/types.ts b/apps/cm/src/pages/dashboard/model/types.ts new file mode 100644 index 000000000..8cd41a37e --- /dev/null +++ b/apps/cm/src/pages/dashboard/model/types.ts @@ -0,0 +1,7 @@ +export interface PanelItem { + id: string; + component: React.ReactNode; + className?: string; +} + +export type PanelType = "top" | "bottom"; diff --git a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx index 5f9ce3440..41e855c01 100644 --- a/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx +++ b/apps/cm/src/pages/dashboard/ui/dashboard-page.tsx @@ -6,6 +6,8 @@ import { tokenManager } from "@/entities/auth/lib/token-manager"; import { client } from "@/shared/api"; import { ENDPOINT } from "@/shared/config"; import { KoreaBasinMap } from "../lib/korea-basin-map"; +import { useDndSensors } from "../lib/hooks/use-dnd-sensors"; +import { usePanelOrder } from "../lib/hooks/use-panel-order"; import { Chart as ChartJS, CategoryScale, @@ -18,18 +20,15 @@ import { Tooltip, ArcElement, } from "chart.js"; -import SnPplStatus from "./sn-ppl-status"; -import SnOldPpl from "./sn-old-ppl"; -import FoFcltCntRt from "./fo-fclt-cnt-rt"; -import FoWqltInflEffl from "./fo-wqlt-Infl-effl"; -import ReuseChart from "./reuse-chart"; -import ReuseStatus from "./reuse-status"; -import NoticeList from "./notice-list"; -import BannerList from "./banner-list"; -import SupportList from "./support-list"; -import DataStatusList from "./datastatus-list"; +import { DndContext, closestCenter } from "@dnd-kit/core"; +import { SortableContext, rectSortingStrategy } from "@dnd-kit/sortable"; +import { + getInitialTopPanels, + getInitialBottomPanels, +} from "../model/panel-config"; import MyPostList from "./mypost-list"; import ScheduleList from "./schedule-list"; +import { SortablePanel } from "./sortable-panel"; // Chart.js 등록 ChartJS.register( @@ -41,7 +40,7 @@ ChartJS.register( Filler, Legend, Tooltip, - ArcElement + ArcElement, ); interface UserInfo { @@ -54,7 +53,23 @@ interface UserInfo { function DashboardPage() { const [userInfo, setUserInfo] = useState(null); const [error, setError] = useState(""); - const router = useRouter(); + const router = useRouter(); + + // dnd-kit 센서 설정 + const sensors = useDndSensors(); + + // 패널 순서 관리 + const { panels: topPanels, handleDragEnd: handleTopDragEnd } = usePanelOrder({ + initialPanels: getInitialTopPanels(), + storageKey: "dashboard-top-panels-order", + }); + + const { panels: bottomPanels, handleDragEnd: handleBottomDragEnd } = + usePanelOrder({ + initialPanels: getInitialBottomPanels(), + storageKey: "dashboard-bottom-panels-order", + }); + useEffect(() => { const fetchUserInfo = async () => { console.log("fetchUserInfo called"); @@ -115,96 +130,129 @@ function DashboardPage() { return (
- {/* 첫번째 라인 */} -
- {/* 첫번쨰 열 */} -
- {/* 전국 하수처리시설 개소수 및 가동률 */} - - - {/* 오수관로 및 우수관로 현황 */} - - - {/* 하수처리수 재이용 (m³) */} - -
- - {/* 두번쨰 열 */} -
- {/* 수계별 유입ㆍ방류수질 분석 */} - - - {/* 노후하수관로현황 */} - - - {/* 물재이용 현황 (재이용량, m³) */} - -
- - {/* 지도영역 */} -
- -
- - {/* 사용자 정보 카드 */} -
- {error &&
{error}
} - {userInfo ? ( -
-
-
- 프로필 { - const target = e.target as HTMLImageElement; - target.src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Ccircle cx='20' cy='20' r='18' fill='%23e1e5e9'/%3E%3Ctext x='20' y='26' font-size='20' text-anchor='middle' fill='%23999'%3E%3F%3C/text%3E%3C/svg%3E"; - }} - /> + {/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */} +
+ {/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */} +
+ + p.id)} + strategy={rectSortingStrategy} + > +
+ {topPanels.map((panel) => ( + + {panel.component} + + ))}
-
-
{userInfo.userName || "-"}
-
온라인
-
- 2FA 활성화:{" "} - {userInfo.twoFactorEnabled ? "예" : "아니오"} + + +
+ + {/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */} +
+ {/* 지도영역 - 고정 (드래그 불가) */} +
+ +
+ + {/* 사용자 정보 카드 - 고정 (드래그 불가) */} +
+ {error &&
{error}
} + {userInfo ? ( +
+
+
+ 프로필 { + const target = e.target as HTMLImageElement; + target.src = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Ccircle cx='20' cy='20' r='18' fill='%23e1e5e9'/%3E%3Ctext x='20' y='26' font-size='20' text-anchor='middle' fill='%23999'%3E%3F%3C/text%3E%3C/svg%3E"; + }} + /> +
+
+
+ {userInfo.userName || "-"} +
+
온라인
+
+ 2FA 활성화:{" "} + {userInfo.twoFactorEnabled ? "예" : "아니오"} +
+
+
+
+ +
-
-
- - -
+ ) : ( + !error &&
사용자 정보를 불러오는 중...
+ )} + + + +
- ) : ( - !error &&
사용자 정보를 불러오는 중...
- )} - - - - -
-
- - {/* 두번째 라인 */} -
-
- -
-
- -
-
- -
- - {/* 배너 영역 */} -
-
+ + {/* 두번째 라인 - 드래그 가능한 4개 패널 */} + + p.id)} + strategy={rectSortingStrategy} + > +
+ {bottomPanels.map((panel) => ( + +
{panel.component}
+
+ ))} +
+
+
); diff --git a/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx b/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx new file mode 100644 index 000000000..ce9d2c949 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx @@ -0,0 +1,38 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +interface SortablePanelProps { + id: string; + children: React.ReactNode; + className?: string; +} + +export function SortablePanel({ id, children, className }: SortablePanelProps) { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + cursor: isDragging ? "grabbing" : "grab", + }; + + return ( +
+ {children} +
+ ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a17a827c5..9d629f1e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,15 @@ importers: '@common/config': specifier: workspace:* version: link:../../packages/common/config + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.3) '@fontsource-variable/noto-sans-kr': specifier: ^5.2.5 version: 5.2.9 @@ -1735,6 +1744,28 @@ packages: resolution: {integrity: sha512-Y6+WUMsTFWE5jb20IFP4YGa5IrGY/+a/FbOSjDF/wz9gepU2hwCYSXRHP/vPwBvwcY3SVMASt4yXxbXNXigmZQ==} engines: {node: '>=18'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: ^19.2.1 + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: ^19.2.1 + react-dom: ^19.2.1 + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: ^19.2.1 + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: ^19.2.1 + '@emnapi/core@1.7.1': resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} @@ -4999,6 +5030,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true globals@14.0.0: @@ -7268,6 +7300,31 @@ snapshots: gonzales-pe: 4.3.0 node-source-walk: 7.0.1 + '@dnd-kit/accessibility@3.1.1(react@19.2.3)': + dependencies: + react: 19.2.3 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@dnd-kit/utilities': 3.2.2(react@19.2.3) + react: 19.2.3 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.3)': + dependencies: + react: 19.2.3 + tslib: 2.8.1 + '@emnapi/core@1.7.1': dependencies: '@emnapi/wasi-threads': 1.1.0 @@ -10388,7 +10445,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -10439,7 +10496,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.49.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From c9b2ef35b7b01cd885a25b54e6d3103ea3374bf5 Mon Sep 17 00:00:00 2001 From: bicochan Date: Thu, 5 Feb 2026 09:24:28 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[cm]=20fix:=20hydration=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cm/src/pages/dashboard/ui/banner-list.tsx | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/apps/cm/src/pages/dashboard/ui/banner-list.tsx b/apps/cm/src/pages/dashboard/ui/banner-list.tsx index 865540672..d3ae54610 100644 --- a/apps/cm/src/pages/dashboard/ui/banner-list.tsx +++ b/apps/cm/src/pages/dashboard/ui/banner-list.tsx @@ -1,9 +1,13 @@ "use client"; -import { useRef } from "react"; +import { useRef, useState, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; -import { getImageUrlByAtflCd, getBannerList, type Banner } from "../api/banner-service"; +import { + getImageUrlByAtflCd, + getBannerList, + type Banner, +} from "../api/banner-service"; import { getCookie } from "@/shared/lib"; import { @@ -21,7 +25,12 @@ type BannerImageProps = { }; const BannerList: React.FC = () => { - const siteId = getCookie("site-id-key") ?? ""; + const [siteId, setSiteId] = useState(""); + + useEffect(() => { + const id = getCookie("site-id-key") ?? ""; + setSiteId(id); + }, []); const BannerImage = ({ atflCd, lnkgUrlAddr }: BannerImageProps) => { const { data: imageUrl, isLoading } = useQuery({ @@ -91,6 +100,19 @@ const BannerList: React.FC = () => { return maybe.status ?? maybe.response?.status; }; + // siteId가 없을 때 + if (!siteId) { + return ( +
+
+
+ 사이트 ID가 없습니다 +
+
+
+ ); + } + // 401 에러 처리 if (bannerError) { const is401Error = @@ -133,21 +155,8 @@ const BannerList: React.FC = () => { ); } - // siteId가 없을 때 - if (!siteId) { - return ( -
-
-
- 사이트 ID가 없습니다 -
-
-
- ); - } - return ( -
+
Date: Fri, 6 Feb 2026 16:33:49 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[cm]=20feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8C=A8=EB=84=90=20=ED=97=A4=EB=8D=94=EC=97=90=20?= =?UTF-8?q?=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=95=B8=EB=93=A4=EB=9F=AC=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/lib/hooks/use-panel-order.ts | 37 +++--- .../cm/src/pages/dashboard/ui/banner-list.tsx | 8 ++ .../src/pages/dashboard/ui/dashboard-page.tsx | 110 +++++++++++------- .../pages/dashboard/ui/datastatus-list.tsx | 8 +- .../src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx | 15 ++- .../pages/dashboard/ui/fo-wqlt-Infl-effl.tsx | 15 ++- .../cm/src/pages/dashboard/ui/notice-list.tsx | 15 ++- .../cm/src/pages/dashboard/ui/reuse-chart.tsx | 15 ++- .../src/pages/dashboard/ui/reuse-status.tsx | 15 ++- apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx | 15 ++- .../src/pages/dashboard/ui/sn-ppl-status.tsx | 16 ++- .../src/pages/dashboard/ui/sortable-panel.tsx | 46 ++++++-- .../src/pages/dashboard/ui/support-list.tsx | 8 +- 13 files changed, 237 insertions(+), 86 deletions(-) diff --git a/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts b/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts index 8842e43db..412774210 100644 --- a/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts +++ b/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { arrayMove } from "@dnd-kit/sortable"; import type { DragEndEvent } from "@dnd-kit/core"; import type { PanelItem } from "../../model/types"; @@ -12,25 +12,28 @@ export function usePanelOrder({ initialPanels, storageKey, }: UsePanelOrderProps) { - const [panels, setPanels] = useState(() => { - if (typeof window !== "undefined") { - const saved = localStorage.getItem(storageKey); - if (saved) { - try { - const savedIds = JSON.parse(saved); - const restoredPanels = savedIds - .map((id: string) => initialPanels.find((p) => p.id === id)) - .filter(Boolean); - if (restoredPanels.length === initialPanels.length) { - return restoredPanels; - } - } catch { - return initialPanels; + const [panels, setPanels] = useState(initialPanels); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + if (isInitialized) return; + + const saved = localStorage.getItem(storageKey); + if (saved) { + try { + const savedIds = JSON.parse(saved); + const restoredPanels = savedIds + .map((id: string) => initialPanels.find((p) => p.id === id)) + .filter(Boolean); + if (restoredPanels.length === initialPanels.length) { + setPanels(restoredPanels); } + } catch { + setPanels(initialPanels); } } - return initialPanels; - }); + setIsInitialized(true); + }, [storageKey]); const handleDragEnd = (event: DragEndEvent) => { const { active, over } = event; diff --git a/apps/cm/src/pages/dashboard/ui/banner-list.tsx b/apps/cm/src/pages/dashboard/ui/banner-list.tsx index d3ae54610..6404faa85 100644 --- a/apps/cm/src/pages/dashboard/ui/banner-list.tsx +++ b/apps/cm/src/pages/dashboard/ui/banner-list.tsx @@ -9,6 +9,7 @@ import { type Banner, } from "../api/banner-service"; import { getCookie } from "@/shared/lib"; +import { useDragHandle } from "./sortable-panel"; import { Carousel, @@ -25,6 +26,7 @@ type BannerImageProps = { }; const BannerList: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); const [siteId, setSiteId] = useState(""); useEffect(() => { @@ -157,6 +159,12 @@ const BannerList: React.FC = () => { return (
+ {/* 드래그 핸들 - 배너 위에 오버레이 */} +
(null); const [error, setError] = useState(""); + const [isMounted, setIsMounted] = useState(false); const router = useRouter(); // dnd-kit 센서 설정 @@ -70,6 +71,11 @@ function DashboardPage() { storageKey: "dashboard-bottom-panels-order", }); + // 클라이언트에서만 드래그 기능 활성화 (hydration 문제 방지) + useEffect(() => { + setIsMounted(true); + }, []); + useEffect(() => { const fetchUserInfo = async () => { console.log("fetchUserInfo called"); @@ -134,28 +140,38 @@ function DashboardPage() {
{/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */}
- - p.id)} - strategy={rectSortingStrategy} + {isMounted ? ( + -
- {topPanels.map((panel) => ( - - {panel.component} - - ))} -
-
-
+ p.id)} + strategy={rectSortingStrategy} + > +
+ {topPanels.map((panel) => ( + + {panel.component} + + ))} +
+
+ + ) : ( +
+ {topPanels.map((panel) => ( +
+ {panel.component} +
+ ))} +
+ )}
{/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */} @@ -231,28 +247,38 @@ function DashboardPage() {
{/* 두번째 라인 - 드래그 가능한 4개 패널 */} - - p.id)} - strategy={rectSortingStrategy} + {isMounted ? ( + -
- {bottomPanels.map((panel) => ( - -
{panel.component}
-
- ))} -
-
-
+ p.id)} + strategy={rectSortingStrategy} + > +
+ {bottomPanels.map((panel) => ( + +
{panel.component}
+
+ ))} +
+
+ + ) : ( +
+ {bottomPanels.map((panel) => ( +
+
{panel.component}
+
+ ))} +
+ )}
); diff --git a/apps/cm/src/pages/dashboard/ui/datastatus-list.tsx b/apps/cm/src/pages/dashboard/ui/datastatus-list.tsx index bae89e338..74e10afea 100644 --- a/apps/cm/src/pages/dashboard/ui/datastatus-list.tsx +++ b/apps/cm/src/pages/dashboard/ui/datastatus-list.tsx @@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query"; import { ENDPOINT } from "@/shared/config"; import { getDataStatusList, type DataStatus } from "../api/datastatus-service"; import type { PageableList } from "@/shared/model"; +import { useDragHandle } from "./sortable-panel"; import { Carousel, @@ -35,6 +36,7 @@ const getRegionInfo = (regionName: string) => { }; const DataStatusList: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); const plugin = useRef( Autoplay({ delay: 3000, stopOnInteraction: true }) ); @@ -106,7 +108,11 @@ const DataStatusList: React.FC = () => { return ( <> -
+

자료현황

diff --git a/apps/cm/src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx b/apps/cm/src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx index 2a0264e43..5db22db6e 100644 --- a/apps/cm/src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx +++ b/apps/cm/src/pages/dashboard/ui/fo-fclt-cnt-rt.tsx @@ -9,8 +9,10 @@ import { Doughnut } from "react-chartjs-2"; import type { Chart as ChartJS } from "chart.js"; // import type { mapOperFcltItem, mapBasinData } from "../model/fo-interface"; import { OperationApi, operFcltStore, basinStore } from "../api/fo-service"; +import { useDragHandle } from "./sortable-panel"; const FoFcltCntRt: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); const [year, setYear] = useState(null); const { selectedBasin, setSelectedBasin } = useStore(basinStore); const operFcltStatus = useStore(operFcltStore, (state) => state.operFcltStatus); @@ -57,9 +59,18 @@ const FoFcltCntRt: React.FC = () => { return (
-
+

수계별 시설운영 개소수 및 가동률

- +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
diff --git a/apps/cm/src/pages/dashboard/ui/fo-wqlt-Infl-effl.tsx b/apps/cm/src/pages/dashboard/ui/fo-wqlt-Infl-effl.tsx index 4c01fbdd0..6b416c5ed 100644 --- a/apps/cm/src/pages/dashboard/ui/fo-wqlt-Infl-effl.tsx +++ b/apps/cm/src/pages/dashboard/ui/fo-wqlt-Infl-effl.tsx @@ -14,6 +14,7 @@ import { import type { OperWqltContent } from "../model/fo-interface"; import { OperationApi, basinStore } from "../api/fo-service"; import { defaultInitOperWqltState } from "../api/fo-service"; +import { useDragHandle } from "./sortable-panel"; // Chart.js 등록 ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); @@ -21,6 +22,7 @@ ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, T type WaterQualityType = 'BOD' | 'TOC' | 'SS' | 'TP' | 'TN'; const FoWqltInflEffl: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); const { selectedBasin, setSelectedBasin } = useStore(basinStore); const [selectedTab, setSelectedTab] = useState('BOD'); const [wqltData, setWqltData] = useState([]); @@ -195,9 +197,18 @@ const FoWqltInflEffl: React.FC = () => { return (
-
+

수계별 유입ㆍ방류수질 분석

- +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
diff --git a/apps/cm/src/pages/dashboard/ui/notice-list.tsx b/apps/cm/src/pages/dashboard/ui/notice-list.tsx index 86e21a4a3..b69e5ae0d 100644 --- a/apps/cm/src/pages/dashboard/ui/notice-list.tsx +++ b/apps/cm/src/pages/dashboard/ui/notice-list.tsx @@ -10,9 +10,11 @@ import { noticePostTableColumns, } from "../model/table-columns"; import { Button } from "@common/components"; +import { useDragHandle } from "./sortable-panel"; const NoticeList: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); const router = useRouter(); const noticePostQuery = useQuery({ queryKey: [ENDPOINT.BOARD_SERVICE.POSTS], @@ -51,9 +53,18 @@ const NoticeList: React.FC = () => { return (
-
+

공지사항

- +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
{noticePostTableComponent}
diff --git a/apps/cm/src/pages/dashboard/ui/reuse-chart.tsx b/apps/cm/src/pages/dashboard/ui/reuse-chart.tsx index edcc19b97..55d9e6abb 100644 --- a/apps/cm/src/pages/dashboard/ui/reuse-chart.tsx +++ b/apps/cm/src/pages/dashboard/ui/reuse-chart.tsx @@ -8,6 +8,7 @@ import { Line } from 'react-chartjs-2'; import { Button } from "@common/components"; import type { ReuseStatusContent } from "../model/fo-interface"; import { OperationApi, reuseStore } from "../api/fo-service"; +import { useDragHandle } from "./sortable-panel"; import { Chart as ChartJS, CategoryScale, @@ -36,6 +37,7 @@ ChartJS.register( const ReuseChart: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); const [activeIndex, setActiveIndex] = useState(0); const intervalRef = useRef(null); const reuseStatusData = useStore( @@ -183,9 +185,18 @@ const ReuseChart: React.FC = () => { return (
-
+

{chartItems[activeIndex]?.title}

- +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
diff --git a/apps/cm/src/pages/dashboard/ui/reuse-status.tsx b/apps/cm/src/pages/dashboard/ui/reuse-status.tsx index d1e7c4c15..6c596eba9 100644 --- a/apps/cm/src/pages/dashboard/ui/reuse-status.tsx +++ b/apps/cm/src/pages/dashboard/ui/reuse-status.tsx @@ -7,8 +7,10 @@ import { useStore } from "zustand"; import { Button } from "@common/components"; import type { ReuseStatusContent } from "../model/fo-interface"; import { OperationApi, reuseStore} from "../api/fo-service"; +import { useDragHandle } from "./sortable-panel"; const ReuseStatus: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); // const [reuseStatusData, setReuseStatusData] = useState([]); const reuseStatusData = useStore( reuseStore, @@ -32,9 +34,18 @@ const ReuseStatus: React.FC = () => { return (
-
+

물재이용 현황 (재이용량, m³)

- +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
diff --git a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx index b1a34139c..fa502e7c5 100644 --- a/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx +++ b/apps/cm/src/pages/dashboard/ui/sn-old-ppl.tsx @@ -17,12 +17,14 @@ import { Bar } from "react-chartjs-2"; import { Button } from "@common/components"; import type { PplLengthContent, ChartDataSet, OldPplChartData } from "../model/sn-interface"; import { SewerageApi, useOldPplStore,useMapOldPplStore, transformChartDataToMapData } from "../api/sn-service"; +import { useDragHandle } from "./sortable-panel"; // ChartJS 컴포넌트 등록 ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); export default function SnOldPpl() { + const { dragHandleProps, isDragging } = useDragHandle(); const [oldPplLenData, setOldPplLenData] = useState([]); const {chartData, setChartData} = useOldPplStore(); const {setMapData} = useMapOldPplStore(); @@ -170,9 +172,18 @@ export default function SnOldPpl() { return (
-
+

노후 하수관로 현황

- +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
diff --git a/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx b/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx index 014f592c4..022430da0 100644 --- a/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx +++ b/apps/cm/src/pages/dashboard/ui/sn-ppl-status.tsx @@ -17,6 +17,7 @@ import { Bar } from "react-chartjs-2"; import { Button } from "@common/components"; import type { PplLengthContent } from "../model/sn-interface"; import { SewerageApi,useUsePplStore } from "../api/sn-service"; +import { useDragHandle } from "./sortable-panel"; interface ChartDataSet { label: string; @@ -29,6 +30,7 @@ interface ChartDataSet { ChartJS.register(CategoryScale, LinearScale, BarElement, Title, Tooltip, Legend); export default function SnPplStatus() { + const { dragHandleProps, isDragging } = useDragHandle(); const {chartData, setChartData} = useUsePplStore(); const [usgPplLenData, setUsgPplLenData] = useState([]); @@ -157,10 +159,18 @@ export default function SnPplStatus() { }; return (
-
+

오수관로 및 우수관로 현황

- - +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
diff --git a/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx b/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx index ce9d2c949..1fb891980 100644 --- a/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx +++ b/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx @@ -1,5 +1,6 @@ import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; +import { createContext, useContext } from "react"; interface SortablePanelProps { id: string; @@ -7,6 +8,13 @@ interface SortablePanelProps { className?: string; } +interface DragHandleContextValue { + dragHandleProps: React.HTMLAttributes; + isDragging: boolean; +} + +const DragHandleContext = createContext(null); + export function SortablePanel({ id, children, className }: SortablePanelProps) { const { attributes, @@ -21,18 +29,36 @@ export function SortablePanel({ id, children, className }: SortablePanelProps) { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - cursor: isDragging ? "grabbing" : "grab", + }; + + const contextValue: DragHandleContextValue = { + dragHandleProps: { ...attributes, ...listeners }, + isDragging, }; return ( -
- {children} -
+ +
+ {children} +
+
); } + +export function useDragHandle() { + const context = useContext(DragHandleContext); + + // Context가 없을 때 (서버 렌더링 또는 isMounted = false) 기본값 반환 + if (!context) { + return { + dragHandleProps: {}, + isDragging: false, + }; + } + + return context; +} diff --git a/apps/cm/src/pages/dashboard/ui/support-list.tsx b/apps/cm/src/pages/dashboard/ui/support-list.tsx index f62cce9b1..f37dad7eb 100644 --- a/apps/cm/src/pages/dashboard/ui/support-list.tsx +++ b/apps/cm/src/pages/dashboard/ui/support-list.tsx @@ -7,8 +7,10 @@ import { useDataTable } from "@/shared/lib"; import type { PageableList } from "@/shared/model"; import { supportTableColumns } from "../model/support-columns"; import { getSupportList, type Support } from "../api/support-service"; +import { useDragHandle } from "./sortable-panel"; const SupportList: React.FC = () => { + const { dragHandleProps, isDragging } = useDragHandle(); const supportQuery = useQuery>({ queryKey: [ENDPOINT.IC_SERVICE.GROUPING_STATISTICS], queryFn: async () => { @@ -29,7 +31,11 @@ const SupportList: React.FC = () => { return (
-
+

지원현황

{supportTableComponent}