From ca86c0a10f300dcde279dd227f3f3ea3b9e15008 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 18 Dec 2025 14:42:58 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=BB=B4=ED=8C=A9?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=93=9C=20=EC=B6=94=EA=B0=80=20(1x1=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EC=A6=88=20=EB=8C=80=EC=9D=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 2 +- .../widgets/yard-3d/DigitalTwinEditor.tsx | 16 +- .../widgets/yard-3d/DigitalTwinViewer.tsx | 16 +- .../dashboard/widgets/RiskAlertTestWidget.tsx | 81 +++++++++- .../dashboard/widgets/RiskAlertWidget.tsx | 73 ++++++++- .../dashboard/widgets/WeatherWidget.tsx | 143 +++++++++++++++--- .../report/designer/QueryManager.tsx | 16 +- .../report/designer/ReportPreviewModal.tsx | 8 +- 8 files changed, 301 insertions(+), 54 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 090985ba..6d254cfe 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -916,7 +916,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "weather" ? ( // 날씨 위젯 렌더링
- +
) : element.type === "widget" && element.subtype === "exchange" ? ( // 환율 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index 03abee6f..aad0e2b5 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2155,23 +2155,23 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi - {materials.map((material, index) => { - const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const keyColumn = hierarchyConfig?.material?.keyColumn || "STKKEY"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; const layerNumber = material[layerColumn] || index + 1; - return ( + return ( {layerNumber}단 {displayColumns.map((col) => ( {material[col.column] || "-"} - ))} + ))} - ); - })} + ); + })} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index a702a047..6d1f4a31 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -660,25 +660,25 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) - {materials.map((material, index) => { + {materials.map((material, index) => { const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; - return ( + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + return ( {material[layerColumn]}단 - {displayColumns.map((colConfig: any) => ( + {displayColumns.map((colConfig: any) => ( {material[colConfig.column] || "-"} - ))} + ))} - ); - })} + ); + })} diff --git a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx index 30ee99a5..1806ff34 100644 --- a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo } from "react"; +import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -8,6 +8,9 @@ import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { getApiUrl } from "@/lib/utils/apiUrl"; +// 컴팩트 모드 임계값 (픽셀) +const COMPACT_HEIGHT_THRESHOLD = 180; + type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other"; interface Alert { @@ -31,6 +34,29 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp const [error, setError] = useState(null); const [filter, setFilter] = useState("all"); const [lastRefreshTime, setLastRefreshTime] = useState(null); + + // 컨테이너 높이 측정을 위한 ref + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(300); + + // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) + const isCompact = element?.size?.height + ? element.size.height < COMPACT_HEIGHT_THRESHOLD + : containerHeight < COMPACT_HEIGHT_THRESHOLD; + + // 컨테이너 높이 측정 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; @@ -549,8 +575,57 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp ); } + // 통계 계산 + const stats = { + accident: alerts.filter((a) => a.type === "accident").length, + weather: alerts.filter((a) => a.type === "weather").length, + construction: alerts.filter((a) => a.type === "construction").length, + high: alerts.filter((a) => a.severity === "high").length, + }; + + // 컴팩트 모드 렌더링 - 알림 목록만 스크롤 + if (isCompact) { + return ( +
+ {filteredAlerts.length === 0 ? ( +
+

알림 없음

+
+ ) : ( + filteredAlerts.map((alert, idx) => ( +
+
+ {getTypeIcon(alert.type)} + {alert.title} + + {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"} + +
+ {alert.location && ( +

{alert.location}

+ )} +
+ )) + )} +
+ ); + } + + // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
@@ -631,7 +706,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
{alert.location && ( -

📍 {alert.location}

+

{alert.location}

)}

{alert.description}

diff --git a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx index 3e638f3c..7728cc5d 100644 --- a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -8,6 +8,9 @@ import { RefreshCw, AlertTriangle, Cloud, Construction } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import { DashboardElement } from "@/components/admin/dashboard/types"; +// 컴팩트 모드 임계값 (픽셀) +const COMPACT_HEIGHT_THRESHOLD = 180; + // 알림 타입 type AlertType = "accident" | "weather" | "construction"; @@ -32,6 +35,29 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { const [filter, setFilter] = useState("all"); const [lastUpdated, setLastUpdated] = useState(null); const [newAlertIds, setNewAlertIds] = useState>(new Set()); + + // 컨테이너 높이 측정을 위한 ref + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(300); + + // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) + const isCompact = element?.size?.height + ? element.size.height < COMPACT_HEIGHT_THRESHOLD + : containerHeight < COMPACT_HEIGHT_THRESHOLD; + + // 컨테이너 높이 측정 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); // 데이터 로드 (백엔드 캐시 조회) const loadData = async () => { @@ -176,8 +202,49 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { high: alerts.filter((a) => a.severity === "high").length, }; + // 컴팩트 모드 렌더링 - 알림 목록만 스크롤 + if (isCompact) { + return ( +
+ {filteredAlerts.length === 0 ? ( +
+

알림 없음

+
+ ) : ( + filteredAlerts.map((alert) => ( +
+
+ {getAlertIcon(alert.type)} + {alert.title} + + {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"} + +
+ {alert.location && ( +

{alert.location}

+ )} +
+ )) + )} +
+ ); + } + + // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
@@ -294,7 +361,7 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { {/* 안내 메시지 */}
- 💡 1분마다 자동으로 업데이트됩니다 + 1분마다 자동으로 업데이트됩니다
); diff --git a/frontend/components/dashboard/widgets/WeatherWidget.tsx b/frontend/components/dashboard/widgets/WeatherWidget.tsx index 56c3aaf6..3d1d7157 100644 --- a/frontend/components/dashboard/widgets/WeatherWidget.tsx +++ b/frontend/components/dashboard/widgets/WeatherWidget.tsx @@ -3,9 +3,10 @@ /** * 날씨 위젯 컴포넌트 * - 실시간 날씨 정보를 표시 + * - 컴팩트 모드: 높이가 작을 때 핵심 정보만 표시 */ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { getWeather, WeatherData } from '@/lib/api/openApi'; import { Cloud, @@ -26,6 +27,9 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { cn } from '@/lib/utils'; import { DashboardElement } from '@/components/admin/dashboard/types'; +// 컴팩트 모드 임계값 (픽셀) +const COMPACT_HEIGHT_THRESHOLD = 180; + interface WeatherWidgetProps { element?: DashboardElement; city?: string; @@ -45,6 +49,29 @@ export default function WeatherWidget({ const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); + // 컨테이너 높이 측정을 위한 ref + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(300); + + // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) + const isCompact = element?.size?.height + ? element.size.height < COMPACT_HEIGHT_THRESHOLD + : containerHeight < COMPACT_HEIGHT_THRESHOLD; + + // 컨테이너 높이 측정 + useEffect(() => { + if (!containerRef.current) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + // 표시할 날씨 정보 선택 const [selectedItems, setSelectedItems] = useState([ 'temperature', @@ -323,12 +350,105 @@ export default function WeatherWidget({ ); } + // 날씨 아이콘 렌더링 헬퍼 + const renderWeatherIcon = (weatherMain: string, size: "sm" | "md" = "sm") => { + const iconClass = size === "sm" ? "h-5 w-5" : "h-8 w-8"; + switch (weatherMain.toLowerCase()) { + case 'clear': + return ; + case 'clouds': + return ; + case 'rain': + case 'drizzle': + return ; + case 'snow': + return ; + default: + return ; + } + }; + + // 컴팩트 모드 렌더링 + if (isCompact) { + return ( +
+ {/* 컴팩트 헤더 - 도시명, 온도, 날씨 아이콘 한 줄에 표시 */} +
+
+ {renderWeatherIcon(weather.weatherMain, "md")} +
+
+ + {weather.temperature}°C + + + {weather.weatherDescription} + +
+ + + + + + + + + 도시를 찾을 수 없습니다. + + {cities.map((city) => ( + { + handleCityChange(currentValue === selectedCity ? selectedCity : currentValue); + setOpen(false); + }} + > + + {city.label} + + ))} + + + + + +
+
+ +
+
+ ); + } + + // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
-

🌤️ {element?.customTitle || "날씨"}

+

{element?.customTitle || "날씨"}

@@ -438,22 +558,7 @@ export default function WeatherWidget({
- {(() => { - const iconClass = "h-5 w-5"; - switch (weather.weatherMain.toLowerCase()) { - case 'clear': - return ; - case 'clouds': - return ; - case 'rain': - case 'drizzle': - return ; - case 'snow': - return ; - default: - return ; - } - })()} + {renderWeatherIcon(weather.weatherMain)}
diff --git a/frontend/components/report/designer/QueryManager.tsx b/frontend/components/report/designer/QueryManager.tsx index e9ccb813..39b0d173 100644 --- a/frontend/components/report/designer/QueryManager.tsx +++ b/frontend/components/report/designer/QueryManager.tsx @@ -274,15 +274,15 @@ export function QueryManager() {
- -
+ > + + +
{/* 쿼리 이름 */}
diff --git a/frontend/components/report/designer/ReportPreviewModal.tsx b/frontend/components/report/designer/ReportPreviewModal.tsx index 2ff70c73..ded27f37 100644 --- a/frontend/components/report/designer/ReportPreviewModal.tsx +++ b/frontend/components/report/designer/ReportPreviewModal.tsx @@ -486,11 +486,11 @@ export function ReportPreviewModal({ isOpen, onClose }: ReportPreviewModalProps) } } return component; - }), - ); + }), + ); return { ...page, components: componentsWithBase64 }; - }), - ); + }), + ); // 쿼리 결과 수집 const queryResults: Record[] }> = {}; From 79c1a456f0315a9b6cf0a5c87eddd831ed3b5b6c Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 18 Dec 2025 15:04:55 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EC=9C=84?= =?UTF-8?q?=EC=A0=AF=20=EC=BB=B4=ED=8C=A9=ED=8A=B8=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(=EC=84=B8=EB=A1=9C=201=EC=B9=B8=20?= =?UTF-8?q?=EB=8C=80=EC=9D=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/ListWidget.tsx | 147 ++++++++--- .../dashboard/widgets/ListTestWidget.tsx | 233 ++++++++++++------ 2 files changed, 263 insertions(+), 117 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 194f7210..2f05a927 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -14,7 +14,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { getApiUrl } from "@/lib/utils/apiUrl"; -import { Truck, Clock, MapPin, Package, Info } from "lucide-react"; +import { Truck, Clock, MapPin, Package, Info, ChevronLeft, ChevronRight } from "lucide-react"; interface ListWidgetProps { element: DashboardElement; @@ -32,6 +32,8 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = React.useRef(null); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); @@ -39,6 +41,25 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + // 컨테이너 높이 감지 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, []); + + // 컴팩트 모드 여부 (높이 300px 이하 또는 element 높이가 300px 이하) + const elementHeight = element?.size?.height || 0; + const isCompactHeight = elementHeight > 0 ? elementHeight < 300 : (containerHeight > 0 && containerHeight < 300); + const config = element.listConfig || { columnMode: "auto", viewMode: "table", @@ -541,14 +562,64 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows; return ( -
- {/* 제목 - 항상 표시 */} -
-

{element.customTitle || element.title}

