From 8491d0243a9d19ffe0e203ffc183fdead0105ec6 Mon Sep 17 00:00:00 2001 From: bicochan Date: Wed, 4 Feb 2026 15:22:17 +0900 Subject: [PATCH 01/24] =?UTF-8?q?[cm]=20feat:=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80=20dnd=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 --- 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 02/24] =?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 03/24] =?UTF-8?q?[cm]=20feat:=20=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=ED=8C=A8=EB=84=90=20=ED=97=A4=EB=8D=94?= =?UTF-8?q?=EC=97=90=20=EB=93=9C=EB=9E=98=EA=B7=B8=20=ED=95=B8=EB=93=A4?= =?UTF-8?q?=EB=9F=AC=20=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} From 620f4275aeef609c5be4d3f61aa5f04b8b176ef3 Mon Sep 17 00:00:00 2001 From: jcchoi Date: Tue, 24 Mar 2026 16:48:41 +0900 Subject: [PATCH 04/24] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=88=98=EC=A0=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/cm/src/pages/auth/login/ui/login-page.tsx | 4 ++-- apps/cs/src/pages/auth/login/ui/login-page.tsx | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/cm/src/pages/auth/login/ui/login-page.tsx b/apps/cm/src/pages/auth/login/ui/login-page.tsx index 297d28a8f..04d88801e 100644 --- a/apps/cm/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cm/src/pages/auth/login/ui/login-page.tsx @@ -147,9 +147,9 @@ function LoginPage() {

유역하수도통합플랫폼
- 하수도 포탈 + 유역하수도 포탈

-

유역하수도통합플랫폼 하수도 포탈에 오신 것을 환영합니다.

+

유역하수도통합플랫폼 유역하수도포탈에 오신 것을 환영합니다.

logo diff --git a/apps/cs/src/pages/auth/login/ui/login-page.tsx b/apps/cs/src/pages/auth/login/ui/login-page.tsx index fd1e2f809..8b30bedaf 100644 --- a/apps/cs/src/pages/auth/login/ui/login-page.tsx +++ b/apps/cs/src/pages/auth/login/ui/login-page.tsx @@ -31,12 +31,12 @@ function LoginPage() {
-

- 하수도정보시스템 +

+ 유역하수도통합플랫폼
- CMS 통합관리시스템 + 하수도플랫폼 통합관리(CMS)시스템

-

하수도정보시스템 CMS 통합관리시스템에 오신 것을 환영합니다.

+

유역하수도통합플랫폼 통합관리(CMS)시스템에 오신 것을 환영합니다.

logo From 9d18ef75272929de1a79bccbf19d30ff62e09561 Mon Sep 17 00:00:00 2001 From: bicochan Date: Mon, 13 Apr 2026 10:58:41 +0900 Subject: [PATCH 05/24] =?UTF-8?q?[cs]=20WIP:=20=EC=95=8C=EB=A6=BC=ED=86=A1?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=EA=B4=80=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(with-base-layout)/ntftalk-temp/page.tsx | 3 + apps/cs/docs/spec/ntftalk/SPEC.md | 121 +++- apps/cs/eslint.config.mjs | 1 + apps/cs/package.json | 3 +- .../entities/code/model/lwpocode-interface.ts | 1 - .../entities/code/model/upcode-interface.ts | 7 +- .../ntftalk/api/ntftalk-biztalk-service.ts | 64 ++ apps/cs/src/entities/ntftalk/index.ts | 9 + .../model/ntftalk-biztalk-interface.ts | 48 ++ .../ntftalk/model/ntftalk-temp-interface.ts | 4 + apps/cs/src/pages/code/ui/lwpocode-dialog.tsx | 1 - apps/cs/src/pages/code/ui/upcode-dialog.tsx | 10 +- apps/cs/src/pages/ntftalk-temp/index.ts | 1 + .../model/ntftalk-temp-save-schema.ts | 14 + .../ntftalk-temp/model/ntftalk-var-grid.ts | 19 + .../model/template-table-columns.tsx | 99 +++ .../ntftalk-temp/model/var-table-columns.tsx | 130 ++++ apps/cs/src/pages/ntftalk-temp/page.tsx | 0 .../ui/ntftalk-temp-detail-dialog.tsx | 109 +++ .../ntftalk-temp/ui/ntftalk-temp-page.tsx | 674 ++++++++++++++++++ .../ui/ntftalk-temp-regist-dialog.tsx | 439 ++++++++++++ apps/cs/src/shared/config/api.ts | 1 + pnpm-lock.yaml | 11 +- 23 files changed, 1751 insertions(+), 18 deletions(-) create mode 100644 apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx create mode 100644 apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts create mode 100644 apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/index.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts create mode 100644 apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx delete mode 100644 apps/cs/src/pages/ntftalk-temp/page.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx create mode 100644 apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx diff --git a/apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx b/apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx new file mode 100644 index 000000000..04ff3524a --- /dev/null +++ b/apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx @@ -0,0 +1,3 @@ +import { NtftalkTempPage } from "@/pages/ntftalk-temp"; + +export default NtftalkTempPage; diff --git a/apps/cs/docs/spec/ntftalk/SPEC.md b/apps/cs/docs/spec/ntftalk/SPEC.md index ec8a7455e..0b84ace4a 100644 --- a/apps/cs/docs/spec/ntftalk/SPEC.md +++ b/apps/cs/docs/spec/ntftalk/SPEC.md @@ -46,14 +46,14 @@ ### 4-1. 필드 정의 -| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | -| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------------------------- | -| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | 부분/완전일치 여부는 백엔드 정책 확인 필요 | -| 템플릿명 | 텍스트 | N | 공백 | `templateName` | 부분/완전일치 여부는 백엔드 정책 확인 필요 | -| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) | -| 채널 | 드롭다운 | N | 전체 | `senderKey` | 화면명 `@채널`과 `senderKey` 매핑 | -| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | 예: 승인/반려/요청 등 코드값 | -| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의(백엔드 확장 필요) | +| 표시명 | 입력 유형 | 필수 | 기본값 | API 파라미터 매핑 | 비고 | +| ---------- | --------- | ---- | ------ | ------------------ | --------------------------------------- | +| 템플릿코드 | 텍스트 | N | 공백 | `templateCode` | | +| 템플릿명 | 텍스트 | N | 공백 | `templateName` | | +| 템플릿내용 | 텍스트 | N | 공백 | - | 현재 `NtftalkTempSearchParams`에 미정의 | +| 채널 | 드롭다운 | N | 전체 | `senderKey` | | +| 검수상태 | 드롭다운 | N | 전체 | `inspectionStatus` | | +| 메시지유형 | 드롭다운 | N | 전체 | - | 현재 `NtftalkTempSearchParams`에 미정의 | ### 4-2. 버튼 @@ -155,3 +155,108 @@ Base: `ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR`(`/ntftalk-service/api/v1/ntftalk-va | 변수 등록 | POST | `/ntftalk-var` | `NtftalkVarSavePayload` | `NtftalkVar` | `saveNtftalkVar` | | 변수 수정 | PUT | `/ntftalk-var/{varCd}` | Path: `varCd`, Body: `NtftalkVarUpdatePayload` | `NtftalkVar` | `updateNtftalkVar` | | 변수 삭제 | DELETE | `/ntftalk-var/{varCd}` | Path: `varCd` | 없음 | `deleteNtftalkVar` | + +--- + +## 8. UI 구현 가이드 + +### 8-1. 기술 스택·원칙 + +- **UI**: `@common/components` 공통 컴포넌트 사용 +- **테이블**: `@tanstack/react-table` + `useDataTable`(`src/shared/lib/use-data-table.tsx`) +- **데이터**: `@tanstack/react-query`(등록/수정 폼에 검증이 필요하면 `react-hook-form` + `zod` 사용) +- **레이아웃**: App Router `app/(with-base-layout)/.../page.tsx`에서 페이지 UI 컴포넌트만 마운트 + +### 8-2. 모듈·파일 배치 + +| 구분 | 경로 | +| ------------- | ----------------------------------------------------------------------- | +| 라우트 엔트리 | `apps/cs/app/(with-base-layout)/ntftalk-temp/page.tsx` | +| 페이지 셸 | `apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx` | +| 컬럼 정의 | `apps/cs/src/pages/ntftalk-temp/model/*-table-columns.tsx` | +| API·타입 | `apps/cs/src/entities/ntftalk/model/*-interface.ts`, `api/*-service.ts` | +| 다이얼로그 | `apps/cs/src/pages/ntftalk-temp/ui/*-dialog.tsx` | + +### 8-3. 영역별 UI 매핑 + +| 영역 | 화면 요소 | 권장 컴포넌트·훅 | 구현 비고 | +| ---- | ------------- | ------------------------------------------------------------ | --------------------------------------------------------------------------------------------- | +| A | 검색 폼 | `Input`, `Label`, `Select`, `Button` | | +| B | 템플릿 그리드 | `useDataTable` + `ColumnDef`, `RowSelectionState`(컬럼 선택) | `manualPagination`, `onRowClick` → **8-4** 상세 다이얼로그 | +| B | 툴바 | `Button` 그룹 | 검수요청·삭제 등 확인은 `useConfirm` + `src/shared/ui/confirm-dialog.tsx` 등 기존 패턴 재사용 | +| C | 변수 그리드 | `useDataTable` + `ColumnDef`, `Input` + `zod` | **변수추가는 인라인**(6-1절). 선택 행 삭제·저장은 변수 API(7-3절)와 매핑. 검증은 `zod` 사용 | +| 공통 | 로딩·빈 목록 | `LoadingWithAnimation`, 빈 데이터 안내 문구 | 기타 목록 페이지와 동일 수준 처리 | + +### 8-4. 다이얼로그·라우팅 + +| UX | 구현 단위 | 컨테이너·비고 | +| --------------------------------- | ------------------------- | ---------------------------------------------------------------------------- | +| 템플릿등록 (5-1) | `NtftalkTempRegistDialog` | `Dialog` + 폼. `saveNtftalkTemp`(7-2) | +| 행 클릭 상세 (5-3) | `NtftalkTempDetailDialog` | **별도 라우트가 아닌 다이얼로그**로 구현. `getNtftalkTemp`(7-2)로 상세 로드 | +| 선택 검수요청·선택삭제·저장 (5-1) | mutation + confirm | 각각 대응 API(7-2) 및 백엔드 정책에 따름. 미확정 엔드포인트는 **TBD**로 표기 | + +### 8-5. 데이터 흐름 + +``` +BEGIN + + // 최초 진입 + CALL 템플릿목록조회(기본 검색 조건) + DISPLAY 알림톡템플릿그리드 (영역 B) + + CALL 변수목록조회(검색 조건) + DISPLAY 변수그리드 (영역 C) + + // 영역 A: 조회·초기화 + ON [조회] 버튼 CLICK DO + CALL 템플릿목록조회(템플릿코드, 템플릿명, 템플릿내용, 채널, 검수상태, 메시지유형) // → 영역 B 갱신, 페이지 1로 초기화 + END ON + + ON [초기화] 버튼 CLICK DO + RESET 검색 조건 → 기본값(공백·전체) + END ON + + // 영역 B: 알림톡 템플릿 (7-2절 참고) + ON [템플릿갱신] CLICK DO + CALL 템플릿목록조회(현재 검색 조건) // → 영역 B 갱신 + END ON + + ON [템플릿등록] CLICK DO + OPEN 템플릿등록 다이얼로그 + END ON + + ON 영역 B 행 CLICK DO + CALL 템플릿상세조회(선택된 templateCode) + DISPLAY 템플릿상세 다이얼로그 + END ON + + ON [선택 검수요청] CLICK DO + CONFIRM 사용자 + CALL 검수요청 API(체크된 행) + END ON + + ON [선택삭제] CLICK DO + CONFIRM 사용자 + CALL 템플릿삭제 API(체크된 행) + END ON + + ON [저장] CLICK DO + CALL 템플릿 일괄저장 API(그리드 변경분) + END ON + + // 영역 C: 변수 (7-3절 참고) + ON [변수추가] CLICK DO + ADD 변수 행 (인라인 편집) + END ON + + ON [선택삭제] CLICK DO (영역 C) + CONFIRM 사용자 + CALL 변수삭제 API(선택 행) + END ON + + ON [저장] CLICK DO (영역 C) + CALL 변수 등록·수정 API(변경분) + END ON + +END +``` diff --git a/apps/cs/eslint.config.mjs b/apps/cs/eslint.config.mjs index 9109635e9..e11052b52 100644 --- a/apps/cs/eslint.config.mjs +++ b/apps/cs/eslint.config.mjs @@ -14,6 +14,7 @@ const eslintConfig = [ { rules: { "react-hooks/exhaustive-deps": "off", + "@typescript-eslint/no-unused-vars": "warn", }, }, ]; diff --git a/apps/cs/package.json b/apps/cs/package.json index 9343e5dfc..5bf3b53e7 100644 --- a/apps/cs/package.json +++ b/apps/cs/package.json @@ -9,8 +9,8 @@ "lint": "next lint" }, "dependencies": { - "@common/auth": "workspace:*", "@common/assets": "workspace:*", + "@common/auth": "workspace:*", "@common/components": "workspace:*", "@common/config": "workspace:*", "@fontsource-variable/noto-sans-kr": "^5.2.5", @@ -54,6 +54,7 @@ "@types/webpack": "^5.28.5", "eslint": "^9.0.0", "eslint-config-next": "15.3.8", + "eslint-plugin-react-hooks": "^5.2.0", "postcss": "^8.4.49", "prettier": "^3.4.2", "prettier-plugin-tailwindcss": "^0.6.11", diff --git a/apps/cs/src/entities/code/model/lwpocode-interface.ts b/apps/cs/src/entities/code/model/lwpocode-interface.ts index 2538c1c30..d44a89ef2 100644 --- a/apps/cs/src/entities/code/model/lwpocode-interface.ts +++ b/apps/cs/src/entities/code/model/lwpocode-interface.ts @@ -44,7 +44,6 @@ export const LwpoCodeFormSchema = z.object({ prntLwpoCd: z.string().max(10).nullish(), useYn: z.string(), sortSeq: z.number(), - delYn: z.string(), }); export type LwpoCodeFormData = z.infer; diff --git a/apps/cs/src/entities/code/model/upcode-interface.ts b/apps/cs/src/entities/code/model/upcode-interface.ts index 73bf4f3ae..c63eccc0a 100644 --- a/apps/cs/src/entities/code/model/upcode-interface.ts +++ b/apps/cs/src/entities/code/model/upcode-interface.ts @@ -32,8 +32,11 @@ export interface UpCodeDetail extends UpCodeFormData { } export const upCodeFormSchema = z.object({ - upCd: z.string().max(10).nonempty(), - upCdNm: z.string().max(100).nonempty(), + upCd: z + .string() + .max(30, "최대 30자까지 입력할 수 있습니다.") + .nonempty("최소 1자 이상 입력해주세요."), + upCdNm: z.string().max(100).nonempty("최소 1자 이상 입력해주세요."), upCdExpln: z.string().max(1000).nullish(), cdSe: z.string().max(10).nonempty(), siteId: z.string().max(2).nullish(), diff --git a/apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts b/apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts new file mode 100644 index 000000000..85f585360 --- /dev/null +++ b/apps/cs/src/entities/ntftalk/api/ntftalk-biztalk-service.ts @@ -0,0 +1,64 @@ +import { client } from "@/shared/api"; +import { ENDPOINT } from "@/shared/config"; +import { + type NtftalkBiztalkCheckInspectionResponse, + type NtftalkBiztalkInspectionRequestItem, + type NtftalkBiztalkInspectionResponse, + type NtftalkBiztalkSender, + type NtftalkBiztalkService, + type NtftalkBiztalkSyncTemplatesParams, + type NtftalkBiztalkSyncTemplatesResponse, +} from "../model/ntftalk-biztalk-interface"; + +export const NtftalkBiztalkApi: NtftalkBiztalkService = { + async getSender(): Promise { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sender`, + ); + return response.data; + }, + + async syncTemplates( + params?: NtftalkBiztalkSyncTemplatesParams, + ): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/sync-templates`, + undefined, + { + params: { + senderKeyType: params?.senderKeyType, + }, + }, + ); + return response.data; + }, + + async requestInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/request-inspection`, + requestList, + ); + return response.data; + }, + + async cancelInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise { + const response = await client.post( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/cancel-inspection`, + requestList, + ); + return response.data; + }, + + async checkInspection( + templateCode: string, + ): Promise { + const response = await client.get( + `${ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK}/check-inspection/${encodeURIComponent(templateCode)}`, + ); + return response.data; + }, +}; diff --git a/apps/cs/src/entities/ntftalk/index.ts b/apps/cs/src/entities/ntftalk/index.ts index 87c3dfe2c..048eaad9e 100644 --- a/apps/cs/src/entities/ntftalk/index.ts +++ b/apps/cs/src/entities/ntftalk/index.ts @@ -1,5 +1,6 @@ export { NtftalkVarApi } from "./api/ntftalk-var-service"; export { NtftalkTempApi } from "./api/ntftalk-temp-service"; +export { NtftalkBiztalkApi } from "./api/ntftalk-biztalk-service"; export type { NtftalkVar, NtftalkVarPageList, @@ -7,6 +8,14 @@ export type { NtftalkVarSearchParams, NtftalkVarUpdatePayload, } from "./model/ntftalk-var-interface"; +export type { + NtftalkBiztalkCheckInspectionResponse, + NtftalkBiztalkInspectionRequestItem, + NtftalkBiztalkInspectionResponse, + NtftalkBiztalkSender, + NtftalkBiztalkSyncTemplatesParams, + NtftalkBiztalkSyncTemplatesResponse, +} from "./model/ntftalk-biztalk-interface"; export type { NtftalkBtnTemp, NtftalkBtnTempSavePayload, diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts new file mode 100644 index 000000000..824eb00d5 --- /dev/null +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-biztalk-interface.ts @@ -0,0 +1,48 @@ +export interface NtftalkBiztalkSender { + senderKey: string; + name: string; + uuid: string; + status: string; +} + +export interface NtftalkBiztalkSyncTemplatesParams { + senderKeyType?: string; +} + +export interface NtftalkBiztalkSyncTemplatesResponse { + syncCount: number; + message: string; +} + +export interface NtftalkBiztalkInspectionRequestItem { + senderKey: string; + templateCode: string; + senderKeyType: string; +} + +export interface NtftalkBiztalkInspectionResponse { + processedCount: number; + message: string; +} + +export interface NtftalkBiztalkCheckInspectionResponse { + templateCode: string; + approved: boolean; + message: string; +} + +export interface NtftalkBiztalkService { + getSender(): Promise; + syncTemplates( + params?: NtftalkBiztalkSyncTemplatesParams, + ): Promise; + requestInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise; + cancelInspection( + requestList: NtftalkBiztalkInspectionRequestItem[], + ): Promise; + checkInspection( + templateCode: string, + ): Promise; +} diff --git a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts index 79d537b28..7e7ab317d 100644 --- a/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts +++ b/apps/cs/src/entities/ntftalk/model/ntftalk-temp-interface.ts @@ -3,9 +3,13 @@ import type { PageableList, PageableParams } from "@/shared/model"; export interface NtftalkTempSearchParams extends PageableParams { templateCode?: string; templateName?: string; + /** 백엔드 스펙 확정 시 전달 */ + templateContent?: string; inspectionStatus?: string; senderKey?: string; status?: string; + /** 백엔드 스펙 확정 시 전달 */ + templateMessageType?: string; } export interface NtftalkBtnTemp { diff --git a/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx b/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx index 3179303dd..69dd31aa2 100644 --- a/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx +++ b/apps/cs/src/pages/code/ui/lwpocode-dialog.tsx @@ -103,7 +103,6 @@ const LwpoCodeManagementDialog = memo( prntLwpoCd: detailData?.prntLwpoCd ?? "", useYn: detailData?.useYn ?? "Y", sortSeq: detailData?.sortSeq ?? 0, - delYn: "N", }, }); diff --git a/apps/cs/src/pages/code/ui/upcode-dialog.tsx b/apps/cs/src/pages/code/ui/upcode-dialog.tsx index 6774bd17e..d54030cca 100644 --- a/apps/cs/src/pages/code/ui/upcode-dialog.tsx +++ b/apps/cs/src/pages/code/ui/upcode-dialog.tsx @@ -31,7 +31,13 @@ import { import { SiteSelect } from "@/entities/site"; import { ENDPOINT } from "@/shared/config"; import { useAlertStore, useMutationWrapper } from "@/shared/lib"; -import { Form, FormField, FormItem, FormControl } from "@/shared/ui/form"; +import { + Form, + FormField, + FormItem, + FormControl, + FormMessage, +} from "@/shared/ui/form"; type UpCodeManagementDialogProps = { upCd?: string; @@ -139,6 +145,7 @@ const UpCodeManagementDialog = memo( disabled={!isRegistration} /> + )} /> @@ -160,6 +167,7 @@ const UpCodeManagementDialog = memo( placeholder="상위코드명을 입력해주세요" /> + )} /> diff --git a/apps/cs/src/pages/ntftalk-temp/index.ts b/apps/cs/src/pages/ntftalk-temp/index.ts new file mode 100644 index 000000000..a15c01db3 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/index.ts @@ -0,0 +1 @@ +export { NtftalkTempPage } from "./ui/ntftalk-temp-page"; diff --git a/apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts new file mode 100644 index 000000000..911c4a277 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-temp-save-schema.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const ntftalkTempSaveFormSchema = z.object({ + templateCode: z.string().min(1, "템플릿코드를 입력하세요."), + senderKey: z.string().min(1, "채널(senderKey)을 입력하세요."), + senderKeyType: z.string().min(1, "발신키 유형을 입력하세요."), + templateName: z.string().min(1, "템플릿명을 입력하세요."), + templateContent: z.string().min(1, "템플릿내용을 입력하세요."), + templateMessageType: z.string().min(1, "메시지유형을 입력하세요."), + templateEmphasizeType: z.string().min(1, "강조유형을 입력하세요."), + templateImageUrl: z.string().optional(), +}); + +export type NtftalkTempSaveFormData = z.infer; diff --git a/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts new file mode 100644 index 000000000..aa4892b1f --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/ntftalk-var-grid.ts @@ -0,0 +1,19 @@ +import type { NtftalkVar } from "@/entities/ntftalk"; + +export interface NtftalkVarGridRow extends NtftalkVar { + clientId?: string; + isDraft?: boolean; +} + +export function createEmptyVarDraft(clientId: string): NtftalkVarGridRow { + return { + clientId, + isDraft: true, + varCd: "", + varNm: "", + varDesc: "", + sender: "", + regDt: "", + regr: "", + }; +} diff --git a/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx b/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx new file mode 100644 index 000000000..29df948ad --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/template-table-columns.tsx @@ -0,0 +1,99 @@ +import type { ColumnDef, Row, Table } from "@tanstack/react-table"; +import { Checkbox } from "@common/components"; +import type { NtftalkTemp } from "@/entities/ntftalk"; + +function formatButtons(row: NtftalkTemp): string { + if (!row.buttons?.length) return "—"; + return row.buttons.map((b) => b.name).join(", "); +} + +export function createTemplateColumns(): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }: { table: Table }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + table.toggleAllPageRowsSelected(!!value)} + aria-label="전체 선택" + /> +
+ ), + cell: ({ row }: { row: Row }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + row.toggleSelected(!!value)} + aria-label="행 선택" + /> +
+ ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "senderKey", + header: "@채널", + }, + { + accessorKey: "templateCode", + header: "템플릿코드", + }, + { + accessorKey: "templateName", + header: "템플릿명", + }, + { + accessorKey: "templateMessageType", + header: "메시지유형", + }, + { + accessorKey: "templateContent", + header: "템플릿내용", + cell: ({ getValue }) => { + const v = getValue() as string; + if (!v) return "—"; + return ( + + {v} + + ); + }, + }, + { + accessorKey: "templateEmphasizeType", + header: "강조유형", + }, + { + id: "buttons", + header: "버튼정보", + cell: ({ row }) => formatButtons(row.original), + }, + { + accessorKey: "inspectionStatus", + header: "검수상태", + }, + { + accessorKey: "status", + header: "상태", + }, + { + accessorKey: "createdAt", + header: "등록일자", + }, + ]; +} diff --git a/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx b/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx new file mode 100644 index 000000000..3b0d39ced --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/model/var-table-columns.tsx @@ -0,0 +1,130 @@ +import type { ColumnDef, Row, Table } from "@tanstack/react-table"; +import { Checkbox, Input } from "@common/components"; +import type { NtftalkVarGridRow } from "./ntftalk-var-grid"; + +export interface VarColumnHandlers { + onDraftFieldChange: ( + clientId: string, + field: "varCd" | "varNm" | "varDesc" | "sender", + value: string, + ) => void; +} + +export function createVarColumns( + handlers: VarColumnHandlers, +): ColumnDef[] { + return [ + { + id: "select", + header: ({ table }: { table: Table }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + table.toggleAllPageRowsSelected(!!value)} + aria-label="전체 선택" + /> +
+ ), + cell: ({ row }: { row: Row }) => ( +
e.stopPropagation()} + onKeyDown={(e) => e.stopPropagation()} + role="presentation" + > + row.toggleSelected(!!value)} + aria-label="행 선택" + /> +
+ ), + enableSorting: false, + }, + { + id: "seq", + header: "순번", + cell: ({ row, table }) => { + const pageIndex = table.getState().pagination.pageIndex; + const pageSize = table.getState().pagination.pageSize; + return pageIndex * pageSize + row.index + 1; + }, + }, + { + accessorKey: "varCd", + header: "변수코드", + cell: ({ row }) => { + const r = row.original; + if (r.isDraft && r.clientId) { + return ( + + handlers.onDraftFieldChange(r.clientId!, "varCd", e.target.value) + } + onClick={(e) => e.stopPropagation()} + /> + ); + } + return r.varCd; + }, + }, + { + accessorKey: "varNm", + header: "변수명", + cell: ({ row }) => { + const r = row.original; + if (r.isDraft && r.clientId) { + return ( + + handlers.onDraftFieldChange(r.clientId!, "varNm", e.target.value) + } + onClick={(e) => e.stopPropagation()} + /> + ); + } + return r.varNm; + }, + }, + { + accessorKey: "varDesc", + header: "설명", + cell: ({ row }) => { + const r = row.original; + if (r.isDraft && r.clientId) { + return ( + + handlers.onDraftFieldChange(r.clientId!, "varDesc", e.target.value) + } + onClick={(e) => e.stopPropagation()} + /> + ); + } + return r.varDesc ?? "—"; + }, + }, + { + accessorKey: "regDt", + header: "등록일자", + }, + { + accessorKey: "regr", + header: "등록자", + }, + ]; +} diff --git a/apps/cs/src/pages/ntftalk-temp/page.tsx b/apps/cs/src/pages/ntftalk-temp/page.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx new file mode 100644 index 000000000..f196df4e3 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-detail-dialog.tsx @@ -0,0 +1,109 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + LoadingWithAnimation, + Table, + TableBody, + TableCell, + TableHead, + TableRow, +} from "@common/components"; +import { NtftalkTempApi } from "@/entities/ntftalk"; +import { ENDPOINT } from "@/shared/config"; +import { NoData } from "@/shared/ui/no-data"; + +interface NtftalkTempDetailDialogProps { + templateCode: string | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function NtftalkTempDetailDialog({ + templateCode, + open, + onOpenChange, +}: NtftalkTempDetailDialogProps) { + const query = useQuery({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP, "detail", templateCode], + queryFn: () => NtftalkTempApi.getNtftalkTemp(templateCode!), + enabled: open && !!templateCode, + }); + + const d = query.data; + + return ( + + + + 알림톡 템플릿 상세 + + {query.isLoading && ( +
+ +
+ )} + {query.isError && } + {d && ( + + + + 메시지 유형 + {d.templateMessageType} + + + @채널 + {d.senderKey} + + + 템플릿코드 + {d.templateCode} + + + 템플릿명 + {d.templateName} + + + 강조유형 + {d.templateEmphasizeType} + + + 템플릿내용 + {d.templateContent} + + + 검수상태 + {d.inspectionStatus} + + {/* + 보안템플릿 여부 + {d.templateEmphasizeType} + + + 비즈톡센터 사용여부 + {d.status} + */} + + 등록일 + {d.createdAt} + + + 템플릿 상태 + {d.status} + + +
+ )} +
+
+ ); +} + +export { NtftalkTempDetailDialog }; diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx new file mode 100644 index 000000000..8cdd4e338 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-page.tsx @@ -0,0 +1,674 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { useFieldArray, useForm, useWatch } from "react-hook-form"; +import { useConfirm } from "@frontend-opensource/use-react-hooks"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + Button, + DataTable, + Input, + Label, + LoadingWithAnimation, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@common/components"; +import { + getCoreRowModel, + type PaginationState, + type RowSelectionState, + useReactTable, +} from "@tanstack/react-table"; +import { z } from "zod"; +import { + NtftalkBiztalkApi, + NtftalkTempApi, + NtftalkVarApi, + type NtftalkBiztalkInspectionRequestItem, + type NtftalkTemp, + type NtftalkTempSearchParams, + type NtftalkVarSearchParams, +} from "@/entities/ntftalk"; +import { AUTH_USER_ID, ENDPOINT } from "@/shared/config"; +import { + getCookie, + useAlertStore, + useDataTable, + useMutationWrapper, +} from "@/shared/lib"; +import { NoData } from "@/shared/ui/no-data"; +import { createTemplateColumns } from "../model/template-table-columns"; +import { + createEmptyVarDraft, + type NtftalkVarGridRow, +} from "../model/ntftalk-var-grid"; +import { createVarColumns } from "../model/var-table-columns"; +import { NtftalkTempDetailDialog } from "./ntftalk-temp-detail-dialog"; +import { NtftalkTempRegistDialog } from "./ntftalk-temp-regist-dialog"; + +const ALL = "__ALL__"; + +const varDraftSaveSchema = z.object({ + varCd: z.string().min(1), + varNm: z.string().min(1), + varDesc: z.string().optional(), + sender: z.string().optional(), +}); + +interface VarDraftFormValues { + draftVars: NtftalkVarGridRow[]; +} + +function NtftalkTempPage() { + const queryClient = useQueryClient(); + const alert = useAlertStore((s) => s.setMessage); + const { confirm } = useConfirm(); + + const [templateCodeInput, setTemplateCodeInput] = useState(""); + const [templateNameInput, setTemplateNameInput] = useState(""); + const [templateContentInput, setTemplateContentInput] = useState(""); + const [senderKeyFilter, setSenderKeyFilter] = useState(ALL); + const [inspectionFilter, setInspectionFilter] = useState(ALL); + const [messageTypeFilter, setMessageTypeFilter] = useState(ALL); + + const [searchParams, setSearchParams] = useState({ + sort: "createdAt,desc", + }); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [rowSelection, setRowSelection] = useState({}); + + const [varPagination, setVarPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [varSearchParams] = useState({ + sort: "regDt,desc", + }); + const [varRowSelection, setVarRowSelection] = useState({}); + const varDraftForm = useForm({ + defaultValues: { draftVars: [] }, + }); + const { control, setValue } = varDraftForm; + const { append, remove, replace } = useFieldArray({ + control, + name: "draftVars", + }); + const draftVars = useWatch({ control, name: "draftVars" }) ?? []; + + const [detailCode, setDetailCode] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [registOpen, setRegistOpen] = useState(false); + + const tempQuery = useQuery({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP, searchParams, pagination], + queryFn: () => + NtftalkTempApi.getNtftalkTempPage({ + ...searchParams, + page: pagination.pageIndex, + size: pagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const varQuery = useQuery({ + queryKey: [ + ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR, + varSearchParams, + varPagination, + ], + queryFn: () => + NtftalkVarApi.getNtftalkVarPage({ + ...varSearchParams, + page: varPagination.pageIndex, + size: varPagination.pageSize, + }), + placeholderData: keepPreviousData, + }); + + const senderQuery = useQuery({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"], + queryFn: () => NtftalkBiztalkApi.getSender(), + }); + + const senderKeyOptions = useMemo(() => { + if (senderQuery.data) return [senderQuery.data]; + return []; + }, [senderQuery.data]); + + const inspectionOptions = useMemo(() => { + const set = new Set(); + (tempQuery.data?.content ?? []).forEach((r) => { + if (r.inspectionStatus) set.add(r.inspectionStatus); + }); + return Array.from(set).sort(); + }, [tempQuery.data]); + + const messageTypeOptions = useMemo(() => { + const set = new Set(); + (tempQuery.data?.content ?? []).forEach((r) => { + if (r.templateMessageType) set.add(r.templateMessageType); + }); + return Array.from(set).sort(); + }, [tempQuery.data]); + + const templateColumns = useMemo(() => createTemplateColumns(), []); + + const { + tableComponent: templateTableComponent, + tableInstance: templateTable, + } = useDataTable({ + query: tempQuery, + tableOptions: { + columns: templateColumns, + state: { pagination, rowSelection }, + manualPagination: true, + enableRowSelection: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setPagination, + onRowSelectionChange: setRowSelection, + }, + dataTableOptions: { + viewPagination: true, + onRowClick: (row: NtftalkTemp) => { + setDetailCode(row.templateCode); + setDetailOpen(true); + }, + }, + }); + + const draftIndexByClientId = useMemo(() => { + const indexByClientId = new Map(); + draftVars.forEach((row, index) => { + if (row.clientId) indexByClientId.set(row.clientId, index); + }); + return indexByClientId; + }, [draftVars]); + + const onDraftFieldChange = useCallback( + ( + clientId: string, + field: "varCd" | "varNm" | "varDesc" | "sender", + value: string, + ) => { + const draftIndex = draftIndexByClientId.get(clientId); + if (draftIndex === undefined) return; + setValue(`draftVars.${draftIndex}.${field}`, value, { + shouldDirty: true, + }); + }, + [draftIndexByClientId, setValue], + ); + + const varColumns = useMemo( + () => createVarColumns({ onDraftFieldChange }), + [onDraftFieldChange], + ); + + const varTableRows = useMemo((): NtftalkVarGridRow[] => { + const server = varQuery.data?.content ?? []; + if (varPagination.pageIndex === 0) return [...server, ...draftVars]; + return server; + }, [varQuery.data, draftVars, varPagination.pageIndex]); + + const varTable = useReactTable({ + data: varTableRows, + columns: varColumns, + state: { pagination: varPagination, rowSelection: varRowSelection }, + manualPagination: true, + rowCount: varQuery.data?.totalElements ?? 0, + pageCount: varQuery.data?.totalPages ?? 0, + enableRowSelection: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setVarPagination, + onRowSelectionChange: setVarRowSelection, + getRowId: (row) => + row.isDraft && row.clientId + ? `draft:${row.clientId}` + : `srv:${row.varCd}`, + }); + + const buildSearchParamsFromInputs = (): NtftalkTempSearchParams => ({ + sort: searchParams.sort ?? "createdAt,desc", + templateCode: templateCodeInput.trim() || undefined, + templateName: templateNameInput.trim() || undefined, + templateContent: templateContentInput.trim() || undefined, + senderKey: senderKeyFilter === ALL ? undefined : senderKeyFilter, + inspectionStatus: inspectionFilter === ALL ? undefined : inspectionFilter, + templateMessageType: + messageTypeFilter === ALL ? undefined : messageTypeFilter, + }); + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPagination((p) => ({ ...p, pageIndex: 0 })); + setSearchParams(buildSearchParamsFromInputs()); + }; + + const handleReset = () => { + setTemplateCodeInput(""); + setTemplateNameInput(""); + setTemplateContentInput(""); + setSenderKeyFilter(ALL); + setInspectionFilter(ALL); + setMessageTypeFilter(ALL); + setPagination((p) => ({ ...p, pageIndex: 0 })); + setSearchParams({ sort: "createdAt,desc" }); + }; + + const syncTemplatesMutation = useMutationWrapper({ + mutationFn: () => NtftalkBiztalkApi.syncTemplates({ senderKeyType: "S" }), + onSuccess: async (data) => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP], + }), + queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"], + }), + ]); + alert(data.message); + }, + }); + + const handleRefreshTemplates = () => { + syncTemplatesMutation.mutate(); + }; + + const deleteTemplatesMutation = useMutationWrapper({ + mutationFn: async (codes: string[]) => { + for (const code of codes) { + await NtftalkTempApi.deleteNtftalkTemp(code); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP], + }); + setRowSelection({}); + alert("삭제되었습니다."); + }, + }); + + const handleDeleteSelectedTemplates = async () => { + const rows = templateTable.getSelectedRowModel().rows; + if (rows.length === 0) { + alert("삭제할 템플릿을 선택해 주세요."); + return; + } + if ( + !(await confirm( + `선택한 ${rows.length}건의 템플릿을 삭제(비표시) 처리하시겠습니까?`, + )) + ) + return; + deleteTemplatesMutation.mutate(rows.map((r) => r.original.templateCode)); + }; + + const inspectionRequestMutation = useMutationWrapper({ + mutationFn: (requestList: NtftalkBiztalkInspectionRequestItem[]) => + NtftalkBiztalkApi.requestInspection(requestList), + onSuccess: async (data) => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP], + }); + setRowSelection({}); + alert(data.message); + }, + }); + + const handleInspectionRequest = async () => { + const rows = templateTable.getSelectedRowModel().rows; + if (rows.length === 0) { + alert("검수요청할 템플릿을 선택해 주세요."); + return; + } + + const requestList: NtftalkBiztalkInspectionRequestItem[] = []; + for (const row of rows) { + const { senderKey, templateCode, senderKeyType } = row.original; + if (!senderKey || !templateCode) { + alert("선택 항목에 발신키 또는 템플릿코드가 없습니다."); + return; + } + requestList.push({ + senderKey, + templateCode, + senderKeyType: senderKeyType || "S", + }); + } + + if ( + !(await confirm(`선택한 ${requestList.length}건을 검수요청하시겠습니까?`)) + ) + return; + inspectionRequestMutation.mutate(requestList); + }; + + const handleSaveTemplateGrid = () => { + alert("템플릿 그리드 셀 편집·일괄 저장은 별도 구현 시 연동합니다."); + }; + + const handleAddVarRow = () => { + append(createEmptyVarDraft(crypto.randomUUID())); + setVarPagination((p) => ({ ...p, pageIndex: 0 })); + }; + + const deleteVarsMutation = useMutationWrapper({ + mutationFn: async (codes: string[]) => { + for (const code of codes) { + await NtftalkVarApi.deleteNtftalkVar(code); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR], + }); + setVarRowSelection({}); + alert("삭제되었습니다."); + }, + }); + + const handleDeleteSelectedVars = async () => { + const selected = varTable.getSelectedRowModel().rows; + if (selected.length === 0) { + alert("삭제할 변수를 선택해 주세요."); + return; + } + if (!(await confirm(`선택한 ${selected.length}건을 삭제하시겠습니까?`))) + return; + + const serverCodes: string[] = []; + const draftIndexes: number[] = []; + selected.forEach((r) => { + const o = r.original; + if (o.isDraft && o.clientId) { + const draftIndex = draftIndexByClientId.get(o.clientId); + if (draftIndex !== undefined) draftIndexes.push(draftIndex); + } else if (o.varCd) serverCodes.push(o.varCd); + }); + + if (draftIndexes.length) + remove([...new Set(draftIndexes)].sort((a, b) => b - a)); + + if (serverCodes.length) deleteVarsMutation.mutate(serverCodes); + }; + + const saveVarsMutation = useMutationWrapper({ + mutationFn: async (drafts: NtftalkVarGridRow[]) => { + const regr = getCookie(AUTH_USER_ID) ?? undefined; + for (const d of drafts) { + const parsed = varDraftSaveSchema.safeParse({ + varCd: d.varCd, + varNm: d.varNm, + varDesc: d.varDesc || undefined, + sender: d.sender || undefined, + }); + if (!parsed.success) + throw new Error( + parsed.error.issues[0]?.message ?? "유효하지 않은 입력입니다.", + ); + await NtftalkVarApi.saveNtftalkVar({ + varCd: parsed.data.varCd, + varNm: parsed.data.varNm, + varDesc: parsed.data.varDesc, + sender: parsed.data.sender, + regr, + }); + } + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR], + }); + replace([]); + setVarRowSelection({}); + alert("저장되었습니다."); + }, + }); + + const handleSaveVars = () => { + if (draftVars.length === 0) { + alert("저장할 신규 변수 행이 없습니다. 변수추가로 행을 추가하세요."); + return; + } + saveVarsMutation.mutate(draftVars); + }; + + const varTableComponent = (() => { + if (varQuery.isLoading) + return ( +
+ +
+ ); + if (varQuery.isError) return ; + return ; + })(); + + return ( +
+
+
+
+
+
+ + setTemplateCodeInput(e.target.value)} + /> +
+
+ + setTemplateNameInput(e.target.value)} + /> +
+
+ + setTemplateContentInput(e.target.value)} + /> +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+ +
+
+

