diff --git a/apps/cm/package.json b/apps/cm/package.json index bde698393..2472516e4 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..412774210 --- /dev/null +++ b/apps/cm/src/pages/dashboard/lib/hooks/use-panel-order.ts @@ -0,0 +1,59 @@ +import { useState, useEffect } 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(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); + } + } + setIsInitialized(true); + }, [storageKey]); + + 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/banner-list.tsx b/apps/cm/src/pages/dashboard/ui/banner-list.tsx index 865540672..6404faa85 100644 --- a/apps/cm/src/pages/dashboard/ui/banner-list.tsx +++ b/apps/cm/src/pages/dashboard/ui/banner-list.tsx @@ -1,10 +1,15 @@ "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 { useDragHandle } from "./sortable-panel"; import { Carousel, @@ -21,7 +26,13 @@ type BannerImageProps = { }; const BannerList: React.FC = () => { - const siteId = getCookie("site-id-key") ?? ""; + const { dragHandleProps, isDragging } = useDragHandle(); + 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 +102,19 @@ const BannerList: React.FC = () => { return maybe.status ?? maybe.response?.status; }; + // siteId가 없을 때 + if (!siteId) { + return ( +
+
+
+ 사이트 ID가 없습니다 +
+
+
+ ); + } + // 401 에러 처리 if (bannerError) { const is401Error = @@ -133,21 +157,14 @@ const BannerList: React.FC = () => { ); } - // siteId가 없을 때 - if (!siteId) { - return ( -
-
-
- 사이트 ID가 없습니다 -
-
-
- ); - } - return ( -
+
+ {/* 드래그 핸들 - 배너 위에 오버레이 */} +
(null); const [error, setError] = useState(""); - const router = useRouter(); + const [isMounted, setIsMounted] = useState(false); + 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", + }); + + // 클라이언트에서만 드래그 기능 활성화 (hydration 문제 방지) + useEffect(() => { + setIsMounted(true); + }, []); + useEffect(() => { const fetchUserInfo = async () => { console.log("fetchUserInfo called"); @@ -115,96 +136,149 @@ function DashboardPage() { return (
- {/* 첫번째 라인 */} -
- {/* 첫번쨰 열 */} -
- {/* 전국 하수처리시설 개소수 및 가동률 */} - - - {/* 오수관로 및 우수관로 현황 */} - - - {/* 하수처리수 재이용 (m³) */} - + {/* 첫번째 라인 - 좌측 6개 패널(2x3) + 우측 지도/사용자 정보 */} +
+ {/* 좌측: 드래그 가능한 6개 패널 (2x3 그리드, 화면의 50%) */} +
+ {isMounted ? ( + + p.id)} + strategy={rectSortingStrategy} + > +
+ {topPanels.map((panel) => ( + + {panel.component} + + ))} +
+
+
+ ) : ( +
+ {topPanels.map((panel) => ( +
+ {panel.component} +
+ ))} +
+ )}
- {/* 두번쨰 열 */} -
- {/* 수계별 유입ㆍ방류수질 분석 */} - + {/* 우측: 고정된 지도와 사용자 정보 (화면의 50%) */} +
+ {/* 지도영역 - 고정 (드래그 불가) */} +
+ +
- {/* 노후하수관로현황 */} - - - {/* 물재이용 현황 (재이용량, 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"; - }} - /> -
-
-
{userInfo.userName || "-"}
-
온라인
-
- 2FA 활성화:{" "} - {userInfo.twoFactorEnabled ? "예" : "아니오"} + {/* 사용자 정보 카드 - 고정 (드래그 불가) */} +
+ {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개 패널 */} + {isMounted ? ( + + 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 502973225..73b2788df 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 new file mode 100644 index 000000000..1fb891980 --- /dev/null +++ b/apps/cm/src/pages/dashboard/ui/sortable-panel.tsx @@ -0,0 +1,64 @@ +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { createContext, useContext } from "react"; + +interface SortablePanelProps { + id: string; + children: React.ReactNode; + className?: string; +} + +interface DragHandleContextValue { + dragHandleProps: React.HTMLAttributes; + isDragging: boolean; +} + +const DragHandleContext = createContext(null); + +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, + }; + + const contextValue: DragHandleContextValue = { + dragHandleProps: { ...attributes, ...listeners }, + isDragging, + }; + + return ( + +
+ {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}