-
+
+ {/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */} + {isCompactHeight ? ( +
+ {data && data.rows.length > 0 && displayColumns.filter((col) => col.visible).length > 0 ? ( +
+ {/* 이전 버튼 */} + - {/* 테이블 뷰 */} - {config.viewMode === "table" && ( + {/* 현재 데이터 */} +
+ {displayColumns.filter((col) => col.visible).slice(0, 4).map((col, colIdx) => ( + + {String(data.rows[currentPage - 1]?.[col.dataKey || col.field] ?? "").substring(0, 25)} + {colIdx < Math.min(displayColumns.filter((c) => c.visible).length, 4) - 1 && " | "} + + ))} +
+ + {/* 다음 버튼 */} + +
+ ) : ( +
데이터 없음
+ )} + + {/* 현재 위치 표시 (작게) */} + {data && data.rows.length > 0 && ( +
+ {currentPage} / {data.rows.length} +
+ )} +
+ ) : ( + <> + {/* 제목 - 항상 표시 */} +
+

{element.customTitle || element.title}

+
+ + {/* 테이블 뷰 */} + {config.viewMode === "table" && (
{config.showHeader && ( @@ -642,36 +713,38 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { )} - {/* 페이지네이션 */} - {config.enablePagination && totalPages > 1 && ( -
-
- {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개 -
-
- -
- {currentPage} - / - {totalPages} + {/* 페이지네이션 */} + {config.enablePagination && totalPages > 1 && ( +
+
+ {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개 +
+
+ +
+ {currentPage} + / + {totalPages} +
+ +
- -
-
+ )} + )} {/* 행 상세 팝업 */} diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index d1303d10..a1609b1a 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -13,7 +13,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react"; +import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info, ChevronLeft, ChevronRight } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -41,6 +41,8 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [lastRefreshTime, setLastRefreshTime] = useState(null); + const [containerHeight, setContainerHeight] = useState(0); + const containerRef = React.useRef(null); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); @@ -48,6 +50,25 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); + // 컨테이너 높이 감지 + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + setContainerHeight(entry.contentRect.height); + } + }); + + resizeObserver.observe(container); + return () => resizeObserver.disconnect(); + }, []); + + // 컴팩트 모드 여부 (높이 300px 이하 또는 element 높이가 300px 이하) + const elementHeight = element?.size?.height || 0; + const isCompactHeight = elementHeight > 0 ? elementHeight < 300 : (containerHeight > 0 && containerHeight < 300); + // // console.log("🧪 ListTestWidget 렌더링!", element); const dataSources = useMemo(() => { @@ -743,87 +764,139 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { }; return ( -
- {/* 헤더 */} -
-
-

- {element?.customTitle || "리스트"} -

-

- {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 - {lastRefreshTime && ( - - • {lastRefreshTime.toLocaleTimeString("ko-KR")} - +

+ {/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */} + {isCompactHeight ? ( +
+ {data && data.rows.length > 0 && displayColumns.length > 0 ? ( +
+ {/* 이전 버튼 */} + + + {/* 현재 데이터 */} +
+ {displayColumns.slice(0, 4).map((field, fieldIdx) => ( + + {String(data.rows[currentPage - 1]?.[field] ?? "").substring(0, 25)} + {fieldIdx < Math.min(displayColumns.length, 4) - 1 && " | "} + + ))} +
+ + {/* 다음 버튼 */} + +
+ ) : ( +
데이터 없음
+ )} + + {/* 현재 위치 표시 (작게) */} + {data && data.rows.length > 0 && ( +
+ {currentPage} / {data.rows.length} +
+ )} +
+ ) : ( + <> + {/* 헤더 */} +
+
+

+ {element?.customTitle || "리스트"} +

+

+ {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 + {lastRefreshTime && ( + + • {lastRefreshTime.toLocaleTimeString("ko-KR")} + + )} +

+
+
+ + {isLoading && } +
+
+ + {/* 컨텐츠 */} +
+ {error ? ( +
+

{error}

+
+ ) : !dataSources || dataSources.length === 0 ? ( +
+

+ 데이터 소스를 연결해주세요 +

+
+ ) : !data || data.rows.length === 0 ? ( +
+

+ 데이터가 없습니다 +

+
+ ) : config.viewMode === "card" ? ( + renderCards() + ) : ( + renderTable() )} -

-
-
- - {isLoading && } -
-
+
- {/* 컨텐츠 */} -
- {error ? ( -
-

{error}

-
- ) : !dataSources || dataSources.length === 0 ? ( -
-

- 데이터 소스를 연결해주세요 -

-
- ) : !data || data.rows.length === 0 ? ( -
-

- 데이터가 없습니다 -

-
- ) : config.viewMode === "card" ? ( - renderCards() - ) : ( - renderTable() - )} -
- - {/* 페이지네이션 */} - {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && ( -
-
- 총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages}) -
-
- - -
-
+ {/* 페이지네이션 */} + {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && ( +
+
+ 총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages}) +
+
+ + +
+
+ )} + )} {/* 행 상세 팝업 */} From a24654c867321dda372784e980f5619877090527 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 18 Dec 2025 15:11:03 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=EB=94=94=EC=A7=80=ED=84=B8=20=ED=8A=B8?= =?UTF-8?q?=EC=9C=88=20=EC=9E=90=EC=9E=AC=20=EB=AA=A9=EB=A1=9D=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EA=B0=80=EB=8F=85=EC=84=B1=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/widgets/yard-3d/DigitalTwinEditor.tsx | 8 ++++---- .../dashboard/widgets/yard-3d/DigitalTwinViewer.tsx | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx index aad0e2b5..6ee36ca1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinEditor.tsx @@ -2146,9 +2146,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi
- + {(hierarchyConfig?.material?.displayColumns || []).map((col) => ( - + {col.label} ))} @@ -2163,9 +2163,9 @@ export default function DigitalTwinEditor({ layoutId, layoutName, onBack }: Digi return ( - {layerNumber}단 + {layerNumber}단 {displayColumns.map((col) => ( - + {material[col.column] || "-"} ))} diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 6d1f4a31..8df5c983 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -645,14 +645,14 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {/* 테이블 형태로 전체 조회 */}
-
+
- + {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( @@ -668,11 +668,11 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) key={`${material.STKKEY}-${index}`} className="hover:bg-accent border-b transition-colors last:border-0" > - {displayColumns.map((colConfig: any) => ( - ))} From da24db8f37000bdded1b7eaef77020c6dca4bc80 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 18 Dec 2025 16:03:47 +0900 Subject: [PATCH 04/11] =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EC=97=85=EC=B2=B4?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=EB=AA=A8=EB=93=9C=20=EB=B0=8F=203D=20?= =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=EC=A0=84=EC=B2=B4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend-node/src/routes/roleRoutes.ts | 18 +-- .../widgets/yard-3d/DigitalTwinViewer.tsx | 131 +++++++++++++++--- 2 files changed, 124 insertions(+), 25 deletions(-) diff --git a/backend-node/src/routes/roleRoutes.ts b/backend-node/src/routes/roleRoutes.ts index 21c17ecb..0f8a64b0 100644 --- a/backend-node/src/routes/roleRoutes.ts +++ b/backend-node/src/routes/roleRoutes.ts @@ -22,6 +22,15 @@ const router = Router(); // 모든 role 라우트에 인증 미들웨어 적용 router.use(authenticateToken); +/** + * 사용자 권한 그룹 조회 (/:id 보다 먼저 정의해야 함) + */ +// 현재 사용자가 속한 권한 그룹 조회 +router.get("/user/my-groups", getUserRoleGroups); + +// 특정 사용자가 속한 권한 그룹 조회 +router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); + /** * 권한 그룹 CRUD */ @@ -67,13 +76,4 @@ router.get("/:id/menu-permissions", requireAdmin, getMenuPermissions); // 메뉴 권한 설정 router.put("/:id/menu-permissions", requireAdmin, setMenuPermissions); -/** - * 사용자 권한 그룹 조회 - */ -// 현재 사용자가 속한 권한 그룹 조회 -router.get("/user/my-groups", getUserRoleGroups); - -// 특정 사용자가 속한 권한 그룹 조회 -router.get("/user/:userId/groups", requireAdmin, getUserRoleGroups); - export default router; diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 8df5c983..c5d3e463 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -1,7 +1,7 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; -import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw } from "lucide-react"; +import { useState, useEffect, useMemo, useRef } from "react"; +import { Loader2, Search, X, Grid3x3, Package, ParkingCircle, RefreshCw, Maximize, Minimize } from "lucide-react"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; @@ -12,6 +12,7 @@ import type { PlacedObject, MaterialData } from "@/types/digitalTwin"; import { getLayoutById, getMaterials } from "@/lib/api/digitalTwin"; import { OBJECT_COLORS, DEFAULT_COLOR } from "./constants"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; +import { apiCall } from "@/lib/api/client"; const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), { ssr: false, @@ -26,6 +27,9 @@ interface DigitalTwinViewerProps { layoutId: number; } +// 외부 업체 역할 코드 +const EXTERNAL_VENDOR_ROLE = "LSTHIRA_EXTERNAL_VENDOR"; + export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) { const { toast } = useToast(); const [placedObjects, setPlacedObjects] = useState([]); @@ -43,6 +47,64 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const [filterType, setFilterType] = useState("all"); const [isRefreshing, setIsRefreshing] = useState(false); + // 외부 업체 모드 + const [isExternalMode, setIsExternalMode] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const canvasContainerRef = useRef(null); + + // 외부 업체 역할 체크 + useEffect(() => { + const checkExternalRole = async () => { + try { + const response = await apiCall("GET", "/roles/user/my-groups"); + console.log("=== 사용자 권한 그룹 조회 ==="); + console.log("API 응답:", response); + console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE); + + if (response.success && response.data) { + console.log("권한 그룹 목록:", response.data); + + // 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인 + const hasExternalRole = response.data.some( + (group: any) => { + console.log("체크 중인 그룹:", group.authCode, group.authName); + return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE; + } + ); + + console.log("외부 업체 역할 보유:", hasExternalRole); + setIsExternalMode(hasExternalRole); + } + } catch (error) { + console.error("역할 조회 실패:", error); + } + }; + + checkExternalRole(); + }, []); + + // 전체 화면 토글 (3D 캔버스 영역만) + const toggleFullscreen = () => { + if (!document.fullscreenElement) { + // 3D 캔버스 컨테이너만 풀스크린 + canvasContainerRef.current?.requestFullscreen(); + setIsFullscreen(true); + } else { + document.exitFullscreen(); + setIsFullscreen(false); + } + }; + + // 전체 화면 변경 감지 + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(!!document.fullscreenElement); + }; + + document.addEventListener("fullscreenchange", handleFullscreenChange); + return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); + }, []); + // 레이아웃 데이터 로드 함수 const loadLayout = async () => { try { @@ -334,23 +396,42 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

{layoutName || "디지털 트윈 야드"}

-

읽기 전용 뷰

+

+ {isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"} +

+
+
+ {/* 전체 화면 버튼 */} + +
-
{/* 메인 영역 */}
- {/* 좌측: 검색/필터 */} + {/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */} + {!isExternalMode && (
{/* 검색 */} @@ -575,9 +656,13 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) )}
+ )} {/* 중앙: 3D 캔버스 */} -
+
{!isLoading && ( {}} /> )} + {/* 풀스크린 모드일 때 종료 버튼 */} + {isFullscreen && ( + + )}
- {/* 우측: 정보 패널 */} + {/* 우측: 정보 패널 - 외부 모드에서는 숨김 */} + {!isExternalMode && (
{selectedObject ? (
@@ -693,6 +791,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
)}
+ )}
); From 61ceab1a7b5bf2627add67a0dc45b846ffe2a0ee Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 18 Dec 2025 16:31:25 +0900 Subject: [PATCH 05/11] =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EC=97=85=EC=B2=B4?= =?UTF-8?q?=EC=9D=BC=20=EB=95=8C=EB=A7=8C=20=EC=A0=84=EC=B2=B4=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinViewer.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index c5d3e463..8d4bdfd1 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -401,20 +401,22 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

- {/* 전체 화면 버튼 */} - + {/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */} + {isExternalMode && ( + + )}
)} - {/* 중앙: 3D 캔버스 */} + {/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */}
- {!isLoading && ( - handleObjectClick(placement?.id || null)} - focusOnPlacementId={null} - onCollisionDetected={() => {}} - /> - )} - {/* 풀스크린 모드일 때 종료 버튼 */} - {isFullscreen && ( - - )} -
+ {/* 중앙: 3D 캔버스 */} +
+ {!isLoading && ( + handleObjectClick(placement?.id || null)} + focusOnPlacementId={null} + onCollisionDetected={() => {}} + /> + )} +
- {/* 우측: 정보 패널 - 외부 모드에서는 숨김 */} - {!isExternalMode && ( -
+ {/* 우측: 정보 패널 */} +
{selectedObject ? (
@@ -792,8 +792,21 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

객체를 선택하세요

)} +
+ + {/* 풀스크린 모드일 때 종료 버튼 */} + {isFullscreen && ( + + )}
- )}
); From adb21a53089e704fa107ba8d95eecba578efc4a6 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 19 Dec 2025 13:47:30 +0900 Subject: [PATCH 07/11] =?UTF-8?q?=EC=9C=84=EC=A0=AF=20=EC=BB=B4=ED=8C=A9?= =?UTF-8?q?=ED=8A=B8=20=EB=AA=A8=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/ListWidget.tsx | 147 +++-------- .../dashboard/widgets/ListTestWidget.tsx | 233 ++++++------------ .../dashboard/widgets/RiskAlertTestWidget.tsx | 81 +----- .../dashboard/widgets/RiskAlertWidget.tsx | 73 +----- .../dashboard/widgets/WeatherWidget.tsx | 143 ++--------- 5 files changed, 142 insertions(+), 535 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 2f05a927..194f7210 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -14,7 +14,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { getApiUrl } from "@/lib/utils/apiUrl"; -import { Truck, Clock, MapPin, Package, Info, ChevronLeft, ChevronRight } from "lucide-react"; +import { Truck, Clock, MapPin, Package, Info } from "lucide-react"; interface ListWidgetProps { element: DashboardElement; @@ -32,8 +32,6 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); - const [containerHeight, setContainerHeight] = useState(0); - const containerRef = React.useRef(null); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); @@ -41,25 +39,6 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); - // 컨테이너 높이 감지 - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerHeight(entry.contentRect.height); - } - }); - - resizeObserver.observe(container); - return () => resizeObserver.disconnect(); - }, []); - - // 컴팩트 모드 여부 (높이 300px 이하 또는 element 높이가 300px 이하) - const elementHeight = element?.size?.height || 0; - const isCompactHeight = elementHeight > 0 ? elementHeight < 300 : (containerHeight > 0 && containerHeight < 300); - const config = element.listConfig || { columnMode: "auto", viewMode: "table", @@ -562,64 +541,14 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows; return ( -
- {/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */} - {isCompactHeight ? ( -
- {data && data.rows.length > 0 && displayColumns.filter((col) => col.visible).length > 0 ? ( -
- {/* 이전 버튼 */} - +
+ {/* 제목 - 항상 표시 */} +
+

{element.customTitle || element.title}

+
- {/* 현재 데이터 */} -
- {displayColumns.filter((col) => col.visible).slice(0, 4).map((col, colIdx) => ( - - {String(data.rows[currentPage - 1]?.[col.dataKey || col.field] ?? "").substring(0, 25)} - {colIdx < Math.min(displayColumns.filter((c) => c.visible).length, 4) - 1 && " | "} - - ))} -
- - {/* 다음 버튼 */} - -
- ) : ( -
데이터 없음
- )} - - {/* 현재 위치 표시 (작게) */} - {data && data.rows.length > 0 && ( -
- {currentPage} / {data.rows.length} -
- )} -
- ) : ( - <> - {/* 제목 - 항상 표시 */} -
-

{element.customTitle || element.title}

-
- - {/* 테이블 뷰 */} - {config.viewMode === "table" && ( + {/* 테이블 뷰 */} + {config.viewMode === "table" && (
{colConfig.label} + {material[layerColumn]}단 + {material[colConfig.column] || "-"}
{config.showHeader && ( @@ -713,38 +642,36 @@ export function ListWidget({ element, onConfigUpdate }: ListWidgetProps) { )} - {/* 페이지네이션 */} - {config.enablePagination && totalPages > 1 && ( -
-
- {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개 -
-
- -
- {currentPage} - / - {totalPages} -
- -
+ {/* 페이지네이션 */} + {config.enablePagination && totalPages > 1 && ( +
+
+ {startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개 +
+
+ +
+ {currentPage} + / + {totalPages}
- )} - + +
+
)} {/* 행 상세 팝업 */} diff --git a/frontend/components/dashboard/widgets/ListTestWidget.tsx b/frontend/components/dashboard/widgets/ListTestWidget.tsx index a1609b1a..d1303d10 100644 --- a/frontend/components/dashboard/widgets/ListTestWidget.tsx +++ b/frontend/components/dashboard/widgets/ListTestWidget.tsx @@ -13,7 +13,7 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info, ChevronLeft, ChevronRight } from "lucide-react"; +import { Loader2, RefreshCw, Truck, Clock, MapPin, Package, Info } from "lucide-react"; import { applyColumnMapping } from "@/lib/utils/columnMapping"; import { getApiUrl } from "@/lib/utils/apiUrl"; @@ -41,8 +41,6 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [error, setError] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [lastRefreshTime, setLastRefreshTime] = useState(null); - const [containerHeight, setContainerHeight] = useState(0); - const containerRef = React.useRef(null); // 행 상세 팝업 상태 const [detailPopupOpen, setDetailPopupOpen] = useState(false); @@ -50,25 +48,6 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { const [detailPopupLoading, setDetailPopupLoading] = useState(false); const [additionalDetailData, setAdditionalDetailData] = useState | null>(null); - // 컨테이너 높이 감지 - useEffect(() => { - const container = containerRef.current; - if (!container) return; - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerHeight(entry.contentRect.height); - } - }); - - resizeObserver.observe(container); - return () => resizeObserver.disconnect(); - }, []); - - // 컴팩트 모드 여부 (높이 300px 이하 또는 element 높이가 300px 이하) - const elementHeight = element?.size?.height || 0; - const isCompactHeight = elementHeight > 0 ? elementHeight < 300 : (containerHeight > 0 && containerHeight < 300); - // // console.log("🧪 ListTestWidget 렌더링!", element); const dataSources = useMemo(() => { @@ -764,139 +743,87 @@ export function ListTestWidget({ element }: ListTestWidgetProps) { }; return ( -
- {/* 컴팩트 모드 (세로 1칸) - 캐러셀 형태로 한 건씩 표시 */} - {isCompactHeight ? ( -
- {data && data.rows.length > 0 && displayColumns.length > 0 ? ( -
- {/* 이전 버튼 */} - - - {/* 현재 데이터 */} -
- {displayColumns.slice(0, 4).map((field, fieldIdx) => ( - - {String(data.rows[currentPage - 1]?.[field] ?? "").substring(0, 25)} - {fieldIdx < Math.min(displayColumns.length, 4) - 1 && " | "} - - ))} -
- - {/* 다음 버튼 */} - -
- ) : ( -
데이터 없음
- )} - - {/* 현재 위치 표시 (작게) */} - {data && data.rows.length > 0 && ( -
- {currentPage} / {data.rows.length} -
- )} -
- ) : ( - <> - {/* 헤더 */} -
-
-

- {element?.customTitle || "리스트"} -

-

- {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 - {lastRefreshTime && ( - - • {lastRefreshTime.toLocaleTimeString("ko-KR")} - - )} -

-
-
- - {isLoading && } -
-
- - {/* 컨텐츠 */} -
- {error ? ( -
-

{error}

-
- ) : !dataSources || dataSources.length === 0 ? ( -
-

- 데이터 소스를 연결해주세요 -

-
- ) : !data || data.rows.length === 0 ? ( -
-

- 데이터가 없습니다 -

-
- ) : config.viewMode === "card" ? ( - renderCards() - ) : ( - renderTable() +
+ {/* 헤더 */} +
+
+

+ {element?.customTitle || "리스트"} +

+

+ {dataSources?.length || 0}개 데이터 소스 • {data?.totalRows || 0}개 행 + {lastRefreshTime && ( + + • {lastRefreshTime.toLocaleTimeString("ko-KR")} + )} -

+

+
+
+ + {isLoading && } +
+
- {/* 페이지네이션 */} - {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && ( -
-
- 총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages}) -
-
- - -
-
- )} - + {/* 컨텐츠 */} +
+ {error ? ( +
+

{error}

+
+ ) : !dataSources || dataSources.length === 0 ? ( +
+

+ 데이터 소스를 연결해주세요 +

+
+ ) : !data || data.rows.length === 0 ? ( +
+

+ 데이터가 없습니다 +

+
+ ) : config.viewMode === "card" ? ( + renderCards() + ) : ( + renderTable() + )} +
+ + {/* 페이지네이션 */} + {config.enablePagination && data && data.rows.length > 0 && totalPages > 1 && ( +
+
+ 총 {data.totalRows}개 항목 (페이지 {currentPage}/{totalPages}) +
+
+ + +
+
)} {/* 행 상세 팝업 */} diff --git a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx index 1806ff34..30ee99a5 100644 --- a/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertTestWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -8,9 +8,6 @@ import { RefreshCw, AlertTriangle, Cloud, Construction, Database as DatabaseIcon import { DashboardElement, ChartDataSource } from "@/components/admin/dashboard/types"; import { getApiUrl } from "@/lib/utils/apiUrl"; -// 컴팩트 모드 임계값 (픽셀) -const COMPACT_HEIGHT_THRESHOLD = 180; - type AlertType = "accident" | "weather" | "construction" | "system" | "security" | "other"; interface Alert { @@ -34,29 +31,6 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp const [error, setError] = useState(null); const [filter, setFilter] = useState("all"); const [lastRefreshTime, setLastRefreshTime] = useState(null); - - // 컨테이너 높이 측정을 위한 ref - const containerRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(300); - - // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) - const isCompact = element?.size?.height - ? element.size.height < COMPACT_HEIGHT_THRESHOLD - : containerHeight < COMPACT_HEIGHT_THRESHOLD; - - // 컨테이너 높이 측정 - useEffect(() => { - if (!containerRef.current) return; - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerHeight(entry.contentRect.height); - } - }); - - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); const dataSources = useMemo(() => { return element?.dataSources || element?.chartConfig?.dataSources; @@ -575,57 +549,8 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp ); } - // 통계 계산 - const stats = { - accident: alerts.filter((a) => a.type === "accident").length, - weather: alerts.filter((a) => a.type === "weather").length, - construction: alerts.filter((a) => a.type === "construction").length, - high: alerts.filter((a) => a.severity === "high").length, - }; - - // 컴팩트 모드 렌더링 - 알림 목록만 스크롤 - if (isCompact) { - return ( -
- {filteredAlerts.length === 0 ? ( -
-

알림 없음

-
- ) : ( - filteredAlerts.map((alert, idx) => ( -
-
- {getTypeIcon(alert.type)} - {alert.title} - - {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"} - -
- {alert.location && ( -

{alert.location}

- )} -
- )) - )} -
- ); - } - - // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
@@ -706,7 +631,7 @@ export default function RiskAlertTestWidget({ element }: RiskAlertTestWidgetProp
{alert.location && ( -

{alert.location}

+

📍 {alert.location}

)}

{alert.description}

diff --git a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx index 7728cc5d..3e638f3c 100644 --- a/frontend/components/dashboard/widgets/RiskAlertWidget.tsx +++ b/frontend/components/dashboard/widgets/RiskAlertWidget.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useState, useEffect } from "react"; import { Card } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -8,9 +8,6 @@ import { RefreshCw, AlertTriangle, Cloud, Construction } from "lucide-react"; import { apiClient } from "@/lib/api/client"; import { DashboardElement } from "@/components/admin/dashboard/types"; -// 컴팩트 모드 임계값 (픽셀) -const COMPACT_HEIGHT_THRESHOLD = 180; - // 알림 타입 type AlertType = "accident" | "weather" | "construction"; @@ -35,29 +32,6 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { const [filter, setFilter] = useState("all"); const [lastUpdated, setLastUpdated] = useState(null); const [newAlertIds, setNewAlertIds] = useState>(new Set()); - - // 컨테이너 높이 측정을 위한 ref - const containerRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(300); - - // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) - const isCompact = element?.size?.height - ? element.size.height < COMPACT_HEIGHT_THRESHOLD - : containerHeight < COMPACT_HEIGHT_THRESHOLD; - - // 컨테이너 높이 측정 - useEffect(() => { - if (!containerRef.current) return; - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerHeight(entry.contentRect.height); - } - }); - - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); // 데이터 로드 (백엔드 캐시 조회) const loadData = async () => { @@ -202,49 +176,8 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { high: alerts.filter((a) => a.severity === "high").length, }; - // 컴팩트 모드 렌더링 - 알림 목록만 스크롤 - if (isCompact) { - return ( -
- {filteredAlerts.length === 0 ? ( -
-

알림 없음

-
- ) : ( - filteredAlerts.map((alert) => ( -
-
- {getAlertIcon(alert.type)} - {alert.title} - - {alert.severity === "high" ? "긴급" : alert.severity === "medium" ? "주의" : "정보"} - -
- {alert.location && ( -

{alert.location}

- )} -
- )) - )} -
- ); - } - - // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
@@ -361,7 +294,7 @@ export default function RiskAlertWidget({ element }: RiskAlertWidgetProps) { {/* 안내 메시지 */}
- 1분마다 자동으로 업데이트됩니다 + 💡 1분마다 자동으로 업데이트됩니다
); diff --git a/frontend/components/dashboard/widgets/WeatherWidget.tsx b/frontend/components/dashboard/widgets/WeatherWidget.tsx index 3d1d7157..56c3aaf6 100644 --- a/frontend/components/dashboard/widgets/WeatherWidget.tsx +++ b/frontend/components/dashboard/widgets/WeatherWidget.tsx @@ -3,10 +3,9 @@ /** * 날씨 위젯 컴포넌트 * - 실시간 날씨 정보를 표시 - * - 컴팩트 모드: 높이가 작을 때 핵심 정보만 표시 */ -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState } from 'react'; import { getWeather, WeatherData } from '@/lib/api/openApi'; import { Cloud, @@ -27,9 +26,6 @@ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, Command import { cn } from '@/lib/utils'; import { DashboardElement } from '@/components/admin/dashboard/types'; -// 컴팩트 모드 임계값 (픽셀) -const COMPACT_HEIGHT_THRESHOLD = 180; - interface WeatherWidgetProps { element?: DashboardElement; city?: string; @@ -49,29 +45,6 @@ export default function WeatherWidget({ const [error, setError] = useState(null); const [lastUpdated, setLastUpdated] = useState(null); - // 컨테이너 높이 측정을 위한 ref - const containerRef = useRef(null); - const [containerHeight, setContainerHeight] = useState(300); - - // 컴팩트 모드 여부 (element.size.height 또는 실제 컨테이너 높이 기반) - const isCompact = element?.size?.height - ? element.size.height < COMPACT_HEIGHT_THRESHOLD - : containerHeight < COMPACT_HEIGHT_THRESHOLD; - - // 컨테이너 높이 측정 - useEffect(() => { - if (!containerRef.current) return; - - const observer = new ResizeObserver((entries) => { - for (const entry of entries) { - setContainerHeight(entry.contentRect.height); - } - }); - - observer.observe(containerRef.current); - return () => observer.disconnect(); - }, []); - // 표시할 날씨 정보 선택 const [selectedItems, setSelectedItems] = useState([ 'temperature', @@ -350,105 +323,12 @@ export default function WeatherWidget({ ); } - // 날씨 아이콘 렌더링 헬퍼 - const renderWeatherIcon = (weatherMain: string, size: "sm" | "md" = "sm") => { - const iconClass = size === "sm" ? "h-5 w-5" : "h-8 w-8"; - switch (weatherMain.toLowerCase()) { - case 'clear': - return ; - case 'clouds': - return ; - case 'rain': - case 'drizzle': - return ; - case 'snow': - return ; - default: - return ; - } - }; - - // 컴팩트 모드 렌더링 - if (isCompact) { - return ( -
- {/* 컴팩트 헤더 - 도시명, 온도, 날씨 아이콘 한 줄에 표시 */} -
-
- {renderWeatherIcon(weather.weatherMain, "md")} -
-
- - {weather.temperature}°C - - - {weather.weatherDescription} - -
- - - - - - - - - 도시를 찾을 수 없습니다. - - {cities.map((city) => ( - { - handleCityChange(currentValue === selectedCity ? selectedCity : currentValue); - setOpen(false); - }} - > - - {city.label} - - ))} - - - - - -
-
- -
-
- ); - } - - // 일반 모드 렌더링 return ( -
+
{/* 헤더 */}
-

{element?.customTitle || "날씨"}

+

🌤️ {element?.customTitle || "날씨"}

@@ -558,7 +438,22 @@ export default function WeatherWidget({
- {renderWeatherIcon(weather.weatherMain)} + {(() => { + const iconClass = "h-5 w-5"; + switch (weather.weatherMain.toLowerCase()) { + case 'clear': + return ; + case 'clouds': + return ; + case 'rain': + case 'drizzle': + return ; + case 'snow': + return ; + default: + return ; + } + })()}
From 228c497569ef46170813a6ba927eeb1d3ecb2920 Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 13:50:13 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EB=A9=94=EB=89=B4=20=EB=B3=B5?= =?UTF-8?q?=EC=82=AC=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0=20-=20FK=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0=20=EB=B0=8F=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=20=EC=B5=9C=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - numbering_rules FK 에러 해결 (menu_objid NULL 설정) - category_column_mapping FK 에러 해결 (삭제 후 재복사) - 채번규칙 매핑 보완 로직 추가 (화면에서 참조하는 채번규칙을 이름으로 찾아 매핑) - 기존 채번규칙/카테고리 매핑의 menu_objid 갱신 로직 추가 - N+1 쿼리 최적화 (배치 조회/삽입으로 변경) - 메뉴 삭제: N개 쿼리 → 1개 - 화면 할당/플로우 수집: N개 쿼리 → 1개 - 화면 정의 조회: N개 쿼리 → 1개 - 레이아웃 삽입: N개 쿼리 → 화면당 1개 - 채번규칙/카테고리 매핑 업데이트: CASE WHEN 배치 처리 - 예상 성능 개선: ~10배 --- backend-node/src/services/menuCopyService.ts | 2102 ++++++++++++------ 1 file changed, 1475 insertions(+), 627 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index b5266377..bc80569f 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -15,6 +15,7 @@ export interface MenuCopyResult { copiedNumberingRules: number; copiedCategoryMappings: number; copiedTableTypeColumns: number; // 테이블 타입관리 입력타입 설정 + copiedCascadingRelations: number; // 연쇄관계 설정 menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -29,6 +30,7 @@ export interface AdditionalCopyOptions { copyNumberingRules?: boolean; copyCategoryMapping?: boolean; copyTableTypeColumns?: boolean; // 테이블 타입관리 입력타입 설정 + copyCascadingRelation?: boolean; // 연쇄관계 설정 } /** @@ -245,7 +247,9 @@ export class MenuCopyService { typeof screenId === "number" ? screenId : parseInt(screenId); if (!isNaN(numId)) { referenced.push(numId); - logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + logger.debug( + ` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})` + ); } } } @@ -255,7 +259,9 @@ export class MenuCopyService { if (props?.componentConfig?.leftScreenId) { const leftScreenId = props.componentConfig.leftScreenId; const numId = - typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + typeof leftScreenId === "number" + ? leftScreenId + : parseInt(leftScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); @@ -265,7 +271,9 @@ export class MenuCopyService { if (props?.componentConfig?.rightScreenId) { const rightScreenId = props.componentConfig.rightScreenId; const numId = - typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + typeof rightScreenId === "number" + ? rightScreenId + : parseInt(rightScreenId); if (!isNaN(numId) && numId > 0) { referenced.push(numId); logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); @@ -291,18 +299,16 @@ export class MenuCopyService { const screenIds = new Set(); const visited = new Set(); - // 1) 메뉴에 직접 할당된 화면 - for (const menuObjid of menuObjids) { - const assignmentsResult = await client.query<{ screen_id: number }>( - `SELECT DISTINCT screen_id - FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); + // 1) 메뉴에 직접 할당된 화면 - 배치 조회 + const assignmentsResult = await client.query<{ screen_id: number }>( + `SELECT DISTINCT screen_id + FROM screen_menu_assignments + WHERE menu_objid = ANY($1) AND company_code = $2`, + [menuObjids, sourceCompanyCode] + ); - for (const assignment of assignmentsResult.rows) { - screenIds.add(assignment.screen_id); - } + for (const assignment of assignmentsResult.rows) { + screenIds.add(assignment.screen_id); } logger.info(`📌 직접 할당 화면: ${screenIds.size}개`); @@ -357,37 +363,62 @@ export class MenuCopyService { logger.info(`🔄 플로우 수집 시작: ${screenIds.size}개 화면`); const flowIds = new Set(); - const flowDetails: Array<{ flowId: number; flowName: string; screenId: number }> = []; + const flowDetails: Array<{ + flowId: number; + flowName: string; + screenId: number; + }> = []; - for (const screenId of screenIds) { - const layoutsResult = await client.query( - `SELECT properties FROM screen_layouts WHERE screen_id = $1`, - [screenId] - ); + // 배치 조회: 모든 화면의 레이아웃을 한 번에 조회 + const screenIdArray = Array.from(screenIds); + if (screenIdArray.length === 0) { + return flowIds; + } - for (const layout of layoutsResult.rows) { - const props = layout.properties; + const layoutsResult = await client.query< + ScreenLayout & { screen_id: number } + >( + `SELECT screen_id, properties FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIdArray] + ); - // webTypeConfig.dataflowConfig.flowConfig.flowId - const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; - const flowName = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; - - if (flowId && typeof flowId === "number" && flowId > 0) { - if (!flowIds.has(flowId)) { - flowIds.add(flowId); - flowDetails.push({ flowId, flowName, screenId }); - logger.info(` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"`); - } + for (const layout of layoutsResult.rows) { + const props = layout.properties; + const screenId = layout.screen_id; + + // webTypeConfig.dataflowConfig.flowConfig.flowId + const flowId = props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowId; + const flowName = + props?.webTypeConfig?.dataflowConfig?.flowConfig?.flowName || "Unknown"; + + if (flowId && typeof flowId === "number" && flowId > 0) { + if (!flowIds.has(flowId)) { + flowIds.add(flowId); + flowDetails.push({ flowId, flowName, screenId }); + logger.info( + ` 📎 화면 ${screenId}에서 플로우 발견: id=${flowId}, name="${flowName}"` + ); } + } - // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) - const selectedDiagramId = props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; - if (selectedDiagramId && typeof selectedDiagramId === "number" && selectedDiagramId > 0) { - if (!flowIds.has(selectedDiagramId)) { - flowIds.add(selectedDiagramId); - flowDetails.push({ flowId: selectedDiagramId, flowName: "SelectedDiagram", screenId }); - logger.info(` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}`); - } + // selectedDiagramId도 확인 (flowId와 동일할 수 있지만 다를 수도 있음) + const selectedDiagramId = + props?.webTypeConfig?.dataflowConfig?.selectedDiagramId; + if ( + selectedDiagramId && + typeof selectedDiagramId === "number" && + selectedDiagramId > 0 + ) { + if (!flowIds.has(selectedDiagramId)) { + flowIds.add(selectedDiagramId); + flowDetails.push({ + flowId: selectedDiagramId, + flowName: "SelectedDiagram", + screenId, + }); + logger.info( + ` 📎 화면 ${screenId}에서 selectedDiagramId 발견: id=${selectedDiagramId}` + ); } } } @@ -398,7 +429,7 @@ export class MenuCopyService { } else { logger.info(`📭 수집된 플로우 없음 (화면에 플로우 참조가 없음)`); } - + return flowIds; } @@ -460,7 +491,13 @@ export class MenuCopyService { const updated = JSON.parse(JSON.stringify(properties)); // 재귀적으로 객체/배열 탐색 - this.recursiveUpdateReferences(updated, screenIdMap, flowIdMap, "", numberingRuleIdMap); + this.recursiveUpdateReferences( + updated, + screenIdMap, + flowIdMap, + "", + numberingRuleIdMap + ); return updated; } @@ -537,13 +574,24 @@ export class MenuCopyService { } // numberingRuleId 매핑 (문자열) - if (key === "numberingRuleId" && numberingRuleIdMap && typeof value === "string" && value) { + if ( + key === "numberingRuleId" && + numberingRuleIdMap && + typeof value === "string" && + value + ) { const newRuleId = numberingRuleIdMap.get(value); if (newRuleId) { obj[key] = newRuleId; logger.info( ` 🔗 채번규칙 참조 업데이트 (${currentPath}): ${value} → ${newRuleId}` ); + } else { + // 매핑이 없는 채번규칙은 빈 값으로 설정 (다른 회사 채번규칙 참조 방지) + logger.warn( + ` ⚠️ 채번규칙 매핑 없음 (${currentPath}): ${value} → 빈 값으로 설정` + ); + obj[key] = ""; } } @@ -588,11 +636,15 @@ export class MenuCopyService { } const sourceMenu = sourceMenuResult.rows[0]; - const isRootMenu = !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; + const isRootMenu = + !sourceMenu.parent_obj_id || sourceMenu.parent_obj_id === 0; // 2. 대상 회사에 같은 원본에서 복사된 메뉴 찾기 (source_menu_objid로 정확히 매칭) // 최상위/하위 구분 없이 모든 복사본 검색 - const existingMenuResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + const existingMenuResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 @@ -606,8 +658,9 @@ export class MenuCopyService { } const existingMenuObjid = existingMenuResult.rows[0].objid; - const existingIsRoot = !existingMenuResult.rows[0].parent_obj_id || - existingMenuResult.rows[0].parent_obj_id === 0; + const existingIsRoot = + !existingMenuResult.rows[0].parent_obj_id || + existingMenuResult.rows[0].parent_obj_id === 0; logger.info( `🔍 기존 복사본 발견: ${sourceMenu.menu_name_kor} (원본: ${sourceMenuObjid}, 복사본: ${existingMenuObjid}, 최상위: ${existingIsRoot})` @@ -647,10 +700,14 @@ export class MenuCopyService { WHERE screen_id = ANY($1) AND company_code = $2`, [screenIds, targetCompanyCode] ); - const sharedScreenIds = new Set(sharedScreensResult.rows.map(r => r.screen_id)); + const sharedScreenIds = new Set( + sharedScreensResult.rows.map((r) => r.screen_id) + ); // 공유되지 않은 화면만 삭제 - const screensToDelete = screenIds.filter(id => !sharedScreenIds.has(id)); + const screensToDelete = screenIds.filter( + (id) => !sharedScreenIds.has(id) + ); if (screensToDelete.length > 0) { // 레이아웃 삭제 @@ -662,14 +719,16 @@ export class MenuCopyService { // 화면 정의 삭제 await client.query( `DELETE FROM screen_definitions - WHERE screen_id = ANY($1) AND company_code = $2`, + WHERE screen_id = ANY($1) AND company_code = $2`, [screensToDelete, targetCompanyCode] ); logger.info(` ✅ 화면 정의 삭제 완료: ${screensToDelete.length}개`); } if (sharedScreenIds.size > 0) { - logger.info(` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)`); + logger.info( + ` ♻️ 공유 화면 유지: ${sharedScreenIds.size}개 (다른 메뉴에서 사용 중)` + ); } } @@ -679,11 +738,43 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-4. 메뉴 삭제 (역순: 하위 메뉴부터) - // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 - for (let i = existingMenus.length - 1; i >= 0; i--) { - await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ - existingMenus[i].objid, + // 5-4. 채번 규칙의 menu_objid 참조 해제 (삭제하지 않고 연결만 끊음) + // 채번 규칙은 회사의 핵심 업무 데이터이므로 보존해야 함 + const updatedNumberingRules = await client.query( + `UPDATE numbering_rules + SET menu_objid = NULL + WHERE menu_objid = ANY($1) AND company_code = $2 + RETURNING rule_id`, + [existingMenuIds, targetCompanyCode] + ); + if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) { + logger.info( + ` ✅ 채번 규칙 메뉴 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` + ); + } + + // 5-5. 카테고리 매핑 삭제 (menu_objid가 NOT NULL이므로 NULL 설정 불가) + // 카테고리 매핑은 메뉴와 강하게 연결되어 있으므로 함께 삭제 + const deletedCategoryMappings = await client.query( + `DELETE FROM category_column_mapping + WHERE menu_objid = ANY($1) AND company_code = $2 + RETURNING mapping_id`, + [existingMenuIds, targetCompanyCode] + ); + if ( + deletedCategoryMappings.rowCount && + deletedCategoryMappings.rowCount > 0 + ) { + logger.info( + ` ✅ 카테고리 매핑 삭제 완료: ${deletedCategoryMappings.rowCount}개` + ); + } + + // 5-6. 메뉴 삭제 (배치 삭제 - 하위 메뉴부터 삭제를 위해 역순 정렬된 ID 사용) + // 외래키 제약이 해제되었으므로 배치 삭제 가능 + if (existingMenuIds.length > 0) { + await client.query(`DELETE FROM menu_info WHERE objid = ANY($1)`, [ + existingMenuIds, ]); } logger.info(` ✅ 메뉴 삭제 완료: ${existingMenus.length}개`); @@ -754,28 +845,44 @@ export class MenuCopyService { client ); - // === 2.5단계: 채번 규칙 복사 (화면 복사 전에 실행하여 참조 업데이트 가능) === + // 변수 초기화 let copiedCodeCategories = 0; let copiedCodes = 0; let copiedNumberingRules = 0; let copiedCategoryMappings = 0; let copiedTableTypeColumns = 0; + let copiedCascadingRelations = 0; let numberingRuleIdMap = new Map(); const menuObjids = menus.map((m) => m.objid); - // 메뉴 ID 맵을 먼저 생성 (채번 규칙 복사에 필요) + // 메뉴 ID 맵을 먼저 생성 (일관된 ID 사용을 위해) const tempMenuIdMap = new Map(); let tempObjId = await this.getNextMenuObjid(client); for (const menu of menus) { tempMenuIdMap.set(menu.objid, tempObjId++); } + // === 3단계: 메뉴 복사 (외래키 의존성 해결을 위해 먼저 실행) === + // 채번 규칙, 코드 카테고리 등이 menu_info를 참조하므로 메뉴를 먼저 생성 + logger.info("\n📂 [3단계] 메뉴 복사 (외래키 선행 조건)"); + const menuIdMap = await this.copyMenus( + menus, + sourceMenuObjid, + sourceCompanyCode, + targetCompanyCode, + new Map(), // screenIdMap은 아직 없음 (나중에 할당에서 처리) + userId, + client, + tempMenuIdMap + ); + + // === 4단계: 채번 규칙 복사 (메뉴 복사 후, 화면 복사 전) === if (additionalCopyOptions?.copyNumberingRules) { - logger.info("\n📦 [2.5단계] 채번 규칙 복사 (화면 복사 전)"); + logger.info("\n📦 [4단계] 채번 규칙 복사"); const ruleResult = await this.copyNumberingRulesWithMap( menuObjids, - tempMenuIdMap, + menuIdMap, // 실제 생성된 메뉴 ID 사용 targetCompanyCode, userId, client @@ -784,8 +891,60 @@ export class MenuCopyService { numberingRuleIdMap = ruleResult.ruleIdMap; } - // === 3단계: 화면 복사 === - logger.info("\n📄 [3단계] 화면 복사"); + // === 4.1단계: 코드 카테고리 + 코드 복사 === + if (additionalCopyOptions?.copyCodeCategory) { + logger.info("\n📦 [4.1단계] 코드 카테고리 + 코드 복사"); + const codeResult = await this.copyCodeCategoriesAndCodes( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + copiedCodeCategories = codeResult.copiedCategories; + copiedCodes = codeResult.copiedCodes; + } + + // === 4.2단계: 카테고리 매핑 + 값 복사 === + if (additionalCopyOptions?.copyCategoryMapping) { + logger.info("\n📦 [4.2단계] 카테고리 매핑 + 값 복사"); + copiedCategoryMappings = await this.copyCategoryMappingsAndValues( + menuObjids, + menuIdMap, + targetCompanyCode, + userId, + client + ); + } + + // === 4.3단계: 연쇄관계 복사 === + if (additionalCopyOptions?.copyCascadingRelation) { + logger.info("\n📦 [4.3단계] 연쇄관계 복사"); + copiedCascadingRelations = await this.copyCascadingRelations( + sourceCompanyCode, + targetCompanyCode, + menuIdMap, + userId, + client + ); + } + + // === 4.9단계: 화면에서 참조하는 채번규칙 매핑 보완 === + // 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + // 대상 회사에서 같은 이름의 채번규칙으로 매핑 + if (screenIds.size > 0) { + logger.info("\n🔗 [4.9단계] 화면 채번규칙 참조 매핑 보완"); + await this.supplementNumberingRuleMapping( + Array.from(screenIds), + sourceCompanyCode, + targetCompanyCode, + numberingRuleIdMap, + client + ); + } + + // === 5단계: 화면 복사 === + logger.info("\n📄 [5단계] 화면 복사"); const screenIdMap = await this.copyScreens( screenIds, targetCompanyCode, @@ -796,20 +955,8 @@ export class MenuCopyService { numberingRuleIdMap ); - // === 4단계: 메뉴 복사 === - logger.info("\n📂 [4단계] 메뉴 복사"); - const menuIdMap = await this.copyMenus( - menus, - sourceMenuObjid, // 원본 최상위 메뉴 ID 전달 - sourceCompanyCode, - targetCompanyCode, - screenIdMap, - userId, - client - ); - - // === 5단계: 화면-메뉴 할당 === - logger.info("\n🔗 [5단계] 화면-메뉴 할당"); + // === 6단계: 화면-메뉴 할당 === + logger.info("\n🔗 [6단계] 화면-메뉴 할당"); await this.createScreenMenuAssignments( menus, menuIdMap, @@ -818,44 +965,15 @@ export class MenuCopyService { client ); - // === 6단계: 추가 복사 옵션 처리 (코드 카테고리, 카테고리 매핑) === - if (additionalCopyOptions) { - // 6-1. 코드 카테고리 + 코드 복사 - if (additionalCopyOptions.copyCodeCategory) { - logger.info("\n📦 [6-1단계] 코드 카테고리 + 코드 복사"); - const codeResult = await this.copyCodeCategoriesAndCodes( - menuObjids, - menuIdMap, - targetCompanyCode, - userId, - client - ); - copiedCodeCategories = codeResult.copiedCategories; - copiedCodes = codeResult.copiedCodes; - } - - // 6-2. 카테고리 매핑 + 값 복사 - if (additionalCopyOptions.copyCategoryMapping) { - logger.info("\n📦 [6-2단계] 카테고리 매핑 + 값 복사"); - copiedCategoryMappings = await this.copyCategoryMappingsAndValues( - menuObjids, - menuIdMap, - targetCompanyCode, - userId, - client - ); - } - - // 6-3. 테이블 타입관리 입력타입 설정 복사 - if (additionalCopyOptions.copyTableTypeColumns) { - logger.info("\n📦 [6-3단계] 테이블 타입 설정 복사"); - copiedTableTypeColumns = await this.copyTableTypeColumns( - Array.from(screenIdMap.keys()), // 원본 화면 IDs - sourceCompanyCode, - targetCompanyCode, - client - ); - } + // === 7단계: 테이블 타입 설정 복사 === + if (additionalCopyOptions?.copyTableTypeColumns) { + logger.info("\n📦 [7단계] 테이블 타입 설정 복사"); + copiedTableTypeColumns = await this.copyTableTypeColumns( + Array.from(screenIdMap.keys()), + sourceCompanyCode, + targetCompanyCode, + client + ); } // 커밋 @@ -872,6 +990,7 @@ export class MenuCopyService { copiedNumberingRules, copiedCategoryMappings, copiedTableTypeColumns, + copiedCascadingRelations, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -889,6 +1008,7 @@ export class MenuCopyService { - 채번규칙: ${copiedNumberingRules}개 - 카테고리 매핑: ${copiedCategoryMappings}개 - 테이블 타입 설정: ${copiedTableTypeColumns}개 + - 연쇄관계: ${copiedCascadingRelations}개 ============================================ `); @@ -921,144 +1041,228 @@ export class MenuCopyService { return flowIdMap; } + const flowIdArray = Array.from(flowIds); logger.info(`🔄 플로우 복사 중: ${flowIds.size}개`); - logger.info(` 📋 복사 대상 flowIds: [${Array.from(flowIds).join(", ")}]`); + logger.info(` 📋 복사 대상 flowIds: [${flowIdArray.join(", ")}]`); - for (const originalFlowId of flowIds) { - try { - // 1) 원본 flow_definition 조회 - const flowDefResult = await client.query( - `SELECT * FROM flow_definition WHERE id = $1`, - [originalFlowId] + // === 최적화: 배치 조회 === + // 1) 모든 원본 플로우 한 번에 조회 + const allFlowDefsResult = await client.query( + `SELECT * FROM flow_definition WHERE id = ANY($1)`, + [flowIdArray] + ); + const flowDefMap = new Map(allFlowDefsResult.rows.map((f) => [f.id, f])); + + // 2) 대상 회사의 기존 플로우 한 번에 조회 (이름+테이블 기준) + const flowNames = allFlowDefsResult.rows.map((f) => f.name); + const existingFlowsResult = await client.query<{ + id: number; + name: string; + table_name: string; + }>( + `SELECT id, name, table_name FROM flow_definition + WHERE company_code = $1 AND name = ANY($2)`, + [targetCompanyCode, flowNames] + ); + const existingFlowMap = new Map( + existingFlowsResult.rows.map((f) => [`${f.name}|${f.table_name}`, f.id]) + ); + + // 3) 복사가 필요한 플로우 ID 목록 + const flowsToCopy: FlowDefinition[] = []; + + for (const originalFlowId of flowIdArray) { + const flowDef = flowDefMap.get(originalFlowId); + if (!flowDef) { + logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); + continue; + } + + const key = `${flowDef.name}|${flowDef.table_name}`; + const existingId = existingFlowMap.get(key); + + if (existingId) { + flowIdMap.set(originalFlowId, existingId); + logger.info( + ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${existingId} (${flowDef.name})` ); + } else { + flowsToCopy.push(flowDef); + } + } - if (flowDefResult.rows.length === 0) { - logger.warn(`⚠️ 플로우를 찾을 수 없음: id=${originalFlowId}`); - continue; - } + // 4) 새 플로우 복사 (배치 처리) + if (flowsToCopy.length > 0) { + // 배치 INSERT로 플로우 생성 + const flowValues = flowsToCopy + .map( + (f, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8})` + ) + .join(", "); - const flowDef = flowDefResult.rows[0]; - logger.info(` 🔍 원본 플로우 발견: id=${originalFlowId}, name="${flowDef.name}", table="${flowDef.table_name}", company="${flowDef.company_code}"`); + const flowParams = flowsToCopy.flatMap((f) => [ + f.name, + f.description, + f.table_name, + f.is_active, + targetCompanyCode, + userId, + f.db_source_type, + f.db_connection_id, + ]); - // 2) 대상 회사에 이미 같은 이름+테이블의 플로우가 있는지 확인 - const existingFlowResult = await client.query<{ id: number }>( - `SELECT id FROM flow_definition - WHERE company_code = $1 AND name = $2 AND table_name = $3 - LIMIT 1`, - [targetCompanyCode, flowDef.name, flowDef.table_name] - ); - - let newFlowId: number; - - if (existingFlowResult.rows.length > 0) { - // 기존 플로우가 있으면 재사용 - newFlowId = existingFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); - logger.info( - ` ♻️ 기존 플로우 재사용: ${originalFlowId} → ${newFlowId} (${flowDef.name})` - ); - continue; // 스텝/연결 복사 생략 (기존 것 사용) - } - - // 3) 새 flow_definition 복사 - const newFlowResult = await client.query<{ id: number }>( - `INSERT INTO flow_definition ( + const newFlowsResult = await client.query<{ id: number }>( + `INSERT INTO flow_definition ( name, description, table_name, is_active, company_code, created_by, db_source_type, db_connection_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ) VALUES ${flowValues} RETURNING id`, - [ - flowDef.name, - flowDef.description, - flowDef.table_name, - flowDef.is_active, - targetCompanyCode, // 새 회사 코드 - userId, - flowDef.db_source_type, - flowDef.db_connection_id, - ] - ); - - newFlowId = newFlowResult.rows[0].id; - flowIdMap.set(originalFlowId, newFlowId); + flowParams + ); + // 새 플로우 ID 매핑 + flowsToCopy.forEach((flowDef, index) => { + const newFlowId = newFlowsResult.rows[index].id; + flowIdMap.set(flowDef.id, newFlowId); logger.info( - ` ✅ 플로우 신규 복사: ${originalFlowId} → ${newFlowId} (${flowDef.name})` + ` ✅ 플로우 신규 복사: ${flowDef.id} → ${newFlowId} (${flowDef.name})` ); + }); - // 3) flow_step 복사 - const stepsResult = await client.query( - `SELECT * FROM flow_step WHERE flow_definition_id = $1 ORDER BY step_order`, - [originalFlowId] - ); + // 5) 스텝 및 연결 복사 (복사된 플로우만) + const originalFlowIdsToCopy = flowsToCopy.map((f) => f.id); + // 모든 스텝 한 번에 조회 + const allStepsResult = await client.query( + `SELECT * FROM flow_step WHERE flow_definition_id = ANY($1) ORDER BY flow_definition_id, step_order`, + [originalFlowIdsToCopy] + ); + + // 플로우별 스텝 그룹핑 + const stepsByFlow = new Map(); + for (const step of allStepsResult.rows) { + if (!stepsByFlow.has(step.flow_definition_id)) { + stepsByFlow.set(step.flow_definition_id, []); + } + stepsByFlow.get(step.flow_definition_id)!.push(step); + } + + // 스텝 복사 (플로우별) + const allStepIdMaps = new Map>(); // originalFlowId -> stepIdMap + + for (const originalFlowId of originalFlowIdsToCopy) { + const newFlowId = flowIdMap.get(originalFlowId)!; + const steps = stepsByFlow.get(originalFlowId) || []; const stepIdMap = new Map(); - for (const step of stepsResult.rows) { - const newStepResult = await client.query<{ id: number }>( + if (steps.length > 0) { + // 배치 INSERT로 스텝 생성 + const stepValues = steps + .map( + (_, i) => + `($${i * 17 + 1}, $${i * 17 + 2}, $${i * 17 + 3}, $${i * 17 + 4}, $${i * 17 + 5}, $${i * 17 + 6}, $${i * 17 + 7}, $${i * 17 + 8}, $${i * 17 + 9}, $${i * 17 + 10}, $${i * 17 + 11}, $${i * 17 + 12}, $${i * 17 + 13}, $${i * 17 + 14}, $${i * 17 + 15}, $${i * 17 + 16}, $${i * 17 + 17})` + ) + .join(", "); + + const stepParams = steps.flatMap((s) => [ + newFlowId, + s.step_name, + s.step_order, + s.condition_json, + s.color, + s.position_x, + s.position_y, + s.table_name, + s.move_type, + s.status_column, + s.status_value, + s.target_table, + s.field_mappings, + s.required_fields, + s.integration_type, + s.integration_config, + s.display_config, + ]); + + const newStepsResult = await client.query<{ id: number }>( `INSERT INTO flow_step ( flow_definition_id, step_name, step_order, condition_json, color, position_x, position_y, table_name, move_type, status_column, status_value, target_table, field_mappings, required_fields, integration_type, integration_config, display_config - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) + ) VALUES ${stepValues} RETURNING id`, - [ - newFlowId, // 새 플로우 ID - step.step_name, - step.step_order, - step.condition_json, - step.color, - step.position_x, - step.position_y, - step.table_name, - step.move_type, - step.status_column, - step.status_value, - step.target_table, - step.field_mappings, - step.required_fields, - step.integration_type, - step.integration_config, - step.display_config, - ] + stepParams ); - const newStepId = newStepResult.rows[0].id; - stepIdMap.set(step.id, newStepId); + steps.forEach((step, index) => { + stepIdMap.set(step.id, newStepsResult.rows[index].id); + }); + + logger.info( + ` ↳ 플로우 ${originalFlowId}: 스텝 ${steps.length}개 복사` + ); } - logger.info(` ↳ 스텝 복사: ${stepIdMap.size}개`); + allStepIdMaps.set(originalFlowId, stepIdMap); + } - // 4) flow_step_connection 복사 (스텝 ID 재매핑) - const connectionsResult = await client.query( - `SELECT * FROM flow_step_connection WHERE flow_definition_id = $1`, - [originalFlowId] + // 모든 연결 한 번에 조회 + const allConnectionsResult = await client.query( + `SELECT * FROM flow_step_connection WHERE flow_definition_id = ANY($1)`, + [originalFlowIdsToCopy] + ); + + // 연결 복사 (배치 INSERT) + const connectionsToInsert: { + newFlowId: number; + newFromStepId: number; + newToStepId: number; + label: string; + }[] = []; + + for (const conn of allConnectionsResult.rows) { + const stepIdMap = allStepIdMaps.get(conn.flow_definition_id); + if (!stepIdMap) continue; + + const newFromStepId = stepIdMap.get(conn.from_step_id); + const newToStepId = stepIdMap.get(conn.to_step_id); + const newFlowId = flowIdMap.get(conn.flow_definition_id); + + if (newFromStepId && newToStepId && newFlowId) { + connectionsToInsert.push({ + newFlowId, + newFromStepId, + newToStepId, + label: conn.label || "", + }); + } + } + + if (connectionsToInsert.length > 0) { + const connValues = connectionsToInsert + .map( + (_, i) => + `($${i * 4 + 1}, $${i * 4 + 2}, $${i * 4 + 3}, $${i * 4 + 4})` + ) + .join(", "); + + const connParams = connectionsToInsert.flatMap((c) => [ + c.newFlowId, + c.newFromStepId, + c.newToStepId, + c.label, + ]); + + await client.query( + `INSERT INTO flow_step_connection ( + flow_definition_id, from_step_id, to_step_id, label + ) VALUES ${connValues}`, + connParams ); - for (const conn of connectionsResult.rows) { - const newFromStepId = stepIdMap.get(conn.from_step_id); - const newToStepId = stepIdMap.get(conn.to_step_id); - - if (!newFromStepId || !newToStepId) { - logger.warn( - `⚠️ 스텝 ID 매핑 실패: ${conn.from_step_id} → ${conn.to_step_id}` - ); - continue; - } - - await client.query( - `INSERT INTO flow_step_connection ( - flow_definition_id, from_step_id, to_step_id, label - ) VALUES ($1, $2, $3, $4)`, - [newFlowId, newFromStepId, newToStepId, conn.label] - ); - } - - logger.info(` ↳ 연결 복사: ${connectionsResult.rows.length}개`); - } catch (error: any) { - logger.error(`❌ 플로우 복사 실패: id=${originalFlowId}`, error); - throw error; + logger.info(` ↳ 연결 ${connectionsToInsert.length}개 복사`); } } @@ -1093,6 +1297,37 @@ export class MenuCopyService { logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); + // === 0단계: 원본 화면 정의 배치 조회 === + const screenIdArray = Array.from(screenIds); + const allScreenDefsResult = await client.query( + `SELECT * FROM screen_definitions WHERE screen_id = ANY($1)`, + [screenIdArray] + ); + const screenDefMap = new Map(); + for (const def of allScreenDefsResult.rows) { + screenDefMap.set(def.screen_id, def); + } + + // 대상 회사의 기존 복사본 배치 조회 (source_screen_id 기준) + const existingCopiesResult = await client.query<{ + screen_id: number; + screen_name: string; + source_screen_id: number; + updated_date: Date; + }>( + `SELECT screen_id, screen_name, source_screen_id, updated_date + FROM screen_definitions + WHERE source_screen_id = ANY($1) AND company_code = $2 AND deleted_date IS NULL`, + [screenIdArray, targetCompanyCode] + ); + const existingCopyMap = new Map< + number, + { screen_id: number; screen_name: string; updated_date: Date } + >(); + for (const copy of existingCopiesResult.rows) { + existingCopyMap.set(copy.source_screen_id, copy); + } + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; @@ -1103,35 +1338,20 @@ export class MenuCopyService { for (const originalScreenId of screenIds) { try { - // 1) 원본 screen_definitions 조회 - const screenDefResult = await client.query( - `SELECT * FROM screen_definitions WHERE screen_id = $1`, - [originalScreenId] - ); + // 1) 원본 screen_definitions 조회 (캐시에서) + const screenDef = screenDefMap.get(originalScreenId); - if (screenDefResult.rows.length === 0) { + if (!screenDef) { logger.warn(`⚠️ 화면을 찾을 수 없음: screen_id=${originalScreenId}`); continue; } - const screenDef = screenDefResult.rows[0]; - - // 2) 기존 복사본 찾기: source_screen_id로 검색 - let existingCopyResult = await client.query<{ - screen_id: number; - screen_name: string; - updated_date: Date; - }>( - `SELECT screen_id, screen_name, updated_date - FROM screen_definitions - WHERE source_screen_id = $1 AND company_code = $2 AND deleted_date IS NULL - LIMIT 1`, - [originalScreenId, targetCompanyCode] - ); + // 2) 기존 복사본 찾기: 캐시에서 조회 (source_screen_id 기준) + let existingCopy = existingCopyMap.get(originalScreenId); // 2-1) source_screen_id가 없는 기존 복사본 (이름 + 테이블로 검색) - 호환성 유지 - if (existingCopyResult.rows.length === 0 && screenDef.screen_name) { - existingCopyResult = await client.query<{ + if (!existingCopy && screenDef.screen_name) { + const legacyCopyResult = await client.query<{ screen_id: number; screen_name: string; updated_date: Date; @@ -1147,14 +1367,15 @@ export class MenuCopyService { [screenDef.screen_name, screenDef.table_name, targetCompanyCode] ); - if (existingCopyResult.rows.length > 0) { + if (legacyCopyResult.rows.length > 0) { + existingCopy = legacyCopyResult.rows[0]; // 기존 복사본에 source_screen_id 업데이트 (마이그레이션) await client.query( `UPDATE screen_definitions SET source_screen_id = $1 WHERE screen_id = $2`, - [originalScreenId, existingCopyResult.rows[0].screen_id] + [originalScreenId, existingCopy.screen_id] ); logger.info( - ` 📝 기존 화면에 source_screen_id 추가: ${existingCopyResult.rows[0].screen_id} ← ${originalScreenId}` + ` 📝 기존 화면에 source_screen_id 추가: ${existingCopy.screen_id} ← ${originalScreenId}` ); } } @@ -1175,10 +1396,9 @@ export class MenuCopyService { } } - if (existingCopyResult.rows.length > 0) { + if (existingCopy) { // === 기존 복사본이 있는 경우: 업데이트 === - const existingScreen = existingCopyResult.rows[0]; - const existingScreenId = existingScreen.screen_id; + const existingScreenId = existingCopy.screen_id; // 원본 레이아웃 조회 const sourceLayoutsResult = await client.query( @@ -1292,10 +1512,7 @@ export class MenuCopyService { }); } } catch (error: any) { - logger.error( - `❌ 화면 처리 실패: screen_id=${originalScreenId}`, - error - ); + logger.error(`❌ 화면 처리 실패: screen_id=${originalScreenId}`, error); throw error; } } @@ -1329,36 +1546,39 @@ export class MenuCopyService { // component_id 매핑 생성 (원본 → 새 ID) const componentIdMap = new Map(); - for (const layout of layoutsResult.rows) { - const newComponentId = `comp_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const timestamp = Date.now(); + layoutsResult.rows.forEach((layout, idx) => { + const newComponentId = `comp_${timestamp}_${idx}_${Math.random().toString(36).substr(2, 5)}`; componentIdMap.set(layout.component_id, newComponentId); - } + }); - // 레이아웃 삽입 - for (const layout of layoutsResult.rows) { - const newComponentId = componentIdMap.get(layout.component_id)!; + // 레이아웃 배치 삽입 준비 + if (layoutsResult.rows.length > 0) { + const layoutValues: string[] = []; + const layoutParams: any[] = []; + let paramIdx = 1; - const newParentId = layout.parent_id - ? componentIdMap.get(layout.parent_id) || layout.parent_id - : null; - const newZoneId = layout.zone_id - ? componentIdMap.get(layout.zone_id) || layout.zone_id - : null; + for (const layout of layoutsResult.rows) { + const newComponentId = componentIdMap.get(layout.component_id)!; - const updatedProperties = this.updateReferencesInProperties( - layout.properties, - screenIdMap, - flowIdMap, - numberingRuleIdMap - ); + const newParentId = layout.parent_id + ? componentIdMap.get(layout.parent_id) || layout.parent_id + : null; + const newZoneId = layout.zone_id + ? componentIdMap.get(layout.zone_id) || layout.zone_id + : null; - await client.query( - `INSERT INTO screen_layouts ( - screen_id, component_type, component_id, parent_id, - position_x, position_y, width, height, properties, - display_order, layout_type, layout_config, zones_config, zone_id - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)`, - [ + const updatedProperties = this.updateReferencesInProperties( + layout.properties, + screenIdMap, + flowIdMap, + numberingRuleIdMap + ); + + layoutValues.push( + `($${paramIdx}, $${paramIdx + 1}, $${paramIdx + 2}, $${paramIdx + 3}, $${paramIdx + 4}, $${paramIdx + 5}, $${paramIdx + 6}, $${paramIdx + 7}, $${paramIdx + 8}, $${paramIdx + 9}, $${paramIdx + 10}, $${paramIdx + 11}, $${paramIdx + 12}, $${paramIdx + 13})` + ); + layoutParams.push( targetScreenId, layout.component_type, newComponentId, @@ -1372,8 +1592,19 @@ export class MenuCopyService { layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, - ] + newZoneId + ); + paramIdx += 14; + } + + // 배치 INSERT + await client.query( + `INSERT INTO screen_layouts ( + screen_id, component_type, component_id, parent_id, + position_x, position_y, width, height, properties, + display_order, layout_type, layout_config, zones_config, zone_id + ) VALUES ${layoutValues.join(", ")}`, + layoutParams ); } @@ -1533,7 +1764,7 @@ export class MenuCopyService { const parentMenu = parentMenuResult.rows[0]; // 대상 회사에서 같은 이름 + 같은 원본 회사에서 복사된 메뉴 찾기 - // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, + // source_menu_objid가 있는 메뉴(복사된 메뉴)만 대상으로, // 해당 source_menu_objid의 원본 메뉴가 같은 회사(sourceCompanyCode)에 속하는지 확인 const sameNameResult = await client.query<{ objid: number }>( `SELECT m.objid FROM menu_info m @@ -1569,7 +1800,8 @@ export class MenuCopyService { targetCompanyCode: string, screenIdMap: Map, userId: string, - client: PoolClient + client: PoolClient, + preAllocatedMenuIdMap?: Map // 미리 할당된 메뉴 ID 맵 (옵션 데이터 복사에 사용된 경우) ): Promise> { const menuIdMap = new Map(); @@ -1587,7 +1819,10 @@ export class MenuCopyService { try { // 0. 이미 복사된 메뉴가 있는지 확인 (고아 메뉴 재연결용) // 1차: source_menu_objid로 검색 - let existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + let existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE source_menu_objid = $1 AND company_code = $2 LIMIT 1`, @@ -1596,7 +1831,10 @@ export class MenuCopyService { // 2차: source_menu_objid가 없는 기존 복사본 (이름 + 메뉴타입으로 검색) - 호환성 유지 if (existingCopyResult.rows.length === 0 && menu.menu_name_kor) { - existingCopyResult = await client.query<{ objid: number; parent_obj_id: number | null }>( + existingCopyResult = await client.query<{ + objid: number; + parent_obj_id: number | null; + }>( `SELECT objid, parent_obj_id FROM menu_info WHERE menu_name_kor = $1 AND company_code = $2 @@ -1676,7 +1914,10 @@ export class MenuCopyService { } // === 신규 메뉴 복사 === - const newObjId = await this.getNextMenuObjid(client); + // 미리 할당된 ID가 있으면 사용, 없으면 새로 생성 + const newObjId = + preAllocatedMenuIdMap?.get(menu.objid) ?? + (await this.getNextMenuObjid(client)); // source_menu_objid 저장: 모든 복사된 메뉴에 원본 ID 저장 (추적용) const sourceMenuObjid = menu.objid; @@ -1733,7 +1974,7 @@ export class MenuCopyService { } /** - * 화면-메뉴 할당 + * 화면-메뉴 할당 (최적화: 배치 조회/삽입) */ private async createScreenMenuAssignments( menus: Menu[], @@ -1744,57 +1985,92 @@ export class MenuCopyService { ): Promise { logger.info(`🔗 화면-메뉴 할당 중...`); - let assignmentCount = 0; + if (menus.length === 0) { + return; + } - for (const menu of menus) { - const newMenuObjid = menuIdMap.get(menu.objid); - if (!newMenuObjid) continue; + // === 최적화: 배치 조회 === + // 1. 모든 원본 메뉴의 화면 할당 한 번에 조회 + const menuObjids = menus.map((m) => m.objid); + const companyCodes = [...new Set(menus.map((m) => m.company_code))]; - // 원본 메뉴에 할당된 화면 조회 - const assignmentsResult = await client.query<{ - screen_id: number; - display_order: number; - is_active: string; - }>( - `SELECT screen_id, display_order, is_active + const allAssignmentsResult = await client.query<{ + menu_objid: number; + screen_id: number; + display_order: number; + is_active: string; + }>( + `SELECT menu_objid, screen_id, display_order, is_active FROM screen_menu_assignments - WHERE menu_objid = $1 AND company_code = $2`, - [menu.objid, menu.company_code] - ); + WHERE menu_objid = ANY($1) AND company_code = ANY($2)`, + [menuObjids, companyCodes] + ); - for (const assignment of assignmentsResult.rows) { - const newScreenId = screenIdMap.get(assignment.screen_id); + if (allAssignmentsResult.rows.length === 0) { + logger.info(` 📭 화면-메뉴 할당 없음`); + return; + } + + // 2. 유효한 할당만 필터링 + const validAssignments: Array<{ + newScreenId: number; + newMenuObjid: number; + displayOrder: number; + isActive: string; + }> = []; + + for (const assignment of allAssignmentsResult.rows) { + const newMenuObjid = menuIdMap.get(assignment.menu_objid); + const newScreenId = screenIdMap.get(assignment.screen_id); + + if (!newMenuObjid || !newScreenId) { if (!newScreenId) { logger.warn( `⚠️ 화면 ID 매핑 없음: screen_id=${assignment.screen_id}` ); - continue; } - - // 새 할당 생성 - await client.query( - `INSERT INTO screen_menu_assignments ( - screen_id, menu_objid, company_code, display_order, is_active, created_by - ) VALUES ($1, $2, $3, $4, $5, $6)`, - [ - newScreenId, // 재매핑 - newMenuObjid, // 재매핑 - targetCompanyCode, - assignment.display_order, - assignment.is_active, - "system", - ] - ); - - assignmentCount++; + continue; } + + validAssignments.push({ + newScreenId, + newMenuObjid, + displayOrder: assignment.display_order, + isActive: assignment.is_active, + }); } - logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); + // 3. 배치 INSERT + if (validAssignments.length > 0) { + const assignmentValues = validAssignments + .map( + (_, i) => + `($${i * 6 + 1}, $${i * 6 + 2}, $${i * 6 + 3}, $${i * 6 + 4}, $${i * 6 + 5}, $${i * 6 + 6})` + ) + .join(", "); + + const assignmentParams = validAssignments.flatMap((a) => [ + a.newScreenId, + a.newMenuObjid, + targetCompanyCode, + a.displayOrder, + a.isActive, + "system", + ]); + + await client.query( + `INSERT INTO screen_menu_assignments ( + screen_id, menu_objid, company_code, display_order, is_active, created_by + ) VALUES ${assignmentValues}`, + assignmentParams + ); + } + + logger.info(`✅ 화면-메뉴 할당 완료: ${validAssignments.length}개`); } /** - * 코드 카테고리 + 코드 복사 + * 코드 카테고리 + 코드 복사 (최적화: 배치 조회/삽입) */ private async copyCodeCategoriesAndCodes( menuObjids: number[], @@ -1806,100 +2082,240 @@ export class MenuCopyService { let copiedCategories = 0; let copiedCodes = 0; - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; - - // 1. 코드 카테고리 조회 - const categoriesResult = await client.query( - `SELECT * FROM code_category WHERE menu_objid = $1`, - [menuObjid] - ); - - for (const category of categoriesResult.rows) { - // 대상 회사에 같은 category_code가 이미 있는지 확인 - const existingCategory = await client.query( - `SELECT category_code FROM code_category - WHERE category_code = $1 AND company_code = $2`, - [category.category_code, targetCompanyCode] - ); - - if (existingCategory.rows.length > 0) { - logger.info(` ♻️ 코드 카테고리 이미 존재 (스킵): ${category.category_code}`); - continue; - } - - // 카테고리 복사 - await client.query( - `INSERT INTO code_category ( - category_code, category_name, category_name_eng, description, - sort_order, is_active, created_date, created_by, company_code, menu_objid - ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7, $8, $9)`, - [ - category.category_code, - category.category_name, - category.category_name_eng, - category.description, - category.sort_order, - category.is_active, - userId, - targetCompanyCode, - newMenuObjid, - ] - ); - copiedCategories++; - logger.info(` ✅ 코드 카테고리 복사: ${category.category_code}`); - - // 2. 해당 카테고리의 코드 조회 및 복사 - const codesResult = await client.query( - `SELECT * FROM code_info - WHERE code_category = $1 AND menu_objid = $2`, - [category.category_code, menuObjid] - ); - - for (const code of codesResult.rows) { - // 대상 회사에 같은 code_value가 이미 있는지 확인 - const existingCode = await client.query( - `SELECT code_value FROM code_info - WHERE code_category = $1 AND code_value = $2 AND company_code = $3`, - [category.category_code, code.code_value, targetCompanyCode] - ); - - if (existingCode.rows.length > 0) { - logger.info(` ♻️ 코드 이미 존재 (스킵): ${code.code_value}`); - continue; - } - - await client.query( - `INSERT INTO code_info ( - code_category, code_value, code_name, code_name_eng, description, - sort_order, is_active, created_date, created_by, company_code, menu_objid - ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), $8, $9, $10)`, - [ - category.category_code, - code.code_value, - code.code_name, - code.code_name_eng, - code.description, - code.sort_order, - code.is_active, - userId, - targetCompanyCode, - newMenuObjid, - ] - ); - copiedCodes++; - } - logger.info(` ↳ 코드 ${codesResult.rows.length}개 복사 완료`); - } + if (menuObjids.length === 0) { + return { copiedCategories, copiedCodes }; } - logger.info(`✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개`); + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 한 번에 조회 + const allCategoriesResult = await client.query( + `SELECT * FROM code_category WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allCategoriesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 카테고리 없음`); + return { copiedCategories, copiedCodes }; + } + + // 2. 대상 회사에 이미 존재하는 카테고리 한 번에 조회 + const categoryCodes = allCategoriesResult.rows.map((c) => c.category_code); + const existingCategoriesResult = await client.query( + `SELECT category_code FROM code_category + WHERE category_code = ANY($1) AND company_code = $2`, + [categoryCodes, targetCompanyCode] + ); + const existingCategoryCodes = new Set( + existingCategoriesResult.rows.map((c) => c.category_code) + ); + + // 3. 복사할 카테고리 필터링 + const categoriesToCopy = allCategoriesResult.rows.filter( + (c) => !existingCategoryCodes.has(c.category_code) + ); + + // 4. 배치 INSERT로 카테고리 복사 + if (categoriesToCopy.length > 0) { + const categoryValues = categoriesToCopy + .map( + (_, i) => + `($${i * 9 + 1}, $${i * 9 + 2}, $${i * 9 + 3}, $${i * 9 + 4}, $${i * 9 + 5}, $${i * 9 + 6}, NOW(), $${i * 9 + 7}, $${i * 9 + 8}, $${i * 9 + 9})` + ) + .join(", "); + + const categoryParams = categoriesToCopy.flatMap((c) => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.category_code, + c.category_name, + c.category_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + await client.query( + `INSERT INTO code_category ( + category_code, category_name, category_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${categoryValues}`, + categoryParams + ); + + copiedCategories = categoriesToCopy.length; + logger.info(` ✅ 코드 카테고리 ${copiedCategories}개 복사`); + } + + // 5. 모든 원본 코드 한 번에 조회 + const allCodesResult = await client.query( + `SELECT * FROM code_info WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allCodesResult.rows.length === 0) { + logger.info(` 📭 복사할 코드 없음`); + return { copiedCategories, copiedCodes }; + } + + // 6. 대상 회사에 이미 존재하는 코드 한 번에 조회 + const existingCodesResult = await client.query( + `SELECT code_category, code_value FROM code_info + WHERE menu_objid = ANY($1) AND company_code = $2`, + [Array.from(menuIdMap.values()), targetCompanyCode] + ); + const existingCodeKeys = new Set( + existingCodesResult.rows.map((c) => `${c.code_category}|${c.code_value}`) + ); + + // 7. 복사할 코드 필터링 + const codesToCopy = allCodesResult.rows.filter( + (c) => !existingCodeKeys.has(`${c.code_category}|${c.code_value}`) + ); + + // 8. 배치 INSERT로 코드 복사 + if (codesToCopy.length > 0) { + const codeValues = codesToCopy + .map( + (_, i) => + `($${i * 10 + 1}, $${i * 10 + 2}, $${i * 10 + 3}, $${i * 10 + 4}, $${i * 10 + 5}, $${i * 10 + 6}, $${i * 10 + 7}, NOW(), $${i * 10 + 8}, $${i * 10 + 9}, $${i * 10 + 10})` + ) + .join(", "); + + const codeParams = codesToCopy.flatMap((c) => { + const newMenuObjid = menuIdMap.get(c.menu_objid); + return [ + c.code_category, + c.code_value, + c.code_name, + c.code_name_eng, + c.description, + c.sort_order, + c.is_active, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + await client.query( + `INSERT INTO code_info ( + code_category, code_value, code_name, code_name_eng, description, + sort_order, is_active, created_date, created_by, company_code, menu_objid + ) VALUES ${codeValues}`, + codeParams + ); + + copiedCodes = codesToCopy.length; + logger.info(` ✅ 코드 ${copiedCodes}개 복사`); + } + + logger.info( + `✅ 코드 카테고리 + 코드 복사 완료: 카테고리 ${copiedCategories}개, 코드 ${copiedCodes}개` + ); return { copiedCategories, copiedCodes }; } /** - * 채번 규칙 복사 (ID 매핑 반환 버전) + * 화면에서 참조하는 채번규칙 매핑 보완 + * 화면 properties에서 참조하는 채번규칙 중 아직 매핑되지 않은 것들을 + * 대상 회사에서 같은 이름(rule_name)의 채번규칙으로 매핑 + */ + private async supplementNumberingRuleMapping( + screenIds: number[], + sourceCompanyCode: string, + targetCompanyCode: string, + numberingRuleIdMap: Map, + client: PoolClient + ): Promise { + if (screenIds.length === 0) return; + + // 1. 화면 레이아웃에서 모든 채번규칙 ID 추출 + const layoutsResult = await client.query( + `SELECT properties::text as props FROM screen_layouts WHERE screen_id = ANY($1)`, + [screenIds] + ); + + const referencedRuleIds = new Set(); + const ruleIdRegex = /"numberingRuleId"\s*:\s*"([^"]+)"/g; + + for (const row of layoutsResult.rows) { + if (!row.props) continue; + let match; + while ((match = ruleIdRegex.exec(row.props)) !== null) { + const ruleId = match[1]; + // 이미 매핑된 것은 제외 + if (ruleId && !numberingRuleIdMap.has(ruleId)) { + referencedRuleIds.add(ruleId); + } + } + } + + if (referencedRuleIds.size === 0) { + logger.info(` 📭 추가 매핑 필요 없음`); + return; + } + + logger.info(` 🔍 매핑 필요한 채번규칙: ${referencedRuleIds.size}개`); + + // 2. 원본 채번규칙 정보 조회 (rule_name으로 대상 회사에서 찾기 위해) + const sourceRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_id = ANY($1)`, + [Array.from(referencedRuleIds)] + ); + + if (sourceRulesResult.rows.length === 0) { + logger.warn( + ` ⚠️ 원본 채번규칙 조회 실패: ${Array.from(referencedRuleIds).join(", ")}` + ); + return; + } + + // 3. 대상 회사에서 같은 이름의 채번규칙 찾기 + const ruleNames = sourceRulesResult.rows.map((r) => r.rule_name); + const targetRulesResult = await client.query( + `SELECT rule_id, rule_name, table_name FROM numbering_rules + WHERE rule_name = ANY($1) AND company_code = $2`, + [ruleNames, targetCompanyCode] + ); + + // rule_name -> target_rule_id 매핑 + const targetRulesByName = new Map(); + for (const r of targetRulesResult.rows) { + // 같은 이름이 여러 개일 수 있으므로 첫 번째만 사용 + if (!targetRulesByName.has(r.rule_name)) { + targetRulesByName.set(r.rule_name, r.rule_id); + } + } + + // 4. 매핑 추가 + let mappedCount = 0; + for (const sourceRule of sourceRulesResult.rows) { + const targetRuleId = targetRulesByName.get(sourceRule.rule_name); + if (targetRuleId) { + numberingRuleIdMap.set(sourceRule.rule_id, targetRuleId); + logger.info( + ` ✅ 채번규칙 매핑 추가: ${sourceRule.rule_id} (${sourceRule.rule_name}) → ${targetRuleId}` + ); + mappedCount++; + } else { + logger.warn( + ` ⚠️ 대상 회사에 같은 이름의 채번규칙 없음: ${sourceRule.rule_name}` + ); + } + } + + logger.info( + ` ✅ 채번규칙 매핑 보완 완료: ${mappedCount}/${referencedRuleIds.size}개` + ); + } + + /** + * 채번 규칙 복사 (최적화: 배치 조회/삽입) * 화면 복사 전에 호출되어 numberingRuleId 참조 업데이트에 사용됨 */ private async copyNumberingRulesWithMap( @@ -1912,99 +2328,180 @@ export class MenuCopyService { let copiedCount = 0; const ruleIdMap = new Map(); - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return { copiedCount, ruleIdMap }; + } - // 채번 규칙 조회 - const rulesResult = await client.query( - `SELECT * FROM numbering_rules WHERE menu_objid = $1`, - [menuObjid] - ); + // === 최적화: 배치 조회 === + // 1. 모든 원본 채번 규칙 한 번에 조회 + const allRulesResult = await client.query( + `SELECT * FROM numbering_rules WHERE menu_objid = ANY($1)`, + [menuObjids] + ); - for (const rule of rulesResult.rows) { - // 대상 회사에 같은 rule_id가 이미 있는지 확인 - const existingRule = await client.query( - `SELECT rule_id FROM numbering_rules - WHERE rule_id = $1 AND company_code = $2`, - [rule.rule_id, targetCompanyCode] - ); + if (allRulesResult.rows.length === 0) { + logger.info(` 📭 복사할 채번 규칙 없음`); + return { copiedCount, ruleIdMap }; + } - if (existingRule.rows.length > 0) { - logger.info(` ♻️ 채번규칙 이미 존재 (스킵): ${rule.rule_id}`); - // 기존 rule_id도 매핑에 추가 (동일한 ID 유지) - ruleIdMap.set(rule.rule_id, rule.rule_id); - continue; + // 2. 대상 회사에 이미 존재하는 채번 규칙 한 번에 조회 + const ruleIds = allRulesResult.rows.map((r) => r.rule_id); + const existingRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE rule_id = ANY($1) AND company_code = $2`, + [ruleIds, targetCompanyCode] + ); + const existingRuleIds = new Set( + existingRulesResult.rows.map((r) => r.rule_id) + ); + + // 3. 복사할 규칙과 스킵할 규칙 분류 + const rulesToCopy: any[] = []; + const originalToNewRuleMap: Array<{ original: string; new: string }> = []; + + // 기존 규칙 중 menu_objid 업데이트가 필요한 규칙들 + const rulesToUpdate: Array<{ ruleId: string; newMenuObjid: number }> = []; + + for (const rule of allRulesResult.rows) { + if (existingRuleIds.has(rule.rule_id)) { + // 기존 규칙은 동일한 ID로 매핑 + ruleIdMap.set(rule.rule_id, rule.rule_id); + + // 새 메뉴 ID로 연결 업데이트 필요 + const newMenuObjid = menuIdMap.get(rule.menu_objid); + if (newMenuObjid) { + rulesToUpdate.push({ ruleId: rule.rule_id, newMenuObjid }); } - - // 새 rule_id 생성 (회사코드_원본rule_id에서 기존 접두사 제거) - const originalSuffix = rule.rule_id.includes('_') - ? rule.rule_id.replace(/^[^_]*_/, '') + logger.info( + ` ♻️ 채번규칙 이미 존재 (메뉴 연결 갱신): ${rule.rule_id}` + ); + } else { + // 새 rule_id 생성 + const originalSuffix = rule.rule_id.includes("_") + ? rule.rule_id.replace(/^[^_]*_/, "") : rule.rule_id; const newRuleId = `${targetCompanyCode}_${originalSuffix}`; - // 매핑 저장 (원본 rule_id → 새 rule_id) ruleIdMap.set(rule.rule_id, newRuleId); - - // 채번 규칙 복사 - await client.query( - `INSERT INTO numbering_rules ( - rule_id, rule_name, description, separator, reset_period, - current_sequence, table_name, column_name, company_code, - created_at, created_by, menu_objid, scope_type, last_generated_date - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), $10, $11, $12, $13)`, - [ - newRuleId, - rule.rule_name, - rule.description, - rule.separator, - rule.reset_period, - 0, // 시퀀스는 0부터 시작 - rule.table_name, - rule.column_name, - targetCompanyCode, - userId, - newMenuObjid, - rule.scope_type, - null, // 마지막 생성일은 null로 초기화 - ] - ); - copiedCount++; - logger.info(` ✅ 채번규칙 복사: ${rule.rule_id} → ${newRuleId}`); - - // 채번 규칙 파트 복사 - const partsResult = await client.query( - `SELECT * FROM numbering_rule_parts WHERE rule_id = $1 ORDER BY part_order`, - [rule.rule_id] - ); - - for (const part of partsResult.rows) { - await client.query( - `INSERT INTO numbering_rule_parts ( - rule_id, part_order, part_type, generation_method, - auto_config, manual_config, company_code, created_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW())`, - [ - newRuleId, - part.part_order, - part.part_type, - part.generation_method, - part.auto_config, - part.manual_config, - targetCompanyCode, - ] - ); - } - logger.info(` ↳ 채번규칙 파트 ${partsResult.rows.length}개 복사`); + originalToNewRuleMap.push({ original: rule.rule_id, new: newRuleId }); + rulesToCopy.push({ ...rule, newRuleId }); } } - logger.info(`✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개`); + // 4. 배치 INSERT로 채번 규칙 복사 + if (rulesToCopy.length > 0) { + const ruleValues = rulesToCopy + .map( + (_, i) => + `($${i * 13 + 1}, $${i * 13 + 2}, $${i * 13 + 3}, $${i * 13 + 4}, $${i * 13 + 5}, $${i * 13 + 6}, $${i * 13 + 7}, $${i * 13 + 8}, $${i * 13 + 9}, NOW(), $${i * 13 + 10}, $${i * 13 + 11}, $${i * 13 + 12}, $${i * 13 + 13})` + ) + .join(", "); + + const ruleParams = rulesToCopy.flatMap((r) => { + const newMenuObjid = menuIdMap.get(r.menu_objid); + return [ + r.newRuleId, + r.rule_name, + r.description, + r.separator, + r.reset_period, + 0, + r.table_name, + r.column_name, + targetCompanyCode, + userId, + newMenuObjid, + r.scope_type, + null, + ]; + }); + + await client.query( + `INSERT INTO numbering_rules ( + rule_id, rule_name, description, separator, reset_period, + current_sequence, table_name, column_name, company_code, + created_at, created_by, menu_objid, scope_type, last_generated_date + ) VALUES ${ruleValues}`, + ruleParams + ); + + copiedCount = rulesToCopy.length; + logger.info(` ✅ 채번 규칙 ${copiedCount}개 복사`); + } + + // 4-1. 기존 채번 규칙의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (rulesToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + const caseWhen = rulesToUpdate + .map((_, i) => `WHEN rule_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .join(" "); + const ruleIdsForUpdate = rulesToUpdate.map((r) => r.ruleId); + const params = rulesToUpdate.flatMap((r) => [r.ruleId, r.newMenuObjid]); + + await client.query( + `UPDATE numbering_rules + SET menu_objid = CASE ${caseWhen} END, updated_at = NOW() + WHERE rule_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, ruleIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 채번 규칙 ${rulesToUpdate.length}개 메뉴 연결 갱신` + ); + } + + // 5. 모든 원본 파트 한 번에 조회 (새로 복사한 규칙만 대상) + if (rulesToCopy.length > 0) { + const originalRuleIds = rulesToCopy.map((r) => r.rule_id); + const allPartsResult = await client.query( + `SELECT * FROM numbering_rule_parts + WHERE rule_id = ANY($1) ORDER BY rule_id, part_order`, + [originalRuleIds] + ); + + // 6. 배치 INSERT로 채번 규칙 파트 복사 + if (allPartsResult.rows.length > 0) { + // 원본 rule_id -> 새 rule_id 매핑 + const ruleMapping = new Map( + originalToNewRuleMap.map((m) => [m.original, m.new]) + ); + + const partValues = allPartsResult.rows + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, $${i * 7 + 7}, NOW())` + ) + .join(", "); + + const partParams = allPartsResult.rows.flatMap((p) => [ + ruleMapping.get(p.rule_id), + p.part_order, + p.part_type, + p.generation_method, + p.auto_config, + p.manual_config, + targetCompanyCode, + ]); + + await client.query( + `INSERT INTO numbering_rule_parts ( + rule_id, part_order, part_type, generation_method, + auto_config, manual_config, company_code, created_at + ) VALUES ${partValues}`, + partParams + ); + + logger.info(` ✅ 채번 규칙 파트 ${allPartsResult.rows.length}개 복사`); + } + } + + logger.info( + `✅ 채번 규칙 복사 완료: ${copiedCount}개, 매핑: ${ruleIdMap.size}개` + ); return { copiedCount, ruleIdMap }; } /** - * 카테고리 매핑 + 값 복사 + * 카테고리 매핑 + 값 복사 (최적화: 배치 조회) */ private async copyCategoryMappingsAndValues( menuObjids: number[], @@ -2015,122 +2512,242 @@ export class MenuCopyService { ): Promise { let copiedCount = 0; - for (const menuObjid of menuObjids) { - const newMenuObjid = menuIdMap.get(menuObjid); - if (!newMenuObjid) continue; + if (menuObjids.length === 0) { + return copiedCount; + } - // 1. 카테고리 컬럼 매핑 조회 - const mappingsResult = await client.query( - `SELECT * FROM category_column_mapping WHERE menu_objid = $1`, - [menuObjid] + // === 최적화: 배치 조회 === + // 1. 모든 원본 카테고리 매핑 한 번에 조회 + const allMappingsResult = await client.query( + `SELECT * FROM category_column_mapping WHERE menu_objid = ANY($1)`, + [menuObjids] + ); + + if (allMappingsResult.rows.length === 0) { + logger.info(` 📭 복사할 카테고리 매핑 없음`); + return copiedCount; + } + + // 2. 대상 회사에 이미 존재하는 매핑 한 번에 조회 + const existingMappingsResult = await client.query( + `SELECT mapping_id, table_name, logical_column_name + FROM category_column_mapping WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingMappingKeys = new Map( + existingMappingsResult.rows.map((m) => [ + `${m.table_name}|${m.logical_column_name}`, + m.mapping_id, + ]) + ); + + // 3. 복사할 매핑 필터링 및 기존 매핑 업데이트 대상 분류 + const mappingsToCopy: any[] = []; + const mappingsToUpdate: Array<{ mappingId: number; newMenuObjid: number }> = + []; + + for (const m of allMappingsResult.rows) { + const key = `${m.table_name}|${m.logical_column_name}`; + if (existingMappingKeys.has(key)) { + // 기존 매핑은 menu_objid만 업데이트 + const existingMappingId = existingMappingKeys.get(key); + const newMenuObjid = menuIdMap.get(m.menu_objid); + if (existingMappingId && newMenuObjid) { + mappingsToUpdate.push({ mappingId: existingMappingId, newMenuObjid }); + } + } else { + mappingsToCopy.push(m); + } + } + + // 새 매핑 ID -> 원본 매핑 정보 추적 + const mappingInsertInfo: Array<{ mapping: any; newMenuObjid: number }> = []; + + if (mappingsToCopy.length > 0) { + const mappingValues = mappingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), $${i * 7 + 7})` + ) + .join(", "); + + const mappingParams = mappingsToCopy.flatMap((m) => { + const newMenuObjid = menuIdMap.get(m.menu_objid) || 0; + mappingInsertInfo.push({ mapping: m, newMenuObjid }); + return [ + m.table_name, + m.logical_column_name, + m.physical_column_name, + newMenuObjid, + targetCompanyCode, + m.description, + userId, + ]; + }); + + const insertResult = await client.query( + `INSERT INTO category_column_mapping ( + table_name, logical_column_name, physical_column_name, + menu_objid, company_code, description, created_at, created_by + ) VALUES ${mappingValues} + RETURNING mapping_id`, + mappingParams ); - for (const mapping of mappingsResult.rows) { - // 대상 회사에 같은 매핑이 이미 있는지 확인 - const existingMapping = await client.query( - `SELECT mapping_id FROM category_column_mapping - WHERE table_name = $1 AND logical_column_name = $2 AND company_code = $3`, - [mapping.table_name, mapping.logical_column_name, targetCompanyCode] + // 새로 생성된 매핑 ID를 기존 매핑 맵에 추가 + insertResult.rows.forEach((row, index) => { + const m = mappingsToCopy[index]; + existingMappingKeys.set( + `${m.table_name}|${m.logical_column_name}`, + row.mapping_id ); + }); - let newMappingId: number; + copiedCount = mappingsToCopy.length; + logger.info(` ✅ 카테고리 매핑 ${copiedCount}개 복사`); + } - if (existingMapping.rows.length > 0) { - logger.info(` ♻️ 카테고리 매핑 이미 존재: ${mapping.table_name}.${mapping.logical_column_name}`); - newMappingId = existingMapping.rows[0].mapping_id; - } else { - // 매핑 복사 - const insertResult = await client.query( - `INSERT INTO category_column_mapping ( - table_name, logical_column_name, physical_column_name, - menu_objid, company_code, description, created_at, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), $7) - RETURNING mapping_id`, - [ - mapping.table_name, - mapping.logical_column_name, - mapping.physical_column_name, - newMenuObjid, - targetCompanyCode, - mapping.description, - userId, - ] - ); - newMappingId = insertResult.rows[0].mapping_id; - copiedCount++; - logger.info(` ✅ 카테고리 매핑 복사: ${mapping.table_name}.${mapping.logical_column_name}`); - } + // 3-1. 기존 카테고리 매핑의 menu_objid 업데이트 (새 메뉴와 연결) - 배치 처리 + if (mappingsToUpdate.length > 0) { + // CASE WHEN을 사용한 배치 업데이트 + const caseWhen = mappingsToUpdate + .map((_, i) => `WHEN mapping_id = $${i * 2 + 1} THEN $${i * 2 + 2}`) + .join(" "); + const mappingIdsForUpdate = mappingsToUpdate.map((m) => m.mappingId); + const params = mappingsToUpdate.flatMap((m) => [ + m.mappingId, + m.newMenuObjid, + ]); - // 2. 카테고리 값 조회 및 복사 (menu_objid 기준) - const valuesResult = await client.query( - `SELECT * FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND menu_objid = $3 - ORDER BY parent_value_id NULLS FIRST, value_order`, - [mapping.table_name, mapping.logical_column_name, menuObjid] - ); + await client.query( + `UPDATE category_column_mapping + SET menu_objid = CASE ${caseWhen} END + WHERE mapping_id = ANY($${params.length + 1}) AND company_code = $${params.length + 2}`, + [...params, mappingIdsForUpdate, targetCompanyCode] + ); + logger.info( + ` ✅ 기존 카테고리 매핑 ${mappingsToUpdate.length}개 메뉴 연결 갱신` + ); + } - // 값 ID 매핑 (부모-자식 관계 유지를 위해) - const valueIdMap = new Map(); + // 4. 모든 원본 카테고리 값 한 번에 조회 + const allValuesResult = await client.query( + `SELECT * FROM table_column_category_values + WHERE menu_objid = ANY($1) + ORDER BY depth NULLS FIRST, parent_value_id NULLS FIRST, value_order`, + [menuObjids] + ); - for (const value of valuesResult.rows) { - // 대상 회사에 같은 값이 이미 있는지 확인 - const existingValue = await client.query( - `SELECT value_id FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND value_code = $3 AND company_code = $4`, - [value.table_name, value.column_name, value.value_code, targetCompanyCode] - ); + if (allValuesResult.rows.length === 0) { + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); + return copiedCount; + } - if (existingValue.rows.length > 0) { - valueIdMap.set(value.value_id, existingValue.rows[0].value_id); - continue; - } + // 5. 대상 회사에 이미 존재하는 값 한 번에 조회 + const existingValuesResult = await client.query( + `SELECT value_id, table_name, column_name, value_code + FROM table_column_category_values WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingValueKeys = new Map( + existingValuesResult.rows.map((v) => [ + `${v.table_name}|${v.column_name}|${v.value_code}`, + v.value_id, + ]) + ); - // 부모 ID 재매핑 - const newParentId = value.parent_value_id - ? valueIdMap.get(value.parent_value_id) || null - : null; + // 6. 값 복사 (부모-자식 관계 유지를 위해 depth 순서로 처리) + const valueIdMap = new Map(); + let copiedValues = 0; - const insertResult = await client.query( - `INSERT INTO table_column_category_values ( - table_name, column_name, value_code, value_label, value_order, - parent_value_id, depth, description, color, icon, - is_active, is_default, company_code, created_at, created_by, menu_objid - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, NOW(), $14, $15) - RETURNING value_id`, - [ - value.table_name, - value.column_name, - value.value_code, - value.value_label, - value.value_order, - newParentId, - value.depth, - value.description, - value.color, - value.icon, - value.is_active, - value.is_default, - targetCompanyCode, - userId, - newMenuObjid, - ] - ); - - valueIdMap.set(value.value_id, insertResult.rows[0].value_id); - } - - if (valuesResult.rows.length > 0) { - logger.info(` ↳ 카테고리 값 ${valuesResult.rows.length}개 처리`); - } + // 이미 존재하는 값들의 ID 매핑 + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + const existingId = existingValueKeys.get(key); + if (existingId) { + valueIdMap.set(value.value_id, existingId); } } + // depth별로 그룹핑하여 배치 처리 (부모가 먼저 삽입되어야 함) + const valuesByDepth = new Map(); + for (const value of allValuesResult.rows) { + const key = `${value.table_name}|${value.column_name}|${value.value_code}`; + if (existingValueKeys.has(key)) continue; // 이미 존재하면 스킵 + + const depth = value.depth ?? 0; + if (!valuesByDepth.has(depth)) { + valuesByDepth.set(depth, []); + } + valuesByDepth.get(depth)!.push(value); + } + + // depth 순서대로 처리 + const sortedDepths = Array.from(valuesByDepth.keys()).sort((a, b) => a - b); + + for (const depth of sortedDepths) { + const values = valuesByDepth.get(depth)!; + if (values.length === 0) continue; + + const valueStrings = values + .map( + (_, i) => + `($${i * 15 + 1}, $${i * 15 + 2}, $${i * 15 + 3}, $${i * 15 + 4}, $${i * 15 + 5}, $${i * 15 + 6}, $${i * 15 + 7}, $${i * 15 + 8}, $${i * 15 + 9}, $${i * 15 + 10}, $${i * 15 + 11}, $${i * 15 + 12}, NOW(), $${i * 15 + 13}, $${i * 15 + 14}, $${i * 15 + 15})` + ) + .join(", "); + + const valueParams = values.flatMap((v) => { + const newMenuObjid = menuIdMap.get(v.menu_objid); + const newParentId = v.parent_value_id + ? valueIdMap.get(v.parent_value_id) || null + : null; + return [ + v.table_name, + v.column_name, + v.value_code, + v.value_label, + v.value_order, + newParentId, + v.depth, + v.description, + v.color, + v.icon, + v.is_active, + v.is_default, + userId, + targetCompanyCode, + newMenuObjid, + ]; + }); + + const insertResult = await client.query( + `INSERT INTO table_column_category_values ( + table_name, column_name, value_code, value_label, value_order, + parent_value_id, depth, description, color, icon, + is_active, is_default, created_at, created_by, company_code, menu_objid + ) VALUES ${valueStrings} + RETURNING value_id`, + valueParams + ); + + // 새 value_id 매핑 + insertResult.rows.forEach((row, index) => { + valueIdMap.set(values[index].value_id, row.value_id); + }); + + copiedValues += values.length; + } + + if (copiedValues > 0) { + logger.info(` ✅ 카테고리 값 ${copiedValues}개 복사`); + } + logger.info(`✅ 카테고리 매핑 + 값 복사 완료: ${copiedCount}개`); return copiedCount; } /** - * 테이블 타입관리 입력타입 설정 복사 + * 테이블 타입관리 입력타입 설정 복사 (최적화: 배치 조회/삽입) * - 복사된 화면에서 사용하는 테이블들의 table_type_columns 설정을 대상 회사로 복사 */ private async copyTableTypeColumns( @@ -2144,8 +2761,8 @@ export class MenuCopyService { } logger.info(`📋 테이블 타입 설정 복사 시작`); - logger.info(` 원본 화면 IDs: ${screenIds.join(", ")}`); + // === 최적화: 배치 조회 === // 1. 복사된 화면에서 사용하는 테이블 목록 조회 const tablesResult = await client.query<{ table_name: string }>( `SELECT DISTINCT table_name FROM screen_definitions @@ -2161,62 +2778,293 @@ export class MenuCopyService { const tableNames = tablesResult.rows.map((r) => r.table_name); logger.info(` 사용 테이블: ${tableNames.join(", ")}`); + // 2. 원본 회사의 모든 테이블 타입 설정 한 번에 조회 + const sourceSettingsResult = await client.query( + `SELECT * FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, sourceCompanyCode] + ); + + if (sourceSettingsResult.rows.length === 0) { + logger.info(` ⚠️ 원본 회사 설정 없음`); + return 0; + } + + // 3. 대상 회사의 기존 설정 한 번에 조회 + const existingSettingsResult = await client.query( + `SELECT table_name, column_name FROM table_type_columns + WHERE table_name = ANY($1) AND company_code = $2`, + [tableNames, targetCompanyCode] + ); + const existingKeys = new Set( + existingSettingsResult.rows.map((s) => `${s.table_name}|${s.column_name}`) + ); + + // 4. 복사할 설정 필터링 + const settingsToCopy = sourceSettingsResult.rows.filter( + (s) => !existingKeys.has(`${s.table_name}|${s.column_name}`) + ); + + logger.info( + ` 원본 설정: ${sourceSettingsResult.rows.length}개, 복사 대상: ${settingsToCopy.length}개` + ); + + // 5. 배치 INSERT + if (settingsToCopy.length > 0) { + const settingValues = settingsToCopy + .map( + (_, i) => + `($${i * 7 + 1}, $${i * 7 + 2}, $${i * 7 + 3}, $${i * 7 + 4}, $${i * 7 + 5}, $${i * 7 + 6}, NOW(), NOW(), $${i * 7 + 7})` + ) + .join(", "); + + const settingParams = settingsToCopy.flatMap((s) => [ + s.table_name, + s.column_name, + s.input_type, + s.detail_settings, + s.is_nullable, + s.display_order, + targetCompanyCode, + ]); + + await client.query( + `INSERT INTO table_type_columns ( + table_name, column_name, input_type, detail_settings, + is_nullable, display_order, created_date, updated_date, company_code + ) VALUES ${settingValues}`, + settingParams + ); + } + + logger.info(`✅ 테이블 타입 설정 복사 완료: ${settingsToCopy.length}개`); + return settingsToCopy.length; + } + + /** + * 연쇄관계 복사 (최적화: 배치 조회/삽입) + * - category_value_cascading_group + category_value_cascading_mapping + * - cascading_relation (테이블 기반) + */ + private async copyCascadingRelations( + sourceCompanyCode: string, + targetCompanyCode: string, + menuIdMap: Map, + userId: string, + client: PoolClient + ): Promise { + logger.info(`📋 연쇄관계 복사 시작`); let copiedCount = 0; - for (const tableName of tableNames) { - // 2. 원본 회사의 테이블 타입 설정 조회 - const sourceSettings = await client.query( - `SELECT * FROM table_type_columns - WHERE table_name = $1 AND company_code = $2`, - [tableName, sourceCompanyCode] + // === 1. category_value_cascading_group 복사 === + const groupsResult = await client.query( + `SELECT * FROM category_value_cascading_group + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + if (groupsResult.rows.length === 0) { + logger.info(` 카테고리 값 연쇄 그룹: 0개`); + } else { + logger.info(` 카테고리 값 연쇄 그룹: ${groupsResult.rows.length}개`); + + // 대상 회사의 기존 그룹 한 번에 조회 + const existingGroupsResult = await client.query( + `SELECT group_id, relation_code FROM category_value_cascading_group + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingGroupsByCode = new Map( + existingGroupsResult.rows.map((g) => [g.relation_code, g.group_id]) ); - if (sourceSettings.rows.length === 0) { - logger.info(` ⚠️ ${tableName}: 원본 회사 설정 없음 (기본 설정 사용)`); - continue; + // group_id 매핑 + const groupIdMap = new Map(); + const groupsToCopy: any[] = []; + + for (const group of groupsResult.rows) { + const existingGroupId = existingGroupsByCode.get(group.relation_code); + if (existingGroupId) { + groupIdMap.set(group.group_id, existingGroupId); + } else { + groupsToCopy.push(group); + } } - for (const setting of sourceSettings.rows) { - // 3. 대상 회사에 같은 설정이 이미 있는지 확인 - const existing = await client.query( - `SELECT id FROM table_type_columns - WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, - [setting.table_name, setting.column_name, targetCompanyCode] - ); + logger.info( + ` 기존: ${groupsResult.rows.length - groupsToCopy.length}개, 신규: ${groupsToCopy.length}개` + ); - if (existing.rows.length > 0) { - // 이미 존재하면 스킵 (대상 회사에서 커스터마이징한 설정 유지) - logger.info( - ` ↳ ${setting.table_name}.${setting.column_name}: 이미 존재 (스킵)` - ); - continue; - } + // 그룹별로 삽입하고 매핑 저장 (RETURNING이 필요해서 배치 불가) + for (const group of groupsToCopy) { + const newParentMenuObjid = group.parent_menu_objid + ? menuIdMap.get(Number(group.parent_menu_objid)) || null + : null; + const newChildMenuObjid = group.child_menu_objid + ? menuIdMap.get(Number(group.child_menu_objid)) || null + : null; - // 새로 삽입 - await client.query( - `INSERT INTO table_type_columns ( - table_name, column_name, input_type, detail_settings, - is_nullable, display_order, created_date, updated_date, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW(), $7)`, + const insertResult = await client.query( + `INSERT INTO category_value_cascading_group ( + relation_code, relation_name, description, + parent_table_name, parent_column_name, parent_menu_objid, + child_table_name, child_column_name, child_menu_objid, + clear_on_parent_change, show_group_label, + empty_parent_message, no_options_message, + company_code, is_active, created_by, created_date + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, NOW()) + RETURNING group_id`, [ - setting.table_name, - setting.column_name, - setting.input_type, - setting.detail_settings, - setting.is_nullable, - setting.display_order, + group.relation_code, + group.relation_name, + group.description, + group.parent_table_name, + group.parent_column_name, + newParentMenuObjid, + group.child_table_name, + group.child_column_name, + newChildMenuObjid, + group.clear_on_parent_change, + group.show_group_label, + group.empty_parent_message, + group.no_options_message, targetCompanyCode, + "Y", + userId, ] ); - logger.info( - ` ↳ ${setting.table_name}.${setting.column_name}: 신규 추가` - ); + + const newGroupId = insertResult.rows[0].group_id; + groupIdMap.set(group.group_id, newGroupId); copiedCount++; } + + // 모든 매핑 한 번에 조회 (복사할 그룹만) + const groupIdsToCopy = groupsToCopy.map((g) => g.group_id); + if (groupIdsToCopy.length > 0) { + const allMappingsResult = await client.query( + `SELECT * FROM category_value_cascading_mapping + WHERE group_id = ANY($1) AND company_code = $2 + ORDER BY group_id, display_order`, + [groupIdsToCopy, sourceCompanyCode] + ); + + // 배치 INSERT + if (allMappingsResult.rows.length > 0) { + const mappingValues = allMappingsResult.rows + .map( + (_, i) => + `($${i * 8 + 1}, $${i * 8 + 2}, $${i * 8 + 3}, $${i * 8 + 4}, $${i * 8 + 5}, $${i * 8 + 6}, $${i * 8 + 7}, $${i * 8 + 8}, NOW())` + ) + .join(", "); + + const mappingParams = allMappingsResult.rows.flatMap((m) => { + const newGroupId = groupIdMap.get(m.group_id); + return [ + newGroupId, + m.parent_value_code, + m.parent_value_label, + m.child_value_code, + m.child_value_label, + m.display_order, + targetCompanyCode, + "Y", + ]; + }); + + await client.query( + `INSERT INTO category_value_cascading_mapping ( + group_id, parent_value_code, parent_value_label, + child_value_code, child_value_label, display_order, + company_code, is_active, created_date + ) VALUES ${mappingValues}`, + mappingParams + ); + + logger.info(` ↳ 매핑 ${allMappingsResult.rows.length}개 복사`); + } + } } - logger.info(`✅ 테이블 타입 설정 복사 완료: ${copiedCount}개`); + // === 2. cascading_relation 복사 (테이블 기반) === + const relationsResult = await client.query( + `SELECT * FROM cascading_relation + WHERE company_code = $1 AND is_active = 'Y'`, + [sourceCompanyCode] + ); + + if (relationsResult.rows.length === 0) { + logger.info(` 기본 연쇄관계: 0개`); + } else { + logger.info(` 기본 연쇄관계: ${relationsResult.rows.length}개`); + + // 대상 회사의 기존 관계 한 번에 조회 + const existingRelationsResult = await client.query( + `SELECT relation_code FROM cascading_relation + WHERE company_code = $1`, + [targetCompanyCode] + ); + const existingRelationCodes = new Set( + existingRelationsResult.rows.map((r) => r.relation_code) + ); + + // 복사할 관계 필터링 + const relationsToCopy = relationsResult.rows.filter( + (r) => !existingRelationCodes.has(r.relation_code) + ); + + logger.info( + ` 기존: ${relationsResult.rows.length - relationsToCopy.length}개, 신규: ${relationsToCopy.length}개` + ); + + // 배치 INSERT + if (relationsToCopy.length > 0) { + const relationValues = relationsToCopy + .map( + (_, i) => + `($${i * 19 + 1}, $${i * 19 + 2}, $${i * 19 + 3}, $${i * 19 + 4}, $${i * 19 + 5}, $${i * 19 + 6}, $${i * 19 + 7}, $${i * 19 + 8}, $${i * 19 + 9}, $${i * 19 + 10}, $${i * 19 + 11}, $${i * 19 + 12}, $${i * 19 + 13}, $${i * 19 + 14}, $${i * 19 + 15}, $${i * 19 + 16}, $${i * 19 + 17}, $${i * 19 + 18}, $${i * 19 + 19}, NOW())` + ) + .join(", "); + + const relationParams = relationsToCopy.flatMap((r) => [ + r.relation_code, + r.relation_name, + r.description, + r.parent_table, + r.parent_value_column, + r.parent_label_column, + r.child_table, + r.child_filter_column, + r.child_value_column, + r.child_label_column, + r.child_order_column, + r.child_order_direction, + r.empty_parent_message, + r.no_options_message, + r.loading_message, + r.clear_on_parent_change, + targetCompanyCode, + "Y", + userId, + ]); + + await client.query( + `INSERT INTO cascading_relation ( + relation_code, relation_name, description, + parent_table, parent_value_column, parent_label_column, + child_table, child_filter_column, child_value_column, child_label_column, + child_order_column, child_order_direction, + empty_parent_message, no_options_message, loading_message, + clear_on_parent_change, company_code, is_active, created_by, created_date + ) VALUES ${relationValues}`, + relationParams + ); + + copiedCount += relationsToCopy.length; + } + } + + logger.info(`✅ 연쇄관계 복사 완료: ${copiedCount}개`); return copiedCount; } - } From 2e7a21506609c5ebe3cc9e6f112037d0a901f301 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 19 Dec 2025 14:00:38 +0900 Subject: [PATCH 09/11] =?UTF-8?q?=EC=98=A4=EB=A5=B8=EC=AA=BD=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=AC=EB=93=9C=20=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinViewer.tsx | 641 +++++++++--------- 1 file changed, 314 insertions(+), 327 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 7e0986a3..3e7b5471 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -61,18 +61,16 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) console.log("=== 사용자 권한 그룹 조회 ==="); console.log("API 응답:", response); console.log("찾는 역할:", EXTERNAL_VENDOR_ROLE); - + if (response.success && response.data) { console.log("권한 그룹 목록:", response.data); - + // 사용자의 권한 그룹 중 LSTHIRA_EXTERNAL_VENDOR가 있는지 확인 - const hasExternalRole = response.data.some( - (group: any) => { - console.log("체크 중인 그룹:", group.authCode, group.authName); - return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE; - } - ); - + const hasExternalRole = response.data.some((group: any) => { + console.log("체크 중인 그룹:", group.authCode, group.authName); + return group.authCode === EXTERNAL_VENDOR_ROLE || group.authName === EXTERNAL_VENDOR_ROLE; + }); + console.log("외부 업체 역할 보유:", hasExternalRole); setIsExternalMode(hasExternalRole); } @@ -101,12 +99,12 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const handleFullscreenChange = () => { const isNowFullscreen = !!document.fullscreenElement; setIsFullscreen(isNowFullscreen); - + // 전체화면 종료 시 레이아웃 강제 리렌더링 if (!isNowFullscreen) { setTimeout(() => { - setLayoutKey(prev => prev + 1); - window.dispatchEvent(new Event('resize')); + setLayoutKey((prev) => prev + 1); + window.dispatchEvent(new Event("resize")); }, 50); } }; @@ -407,9 +405,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

{layoutName || "디지털 트윈 야드"}

-

- {isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"} -

+

{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}

{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */} @@ -420,11 +416,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) onClick={toggleFullscreen} title={isFullscreen ? "전체 화면 종료" : "전체 화면"} > - {isFullscreen ? ( - - ) : ( - - )} + {isFullscreen ? : } {isFullscreen ? "종료" : "전체 화면"} )} @@ -445,234 +437,234 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)
{/* 좌측: 검색/필터 - 외부 모드에서는 숨김 */} {!isExternalMode && ( -
-
- {/* 검색 */} -
- -
- - setSearchQuery(e.target.value)} - placeholder="이름, Area, Location 검색..." - className="h-10 pl-9 text-sm" - /> - {searchQuery && ( - - )} +
+
+ {/* 검색 */} +
+ +
+ + setSearchQuery(e.target.value)} + placeholder="이름, Area, Location 검색..." + className="h-10 pl-9 text-sm" + /> + {searchQuery && ( + + )} +
+ + {/* 타입 필터 */} +
+ + +
+ + {/* 필터 초기화 */} + {(searchQuery || filterType !== "all") && ( + + )}
- {/* 타입 필터 */} -
- - -
+ {/* 객체 목록 */} +
+ + {filteredObjects.length === 0 ? ( +
+ {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} +
+ ) : ( + (() => { + // Area 객체가 있는 경우 계층 트리 아코디언 적용 + const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); - {/* 필터 초기화 */} - {(searchQuery || filterType !== "all") && ( - - )} -
+ // Area가 없으면 기존 평면 리스트 유지 + if (areaObjects.length === 0) { + return ( +
+ {filteredObjects.map((obj) => { + let typeLabel = obj.type; + if (obj.type === "location-bed") typeLabel = "베드(BED)"; + else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; + else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; + else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; + else if (obj.type === "crane-mobile") typeLabel = "크레인"; + else if (obj.type === "area") typeLabel = "Area"; + else if (obj.type === "rack") typeLabel = "랙"; - {/* 객체 목록 */} -
- - {filteredObjects.length === 0 ? ( -
- {searchQuery ? "검색 결과가 없습니다" : "객체가 없습니다"} -
- ) : ( - (() => { - // Area 객체가 있는 경우 계층 트리 아코디언 적용 - const areaObjects = filteredObjects.filter((obj) => obj.type === "area"); - - // Area가 없으면 기존 평면 리스트 유지 - if (areaObjects.length === 0) { - return ( -
- {filteredObjects.map((obj) => { - let typeLabel = obj.type; - if (obj.type === "location-bed") typeLabel = "베드(BED)"; - else if (obj.type === "location-stp") typeLabel = "정차포인트(STP)"; - else if (obj.type === "location-temp") typeLabel = "임시베드(TMP)"; - else if (obj.type === "location-dest") typeLabel = "지정착지(DES)"; - else if (obj.type === "crane-mobile") typeLabel = "크레인"; - else if (obj.type === "area") typeLabel = "Area"; - else if (obj.type === "rack") typeLabel = "랙"; - - return ( -
handleObjectClick(obj.id)} - className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ - selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" - }`} - > -
-
-

{obj.name}

-
- - {typeLabel} + return ( +
handleObjectClick(obj.id)} + className={`bg-background hover:bg-accent cursor-pointer rounded-lg border p-3 transition-all ${ + selectedObject?.id === obj.id ? "ring-primary bg-primary/5 ring-2" : "hover:shadow-sm" + }`} + > +
+
+

{obj.name}

+
+ + {typeLabel} +
+
+ {obj.areaKey && ( +

+ Area: {obj.areaKey} +

+ )} + {obj.locaKey && ( +

+ Location: {obj.locaKey} +

+ )} + {obj.materialCount !== undefined && obj.materialCount > 0 && ( +

+ 자재: {obj.materialCount}개 +

+ )} +
-
- {obj.areaKey && ( -

- Area: {obj.areaKey} -

+ ); + })} +
+ ); + } + + // Area가 있는 경우: Area → Location 계층 아코디언 + return ( + + {areaObjects.map((areaObj) => { + const childLocations = filteredObjects.filter( + (obj) => + obj.type !== "area" && + obj.areaKey === areaObj.areaKey && + (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), + ); + + return ( + + +
{ + e.stopPropagation(); + handleObjectClick(areaObj.id); + }} + > +
+ + {areaObj.name} +
+
+ ({childLocations.length}) + +
+
+
+ + {childLocations.length === 0 ? ( +

Location이 없습니다

+ ) : ( +
+ {childLocations.map((locationObj) => ( +
handleObjectClick(locationObj.id)} + className={`cursor-pointer rounded-lg border p-2 transition-all ${ + selectedObject?.id === locationObj.id + ? "border-primary bg-primary/10" + : "hover:border-primary/50" + }`} + > +
+
+ {locationObj.type === "location-stp" ? ( + + ) : ( + + )} + {locationObj.name} +
+ +
+

+ 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) +

+ {locationObj.locaKey && ( +

+ Location: {locationObj.locaKey} +

+ )} + {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( +

+ 자재: {locationObj.materialCount}개 +

+ )} +
+ ))} +
)} - {obj.locaKey && ( -

- Location: {obj.locaKey} -

- )} - {obj.materialCount !== undefined && obj.materialCount > 0 && ( -

- 자재: {obj.materialCount}개 -

- )} -
-
+ + ); })} -
+ ); - } - - // Area가 있는 경우: Area → Location 계층 아코디언 - return ( - - {areaObjects.map((areaObj) => { - const childLocations = filteredObjects.filter( - (obj) => - obj.type !== "area" && - obj.areaKey === areaObj.areaKey && - (obj.parentId === areaObj.id || obj.externalKey === areaObj.externalKey), - ); - - return ( - - -
{ - e.stopPropagation(); - handleObjectClick(areaObj.id); - }} - > -
- - {areaObj.name} -
-
- ({childLocations.length}) - -
-
-
- - {childLocations.length === 0 ? ( -

Location이 없습니다

- ) : ( -
- {childLocations.map((locationObj) => ( -
handleObjectClick(locationObj.id)} - className={`cursor-pointer rounded-lg border p-2 transition-all ${ - selectedObject?.id === locationObj.id - ? "border-primary bg-primary/10" - : "hover:border-primary/50" - }`} - > -
-
- {locationObj.type === "location-stp" ? ( - - ) : ( - - )} - {locationObj.name} -
- -
-

- 위치: ({locationObj.position.x.toFixed(1)}, {locationObj.position.z.toFixed(1)}) -

- {locationObj.locaKey && ( -

- Location: {locationObj.locaKey} -

- )} - {locationObj.materialCount !== undefined && locationObj.materialCount > 0 && ( -

- 자재: {locationObj.materialCount}개 -

- )} -
- ))} -
- )} -
-
- ); - })} -
- ); - })() - )} + })() + )} +
-
)} {/* 중앙 + 우측 컨테이너 (전체화면 시 함께 표시) */} -
@@ -691,107 +683,102 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) {/* 우측: 정보 패널 */}
- {selectedObject ? ( -
-
-

상세 정보

-

{selectedObject.name}

-
- - {/* 기본 정보 */} -
-
- -

{selectedObject.type}

+ {selectedObject ? ( +
+
+

상세 정보

+

{selectedObject.name}

- {selectedObject.areaKey && ( -
- -

{selectedObject.areaKey}

-
- )} - {selectedObject.locaKey && ( -
- -

{selectedObject.locaKey}

-
- )} - {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( -
- -

{selectedObject.materialCount}개

-
- )} -
- {/* 자재 목록 (Location인 경우) - 테이블 형태 */} - {(selectedObject.type === "location-bed" || - selectedObject.type === "location-stp" || - selectedObject.type === "location-temp" || - selectedObject.type === "location-dest") && ( -
- {loadingMaterials ? ( -
- + {/* 기본 정보 */} +
+
+ +

{selectedObject.type}

+
+ {selectedObject.areaKey && ( +
+ +

{selectedObject.areaKey}

- ) : materials.length === 0 ? ( -
- {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} + )} + {selectedObject.locaKey && ( +
+ +

{selectedObject.locaKey}

- ) : ( -
- - {/* 테이블 형태로 전체 조회 */} -
-
- - - - {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( - - ))} - - - - {materials.map((material, index) => { - const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; - const displayColumns = hierarchyConfig?.material?.displayColumns || []; - return ( - - - {displayColumns.map((colConfig: any) => ( - - ))} - - ); - })} - -
- {colConfig.label} -
- {material[layerColumn]}단 - - {material[colConfig.column] || "-"} -
-
+ )} + {selectedObject.materialCount !== undefined && selectedObject.materialCount > 0 && ( +
+ +

{selectedObject.materialCount}개

)}
- )} -
- ) : ( -
-

객체를 선택하세요

-
- )} + + {/* 자재 목록 (Location인 경우) - 테이블 형태 */} + {(selectedObject.type === "location-bed" || + selectedObject.type === "location-stp" || + selectedObject.type === "location-temp" || + selectedObject.type === "location-dest") && ( +
+ {loadingMaterials ? ( +
+ +
+ ) : materials.length === 0 ? ( +
+ {externalDbConnectionId ? "자재가 없습니다" : "외부 DB 연결이 설정되지 않았습니다"} +
+ ) : ( +
+ + {/* 테이블 형태로 전체 조회 */} +
+ + + + + {(hierarchyConfig?.material?.displayColumns || []).map((colConfig: any) => ( + + ))} + + + + {materials.map((material, index) => { + const layerColumn = hierarchyConfig?.material?.layerColumn || "LOLAYER"; + const displayColumns = hierarchyConfig?.material?.displayColumns || []; + return ( + + + {displayColumns.map((colConfig: any) => ( + + ))} + + ); + })} + +
+ {colConfig.label} +
+ {material[layerColumn]}단 + + {material[colConfig.column] || "-"} +
+
+
+ )} +
+ )} +
+ ) : ( +
+

객체를 선택하세요

+
+ )}
{/* 풀스크린 모드일 때 종료 버튼 */} @@ -800,7 +787,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) variant="outline" size="sm" onClick={toggleFullscreen} - className="absolute top-4 right-4 z-50 bg-background/80 backdrop-blur-sm" + className="bg-background/80 absolute top-4 right-4 z-50 backdrop-blur-sm" > 종료 From 69754a31cb96318d9403ac742d6b30ecd1c96231 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Fri, 19 Dec 2025 14:12:05 +0900 Subject: [PATCH 10/11] =?UTF-8?q?=EB=94=94=EC=A7=80=ED=84=B8=203d=20?= =?UTF-8?q?=EB=B7=B0=EC=96=B4=2010=EC=B4=88=EB=8B=A8=EC=9C=84=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/DigitalTwinViewer.tsx | 161 +++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx index 3e7b5471..ae98c795 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/DigitalTwinViewer.tsx @@ -51,6 +51,7 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) const [isExternalMode, setIsExternalMode] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [layoutKey, setLayoutKey] = useState(0); // 레이아웃 강제 리렌더링용 + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); // 마지막 갱신 시간 const canvasContainerRef = useRef(null); // 외부 업체 역할 체크 @@ -214,6 +215,8 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) }), ); } + // 마지막 갱신 시간 기록 + setLastRefreshedAt(new Date()); } else { throw new Error(response.error || "레이아웃 조회 실패"); } @@ -250,6 +253,155 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps) // eslint-disable-next-line react-hooks/exhaustive-deps }, [layoutId]); + // 10초 주기 자동 갱신 (중앙 관제 화면 자동 새로고침) + useEffect(() => { + const AUTO_REFRESH_INTERVAL = 10000; // 10초 + + const silentRefresh = async () => { + // 로딩 중이거나 새로고침 중이면 스킵 + if (isLoading || isRefreshing) return; + + try { + // 레이아웃 데이터 조용히 갱신 + const response = await getLayoutById(layoutId); + + if (response.success && response.data) { + const { layout, objects } = response.data; + const dbConnectionId = layout.external_db_connection_id || layout.externalDbConnectionId; + + // hierarchy_config 파싱 + let hierarchyConfigData: any = null; + if (layout.hierarchy_config) { + hierarchyConfigData = + typeof layout.hierarchy_config === "string" + ? JSON.parse(layout.hierarchy_config) + : layout.hierarchy_config; + setHierarchyConfig(hierarchyConfigData); + } + + // 객체 데이터 변환 + const loadedObjects: PlacedObject[] = objects.map((obj: any) => { + const objectType = obj.object_type; + return { + id: obj.id, + type: objectType, + name: obj.object_name, + position: { + x: parseFloat(obj.position_x), + y: parseFloat(obj.position_y), + z: parseFloat(obj.position_z), + }, + size: { + x: parseFloat(obj.size_x), + y: parseFloat(obj.size_y), + z: parseFloat(obj.size_z), + }, + rotation: obj.rotation ? parseFloat(obj.rotation) : 0, + color: getObjectColor(objectType, obj.color), + areaKey: obj.area_key, + locaKey: obj.loca_key, + locType: obj.loc_type, + materialCount: obj.loc_type === "STP" ? undefined : obj.material_count, + materialPreview: + obj.loc_type === "STP" || !obj.material_preview_height + ? undefined + : { height: parseFloat(obj.material_preview_height) }, + parentId: obj.parent_id, + displayOrder: obj.display_order, + locked: obj.locked, + visible: obj.visible !== false, + hierarchyLevel: obj.hierarchy_level, + parentKey: obj.parent_key, + externalKey: obj.external_key, + }; + }); + + // 외부 DB 연결이 있고 자재 설정이 있으면, 각 Location의 실제 자재 개수 조회 + if (dbConnectionId && hierarchyConfigData?.material) { + const locationObjects = loadedObjects.filter( + (obj) => + (obj.type === "location-bed" || obj.type === "location-temp" || obj.type === "location-dest") && + obj.locaKey, + ); + + // 각 Location에 대해 자재 개수 조회 (병렬 처리) + const materialCountPromises = locationObjects.map(async (obj) => { + try { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: obj.locaKey!, + }); + if (matResponse.success && matResponse.data) { + return { id: obj.id, count: matResponse.data.length }; + } + } catch { + // 자동 갱신 시에는 에러 로그 생략 + } + return { id: obj.id, count: 0 }; + }); + + const materialCounts = await Promise.all(materialCountPromises); + + // materialCount 업데이트 + const updatedObjects = loadedObjects.map((obj) => { + const countData = materialCounts.find((m) => m.id === obj.id); + if (countData && countData.count > 0) { + return { ...obj, materialCount: countData.count }; + } + return obj; + }); + + setPlacedObjects(updatedObjects); + } else { + setPlacedObjects(loadedObjects); + } + + // 선택된 객체가 있으면 자재 목록도 갱신 + if (selectedObject && dbConnectionId && hierarchyConfigData?.material) { + const currentObj = loadedObjects.find((o) => o.id === selectedObject.id); + if ( + currentObj && + (currentObj.type === "location-bed" || + currentObj.type === "location-temp" || + currentObj.type === "location-dest") && + currentObj.locaKey + ) { + const matResponse = await getMaterials(dbConnectionId, { + tableName: hierarchyConfigData.material.tableName, + keyColumn: hierarchyConfigData.material.keyColumn, + locationKeyColumn: hierarchyConfigData.material.locationKeyColumn, + layerColumn: hierarchyConfigData.material.layerColumn, + locaKey: currentObj.locaKey, + }); + if (matResponse.success && matResponse.data) { + const layerColumn = hierarchyConfigData.material.layerColumn || "LOLAYER"; + const sortedMaterials = matResponse.data.sort( + (a: any, b: any) => (b[layerColumn] || 0) - (a[layerColumn] || 0), + ); + setMaterials(sortedMaterials); + } + } + } + + // 마지막 갱신 시간 기록 + setLastRefreshedAt(new Date()); + } + } catch { + // 자동 갱신 실패 시 조용히 무시 (사용자 경험 방해 안 함) + } + }; + + // 10초마다 자동 갱신 + const intervalId = setInterval(silentRefresh, AUTO_REFRESH_INTERVAL); + + // 컴포넌트 언마운트 시 인터벌 정리 + return () => clearInterval(intervalId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [layoutId, isLoading, isRefreshing, selectedObject]); + // Location의 자재 목록 로드 const loadMaterialsForLocation = async (locaKey: string, externalDbConnectionId: number) => { if (!hierarchyConfig?.material) { @@ -405,7 +557,14 @@ export default function DigitalTwinViewer({ layoutId }: DigitalTwinViewerProps)