+ 알림톡 템플릿 관리 +

+
+ + + + + +
+
+
{templateTableComponent}
+
+ +
+
+

변수 관리

+
+ + + +
+
+
{varTableComponent}
+
+ + { + setDetailOpen(o); + if (!o) setDetailCode(null); + }} + /> + +
+ ); +} + +export { NtftalkTempPage }; diff --git a/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx new file mode 100644 index 000000000..6a8937073 --- /dev/null +++ b/apps/cs/src/pages/ntftalk-temp/ui/ntftalk-temp-regist-dialog.tsx @@ -0,0 +1,439 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { useEffect, useMemo, useState } from "react"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + getCoreRowModel, + type PaginationState, + type RowSelectionState, +} from "@tanstack/react-table"; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + Input, + Label, + LoadingWithAnimation, + RadioGroup, + RadioGroupItem, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Table, + TableBody, + TableCell, + TableHead, + Textarea, + TableRow, + DialogFooter, +} from "@common/components"; +import { + NtftalkBiztalkApi, + NtftalkTempApi, + NtftalkVarApi, + type NtftalkVarSearchParams, +} from "@/entities/ntftalk"; +import { ENDPOINT } from "@/shared/config"; +import { useAlertStore, useDataTable, useMutationWrapper } from "@/shared/lib"; +import { NoData } from "@/shared/ui/no-data"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "@/shared/ui/form"; +import { + ntftalkTempSaveFormSchema, + type NtftalkTempSaveFormData, +} from "../model/ntftalk-temp-save-schema"; +import { createVarColumns } from "../model/var-table-columns"; + +interface NtftalkTempRegistDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const TEMPLATE_MESSAGE_TYPE_BASIC = "BA"; +const SECURITY_TEMPLATE_EMPHASIZE_TYPE = { + enabled: "SECURITY", + disabled: "NONE", +} as const; + +function NtftalkTempRegistDialog({ + open, + onOpenChange, +}: NtftalkTempRegistDialogProps) { + const [isVarDialogOpen, setIsVarDialogOpen] = useState(false); + const [varPagination, setVarPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + const [varRowSelection, setVarRowSelection] = useState({}); + const [varSearchParams] = useState({ + sort: "regDt,desc", + }); + + const queryClient = useQueryClient(); + const alert = useAlertStore((s) => s.setMessage); + const senderQuery = useQuery({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_BIZTALK, "sender"], + queryFn: () => NtftalkBiztalkApi.getSender(), + }); + const varQuery = useQuery({ + queryKey: [ + ENDPOINT.NTFTALK_SERVICE.NTFTALK_VAR, + varSearchParams, + varPagination, + ], + queryFn: () => + NtftalkVarApi.getNtftalkVarPage({ + ...varSearchParams, + page: varPagination.pageIndex, + size: varPagination.pageSize, + }), + placeholderData: keepPreviousData, + enabled: isVarDialogOpen, + }); + + const varColumns = useMemo( + () => + createVarColumns({ + onDraftFieldChange: () => {}, + }), + [], + ); + const { tableComponent, tableInstance } = useDataTable({ + query: varQuery, + tableOptions: { + columns: varColumns, + state: { pagination: varPagination, rowSelection: varRowSelection }, + manualPagination: true, + enableRowSelection: true, + enableMultiRowSelection: true, + getCoreRowModel: getCoreRowModel(), + onPaginationChange: setVarPagination, + onRowSelectionChange: setVarRowSelection, + getRowId: (row) => `srv:${row.varCd}`, + }, + dataTableOptions: { + viewPagination: true, + }, + }); + + const form = useForm({ + resolver: zodResolver(ntftalkTempSaveFormSchema), + defaultValues: { + templateCode: "", + senderKey: "", + senderKeyType: "S", + templateName: "", + templateContent: "", + templateMessageType: TEMPLATE_MESSAGE_TYPE_BASIC, + templateEmphasizeType: SECURITY_TEMPLATE_EMPHASIZE_TYPE.enabled, + templateImageUrl: "", + }, + }); + + useEffect(() => { + if (!open) return; + form.reset(); + }, [open]); + + const { mutateAsync: saveTemp, isPending } = useMutationWrapper({ + mutationFn: (data: NtftalkTempSaveFormData) => + NtftalkTempApi.saveNtftalkTemp({ + ...data, + templateImageUrl: data.templateImageUrl || undefined, + }), + onSuccess: async () => { + await queryClient.invalidateQueries({ + queryKey: [ENDPOINT.NTFTALK_SERVICE.NTFTALK_TEMP], + }); + alert("등록되었습니다."); + form.reset(); + onOpenChange(false); + }, + }); + + const onSubmit = form.handleSubmit((data) => + saveTemp({ + ...data, + senderKeyType: "S", + templateMessageType: TEMPLATE_MESSAGE_TYPE_BASIC, + }), + ); + + useEffect(() => { + if (!senderQuery.data) return; + if (form.getValues("senderKey")) return; + form.setValue("senderKey", senderQuery.data.senderKey); + }, [senderQuery.data, form]); + + function handleInsertVariable() { + setIsVarDialogOpen(true); + } + + function handleApplySelectedVariables() { + const rows = tableInstance.getSelectedRowModel().rows; + if (rows.length === 0) { + alert("추가할 변수를 선택해 주세요."); + return; + } + + const varCodes = rows.map((row) => row.original.varCd?.trim() ?? ""); + if (varCodes.some((varCd) => !varCd)) { + alert("선택 항목 중 변수코드가 없는 항목이 있습니다."); + return; + } + + const currentValue = form.getValues("templateContent"); + const tokens = varCodes.map((varCd) => `${varCd}`); + const appendValue = tokens.join("\n"); + const nextValue = currentValue + ? `${currentValue}\n${appendValue}` + : appendValue; + + form.setValue("templateContent", nextValue, { + shouldValidate: true, + shouldDirty: true, + }); + setVarRowSelection({}); + setIsVarDialogOpen(false); + } + + const varTableComponent = (() => { + if (varQuery.isLoading) + return ( +
+ +
+ ); + if (varQuery.isError) return ; + return tableComponent; + })(); + + return ( + + + + 알림 템플릿 등록 + +
+ + + + + + + + + + 메시지 유형 + + 기본형 + + + + 채널 + + + ( + + + + + + + )} + /> + + + + + 템플릿코드 + + + ( + + + + + + + )} + /> + + + + + 템플릿명 + + + ( + + + + + + + )} + /> + + + + + 보안템플릿여부 + + + ( + + + +
+ + +
+
+ + +
+
+
+ +
+ )} + /> +
+
+ + + 템플릿내용 + + + ( + +
+ +
+ +