From e33664015a67156c9e6887e37e8bfd496995f097 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 10:03:24 +0900 Subject: [PATCH 1/5] =?UTF-8?q?=EC=83=81=EB=8B=A8=20=ED=97=A4=EB=8D=94=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/screens/[screenId]/page.tsx | 2 +- frontend/components/layout/AppLayout.tsx | 223 ++++++++++++------ frontend/components/screen/ScreenDesigner.tsx | 106 +-------- .../components/screen/widgets/TabsWidget.tsx | 162 ++++++++----- 4 files changed, 257 insertions(+), 236 deletions(-) diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 8ab31ff7..5ce253cb 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -308,7 +308,7 @@ function ScreenViewPage() {
{/* 레이아웃 준비 중 로딩 표시 */} {!layoutReady && ( diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 8394cd6d..faba6df5 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -15,6 +15,8 @@ import { ChevronDown, ChevronRight, UserCheck, + LogOut, + User, } from "lucide-react"; import { useMenu } from "@/contexts/MenuContext"; import { useAuth } from "@/hooks/useAuth"; @@ -22,8 +24,17 @@ import { useProfile } from "@/hooks/useProfile"; import { MenuItem } from "@/lib/api/menu"; import { menuScreenApi } from "@/lib/api/screen"; import { toast } from "sonner"; -import { MainHeader } from "./MainHeader"; import { ProfileModal } from "./ProfileModal"; +import { Logo } from "./Logo"; +import { SideMenu } from "./SideMenu"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; // useAuth의 UserInfo 타입을 확장 interface ExtendedUserInfo { @@ -397,82 +408,152 @@ function AppLayoutInner({ children }: AppLayoutProps) { const uiMenus = convertMenuToUI(currentMenus, user as ExtendedUserInfo); return ( -
- {/* MainHeader 컴포넌트 사용 */} - { - // 모바일에서만 토글 동작 - if (isMobile) { - setSidebarOpen(!sidebarOpen); - } - }} - onProfileClick={openProfileModal} - onLogout={handleLogout} - /> +
+ {/* 모바일 사이드바 오버레이 */} + {sidebarOpen && isMobile && ( +
setSidebarOpen(false)} /> + )} -
- {/* 모바일 사이드바 오버레이 */} - {sidebarOpen && isMobile && ( -
setSidebarOpen(false)} /> + {/* 왼쪽 사이드바 */} +
+ + + + + 프로필 + + + + 로그아웃 + + + +
+ + + {/* 가운데 컨텐츠 영역 - 스크롤 가능 */} +
+ {children} +
{/* 프로필 수정 모달 */} { const oldWidth = screenResolution.width; @@ -1273,122 +1273,28 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD const newWidth = newResolution.width; const newHeight = newResolution.height; - console.log("📱 해상도 변경 시작:", { + console.log("📱 해상도 변경:", { from: `${oldWidth}x${oldHeight}`, to: `${newWidth}x${newHeight}`, - hasComponents: layout.components.length > 0, - snapToGrid: layout.gridSettings?.snapToGrid || false, + componentsCount: layout.components.length, }); setScreenResolution(newResolution); - // 컴포넌트가 없으면 해상도만 변경 - if (layout.components.length === 0) { - const updatedLayout = { - ...layout, - screenResolution: newResolution, - }; - setLayout(updatedLayout); - saveToHistory(updatedLayout); - console.log("✅ 해상도 변경 완료 (컴포넌트 없음)"); - return; - } - - // 비율 계산 - const scaleX = newWidth / oldWidth; - const scaleY = newHeight / oldHeight; - - console.log("📐 스케일링 비율:", { - scaleX: `${(scaleX * 100).toFixed(2)}%`, - scaleY: `${(scaleY * 100).toFixed(2)}%`, - }); - - // 컴포넌트 재귀적으로 스케일링하는 함수 - const scaleComponent = (comp: ComponentData): ComponentData => { - // 위치 스케일링 - const scaledPosition = { - x: comp.position.x * scaleX, - y: comp.position.y * scaleY, - z: comp.position.z || 1, - }; - - // 크기 스케일링 - const scaledSize = { - width: comp.size.width * scaleX, - height: comp.size.height * scaleY, - }; - - return { - ...comp, - position: scaledPosition, - size: scaledSize, - }; - }; - - // 모든 컴포넌트 스케일링 (그룹의 자식도 자동으로 스케일링됨) - const scaledComponents = layout.components.map(scaleComponent); - - console.log("🔄 컴포넌트 스케일링 완료:", { - totalComponents: scaledComponents.length, - groupComponents: scaledComponents.filter((c) => c.type === "group").length, - note: "그룹의 자식 컴포넌트도 모두 스케일링됨", - }); - - // 격자 스냅이 활성화된 경우 격자에 맞춰 재조정 - let finalComponents = scaledComponents; - if (layout.gridSettings?.snapToGrid) { - const newGridInfo = calculateGridInfo(newWidth, newHeight, { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: layout.gridSettings.snapToGrid || false, - }); - - const gridUtilSettings = { - columns: layout.gridSettings.columns, - gap: layout.gridSettings.gap, - padding: layout.gridSettings.padding, - snapToGrid: true, - }; - - finalComponents = scaledComponents.map((comp) => { - const snappedPosition = snapToGrid(comp.position, newGridInfo, gridUtilSettings); - const snappedSize = snapSizeToGrid(comp.size, newGridInfo, gridUtilSettings); - - // gridColumns 재계산 - const adjustedGridColumns = adjustGridColumnsFromSize({ size: snappedSize }, newGridInfo, gridUtilSettings); - - return { - ...comp, - position: snappedPosition, - size: snappedSize, - gridColumns: adjustedGridColumns, - }; - }); - - console.log("🧲 격자 스냅 적용 완료"); - } - + // 해상도만 변경하고 컴포넌트 크기/위치는 그대로 유지 const updatedLayout = { ...layout, - components: finalComponents, screenResolution: newResolution, }; setLayout(updatedLayout); saveToHistory(updatedLayout); - toast.success(`해상도 변경 완료! ${scaledComponents.length}개 컴포넌트가 자동으로 조정되었습니다.`, { + toast.success(`해상도가 변경되었습니다.`, { description: `${oldWidth}×${oldHeight} → ${newWidth}×${newHeight}`, }); - console.log("✅ 해상도 변경 완료:", { - newResolution: `${newWidth}x${newHeight}`, - scaledComponents: finalComponents.length, - scaleX: `${(scaleX * 100).toFixed(2)}%`, - scaleY: `${(scaleY * 100).toFixed(2)}%`, - note: "모든 컴포넌트가 비율에 맞게 자동 조정됨", - }); + console.log("✅ 해상도 변경 완료 (컴포넌트 크기/위치 유지)"); }, [layout, saveToHistory, screenResolution], ); diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 73b53783..200e2db3 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,11 +1,12 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { X, Loader2 } from "lucide-react"; import type { TabsComponent, TabItem } from "@/types/screen-management"; import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic"; +import { cn } from "@/lib/utils"; interface TabsWidgetProps { component: TabsComponent; @@ -48,6 +49,8 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge const [visibleTabs, setVisibleTabs] = useState(tabs); const [loadingScreens, setLoadingScreens] = useState>({}); const [screenLayouts, setScreenLayouts] = useState>({}); + // 🆕 한 번이라도 선택된 탭 추적 (지연 로딩 + 캐싱) + const [mountedTabs, setMountedTabs] = useState>(() => new Set([getInitialTab()])); // 컴포넌트 탭 목록 변경 시 동기화 useEffect(() => { @@ -109,6 +112,14 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge const handleTabChange = (tabId: string) => { console.log("🔄 탭 변경:", tabId); setSelectedTab(tabId); + + // 🆕 마운트된 탭 목록에 추가 (한 번 마운트되면 유지) + setMountedTabs(prev => { + if (prev.has(tabId)) return prev; + const newSet = new Set(prev); + newSet.add(tabId); + return newSet; + }); // 해당 탭의 화면 로드 const tab = visibleTabs.find((t) => t.id === tabId); @@ -191,72 +202,95 @@ export function TabsWidget({ component, className, style, menuObjid }: TabsWidge
+ {/* 🆕 forceMount + CSS 숨김으로 탭 전환 시 리렌더링 방지 */}
- {visibleTabs.map((tab) => ( - - {tab.screenId ? ( - loadingScreens[tab.screenId] ? ( -
- - 화면 로딩 중... -
- ) : screenLayouts[tab.screenId] ? ( - (() => { - const layoutData = screenLayouts[tab.screenId]; - const { components = [], screenResolution } = layoutData; - - console.log("🎯 렌더링할 화면 데이터:", { - screenId: tab.screenId, - componentsCount: components.length, - screenResolution, - }); - - const designWidth = screenResolution?.width || 1920; - const designHeight = screenResolution?.height || 1080; - - return ( -
-
- {components.map((component: any) => ( - - ))} + {visibleTabs.map((tab) => { + // 한 번도 선택되지 않은 탭은 렌더링하지 않음 (지연 로딩) + const shouldRender = mountedTabs.has(tab.id); + const isActive = selectedTab === tab.id; + + return ( + + {/* 한 번 마운트된 탭만 내용 렌더링 */} + {shouldRender && ( + <> + {tab.screenId ? ( + loadingScreens[tab.screenId] ? ( +
+ + 화면 로딩 중...
+ ) : screenLayouts[tab.screenId] ? ( + (() => { + const layoutData = screenLayouts[tab.screenId]; + const { components = [], screenResolution } = layoutData; + + // 비활성 탭은 로그 생략 + if (isActive) { + console.log("🎯 렌더링할 화면 데이터:", { + screenId: tab.screenId, + componentsCount: components.length, + screenResolution, + }); + } + + const designWidth = screenResolution?.width || 1920; + const designHeight = screenResolution?.height || 1080; + + return ( +
+
+ {components.map((component: any) => ( + + ))} +
+
+ ); + })() + ) : ( +
+

화면을 불러올 수 없습니다

+
+ ) + ) : ( +
+

연결된 화면이 없습니다

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

화면을 불러올 수 없습니다

-
- ) - ) : ( -
-

연결된 화면이 없습니다

-
- )} -
- ))} + )} + + )} + + ); + })}
-- 2.43.0 From e83fbed71c0ad0d8a3381694177fb34269668cc9 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 10:09:31 +0900 Subject: [PATCH 2/5] =?UTF-8?q?=EC=85=80=EB=A0=89=ED=8A=B8=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=EA=B0=92=20=EB=93=A4=EC=96=B4=EA=B0=80=EB=8A=94=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../select-basic/SelectBasicComponent.tsx | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx index d4ad416e..f19fafdc 100644 --- a/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx +++ b/frontend/lib/registry/components/select-basic/SelectBasicComponent.tsx @@ -732,16 +732,16 @@ const SelectBasicComponent: React.FC = ({
로딩 중...
) : allOptions.length > 0 ? ( allOptions.map((option, index) => { - const isSelected = selectedValues.includes(option.value); + const isOptionSelected = selectedValues.includes(option.value); return (
{ - const newVals = isSelected + const newVals = isOptionSelected ? selectedValues.filter((v) => v !== option.value) : [...selectedValues, option.value]; setSelectedValues(newVals); @@ -754,9 +754,21 @@ const SelectBasicComponent: React.FC = ({
{}} - className="h-4 w-4" + checked={isOptionSelected} + value={option.value} + onChange={(e) => { + // 체크박스 직접 클릭 시에도 올바른 값으로 처리 + e.stopPropagation(); + const newVals = isOptionSelected + ? selectedValues.filter((v) => v !== option.value) + : [...selectedValues, option.value]; + setSelectedValues(newVals); + const newValue = newVals.join(","); + if (isInteractive && onFormDataChange && component.columnName) { + onFormDataChange(component.columnName, newValue); + } + }} + className="h-4 w-4 pointer-events-auto" /> {option.label || option.value}
-- 2.43.0 From 8317af92cdef5e7266f6b21176b64d63c96b71d9 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 10:24:07 +0900 Subject: [PATCH 3/5] =?UTF-8?q?=EC=9E=85=EB=A0=A5=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=8B=9C=20=EB=B0=94=EB=A1=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=EA=B0=80=EB=8A=A5=ED=95=98=EA=B2=8C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/services/screenManagementService.ts | 134 +++++++++++++++++- .../src/services/tableManagementService.ts | 132 +++++++++++++++++ 2 files changed, 265 insertions(+), 1 deletion(-) diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 007a39e7..646fc8d6 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -1517,11 +1517,23 @@ export class ScreenManagementService { }; } + // 🔥 최신 inputType 정보 조회 (table_type_columns에서) + const inputTypeMap = await this.getLatestInputTypes(componentLayouts, companyCode); + const components: ComponentData[] = componentLayouts.map((layout) => { const properties = layout.properties as any; + + // 🔥 최신 inputType으로 widgetType 및 componentType 업데이트 + const tableName = properties?.tableName; + const columnName = properties?.columnName; + const latestTypeInfo = tableName && columnName + ? inputTypeMap.get(`${tableName}.${columnName}`) + : null; + const component = { id: layout.component_id, - type: layout.component_type as any, + // 🔥 최신 componentType이 있으면 type 덮어쓰기 + type: latestTypeInfo?.componentType || layout.component_type as any, position: { x: layout.position_x, y: layout.position_y, @@ -1530,6 +1542,17 @@ export class ScreenManagementService { size: { width: layout.width, height: layout.height }, parentId: layout.parent_id, ...properties, + // 🔥 최신 inputType이 있으면 widgetType, componentType 덮어쓰기 + ...(latestTypeInfo && { + widgetType: latestTypeInfo.inputType, + inputType: latestTypeInfo.inputType, + componentType: latestTypeInfo.componentType, + componentConfig: { + ...properties?.componentConfig, + type: latestTypeInfo.componentType, + inputType: latestTypeInfo.inputType, + }, + }), }; console.log(`로드된 컴포넌트:`, { @@ -1539,6 +1562,9 @@ export class ScreenManagementService { size: component.size, parentId: component.parentId, title: (component as any).title, + widgetType: (component as any).widgetType, + componentType: (component as any).componentType, + latestTypeInfo, }); return component; @@ -1558,6 +1584,112 @@ export class ScreenManagementService { }; } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컴포넌트들의 최신 inputType 정보 조회 + * @param layouts - 레이아웃 목록 + * @param companyCode - 회사 코드 + * @returns Map<"tableName.columnName", { inputType, componentType }> + */ + private async getLatestInputTypes( + layouts: any[], + companyCode: string + ): Promise> { + const inputTypeMap = new Map(); + + // tableName과 columnName이 있는 컴포넌트들의 고유 조합 추출 + const tableColumnPairs = new Set(); + for (const layout of layouts) { + const properties = layout.properties as any; + if (properties?.tableName && properties?.columnName) { + tableColumnPairs.add(`${properties.tableName}|${properties.columnName}`); + } + } + + if (tableColumnPairs.size === 0) { + return inputTypeMap; + } + + // 각 테이블-컬럼 조합에 대해 최신 inputType 조회 + const pairs = Array.from(tableColumnPairs).map(pair => { + const [tableName, columnName] = pair.split('|'); + return { tableName, columnName }; + }); + + // 배치 쿼리로 한 번에 조회 + const placeholders = pairs.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(', '); + const params = pairs.flatMap(p => [p.tableName, p.columnName]); + + try { + const results = await query<{ table_name: string; column_name: string; input_type: string }>( + `SELECT table_name, column_name, input_type + FROM table_type_columns + WHERE (table_name, column_name) IN (${placeholders}) + AND company_code = $${params.length + 1}`, + [...params, companyCode] + ); + + for (const row of results) { + const componentType = this.getComponentIdFromInputType(row.input_type); + inputTypeMap.set(`${row.table_name}.${row.column_name}`, { + inputType: row.input_type, + componentType: componentType, + }); + } + + console.log(`최신 inputType 조회 완료: ${results.length}개`); + } catch (error) { + console.warn(`최신 inputType 조회 실패 (무시됨):`, error); + } + + return inputTypeMap; + } + // ======================================== // 템플릿 관리 // ======================================== diff --git a/backend-node/src/services/tableManagementService.ts b/backend-node/src/services/tableManagementService.ts index 64eb44c8..8e01903b 100644 --- a/backend-node/src/services/tableManagementService.ts +++ b/backend-node/src/services/tableManagementService.ts @@ -797,6 +797,9 @@ export class TableManagementService { ] ); + // 🔥 해당 컬럼을 사용하는 화면 레이아웃의 widgetType도 업데이트 + await this.syncScreenLayoutsInputType(tableName, columnName, inputType, companyCode); + // 🔥 캐시 무효화: 해당 테이블의 컬럼 캐시 삭제 const cacheKeyPattern = `${CacheKeys.TABLE_COLUMNS(tableName, 1, 1000)}_${companyCode}`; cache.delete(cacheKeyPattern); @@ -816,6 +819,135 @@ export class TableManagementService { } } + /** + * 입력 타입에 해당하는 컴포넌트 ID 반환 + * (프론트엔드 webTypeMapping.ts와 동일한 매핑) + */ + private getComponentIdFromInputType(inputType: string): string { + const mapping: Record = { + // 텍스트 입력 + text: "text-input", + email: "text-input", + password: "text-input", + tel: "text-input", + // 숫자 입력 + number: "number-input", + decimal: "number-input", + // 날짜/시간 + date: "date-input", + datetime: "date-input", + time: "date-input", + // 텍스트 영역 + textarea: "textarea-basic", + // 선택 + select: "select-basic", + dropdown: "select-basic", + // 체크박스/라디오 + checkbox: "checkbox-basic", + radio: "radio-basic", + boolean: "toggle-switch", + // 파일 + file: "file-upload", + // 이미지 + image: "image-widget", + img: "image-widget", + picture: "image-widget", + photo: "image-widget", + // 버튼 + button: "button-primary", + // 기타 + label: "text-display", + code: "select-basic", + entity: "select-basic", + category: "select-basic", + }; + + return mapping[inputType] || "text-input"; + } + + /** + * 컬럼 입력 타입 변경 시 해당 컬럼을 사용하는 화면 레이아웃의 widgetType 및 componentType 동기화 + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param inputType - 새로운 입력 타입 + * @param companyCode - 회사 코드 + */ + private async syncScreenLayoutsInputType( + tableName: string, + columnName: string, + inputType: string, + companyCode: string + ): Promise { + try { + // 해당 컬럼을 사용하는 화면 레이아웃 조회 + const affectedLayouts = await query<{ + layout_id: number; + screen_id: number; + component_id: string; + component_type: string; + properties: any; + }>( + `SELECT sl.layout_id, sl.screen_id, sl.component_id, sl.component_type, sl.properties + FROM screen_layouts sl + JOIN screen_definitions sd ON sl.screen_id = sd.screen_id + WHERE sl.properties->>'tableName' = $1 + AND sl.properties->>'columnName' = $2 + AND (sd.company_code = $3 OR $3 = '*')`, + [tableName, columnName, companyCode] + ); + + if (affectedLayouts.length === 0) { + logger.info( + `화면 레이아웃 동기화: ${tableName}.${columnName}을 사용하는 화면 없음` + ); + return; + } + + logger.info( + `화면 레이아웃 동기화 시작: ${affectedLayouts.length}개 컴포넌트 발견` + ); + + // 새로운 componentType 계산 + const newComponentType = this.getComponentIdFromInputType(inputType); + + // 각 레이아웃의 widgetType, componentType 업데이트 + for (const layout of affectedLayouts) { + const updatedProperties = { + ...layout.properties, + widgetType: inputType, + inputType: inputType, + // componentConfig 내부의 type도 업데이트 + componentConfig: { + ...layout.properties?.componentConfig, + type: newComponentType, + inputType: inputType, + }, + }; + + await query( + `UPDATE screen_layouts + SET properties = $1, component_type = $2 + WHERE layout_id = $3`, + [JSON.stringify(updatedProperties), newComponentType, layout.layout_id] + ); + + logger.info( + `화면 레이아웃 업데이트: screen_id=${layout.screen_id}, component_id=${layout.component_id}, widgetType=${inputType}, componentType=${newComponentType}` + ); + } + + logger.info( + `화면 레이아웃 동기화 완료: ${affectedLayouts.length}개 컴포넌트 업데이트됨` + ); + } catch (error) { + // 화면 레이아웃 동기화 실패는 치명적이지 않으므로 로그만 남기고 계속 진행 + logger.warn( + `화면 레이아웃 동기화 실패 (무시됨): ${tableName}.${columnName}`, + error + ); + } + } + /** * 입력 타입별 기본 상세 설정 생성 */ -- 2.43.0 From eb5ea411c9d859328e5ed5476bc5a5c5f0dc3568 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 3 Dec 2025 16:02:09 +0900 Subject: [PATCH 4/5] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=9D=BC=EA=B4=84?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 43 ++- .../controllers/screenManagementController.ts | 47 +++ .../src/routes/screenManagementRoutes.ts | 2 + backend-node/src/services/menuCopyService.ts | 325 +++++++++++++----- .../src/services/screenManagementService.ts | 128 +++++++ frontend/components/screen/ScreenList.tsx | 251 ++++++++++++-- frontend/lib/api/screen.ts | 16 + .../card-display/CardDisplayComponent.tsx | 207 ++++++----- ..._임베딩_및_데이터_전달_시스템_구현_계획서.md | 1 + 화면_임베딩_시스템_Phase1-4_구현_완료.md | 1 + 화면_임베딩_시스템_충돌_분석_보고서.md | 1 + 11 files changed, 830 insertions(+), 192 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index da0ea772..3ac5d26b 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1428,10 +1428,51 @@ export async function deleteMenu( } } + // 외래키 제약 조건이 있는 관련 테이블 데이터 먼저 정리 + const menuObjid = Number(menuId); + + // 1. category_column_mapping에서 menu_objid를 NULL로 설정 + await query( + `UPDATE category_column_mapping SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 2. code_category에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 3. code_info에서 menu_objid를 NULL로 설정 + await query( + `UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 4. numbering_rules에서 menu_objid를 NULL로 설정 + await query( + `UPDATE numbering_rules SET menu_objid = NULL WHERE menu_objid = $1`, + [menuObjid] + ); + + // 5. rel_menu_auth에서 관련 권한 삭제 + await query( + `DELETE FROM rel_menu_auth WHERE menu_objid = $1`, + [menuObjid] + ); + + // 6. screen_menu_assignments에서 관련 할당 삭제 + await query( + `DELETE FROM screen_menu_assignments WHERE menu_objid = $1`, + [menuObjid] + ); + + logger.info("메뉴 관련 데이터 정리 완료", { menuObjid }); + // Raw Query를 사용한 메뉴 삭제 const [deletedMenu] = await query( `DELETE FROM menu_info WHERE objid = $1 RETURNING *`, - [Number(menuId)] + [menuObjid] ); logger.info("메뉴 삭제 성공", { deletedMenu }); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index c7ecf75e..5605031e 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -325,6 +325,53 @@ export const getDeletedScreens = async ( } }; +// 활성 화면 일괄 삭제 (휴지통으로 이동) +export const bulkDeleteScreens = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { companyCode, userId } = req.user as any; + const { screenIds, deleteReason, force } = req.body; + + if (!Array.isArray(screenIds) || screenIds.length === 0) { + return res.status(400).json({ + success: false, + message: "삭제할 화면 ID 목록이 필요합니다.", + }); + } + + const result = await screenManagementService.bulkDeleteScreens( + screenIds, + companyCode, + userId, + deleteReason, + force || false + ); + + let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`; + if (result.skippedCount > 0) { + message += ` (${result.skippedCount}개 화면은 삭제되지 않았습니다.)`; + } + + return res.json({ + success: true, + message, + result: { + deletedCount: result.deletedCount, + skippedCount: result.skippedCount, + errors: result.errors, + }, + }); + } catch (error) { + console.error("활성 화면 일괄 삭제 실패:", error); + return res.status(500).json({ + success: false, + message: "일괄 삭제에 실패했습니다.", + }); + } +}; + // 휴지통 화면 일괄 영구 삭제 export const bulkPermanentDeleteScreens = async ( req: AuthenticatedRequest, diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 4207c719..67263277 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -8,6 +8,7 @@ import { updateScreen, updateScreenInfo, deleteScreen, + bulkDeleteScreens, checkScreenDependencies, restoreScreen, permanentDeleteScreen, @@ -44,6 +45,7 @@ router.put("/screens/:id", updateScreen); router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 +router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동) router.get("/screens/:id/linked-modals", detectLinkedScreens); // 연결된 모달 화면 감지 router.post("/screens/check-duplicate-name", checkDuplicateScreenName); // 화면명 중복 체크 router.post("/screens/:id/copy", copyScreen); // 단일 화면 복사 (하위 호환용) diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 70b45af4..a0e707c1 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -53,6 +53,7 @@ interface ScreenDefinition { layout_metadata: any; db_source_type: string | null; db_connection_id: number | null; + source_screen_id: number | null; // 원본 화면 ID (복사 추적용) } /** @@ -234,6 +235,27 @@ export class MenuCopyService { } } } + + // 4) 화면 분할 패널 (screen-split-panel: leftScreenId, rightScreenId) + if (props?.componentConfig?.leftScreenId) { + const leftScreenId = props.componentConfig.leftScreenId; + const numId = + typeof leftScreenId === "number" ? leftScreenId : parseInt(leftScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 좌측 화면 참조 발견: ${numId}`); + } + } + + if (props?.componentConfig?.rightScreenId) { + const rightScreenId = props.componentConfig.rightScreenId; + const numId = + typeof rightScreenId === "number" ? rightScreenId : parseInt(rightScreenId); + if (!isNaN(numId) && numId > 0) { + referenced.push(numId); + logger.debug(` 📐 분할 패널 우측 화면 참조 발견: ${numId}`); + } + } } return referenced; @@ -431,14 +453,16 @@ export class MenuCopyService { const value = obj[key]; const currentPath = path ? `${path}.${key}` : key; - // screen_id, screenId, targetScreenId 매핑 (숫자 또는 숫자 문자열) + // screen_id, screenId, targetScreenId, leftScreenId, rightScreenId 매핑 (숫자 또는 숫자 문자열) if ( key === "screen_id" || key === "screenId" || - key === "targetScreenId" + key === "targetScreenId" || + key === "leftScreenId" || + key === "rightScreenId" ) { const numValue = typeof value === "number" ? value : parseInt(value); - if (!isNaN(numValue)) { + if (!isNaN(numValue) && numValue > 0) { const newId = screenIdMap.get(numValue); if (newId) { obj[key] = typeof value === "number" ? newId : String(newId); // 원래 타입 유지 @@ -856,7 +880,10 @@ export class MenuCopyService { } /** - * 화면 복사 + * 화면 복사 (업데이트 또는 신규 생성) + * - source_screen_id로 기존 복사본 찾기 + * - 변경된 내용이 있으면 업데이트 + * - 없으면 새로 복사 */ private async copyScreens( screenIds: Set, @@ -876,18 +903,19 @@ export class MenuCopyService { return screenIdMap; } - logger.info(`📄 화면 복사 중: ${screenIds.size}개`); + logger.info(`📄 화면 복사/업데이트 중: ${screenIds.size}개`); - // === 1단계: 모든 screen_definitions 먼저 복사 (screenIdMap 생성) === + // === 1단계: 모든 screen_definitions 처리 (screenIdMap 생성) === const screenDefsToProcess: Array<{ originalScreenId: number; - newScreenId: number; + targetScreenId: number; screenDef: ScreenDefinition; + isUpdate: boolean; // 업데이트인지 신규 생성인지 }> = []; for (const originalScreenId of screenIds) { try { - // 1) screen_definitions 조회 + // 1) 원본 screen_definitions 조회 const screenDefResult = await client.query( `SELECT * FROM screen_definitions WHERE screen_id = $1`, [originalScreenId] @@ -900,122 +928,198 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; - // 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인 - const existingScreenResult = await client.query<{ screen_id: number }>( - `SELECT screen_id FROM screen_definitions - WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + // 2) 기존 복사본 찾기: source_screen_id로 검색 + const 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`, - [screenDef.screen_code, targetCompanyCode] + [originalScreenId, targetCompanyCode] ); - if (existingScreenResult.rows.length > 0) { - // 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑 - const existingScreenId = existingScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, existingScreenId); - logger.info( - ` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})` - ); - continue; // 레이아웃 복사도 스킵 - } - - // 3) 새 screen_code 생성 - const newScreenCode = await this.generateUniqueScreenCode( - targetCompanyCode, - client - ); - - // 4) 화면명 변환 적용 + // 3) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { - // 1. 제거할 텍스트 제거 if (screenNameConfig.removeText?.trim()) { transformedScreenName = transformedScreenName.replace( new RegExp(screenNameConfig.removeText.trim(), "g"), "" ); - transformedScreenName = transformedScreenName.trim(); // 앞뒤 공백 제거 + transformedScreenName = transformedScreenName.trim(); } - - // 2. 접두사 추가 if (screenNameConfig.addPrefix?.trim()) { transformedScreenName = screenNameConfig.addPrefix.trim() + " " + transformedScreenName; } } - // 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) - const newScreenResult = await client.query<{ screen_id: number }>( - `INSERT INTO screen_definitions ( - screen_name, screen_code, table_name, company_code, - description, is_active, layout_metadata, - db_source_type, db_connection_id, created_by, - deleted_date, deleted_by, delete_reason - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) - RETURNING screen_id`, - [ - transformedScreenName, // 변환된 화면명 - newScreenCode, // 새 화면 코드 - screenDef.table_name, - targetCompanyCode, // 새 회사 코드 - screenDef.description, - screenDef.is_active === "D" ? "Y" : screenDef.is_active, // 삭제된 화면은 활성화 - screenDef.layout_metadata, - screenDef.db_source_type, - screenDef.db_connection_id, - userId, - null, // deleted_date: NULL (새 화면은 삭제되지 않음) - null, // deleted_by: NULL - null, // delete_reason: NULL - ] - ); + if (existingCopyResult.rows.length > 0) { + // === 기존 복사본이 있는 경우: 업데이트 === + const existingScreen = existingCopyResult.rows[0]; + const existingScreenId = existingScreen.screen_id; - const newScreenId = newScreenResult.rows[0].screen_id; - screenIdMap.set(originalScreenId, newScreenId); + // 원본 레이아웃 조회 + const sourceLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [originalScreenId] + ); - logger.info( - ` ✅ 화면 정의 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` - ); + // 대상 레이아웃 조회 + const targetLayoutsResult = await client.query( + `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, + [existingScreenId] + ); - // 저장해서 2단계에서 처리 - screenDefsToProcess.push({ originalScreenId, newScreenId, screenDef }); + // 변경 여부 확인 (레이아웃 개수 또는 내용 비교) + const hasChanges = this.hasLayoutChanges( + sourceLayoutsResult.rows, + targetLayoutsResult.rows + ); + + if (hasChanges) { + // 변경 사항이 있으면 업데이트 + logger.info( + ` 🔄 화면 업데이트 필요: ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + + // screen_definitions 업데이트 + await client.query( + `UPDATE screen_definitions SET + screen_name = $1, + table_name = $2, + description = $3, + is_active = $4, + layout_metadata = $5, + db_source_type = $6, + db_connection_id = $7, + updated_by = $8, + updated_date = NOW() + WHERE screen_id = $9`, + [ + transformedScreenName, + screenDef.table_name, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + existingScreenId, + ] + ); + + screenIdMap.set(originalScreenId, existingScreenId); + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: existingScreenId, + screenDef, + isUpdate: true, + }); + } else { + // 변경 사항이 없으면 스킵 + screenIdMap.set(originalScreenId, existingScreenId); + logger.info( + ` ⏭️ 화면 변경 없음 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_name})` + ); + } + } else { + // === 기존 복사본이 없는 경우: 신규 생성 === + const newScreenCode = await this.generateUniqueScreenCode( + targetCompanyCode, + client + ); + + const newScreenResult = await client.query<{ screen_id: number }>( + `INSERT INTO screen_definitions ( + screen_name, screen_code, table_name, company_code, + description, is_active, layout_metadata, + db_source_type, db_connection_id, created_by, + deleted_date, deleted_by, delete_reason, source_screen_id + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING screen_id`, + [ + transformedScreenName, + newScreenCode, + screenDef.table_name, + targetCompanyCode, + screenDef.description, + screenDef.is_active === "D" ? "Y" : screenDef.is_active, + screenDef.layout_metadata, + screenDef.db_source_type, + screenDef.db_connection_id, + userId, + null, + null, + null, + originalScreenId, // source_screen_id 저장 + ] + ); + + const newScreenId = newScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, newScreenId); + + logger.info( + ` ✅ 화면 신규 복사: ${originalScreenId} → ${newScreenId} (${screenDef.screen_name})` + ); + + screenDefsToProcess.push({ + originalScreenId, + targetScreenId: newScreenId, + screenDef, + isUpdate: false, + }); + } } catch (error: any) { logger.error( - `❌ 화면 정의 복사 실패: screen_id=${originalScreenId}`, + `❌ 화면 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - // === 2단계: screen_layouts 복사 (이제 screenIdMap이 완성됨) === + // === 2단계: screen_layouts 처리 (이제 screenIdMap이 완성됨) === logger.info( - `\n📐 레이아웃 복사 시작 (screenIdMap 완성: ${screenIdMap.size}개)` + `\n📐 레이아웃 처리 시작 (screenIdMap 완성: ${screenIdMap.size}개)` ); for (const { originalScreenId, - newScreenId, + targetScreenId, screenDef, + isUpdate, } of screenDefsToProcess) { try { - // screen_layouts 복사 + // 원본 레이아웃 조회 const layoutsResult = await client.query( `SELECT * FROM screen_layouts WHERE screen_id = $1 ORDER BY display_order`, [originalScreenId] ); - // 1단계: component_id 매핑 생성 (원본 → 새 ID) + if (isUpdate) { + // 업데이트: 기존 레이아웃 삭제 후 새로 삽입 + await client.query( + `DELETE FROM screen_layouts WHERE screen_id = $1`, + [targetScreenId] + ); + logger.info(` ↳ 기존 레이아웃 삭제 (업데이트 준비)`); + } + + // 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)}`; componentIdMap.set(layout.component_id, newComponentId); } - // 2단계: screen_layouts 복사 (parent_id, zone_id도 매핑) + // 레이아웃 삽입 for (const layout of layoutsResult.rows) { const newComponentId = componentIdMap.get(layout.component_id)!; - // parent_id와 zone_id 매핑 (다른 컴포넌트를 참조하는 경우) const newParentId = layout.parent_id ? componentIdMap.get(layout.parent_id) || layout.parent_id : null; @@ -1023,7 +1127,6 @@ export class MenuCopyService { ? componentIdMap.get(layout.zone_id) || layout.zone_id : null; - // properties 내부 참조 업데이트 const updatedProperties = this.updateReferencesInProperties( layout.properties, screenIdMap, @@ -1037,38 +1140,94 @@ export class MenuCopyService { 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)`, [ - newScreenId, // 새 화면 ID + targetScreenId, layout.component_type, - newComponentId, // 새 컴포넌트 ID - newParentId, // 매핑된 parent_id + newComponentId, + newParentId, layout.position_x, layout.position_y, layout.width, layout.height, - updatedProperties, // 업데이트된 속성 + updatedProperties, layout.display_order, layout.layout_type, layout.layout_config, layout.zones_config, - newZoneId, // 매핑된 zone_id + newZoneId, ] ); } - logger.info(` ↳ 레이아웃 복사: ${layoutsResult.rows.length}개`); + const action = isUpdate ? "업데이트" : "복사"; + logger.info(` ↳ 레이아웃 ${action}: ${layoutsResult.rows.length}개`); } catch (error: any) { logger.error( - `❌ 레이아웃 복사 실패: screen_id=${originalScreenId}`, + `❌ 레이아웃 처리 실패: screen_id=${originalScreenId}`, error ); throw error; } } - logger.info(`\n✅ 화면 복사 완료: ${screenIdMap.size}개`); + // 통계 출력 + const newCount = screenDefsToProcess.filter((s) => !s.isUpdate).length; + const updateCount = screenDefsToProcess.filter((s) => s.isUpdate).length; + const skipCount = screenIds.size - screenDefsToProcess.length; + + logger.info(` +✅ 화면 처리 완료: + - 신규 복사: ${newCount}개 + - 업데이트: ${updateCount}개 + - 스킵 (변경 없음): ${skipCount}개 + - 총 매핑: ${screenIdMap.size}개 + `); + return screenIdMap; } + /** + * 레이아웃 변경 여부 확인 + */ + private hasLayoutChanges( + sourceLayouts: ScreenLayout[], + targetLayouts: ScreenLayout[] + ): boolean { + // 1. 레이아웃 개수가 다르면 변경됨 + if (sourceLayouts.length !== targetLayouts.length) { + return true; + } + + // 2. 각 레이아웃의 주요 속성 비교 + for (let i = 0; i < sourceLayouts.length; i++) { + const source = sourceLayouts[i]; + const target = targetLayouts[i]; + + // component_type이 다르면 변경됨 + if (source.component_type !== target.component_type) { + return true; + } + + // 위치/크기가 다르면 변경됨 + if ( + source.position_x !== target.position_x || + source.position_y !== target.position_y || + source.width !== target.width || + source.height !== target.height + ) { + return true; + } + + // properties의 JSON 문자열 비교 (깊은 비교) + const sourceProps = JSON.stringify(source.properties || {}); + const targetProps = JSON.stringify(target.properties || {}); + if (sourceProps !== targetProps) { + return true; + } + } + + return false; + } + /** * 메뉴 위상 정렬 (부모 먼저) */ diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 646fc8d6..6628cf4c 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -892,6 +892,134 @@ export class ScreenManagementService { }; } + /** + * 활성 화면 일괄 삭제 (휴지통으로 이동) + */ + async bulkDeleteScreens( + screenIds: number[], + userCompanyCode: string, + deletedBy: string, + deleteReason?: string, + force: boolean = false + ): Promise<{ + deletedCount: number; + skippedCount: number; + errors: Array<{ screenId: number; error: string }>; + }> { + if (screenIds.length === 0) { + throw new Error("삭제할 화면을 선택해주세요."); + } + + let deletedCount = 0; + let skippedCount = 0; + const errors: Array<{ screenId: number; error: string }> = []; + + // 각 화면을 개별적으로 삭제 처리 + for (const screenId of screenIds) { + try { + // 권한 확인 (Raw Query) + const existingResult = await query<{ + company_code: string | null; + is_active: string; + screen_name: string; + }>( + `SELECT company_code, is_active, screen_name FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId] + ); + + if (existingResult.length === 0) { + skippedCount++; + errors.push({ + screenId, + error: "화면을 찾을 수 없습니다.", + }); + continue; + } + + const existingScreen = existingResult[0]; + + // 권한 확인 + if ( + userCompanyCode !== "*" && + existingScreen.company_code !== userCompanyCode + ) { + skippedCount++; + errors.push({ + screenId, + error: "이 화면을 삭제할 권한이 없습니다.", + }); + continue; + } + + // 이미 삭제된 화면인지 확인 + if (existingScreen.is_active === "D") { + skippedCount++; + errors.push({ + screenId, + error: "이미 삭제된 화면입니다.", + }); + continue; + } + + // 강제 삭제가 아닌 경우 의존성 체크 + if (!force) { + const dependencyCheck = await this.checkScreenDependencies( + screenId, + userCompanyCode + ); + if (dependencyCheck.hasDependencies) { + skippedCount++; + errors.push({ + screenId, + error: `다른 화면에서 사용 중 (${dependencyCheck.dependencies.length}개 참조)`, + }); + continue; + } + } + + // 트랜잭션으로 화면 삭제와 메뉴 할당 정리를 함께 처리 + await transaction(async (client) => { + const now = new Date(); + + // 소프트 삭제 (휴지통으로 이동) + await client.query( + `UPDATE screen_definitions + SET is_active = 'D', + deleted_date = $1, + deleted_by = $2, + delete_reason = $3, + updated_date = $4, + updated_by = $5 + WHERE screen_id = $6`, + [now, deletedBy, deleteReason || null, now, deletedBy, screenId] + ); + + // 메뉴 할당 정리 (삭제된 화면의 메뉴 할당 제거) + await client.query( + `DELETE FROM screen_menu_assignments WHERE screen_id = $1`, + [screenId] + ); + }); + + deletedCount++; + logger.info(`화면 삭제 완료: ${screenId} (${existingScreen.screen_name})`); + } catch (error) { + skippedCount++; + errors.push({ + screenId, + error: error instanceof Error ? error.message : "알 수 없는 오류", + }); + logger.error(`화면 삭제 실패: ${screenId}`, error); + } + } + + logger.info( + `일괄 삭제 완료: 성공 ${deletedCount}개, 실패 ${skippedCount}개` + ); + + return { deletedCount, skippedCount, errors }; + } + /** * 휴지통 화면 일괄 영구 삭제 */ diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index f56ecb51..916fe60e 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -120,11 +120,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const [permanentDeleteDialogOpen, setPermanentDeleteDialogOpen] = useState(false); const [screenToPermanentDelete, setScreenToPermanentDelete] = useState(null); - // 일괄삭제 관련 상태 + // 휴지통 일괄삭제 관련 상태 const [selectedScreenIds, setSelectedScreenIds] = useState([]); const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false); + // 활성 화면 일괄삭제 관련 상태 + const [selectedActiveScreenIds, setSelectedActiveScreenIds] = useState([]); + const [activeBulkDeleteDialogOpen, setActiveBulkDeleteDialogOpen] = useState(false); + const [activeBulkDeleteReason, setActiveBulkDeleteReason] = useState(""); + const [activeBulkDeleting, setActiveBulkDeleting] = useState(false); + // 편집 관련 상태 const [editDialogOpen, setEditDialogOpen] = useState(false); const [screenToEdit, setScreenToEdit] = useState(null); @@ -479,7 +485,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } }; - // 체크박스 선택 처리 + // 휴지통 체크박스 선택 처리 const handleScreenCheck = (screenId: number, checked: boolean) => { if (checked) { setSelectedScreenIds((prev) => [...prev, screenId]); @@ -488,7 +494,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } }; - // 전체 선택/해제 + // 휴지통 전체 선택/해제 const handleSelectAll = (checked: boolean) => { if (checked) { setSelectedScreenIds(deletedScreens.map((screen) => screen.screenId)); @@ -497,7 +503,7 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr } }; - // 일괄삭제 실행 + // 휴지통 일괄삭제 실행 const handleBulkDelete = () => { if (selectedScreenIds.length === 0) { alert("삭제할 화면을 선택해주세요."); @@ -506,6 +512,70 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr setBulkDeleteDialogOpen(true); }; + // 활성 화면 체크박스 선택 처리 + const handleActiveScreenCheck = (screenId: number, checked: boolean) => { + if (checked) { + setSelectedActiveScreenIds((prev) => [...prev, screenId]); + } else { + setSelectedActiveScreenIds((prev) => prev.filter((id) => id !== screenId)); + } + }; + + // 활성 화면 전체 선택/해제 + const handleActiveSelectAll = (checked: boolean) => { + if (checked) { + setSelectedActiveScreenIds(screens.map((screen) => screen.screenId)); + } else { + setSelectedActiveScreenIds([]); + } + }; + + // 활성 화면 일괄삭제 실행 + const handleActiveBulkDelete = () => { + if (selectedActiveScreenIds.length === 0) { + alert("삭제할 화면을 선택해주세요."); + return; + } + setActiveBulkDeleteDialogOpen(true); + }; + + // 활성 화면 일괄삭제 확인 + const confirmActiveBulkDelete = async () => { + if (selectedActiveScreenIds.length === 0) return; + + try { + setActiveBulkDeleting(true); + const result = await screenApi.bulkDeleteScreens( + selectedActiveScreenIds, + activeBulkDeleteReason || undefined, + true // 강제 삭제 (의존성 무시) + ); + + // 삭제된 화면들을 목록에서 제거 + setScreens((prev) => prev.filter((screen) => !selectedActiveScreenIds.includes(screen.screenId))); + + setSelectedActiveScreenIds([]); + setActiveBulkDeleteDialogOpen(false); + setActiveBulkDeleteReason(""); + + // 결과 메시지 표시 + let message = `${result.deletedCount}개 화면이 휴지통으로 이동되었습니다.`; + if (result.skippedCount > 0) { + message += `\n${result.skippedCount}개 화면은 삭제되지 않았습니다.`; + } + if (result.errors.length > 0) { + message += `\n오류 발생: ${result.errors.map((e) => `화면 ${e.screenId}: ${e.error}`).join(", ")}`; + } + + alert(message); + } catch (error) { + console.error("일괄 삭제 실패:", error); + alert("일괄 삭제에 실패했습니다."); + } finally { + setActiveBulkDeleting(false); + } + }; + const confirmBulkDelete = async () => { if (selectedScreenIds.length === 0) return; @@ -633,7 +703,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 탭 구조 */} - + { + setActiveTab(value); + // 탭 전환 시 선택 상태 초기화 + setSelectedActiveScreenIds([]); + setSelectedScreenIds([]); + }}> 활성 화면 휴지통 @@ -641,11 +716,47 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr {/* 활성 화면 탭 */} + {/* 선택 삭제 헤더 (선택된 항목이 있을 때만 표시) */} + {selectedActiveScreenIds.length > 0 && ( +
+ + {selectedActiveScreenIds.length}개 화면 선택됨 + +
+ + +
+
+ )} + {/* 데스크톱 테이블 뷰 (lg 이상) */}
+ + 0 && selectedActiveScreenIds.length === screens.length} + onCheckedChange={handleActiveSelectAll} + aria-label="전체 선택" + /> + 화면명 테이블명 상태 @@ -659,9 +770,17 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr key={screen.screenId} className={`bg-background hover:bg-muted/50 cursor-pointer border-b transition-colors ${ selectedScreen?.screenId === screen.screenId ? "border-primary/20 bg-accent" : "" - }`} + } ${selectedActiveScreenIds.includes(screen.screenId) ? "bg-muted/30" : ""}`} onClick={() => onDesignScreen(screen)} > + + handleActiveScreenCheck(screen.screenId, checked as boolean)} + onClick={(e) => e.stopPropagation()} + aria-label={`${screen.screenName} 선택`} + /> +
{screen.screenName}
@@ -757,24 +876,57 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
{/* 모바일/태블릿 카드 뷰 (lg 미만) */} -
- {screens.map((screen) => ( -
handleScreenSelect(screen)} - > - {/* 헤더 */} -
-
-

{screen.screenName}

+
+ {/* 선택 헤더 */} +
+
+ 0 && selectedActiveScreenIds.length === screens.length} + onCheckedChange={handleActiveSelectAll} + aria-label="전체 선택" + /> + 전체 선택 +
+ {selectedActiveScreenIds.length > 0 && ( + + )} +
+ + {/* 카드 목록 */} +
+ {screens.map((screen) => ( +
handleScreenSelect(screen)} + > + {/* 헤더 */} +
+ handleActiveScreenCheck(screen.screenId, checked as boolean)} + onClick={(e) => e.stopPropagation()} + className="mt-1" + aria-label={`${screen.screenName} 선택`} + /> +
+

{screen.screenName}

+
+ + {screen.isActive === "Y" ? "활성" : "비활성"} +
- - {screen.isActive === "Y" ? "활성" : "비활성"} - -
{/* 설명 */} {screen.description &&

{screen.description}

} @@ -863,11 +1015,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
))} - {filteredScreens.length === 0 && ( -
-

검색 결과가 없습니다.

-
- )} + {filteredScreens.length === 0 && ( +
+

검색 결과가 없습니다.

+
+ )} +
@@ -1225,13 +1378,13 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr - {/* 일괄삭제 확인 다이얼로그 */} + {/* 휴지통 일괄삭제 확인 다이얼로그 */} 일괄 영구 삭제 확인 - ⚠️ 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까? + 선택된 {selectedScreenIds.length}개 화면을 영구적으로 삭제하시겠습니까?
이 작업은 되돌릴 수 없습니다!
@@ -1254,6 +1407,44 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
+ {/* 활성 화면 일괄삭제 확인 다이얼로그 */} + + + + 선택 화면 삭제 확인 + + 선택된 {selectedActiveScreenIds.length}개 화면을 휴지통으로 이동하시겠습니까? +
+ 휴지통에서 언제든지 복원할 수 있습니다. +
+
+
+ +