{layoutName || "디지털 트윈 야드"}

-

{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}

+
+

{isExternalMode ? "야드 관제 화면" : "읽기 전용 뷰"}

+ {lastRefreshedAt && ( + + 마지막 갱신: {lastRefreshedAt.toLocaleTimeString("ko-KR")} + + )} +
{/* 전체 화면 버튼 - 외부 업체 모드에서만 표시 */} From 483dbf8a1f91c6a7372827b263a1b592a79e6cba Mon Sep 17 00:00:00 2001 From: kjs Date: Fri, 19 Dec 2025 15:52:44 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20scope=5Ftype=3Dmenu=20=EC=B1=84?= =?UTF-8?q?=EB=B2=88=EA=B7=9C=EC=B9=99=20=EC=82=AD=EC=A0=9C=20=EC=8B=9C=20?= =?UTF-8?q?check=20constraint=20=EC=9C=84=EB=B0=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scope_type='menu'인 채번규칙: 메뉴 삭제 시 함께 삭제 (파트 포함) - scope_type!='menu'인 채번규칙: menu_objid만 NULL로 설정 (규칙 보존) - check_menu_scope_requires_menu_objid 제약조건 준수 --- backend-node/src/services/menuCopyService.ts | 34 +++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index bc80569f..26c8b779 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -738,18 +738,44 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-4. 채번 규칙의 menu_objid 참조 해제 (삭제하지 않고 연결만 끊음) - // 채번 규칙은 회사의 핵심 업무 데이터이므로 보존해야 함 + // 5-4. 채번 규칙 처리 (체크 제약조건 고려) + // scope_type = 'menu'인 채번 규칙: 메뉴 전용이므로 삭제 (파트 포함) + // check_menu_scope_requires_menu_objid 제약: scope_type='menu'이면 menu_objid NOT NULL 필수 + const menuScopedRulesResult = await client.query( + `SELECT rule_id FROM numbering_rules + WHERE menu_objid = ANY($1) AND company_code = $2 AND scope_type = 'menu'`, + [existingMenuIds, targetCompanyCode] + ); + if (menuScopedRulesResult.rows.length > 0) { + const menuScopedRuleIds = menuScopedRulesResult.rows.map( + (r) => r.rule_id + ); + // 채번 규칙 파트 먼저 삭제 + await client.query( + `DELETE FROM numbering_rule_parts WHERE rule_id = ANY($1)`, + [menuScopedRuleIds] + ); + // 채번 규칙 삭제 + await client.query(`DELETE FROM numbering_rules WHERE rule_id = ANY($1)`, [ + menuScopedRuleIds, + ]); + logger.info( + ` ✅ 메뉴 전용 채번 규칙 삭제: ${menuScopedRuleIds.length}개` + ); + } + + // scope_type != 'menu'인 채번 규칙: menu_objid만 NULL로 설정 (규칙 보존) const updatedNumberingRules = await client.query( `UPDATE numbering_rules SET menu_objid = NULL - WHERE menu_objid = ANY($1) AND company_code = $2 + WHERE menu_objid = ANY($1) AND company_code = $2 + AND (scope_type IS NULL OR scope_type != 'menu') RETURNING rule_id`, [existingMenuIds, targetCompanyCode] ); if (updatedNumberingRules.rowCount && updatedNumberingRules.rowCount > 0) { logger.info( - ` ✅ 채번 규칙 메뉴 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` + ` ✅ 테이블 스코프 채번 규칙 연결 해제: ${updatedNumberingRules.rowCount}개 (데이터 보존됨)` ); }