From 1d1597f8e7ceedf9fca41aff76d59dae486ad7b5 Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 24 Nov 2025 10:02:56 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EB=A9=94=EB=89=B4=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EB=8A=94=20=ED=98=84=EC=83=81=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 --- backend-node/src/controllers/adminController.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 746bf931..a7e8404f 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1324,7 +1324,7 @@ export async function updateMenu( if (!menuUrl) { await query( `UPDATE screen_menu_assignments - SET is_active = 'N', updated_date = NOW() + SET is_active = 'N' WHERE menu_objid = $1 AND company_code = $2`, [Number(menuId), companyCode] ); From ddb1d4cf60a9ba9e04120ca00e6b645230b5e13e Mon Sep 17 00:00:00 2001 From: kjs Date: Mon, 24 Nov 2025 12:02:23 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=A2=8C=EC=9A=B0=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=94=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/main/page.tsx | 2 +- frontend/app/(main)/page.tsx | 2 +- .../app/(main)/screens/[screenId]/page.tsx | 968 +++++++++--------- frontend/components/layout/AppLayout.tsx | 2 +- .../screen/ResponsiveDesignerContainer.tsx | 8 +- 5 files changed, 504 insertions(+), 478 deletions(-) diff --git a/frontend/app/(main)/main/page.tsx b/frontend/app/(main)/main/page.tsx index 45f75f67..00ef509b 100644 --- a/frontend/app/(main)/main/page.tsx +++ b/frontend/app/(main)/main/page.tsx @@ -9,7 +9,7 @@ import { Badge } from "@/components/ui/badge"; */ export default function MainPage() { return ( -
+
{/* 메인 컨텐츠 */} {/* Welcome Message */} diff --git a/frontend/app/(main)/page.tsx b/frontend/app/(main)/page.tsx index 53c6dfb1..f5d7a153 100644 --- a/frontend/app/(main)/page.tsx +++ b/frontend/app/(main)/page.tsx @@ -1,6 +1,6 @@ export default function MainHomePage() { return ( -
+
{/* 대시보드 컨텐츠 */}

WACE 솔루션에 오신 것을 환영합니다!

diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 3b75f262..ce99a685 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -26,7 +26,7 @@ function ScreenViewPage() { const searchParams = useSearchParams(); const router = useRouter(); const screenId = parseInt(params.screenId as string); - + // URL 쿼리에서 menuObjid 가져오기 (메뉴 스코프) const menuObjid = searchParams.get("menuObjid") ? parseInt(searchParams.get("menuObjid")!) : undefined; @@ -178,31 +178,26 @@ function ScreenViewPage() { for (const comp of layout.components) { // type: "component" 또는 type: "widget" 모두 처리 - if (comp.type === 'widget' || comp.type === 'component') { + if (comp.type === "widget" || comp.type === "component") { const widget = comp as any; const fieldName = widget.columnName || widget.id; - + // autoFill 처리 if (widget.autoFill?.enabled || (comp as any).autoFill?.enabled) { const autoFillConfig = widget.autoFill || (comp as any).autoFill; const currentValue = formData[fieldName]; - - if (currentValue === undefined || currentValue === '') { + + if (currentValue === undefined || currentValue === "") { const { sourceTable, filterColumn, userField, displayColumn } = autoFillConfig; - + // 사용자 정보에서 필터 값 가져오기 const userValue = user?.[userField as keyof typeof user]; - + if (userValue && sourceTable && filterColumn && displayColumn) { try { const { tableTypeApi } = await import("@/lib/api/screen"); - const result = await tableTypeApi.getTableRecord( - sourceTable, - filterColumn, - userValue, - displayColumn - ); - + const result = await tableTypeApi.getTableRecord(sourceTable, filterColumn, userValue, displayColumn); + setFormData((prev) => ({ ...prev, [fieldName]: result.value, @@ -233,24 +228,27 @@ function ScreenViewPage() { const designWidth = layout?.screenResolution?.width || 1200; const designHeight = layout?.screenResolution?.height || 800; - // containerRef는 이미 패딩이 적용된 영역 내부이므로 offsetWidth는 패딩을 제외한 크기입니다 + // 컨테이너의 실제 크기 const containerWidth = containerRef.current.offsetWidth; const containerHeight = containerRef.current.offsetHeight; - // 화면이 잘리지 않도록 가로/세로 중 작은 쪽 기준으로 스케일 조정 - const scaleX = containerWidth / designWidth; - const scaleY = containerHeight / designHeight; - // 전체 화면이 보이도록 작은 쪽 기준으로 스케일 설정 - const newScale = Math.min(scaleX, scaleY); - + // 여백 설정: 좌우 16px씩 (총 32px), 상단 패딩 32px (pt-8) + const MARGIN_X = 32; + const availableWidth = containerWidth - MARGIN_X; + + // 가로 기준 스케일 계산 (좌우 여백 16px씩 고정) + const newScale = availableWidth / designWidth; + console.log("📐 스케일 계산:", { containerWidth, containerHeight, + MARGIN_X, + availableWidth, designWidth, designHeight, - scaleX, - scaleY, finalScale: newScale, + "스케일된 화면 크기": `${designWidth * newScale}px × ${designHeight * newScale}px`, + "실제 좌우 여백": `${(containerWidth - designWidth * newScale) / 2}px씩`, }); setScale(newScale); @@ -307,503 +305,531 @@ function ScreenViewPage() { return ( -
- {/* 레이아웃 준비 중 로딩 표시 */} - {!layoutReady && ( -
-
- -

화면 준비 중...

-
+
+ {/* 레이아웃 준비 중 로딩 표시 */} + {!layoutReady && ( +
+
+ +

화면 준비 중...

- )} +
+ )} - {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} - {layoutReady && layout && layout.components.length > 0 ? ( -
- {/* 최상위 컴포넌트들 렌더링 */} - {(() => { - // 🆕 플로우 버튼 그룹 감지 및 처리 - const topLevelComponents = layout.components.filter((component) => !component.parentId); + {/* 절대 위치 기반 렌더링 (화면관리와 동일한 방식) */} + {layoutReady && layout && layout.components.length > 0 ? ( +
+ {/* 최상위 컴포넌트들 렌더링 */} + {(() => { + // 🆕 플로우 버튼 그룹 감지 및 처리 + const topLevelComponents = layout.components.filter((component) => !component.parentId); - // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 - // 모든 컴포넌트는 원본 위치 그대로 사용 - const widthOffset = 0; + // 화면 관리에서 설정한 해상도를 사용하므로 widthOffset 계산 불필요 + // 모든 컴포넌트는 원본 위치 그대로 사용 + const widthOffset = 0; - const buttonGroups: Record = {}; - const processedButtonIds = new Set(); - // 🔍 전체 버튼 목록 확인 - const allButtons = topLevelComponents.filter((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); - return isButton; - }); + const buttonGroups: Record = {}; + const processedButtonIds = new Set(); + // 🔍 전체 버튼 목록 확인 + const allButtons = topLevelComponents.filter((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); + return isButton; + }); - console.log( - "🔍 메뉴에서 발견된 전체 버튼:", - allButtons.map((b) => ({ - id: b.id, - label: b.label, - positionX: b.position.x, - positionY: b.position.y, - width: b.size?.width, - height: b.size?.height, - })), - ); + console.log( + "🔍 메뉴에서 발견된 전체 버튼:", + allButtons.map((b) => ({ + id: b.id, + label: b.label, + positionX: b.position.x, + positionY: b.position.y, + width: b.size?.width, + height: b.size?.height, + })), + ); - topLevelComponents.forEach((component) => { - const isButton = - (component.type === "component" && - ["button-primary", "button-secondary"].includes((component as any).componentType)) || - (component.type === "widget" && (component as any).widgetType === "button"); + topLevelComponents.forEach((component) => { + const isButton = + (component.type === "component" && + ["button-primary", "button-secondary"].includes((component as any).componentType)) || + (component.type === "widget" && (component as any).widgetType === "button"); - if (isButton) { - const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as - | FlowVisibilityConfig - | undefined; + if (isButton) { + const flowConfig = (component as any).webTypeConfig?.flowVisibilityConfig as + | FlowVisibilityConfig + | undefined; - // 🔧 임시: 버튼 그룹 기능 완전 비활성화 - // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 - const DISABLE_BUTTON_GROUPS = false; + // 🔧 임시: 버튼 그룹 기능 완전 비활성화 + // TODO: 사용자가 명시적으로 그룹을 원하는 경우에만 활성화하도록 UI 개선 필요 + const DISABLE_BUTTON_GROUPS = false; - if ( - !DISABLE_BUTTON_GROUPS && - flowConfig?.enabled && - flowConfig.layoutBehavior === "auto-compact" && - flowConfig.groupId - ) { - if (!buttonGroups[flowConfig.groupId]) { - buttonGroups[flowConfig.groupId] = []; + if ( + !DISABLE_BUTTON_GROUPS && + flowConfig?.enabled && + flowConfig.layoutBehavior === "auto-compact" && + flowConfig.groupId + ) { + if (!buttonGroups[flowConfig.groupId]) { + buttonGroups[flowConfig.groupId] = []; + } + buttonGroups[flowConfig.groupId].push(component); + processedButtonIds.add(component.id); } - buttonGroups[flowConfig.groupId].push(component); - processedButtonIds.add(component.id); + // else: 모든 버튼을 개별 렌더링 } - // else: 모든 버튼을 개별 렌더링 - } - }); + }); - const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); + const regularComponents = topLevelComponents.filter((c) => !processedButtonIds.has(c.id)); - // TableSearchWidget들을 먼저 찾기 - const tableSearchWidgets = regularComponents.filter( - (c) => (c as any).componentId === "table-search-widget" - ); + // TableSearchWidget들을 먼저 찾기 + const tableSearchWidgets = regularComponents.filter( + (c) => (c as any).componentId === "table-search-widget", + ); - // 디버그: 모든 컴포넌트 타입 확인 - console.log("🔍 전체 컴포넌트 타입:", regularComponents.map(c => ({ - id: c.id, - type: c.type, - componentType: (c as any).componentType, - componentId: (c as any).componentId, - }))); - - // 🆕 조건부 컨테이너들을 찾기 - const conditionalContainers = regularComponents.filter( - (c) => (c as any).componentId === "conditional-container" || (c as any).componentType === "conditional-container" - ); - - console.log("🔍 조건부 컨테이너 발견:", conditionalContainers.map(c => ({ - id: c.id, - y: c.position.y, - size: c.size, - }))); + // 디버그: 모든 컴포넌트 타입 확인 + console.log( + "🔍 전체 컴포넌트 타입:", + regularComponents.map((c) => ({ + id: c.id, + type: c.type, + componentType: (c as any).componentType, + componentId: (c as any).componentId, + })), + ); - // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 - const adjustedComponents = regularComponents.map((component) => { - const isTableSearchWidget = (component as any).componentId === "table-search-widget"; - const isConditionalContainer = (component as any).componentId === "conditional-container"; - - if (isTableSearchWidget || isConditionalContainer) { - // 자기 자신은 조정하지 않음 - return component; - } - - let totalHeightAdjustment = 0; - - // TableSearchWidget 높이 조정 - for (const widget of tableSearchWidgets) { - const isBelow = component.position.y > widget.position.y; - const heightDiff = getHeightDiff(screenId, widget.id); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; + // 🆕 조건부 컨테이너들을 찾기 + const conditionalContainers = regularComponents.filter( + (c) => + (c as any).componentId === "conditional-container" || + (c as any).componentType === "conditional-container", + ); + + console.log( + "🔍 조건부 컨테이너 발견:", + conditionalContainers.map((c) => ({ + id: c.id, + y: c.position.y, + size: c.size, + })), + ); + + // TableSearchWidget 및 조건부 컨테이너 높이 차이를 계산하여 Y 위치 조정 + const adjustedComponents = regularComponents.map((component) => { + const isTableSearchWidget = (component as any).componentId === "table-search-widget"; + const isConditionalContainer = (component as any).componentId === "conditional-container"; + + if (isTableSearchWidget || isConditionalContainer) { + // 자기 자신은 조정하지 않음 + return component; } - } - - // 🆕 조건부 컨테이너 높이 조정 - for (const container of conditionalContainers) { - const isBelow = component.position.y > container.position.y; - const actualHeight = conditionalContainerHeights[container.id]; - const originalHeight = container.size?.height || 200; - const heightDiff = actualHeight ? (actualHeight - originalHeight) : 0; - - console.log(`🔍 높이 조정 체크:`, { - componentId: component.id, - componentY: component.position.y, - containerY: container.position.y, - isBelow, - actualHeight, - originalHeight, - heightDiff, - containerId: container.id, - containerSize: container.size, - }); - - if (isBelow && heightDiff > 0) { - totalHeightAdjustment += heightDiff; - console.log(`📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`); + + let totalHeightAdjustment = 0; + + // TableSearchWidget 높이 조정 + for (const widget of tableSearchWidgets) { + const isBelow = component.position.y > widget.position.y; + const heightDiff = getHeightDiff(screenId, widget.id); + + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + } } - } - - if (totalHeightAdjustment > 0) { - return { - ...component, - position: { - ...component.position, - y: component.position.y + totalHeightAdjustment, - }, - }; - } - - return component; - }); - return ( - <> - {/* 일반 컴포넌트들 */} - {adjustedComponents.map((component) => { - // 화면 관리 해상도를 사용하므로 위치 조정 불필요 - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터:", selectedData); - console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터:", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); // 선택 해제 - setFlowSelectedStepId(null); - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - onHeightChange={(componentId, newHeight) => { - setConditionalContainerHeights((prev) => ({ - ...prev, - [componentId]: newHeight, - })); - }} - > - {/* 자식 컴포넌트들 */} - {(component.type === "group" || component.type === "container" || component.type === "area") && - layout.components - .filter((child) => child.parentId === component.id) - .map((child) => { - // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 - const relativeChildComponent = { - ...child, - position: { - x: child.position.x - component.position.x, - y: child.position.y - component.position.y, - z: child.position.z || 1, - }, - }; + // 🆕 조건부 컨테이너 높이 조정 + for (const container of conditionalContainers) { + const isBelow = component.position.y > container.position.y; + const actualHeight = conditionalContainerHeights[container.id]; + const originalHeight = container.size?.height || 200; + const heightDiff = actualHeight ? actualHeight - originalHeight : 0; - return ( - {}} - menuObjid={menuObjid} - screenId={screenId} - tableName={screen?.tableName} - userId={user?.userId} - userName={userName} - companyCode={companyCode} - menuObjid={menuObjid} - selectedRowsData={selectedRowsData} - sortBy={tableSortBy} - sortOrder={tableSortOrder} - columnOrder={tableColumnOrder} - tableDisplayData={tableDisplayData} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { - console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); - console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); - console.log("📊 화면 표시 데이터 (자식):", { count: tableDisplayData?.length, firstRow: tableDisplayData?.[0] }); - setSelectedRowsData(selectedData); - setTableSortBy(sortBy); - setTableSortOrder(sortOrder || "asc"); - setTableColumnOrder(columnOrder); - setTableDisplayData(tableDisplayData || []); - }} - refreshKey={tableRefreshKey} - onRefresh={() => { - console.log("🔄 테이블 새로고침 요청됨 (자식)"); - setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); // 선택 해제 - }} - formData={formData} - onFormDataChange={(fieldName, value) => { - setFormData((prev) => ({ ...prev, [fieldName]: value })); - }} - /> - ); - })} - - ); - })} - - {/* 🆕 플로우 버튼 그룹들 */} - {Object.entries(buttonGroups).map(([groupId, buttons]) => { - if (buttons.length === 0) return null; - - const firstButton = buttons[0]; - const groupConfig = (firstButton as any).webTypeConfig - ?.flowVisibilityConfig as FlowVisibilityConfig; - - // 🔍 버튼 그룹 설정 확인 - console.log("🔍 버튼 그룹 설정:", { - groupId, - buttonCount: buttons.length, - buttons: buttons.map((b) => ({ - id: b.id, - label: b.label, - x: b.position.x, - y: b.position.y, - })), - groupConfig: { - layoutBehavior: groupConfig.layoutBehavior, - groupDirection: groupConfig.groupDirection, - groupAlign: groupConfig.groupAlign, - groupGap: groupConfig.groupGap, - }, + console.log(`🔍 높이 조정 체크:`, { + componentId: component.id, + componentY: component.position.y, + containerY: container.position.y, + isBelow, + actualHeight, + originalHeight, + heightDiff, + containerId: container.id, + containerSize: container.size, }); - // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, - // 각 버튼의 상대 위치는 원래 위치를 유지 - const firstButtonPosition = { - x: buttons[0].position.x, - y: buttons[0].position.y, - z: buttons[0].position.z || 2, - }; - - // 버튼 그룹 위치에도 widthOffset 적용 - const adjustedGroupPosition = { - ...firstButtonPosition, - x: firstButtonPosition.x + widthOffset, - }; - - // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 - const direction = groupConfig.groupDirection || "horizontal"; - const gap = groupConfig.groupGap ?? 8; - - let groupWidth = 0; - let groupHeight = 0; - - if (direction === "horizontal") { - groupWidth = buttons.reduce((total, button, index) => { - const buttonWidth = button.size?.width || 100; - const gapWidth = index < buttons.length - 1 ? gap : 0; - return total + buttonWidth + gapWidth; - }, 0); - groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); - } else { - groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); - groupHeight = buttons.reduce((total, button, index) => { - const buttonHeight = button.size?.height || 40; - const gapHeight = index < buttons.length - 1 ? gap : 0; - return total + buttonHeight + gapHeight; - }, 0); + if (isBelow && heightDiff > 0) { + totalHeightAdjustment += heightDiff; + console.log( + `📐 컴포넌트 ${component.id} 위치 조정: ${heightDiff}px (조건부 컨테이너 ${container.id})`, + ); } + } - return ( -
- 0) { + return { + ...component, + position: { + ...component.position, + y: component.position.y + totalHeightAdjustment, + }, + }; + } + + return component; + }); + + return ( + <> + {/* 일반 컴포넌트들 */} + {adjustedComponents.map((component) => { + // 화면 관리 해상도를 사용하므로 위치 조정 불필요 + return ( + { - // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 - const relativeButton = { - ...button, - position: { - x: button.position.x - firstButtonPosition.x, - y: button.position.y - firstButtonPosition.y, - z: button.position.z || 1, - }, - }; + onClick={() => {}} + menuObjid={menuObjid} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + menuObjid={menuObjid} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + tableDisplayData={tableDisplayData} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder, tableDisplayData) => { + console.log("🔍 화면에서 선택된 행 데이터:", selectedData); + console.log("📊 정렬 정보:", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터:", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + setTableDisplayData(tableDisplayData || []); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); // 선택 해제 + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); // 선택 해제 + setFlowSelectedStepId(null); + }} + formData={formData} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + onHeightChange={(componentId, newHeight) => { + setConditionalContainerHeights((prev) => ({ + ...prev, + [componentId]: newHeight, + })); + }} + > + {/* 자식 컴포넌트들 */} + {(component.type === "group" || + component.type === "container" || + component.type === "area") && + layout.components + .filter((child) => child.parentId === component.id) + .map((child) => { + // 자식 컴포넌트의 위치를 부모 기준 상대 좌표로 조정 + const relativeChildComponent = { + ...child, + position: { + x: child.position.x - component.position.x, + y: child.position.y - component.position.y, + z: child.position.z || 1, + }, + }; - return ( -
-
- {}} + onClick={() => {}} + menuObjid={menuObjid} screenId={screenId} tableName={screen?.tableName} userId={user?.userId} userName={userName} companyCode={companyCode} - tableDisplayData={tableDisplayData} + menuObjid={menuObjid} selectedRowsData={selectedRowsData} sortBy={tableSortBy} sortOrder={tableSortOrder} columnOrder={tableColumnOrder} - onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + tableDisplayData={tableDisplayData} + onSelectedRowsChange={( + _, + selectedData, + sortBy, + sortOrder, + columnOrder, + tableDisplayData, + ) => { + console.log("🔍 화면에서 선택된 행 데이터 (자식):", selectedData); + console.log("📊 정렬 정보 (자식):", { sortBy, sortOrder, columnOrder }); + console.log("📊 화면 표시 데이터 (자식):", { + count: tableDisplayData?.length, + firstRow: tableDisplayData?.[0], + }); setSelectedRowsData(selectedData); setTableSortBy(sortBy); setTableSortOrder(sortOrder || "asc"); setTableColumnOrder(columnOrder); - }} - flowSelectedData={flowSelectedData} - flowSelectedStepId={flowSelectedStepId} - onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { - setFlowSelectedData(selectedData); - setFlowSelectedStepId(stepId); + setTableDisplayData(tableDisplayData || []); }} refreshKey={tableRefreshKey} onRefresh={() => { + console.log("🔄 테이블 새로고침 요청됨 (자식)"); setTableRefreshKey((prev) => prev + 1); - setSelectedRowsData([]); - }} - flowRefreshKey={flowRefreshKey} - onFlowRefresh={() => { - setFlowRefreshKey((prev) => prev + 1); - setFlowSelectedData([]); - setFlowSelectedStepId(null); + setSelectedRowsData([]); // 선택 해제 }} + formData={formData} onFormDataChange={(fieldName, value) => { setFormData((prev) => ({ ...prev, [fieldName]: value })); }} /> -
-
- ); - }} - /> -
- ); - })} - - ); - })()} -
- ) : ( - // 빈 화면일 때 -
-
-
- 📄 -
-

화면이 비어있습니다

-

이 화면에는 아직 설계된 컴포넌트가 없습니다.

-
-
- )} + ); + })} + + ); + })} - {/* 편집 모달 */} - { - setEditModalOpen(false); - setEditModalConfig({}); - }} - screenId={editModalConfig.screenId} - modalSize={editModalConfig.modalSize} - editData={editModalConfig.editData} - onSave={editModalConfig.onSave} - modalTitle={editModalConfig.modalTitle} - modalDescription={editModalConfig.modalDescription} - onDataChange={(changedFormData) => { - console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); - // 변경된 데이터를 메인 폼에 반영 - setFormData((prev) => { - const updatedFormData = { - ...prev, - ...changedFormData, // 변경된 필드들만 업데이트 - }; - console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); - return updatedFormData; - }); - }} - /> + {/* 🆕 플로우 버튼 그룹들 */} + {Object.entries(buttonGroups).map(([groupId, buttons]) => { + if (buttons.length === 0) return null; + + const firstButton = buttons[0]; + const groupConfig = (firstButton as any).webTypeConfig + ?.flowVisibilityConfig as FlowVisibilityConfig; + + // 🔍 버튼 그룹 설정 확인 + console.log("🔍 버튼 그룹 설정:", { + groupId, + buttonCount: buttons.length, + buttons: buttons.map((b) => ({ + id: b.id, + label: b.label, + x: b.position.x, + y: b.position.y, + })), + groupConfig: { + layoutBehavior: groupConfig.layoutBehavior, + groupDirection: groupConfig.groupDirection, + groupAlign: groupConfig.groupAlign, + groupGap: groupConfig.groupGap, + }, + }); + + // 🔧 수정: 그룹 컨테이너는 첫 번째 버튼 위치를 기준으로 하되, + // 각 버튼의 상대 위치는 원래 위치를 유지 + const firstButtonPosition = { + x: buttons[0].position.x, + y: buttons[0].position.y, + z: buttons[0].position.z || 2, + }; + + // 버튼 그룹 위치에도 widthOffset 적용 + const adjustedGroupPosition = { + ...firstButtonPosition, + x: firstButtonPosition.x + widthOffset, + }; + + // 그룹의 크기 계산: 버튼들의 실제 크기 + 간격을 기준으로 계산 + const direction = groupConfig.groupDirection || "horizontal"; + const gap = groupConfig.groupGap ?? 8; + + let groupWidth = 0; + let groupHeight = 0; + + if (direction === "horizontal") { + groupWidth = buttons.reduce((total, button, index) => { + const buttonWidth = button.size?.width || 100; + const gapWidth = index < buttons.length - 1 ? gap : 0; + return total + buttonWidth + gapWidth; + }, 0); + groupHeight = Math.max(...buttons.map((b) => b.size?.height || 40)); + } else { + groupWidth = Math.max(...buttons.map((b) => b.size?.width || 100)); + groupHeight = buttons.reduce((total, button, index) => { + const buttonHeight = button.size?.height || 40; + const gapHeight = index < buttons.length - 1 ? gap : 0; + return total + buttonHeight + gapHeight; + }, 0); + } + + return ( +
+ { + // 🔧 각 버튼의 상대 위치 = 버튼의 원래 위치 - 첫 번째 버튼 위치 + const relativeButton = { + ...button, + position: { + x: button.position.x - firstButtonPosition.x, + y: button.position.y - firstButtonPosition.y, + z: button.position.z || 1, + }, + }; + + return ( +
+
+ {}} + screenId={screenId} + tableName={screen?.tableName} + userId={user?.userId} + userName={userName} + companyCode={companyCode} + tableDisplayData={tableDisplayData} + selectedRowsData={selectedRowsData} + sortBy={tableSortBy} + sortOrder={tableSortOrder} + columnOrder={tableColumnOrder} + onSelectedRowsChange={(_, selectedData, sortBy, sortOrder, columnOrder) => { + setSelectedRowsData(selectedData); + setTableSortBy(sortBy); + setTableSortOrder(sortOrder || "asc"); + setTableColumnOrder(columnOrder); + }} + flowSelectedData={flowSelectedData} + flowSelectedStepId={flowSelectedStepId} + onFlowSelectedDataChange={(selectedData: any[], stepId: number | null) => { + setFlowSelectedData(selectedData); + setFlowSelectedStepId(stepId); + }} + refreshKey={tableRefreshKey} + onRefresh={() => { + setTableRefreshKey((prev) => prev + 1); + setSelectedRowsData([]); + }} + flowRefreshKey={flowRefreshKey} + onFlowRefresh={() => { + setFlowRefreshKey((prev) => prev + 1); + setFlowSelectedData([]); + setFlowSelectedStepId(null); + }} + onFormDataChange={(fieldName, value) => { + setFormData((prev) => ({ ...prev, [fieldName]: value })); + }} + /> +
+
+ ); + }} + /> +
+ ); + })} + + ); + })()} +
+ ) : ( + // 빈 화면일 때 +
+
+
+ 📄 +
+

화면이 비어있습니다

+

이 화면에는 아직 설계된 컴포넌트가 없습니다.

+
+
+ )} + + {/* 편집 모달 */} + { + setEditModalOpen(false); + setEditModalConfig({}); + }} + screenId={editModalConfig.screenId} + modalSize={editModalConfig.modalSize} + editData={editModalConfig.editData} + onSave={editModalConfig.onSave} + modalTitle={editModalConfig.modalTitle} + modalDescription={editModalConfig.modalDescription} + onDataChange={(changedFormData) => { + console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData); + // 변경된 데이터를 메인 폼에 반영 + setFormData((prev) => { + const updatedFormData = { + ...prev, + ...changedFormData, // 변경된 필드들만 업데이트 + }; + console.log("📊 메인 폼 데이터 업데이트:", updatedFormData); + return updatedFormData; + }); + }} + />
diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index e87dc73d..8394cd6d 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -470,7 +470,7 @@ function AppLayoutInner({ children }: AppLayoutProps) { {/* 가운데 컨텐츠 영역 - 스크롤 가능 */}
-
{children}
+ {children}
diff --git a/frontend/components/screen/ResponsiveDesignerContainer.tsx b/frontend/components/screen/ResponsiveDesignerContainer.tsx index 57f55b14..392e59ce 100644 --- a/frontend/components/screen/ResponsiveDesignerContainer.tsx +++ b/frontend/components/screen/ResponsiveDesignerContainer.tsx @@ -35,9 +35,9 @@ export const ResponsiveDesignerContainer: React.FC Date: Mon, 24 Nov 2025 17:24:47 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=ED=83=AD=EA=B8=B0=EB=8A=A5=20=EC=A4=91?= =?UTF-8?q?=EA=B0=84=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 39 +- .../screen/InteractiveScreenViewer.tsx | 31 + .../components/screen/RealtimePreview.tsx | 38 ++ .../screen/config-panels/TabsConfigPanel.tsx | 391 +++++++++++++ .../screen/panels/UnifiedPropertiesPanel.tsx | 24 +- .../components/screen/widgets/TabsWidget.tsx | 319 +++++------ frontend/lib/registry/components/index.ts | 3 + .../components/tabs/tabs-component.tsx | 131 +++++ .../lib/utils/getComponentConfigPanel.tsx | 3 + .../lib/utils/getConfigPanelComponent.tsx | 24 + frontend/types/screen-management.ts | 46 +- frontend/types/unified-core.ts | 3 +- 시연_시나리오.md | 542 ++++++++++++++++++ 13 files changed, 1403 insertions(+), 191 deletions(-) create mode 100644 frontend/components/screen/config-panels/TabsConfigPanel.tsx create mode 100644 frontend/lib/registry/components/tabs/tabs-component.tsx create mode 100644 시연_시나리오.md diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a7e8404f..da0ea772 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1097,7 +1097,11 @@ export async function saveMenu( let requestCompanyCode = menuData.companyCode || menuData.company_code; // "none"이나 빈 값은 undefined로 처리하여 사용자 회사 코드 사용 - if (requestCompanyCode === "none" || requestCompanyCode === "" || !requestCompanyCode) { + if ( + requestCompanyCode === "none" || + requestCompanyCode === "" || + !requestCompanyCode + ) { requestCompanyCode = undefined; } @@ -1252,7 +1256,8 @@ export async function updateMenu( } } - const requestCompanyCode = menuData.companyCode || menuData.company_code || currentMenu.company_code; + const requestCompanyCode = + menuData.companyCode || menuData.company_code || currentMenu.company_code; // company_code 변경 시도하는 경우 권한 체크 if (requestCompanyCode !== currentMenu.company_code) { @@ -1268,7 +1273,10 @@ export async function updateMenu( } } // 회사 관리자는 자기 회사로만 변경 가능 - else if (userCompanyCode !== "*" && requestCompanyCode !== userCompanyCode) { + else if ( + userCompanyCode !== "*" && + requestCompanyCode !== userCompanyCode + ) { res.status(403).json({ success: false, message: "해당 회사로 변경할 권한이 없습니다.", @@ -1493,8 +1501,13 @@ export async function deleteMenusBatch( ); // 권한 체크: 공통 메뉴 포함 여부 확인 - const hasCommonMenu = menusToDelete.some((menu: any) => menu.company_code === "*"); - if (hasCommonMenu && (userCompanyCode !== "*" || userType !== "SUPER_ADMIN")) { + const hasCommonMenu = menusToDelete.some( + (menu: any) => menu.company_code === "*" + ); + if ( + hasCommonMenu && + (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") + ) { res.status(403).json({ success: false, message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.", @@ -1506,7 +1519,8 @@ export async function deleteMenusBatch( // 회사 관리자는 자기 회사 메뉴만 삭제 가능 if (userCompanyCode !== "*") { const unauthorizedMenus = menusToDelete.filter( - (menu: any) => menu.company_code !== userCompanyCode && menu.company_code !== "*" + (menu: any) => + menu.company_code !== userCompanyCode && menu.company_code !== "*" ); if (unauthorizedMenus.length > 0) { res.status(403).json({ @@ -2674,7 +2688,10 @@ export const getCompanyByCode = async ( res.status(200).json(response); } catch (error) { - logger.error("회사 정보 조회 실패", { error, companyCode: req.params.companyCode }); + logger.error("회사 정보 조회 실패", { + error, + companyCode: req.params.companyCode, + }); res.status(500).json({ success: false, message: "회사 정보 조회 중 오류가 발생했습니다.", @@ -2740,7 +2757,9 @@ export const updateCompany = async ( // 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외) if (business_registration_number && business_registration_number.trim()) { // 유효성 검증 - const businessNumberValidation = validateBusinessNumber(business_registration_number.trim()); + const businessNumberValidation = validateBusinessNumber( + business_registration_number.trim() + ); if (!businessNumberValidation.isValid) { res.status(400).json({ success: false, @@ -3283,7 +3302,9 @@ export async function copyMenu( // 권한 체크: 최고 관리자만 가능 if (!isSuperAdmin && userType !== "SUPER_ADMIN") { - logger.warn(`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`); + logger.warn( + `권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})` + ); res.status(403).json({ success: false, message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다", diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 8c93380e..4b5d70e8 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -397,6 +397,37 @@ export const InteractiveScreenViewer: React.FC = ( ); } + // 탭 컴포넌트 처리 + if (comp.type === "tabs" || (comp.type === "component" && comp.componentId === "tabs-widget")) { + const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; + + // componentConfig에서 탭 정보 추출 + const tabsConfig = comp.componentConfig || {}; + const tabsComponent = { + ...comp, + type: "tabs" as const, + tabs: tabsConfig.tabs || [], + defaultTab: tabsConfig.defaultTab, + orientation: tabsConfig.orientation || "horizontal", + variant: tabsConfig.variant || "default", + allowCloseable: tabsConfig.allowCloseable || false, + persistSelection: tabsConfig.persistSelection || false, + }; + + console.log("🔍 탭 컴포넌트 렌더링:", { + originalType: comp.type, + componentId: (comp as any).componentId, + tabs: tabsComponent.tabs, + tabsConfig, + }); + + return ( +
+ +
+ ); + } + const { widgetType, label, placeholder, required, readonly, columnName } = comp; const fieldName = columnName || comp.id; const currentValue = formData[fieldName] || ""; diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 81f8dc21..7a0dbc34 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -554,6 +554,44 @@ export const RealtimePreviewDynamic: React.FC = ({ ); })()} + {/* 탭 컴포넌트 타입 */} + {(type === "tabs" || (type === "component" && (component as any).componentId === "tabs-widget")) && + (() => { + const tabsComponent = component as any; + // componentConfig에서 탭 정보 가져오기 + const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || []; + + return ( +
+
+
+ +
+

탭 컴포넌트

+

+ {tabs.length > 0 + ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` + : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} +

+ {tabs.length > 0 && ( +
+ {tabs.map((tab: any, index: number) => ( + + {tab.label || `탭 ${index + 1}`} + {tab.screenName && ( + + ({tab.screenName}) + + )} + + ))} +
+ )} +
+
+ ); + })()} + {/* 그룹 타입 */} {type === "group" && (
diff --git a/frontend/components/screen/config-panels/TabsConfigPanel.tsx b/frontend/components/screen/config-panels/TabsConfigPanel.tsx new file mode 100644 index 00000000..7d401e69 --- /dev/null +++ b/frontend/components/screen/config-panels/TabsConfigPanel.tsx @@ -0,0 +1,391 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { Check, ChevronsUpDown, Plus, X, GripVertical, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { TabItem, TabsComponent } from "@/types/screen-management"; + +interface TabsConfigPanelProps { + config: any; + onChange: (config: any) => void; +} + +interface ScreenInfo { + screenId: number; + screenName: string; + screenCode: string; + tableName: string; +} + +export function TabsConfigPanel({ config, onChange }: TabsConfigPanelProps) { + const [screens, setScreens] = useState([]); + const [loading, setLoading] = useState(false); + const [localTabs, setLocalTabs] = useState(config.tabs || []); + + // 화면 목록 로드 + useEffect(() => { + const loadScreens = async () => { + try { + setLoading(true); + + // API 클라이언트 동적 import (named export 사용) + const { apiClient } = await import("@/lib/api/client"); + + // 전체 화면 목록 조회 (페이징 사이즈 크게) + const response = await apiClient.get("/screen-management/screens", { + params: { size: 1000 } + }); + + console.log("화면 목록 조회 성공:", response.data); + + if (response.data.success && response.data.data) { + setScreens(response.data.data); + } + } catch (error: any) { + console.error("Failed to load screens:", error); + console.error("Error response:", error.response?.data); + } finally { + setLoading(false); + } + }; + + loadScreens(); + }, []); + + // 컴포넌트 변경 시 로컬 상태 동기화 + useEffect(() => { + setLocalTabs(config.tabs || []); + }, [config.tabs]); + + // 탭 추가 + const handleAddTab = () => { + const newTab: TabItem = { + id: `tab-${Date.now()}`, + label: `새 탭 ${localTabs.length + 1}`, + order: localTabs.length, + disabled: false, + }; + + const updatedTabs = [...localTabs, newTab]; + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 제거 + const handleRemoveTab = (tabId: string) => { + const updatedTabs = localTabs.filter((tab) => tab.id !== tabId); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 라벨 변경 + const handleLabelChange = (tabId: string, label: string) => { + const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, label } : tab)); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 화면 선택 + const handleScreenSelect = (tabId: string, screenId: number, screenName: string) => { + const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, screenId, screenName } : tab)); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 비활성화 토글 + const handleDisabledToggle = (tabId: string, disabled: boolean) => { + const updatedTabs = localTabs.map((tab) => (tab.id === tabId ? { ...tab, disabled } : tab)); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + // 탭 순서 변경 + const handleMoveTab = (tabId: string, direction: "up" | "down") => { + const index = localTabs.findIndex((tab) => tab.id === tabId); + if ( + (direction === "up" && index === 0) || + (direction === "down" && index === localTabs.length - 1) + ) { + return; + } + + const newTabs = [...localTabs]; + const targetIndex = direction === "up" ? index - 1 : index + 1; + [newTabs[index], newTabs[targetIndex]] = [newTabs[targetIndex], newTabs[index]]; + + // order 값 재조정 + const updatedTabs = newTabs.map((tab, idx) => ({ ...tab, order: idx })); + setLocalTabs(updatedTabs); + onChange({ ...config, tabs: updatedTabs }); + }; + + return ( +
+
+

탭 설정

+ +
+ {/* 탭 방향 */} +
+ + +
+ + {/* 탭 스타일 */} +
+ + +
+ + {/* 선택 상태 유지 */} +
+
+ +

+ 페이지 새로고침 후에도 선택한 탭이 유지됩니다 +

+
+ onChange({ ...config, persistSelection: checked })} + /> +
+ + {/* 탭 닫기 버튼 */} +
+
+ +

+ 각 탭에 닫기 버튼을 표시합니다 +

+
+ onChange({ ...config, allowCloseable: checked })} + /> +
+
+
+ +
+
+

탭 목록

+ +
+ + {localTabs.length === 0 ? ( +
+

탭이 없습니다

+

+ 탭 추가 버튼을 클릭하여 새 탭을 생성하세요 +

+
+ ) : ( +
+ {localTabs.map((tab, index) => ( +
+
+
+ + 탭 {index + 1} +
+
+ + + +
+
+ +
+ {/* 탭 라벨 */} +
+ + handleLabelChange(tab.id, e.target.value)} + placeholder="탭 이름" + className="h-8 text-xs sm:h-9 sm:text-sm" + /> +
+ + {/* 화면 선택 */} +
+ + {loading ? ( +
+ + 로딩 중... +
+ ) : ( + + handleScreenSelect(tab.id, screenId, screenName) + } + /> + )} + {tab.screenName && ( +

+ 선택된 화면: {tab.screenName} +

+ )} +
+ + {/* 비활성화 */} +
+ + handleDisabledToggle(tab.id, checked)} + /> +
+
+
+ ))} +
+ )} +
+
+ ); +} + +// 화면 선택 Combobox 컴포넌트 +function ScreenSelectCombobox({ + screens, + selectedScreenId, + onSelect, +}: { + screens: ScreenInfo[]; + selectedScreenId?: number; + onSelect: (screenId: number, screenName: string) => void; +}) { + const [open, setOpen] = useState(false); + + const selectedScreen = screens.find((s) => s.screenId === selectedScreenId); + + return ( + + + + + + + + + + 화면을 찾을 수 없습니다. + + + {screens.map((screen) => ( + { + onSelect(screen.screenId, screen.screenName); + setOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {screen.screenName} + + 코드: {screen.screenCode} | 테이블: {screen.tableName} + +
+
+ ))} +
+
+
+
+
+ ); +} + diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 61051439..4485cb7e 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, Suspense } from "react"; import { Label } from "@/components/ui/label"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -341,14 +341,20 @@ export const UnifiedPropertiesPanel: React.FC = ({

{definition.name} 설정

- + +
설정 패널 로딩 중...
+
+ }> + +
); }; diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 03dec3ba..90608a4b 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -1,210 +1,187 @@ "use client"; import React, { useState, useEffect } from "react"; -import { TabsComponent, TabItem, ScreenDefinition } from "@/types"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { Card } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Loader2, FileQuestion } from "lucide-react"; -import { screenApi } from "@/lib/api/screen"; +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"; interface TabsWidgetProps { component: TabsComponent; - isPreview?: boolean; + className?: string; + style?: React.CSSProperties; } -/** - * 탭 위젯 컴포넌트 - * 각 탭에 다른 화면을 표시할 수 있습니다 - */ -export const TabsWidget: React.FC = ({ component, isPreview = false }) => { - // componentConfig에서 설정 읽기 (새 컴포넌트 시스템) - const config = (component as any).componentConfig || component; - const { tabs = [], defaultTab, orientation = "horizontal", variant = "default" } = config; - - // console.log("🔍 TabsWidget 렌더링:", { - // component, - // componentConfig: (component as any).componentConfig, - // tabs, - // tabsLength: tabs.length - // }); +export function TabsWidget({ component, className, style }: TabsWidgetProps) { + const { + tabs = [], + defaultTab, + orientation = "horizontal", + variant = "default", + allowCloseable = false, + persistSelection = false, + } = component; - const [activeTab, setActiveTab] = useState(defaultTab || tabs[0]?.id || ""); - const [loadedScreens, setLoadedScreens] = useState>({}); + console.log("🎨 TabsWidget 렌더링:", { + componentId: component.id, + tabs, + tabsLength: tabs.length, + component, + }); + + const storageKey = `tabs-${component.id}-selected`; + + // 초기 선택 탭 결정 + const getInitialTab = () => { + if (persistSelection && typeof window !== "undefined") { + const saved = localStorage.getItem(storageKey); + if (saved && tabs.some((t) => t.id === saved)) { + return saved; + } + } + return defaultTab || tabs[0]?.id || ""; + }; + + const [selectedTab, setSelectedTab] = useState(getInitialTab()); + const [visibleTabs, setVisibleTabs] = useState(tabs); const [loadingScreens, setLoadingScreens] = useState>({}); - const [screenErrors, setScreenErrors] = useState>({}); + const [screenLayouts, setScreenLayouts] = useState>({}); - // 탭 변경 시 화면 로드 + // 컴포넌트 탭 목록 변경 시 동기화 useEffect(() => { - if (!activeTab) return; + setVisibleTabs(tabs.filter((tab) => !tab.disabled)); + }, [tabs]); - const currentTab = tabs.find((tab) => tab.id === activeTab); - if (!currentTab || !currentTab.screenId) return; + // 선택된 탭 변경 시 localStorage에 저장 + useEffect(() => { + if (persistSelection && typeof window !== "undefined") { + localStorage.setItem(storageKey, selectedTab); + } + }, [selectedTab, persistSelection, storageKey]); - // 이미 로드된 화면이면 스킵 - if (loadedScreens[activeTab]) return; + // 화면 레이아웃 로드 + const loadScreenLayout = async (screenId: number) => { + if (screenLayouts[screenId]) { + return; // 이미 로드됨 + } - // 이미 로딩 중이면 스킵 - if (loadingScreens[activeTab]) return; - - // 화면 로드 시작 - loadScreen(activeTab, currentTab.screenId); - }, [activeTab, tabs]); - - const loadScreen = async (tabId: string, screenId: number) => { - setLoadingScreens((prev) => ({ ...prev, [tabId]: true })); - setScreenErrors((prev) => ({ ...prev, [tabId]: "" })); + setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); try { - const layoutData = await screenApi.getLayout(screenId); - - if (layoutData) { - setLoadedScreens((prev) => ({ - ...prev, - [tabId]: { - screenId, - layout: layoutData, - }, - })); - } else { - setScreenErrors((prev) => ({ - ...prev, - [tabId]: "화면을 불러올 수 없습니다", - })); + const response = await fetch(`/api/screen-management/screens/${screenId}/layout`); + if (response.ok) { + const data = await response.json(); + if (data.success && data.data) { + setScreenLayouts((prev) => ({ ...prev, [screenId]: data.data })); + } } - } catch (error: any) { - setScreenErrors((prev) => ({ - ...prev, - [tabId]: error.message || "화면 로드 중 오류가 발생했습니다", - })); + } catch (error) { + console.error(`Failed to load screen layout ${screenId}:`, error); } finally { - setLoadingScreens((prev) => ({ ...prev, [tabId]: false })); + setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); } }; - // 탭 콘텐츠 렌더링 - const renderTabContent = (tab: TabItem) => { - const isLoading = loadingScreens[tab.id]; - const error = screenErrors[tab.id]; - const screenData = loadedScreens[tab.id]; + // 탭 변경 핸들러 + const handleTabChange = (tabId: string) => { + setSelectedTab(tabId); - // 로딩 중 - if (isLoading) { - return ( -
- -

화면을 불러오는 중...

-
- ); + // 해당 탭의 화면 로드 + const tab = visibleTabs.find((t) => t.id === tabId); + if (tab && tab.screenId && !screenLayouts[tab.screenId]) { + loadScreenLayout(tab.screenId); } + }; - // 에러 발생 - if (error) { - return ( -
- -
-

화면 로드 실패

-

{error}

-
-
- ); + // 탭 닫기 핸들러 + const handleCloseTab = (tabId: string, e: React.MouseEvent) => { + e.stopPropagation(); + + const updatedTabs = visibleTabs.filter((tab) => tab.id !== tabId); + setVisibleTabs(updatedTabs); + + // 닫은 탭이 선택된 탭이었다면 다음 탭 선택 + if (selectedTab === tabId && updatedTabs.length > 0) { + setSelectedTab(updatedTabs[0].id); } + }; - // 화면 ID가 없는 경우 - if (!tab.screenId) { - return ( -
- -
-

화면이 할당되지 않았습니다

-

상세설정에서 화면을 선택하세요

-
-
- ); - } - - // 화면 렌더링 - 원본 화면의 모든 컴포넌트를 그대로 렌더링 - if (screenData && screenData.layout && screenData.layout.components) { - const components = screenData.layout.components; - const screenResolution = screenData.layout.screenResolution || { width: 1920, height: 1080 }; - - return ( -
-
- {components.map((comp) => ( - - ))} -
-
- ); - } + // 탭 스타일 클래스 + const getTabsListClass = () => { + const baseClass = orientation === "vertical" ? "flex-col" : ""; + const variantClass = + variant === "pills" + ? "bg-muted p-1 rounded-lg" + : variant === "underline" + ? "border-b" + : "bg-muted p-1"; + return `${baseClass} ${variantClass}`; + }; + if (visibleTabs.length === 0) { return ( -
- -
-

화면 데이터를 불러올 수 없습니다

-
+
+

탭이 없습니다

); - }; - - // 빈 탭 목록 - if (tabs.length === 0) { - return ( - -
-

탭이 없습니다

-

상세설정에서 탭을 추가하세요

-
-
- ); } return ( -
- - - {tabs.map((tab) => ( - - {tab.label} - {tab.screenName && ( - - {tab.screenName} - - )} + + + {visibleTabs.map((tab) => ( +
+ + {tab.label} - ))} - - - {tabs.map((tab) => ( - - {renderTabContent(tab)} - + {allowCloseable && ( + + )} +
))} -
-
- ); -}; + + {visibleTabs.map((tab) => ( + + {tab.screenId ? ( + loadingScreens[tab.screenId] ? ( +
+ + 화면 로딩 중... +
+ ) : screenLayouts[tab.screenId] ? ( +
+ +
+ ) : ( +
+

화면을 불러올 수 없습니다

+
+ ) + ) : ( +
+

연결된 화면이 없습니다

+
+ )} +
+ ))} + + ); +} diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 98e53425..12e6e944 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -59,6 +59,9 @@ import "./selected-items-detail-input/SelectedItemsDetailInputRenderer"; import "./section-paper/SectionPaperRenderer"; // Section Paper (색종이 - 배경색 기반 그룹화) - Renderer 방식 import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 기반 그룹화) - Renderer 방식 +// 🆕 탭 컴포넌트 +import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트 + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/tabs/tabs-component.tsx b/frontend/lib/registry/components/tabs/tabs-component.tsx new file mode 100644 index 00000000..18fbf297 --- /dev/null +++ b/frontend/lib/registry/components/tabs/tabs-component.tsx @@ -0,0 +1,131 @@ +"use client"; + +import React from "react"; +import { ComponentRegistry } from "../../ComponentRegistry"; +import { ComponentCategory } from "@/types/component"; +import { Folder } from "lucide-react"; +import type { TabsComponent, TabItem } from "@/types/screen-management"; + +/** + * 탭 컴포넌트 정의 + * + * 여러 화면을 탭으로 구분하여 전환할 수 있는 컴포넌트 + */ +ComponentRegistry.registerComponent({ + id: "tabs-widget", + name: "탭 컴포넌트", + description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.", + category: ComponentCategory.LAYOUT, + webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값 + component: () => null as any, // 레이아웃 컴포넌트이므로 임시값 + defaultConfig: {}, + tags: ["tabs", "navigation", "layout", "screen"], + icon: Folder, + version: "1.0.0", + + defaultSize: { + width: 800, + height: 600, + }, + + defaultProps: { + type: "tabs" as const, + tabs: [ + { + id: "tab-1", + label: "탭 1", + order: 0, + disabled: false, + }, + { + id: "tab-2", + label: "탭 2", + order: 1, + disabled: false, + }, + ] as TabItem[], + defaultTab: "tab-1", + orientation: "horizontal" as const, + variant: "default" as const, + allowCloseable: false, + persistSelection: false, + }, + + // 에디터 모드에서의 렌더링 + renderEditor: ({ component, isSelected, onClick, onDragStart, onDragEnd, children }) => { + const tabsComponent = component as TabsComponent; + const tabs = tabsComponent.tabs || []; + + return ( +
+
+
+ +
+

탭 컴포넌트

+

+ {tabs.length > 0 + ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` + : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} +

+ {tabs.length > 0 && ( +
+ {tabs.map((tab: TabItem, index: number) => ( + + {tab.label || `탭 ${index + 1}`} + + ))} +
+ )} +
+
+ ); + }, + + // 인터랙티브 모드에서의 렌더링 (실제 동작) + renderInteractive: ({ component }) => { + // InteractiveScreenViewer에서 TabsWidget을 사용하므로 여기서는 null 반환 + return null; + }, + + // 설정 패널 (동적 로딩) + configPanel: React.lazy(() => + import("@/components/screen/config-panels/TabsConfigPanel").then(module => ({ + default: module.TabsConfigPanel + })) + ), + + // 검증 함수 + validate: (component) => { + const tabsComponent = component as TabsComponent; + const errors: string[] = []; + + if (!tabsComponent.tabs || tabsComponent.tabs.length === 0) { + errors.push("최소 1개 이상의 탭이 필요합니다."); + } + + if (tabsComponent.tabs) { + const tabIds = tabsComponent.tabs.map((t) => t.id); + const uniqueIds = new Set(tabIds); + if (tabIds.length !== uniqueIds.size) { + errors.push("탭 ID가 중복되었습니다."); + } + } + + return { + isValid: errors.length === 0, + errors, + }; + }, +}); + +console.log("✅ 탭 컴포넌트 등록 완료"); + diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index 6bae6944..f921016c 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -42,6 +42,8 @@ const CONFIG_PANEL_MAP: Record Promise> = { // 🆕 섹션 그룹화 레이아웃 "section-card": () => import("@/lib/registry/components/section-card/SectionCardConfigPanel"), "section-paper": () => import("@/lib/registry/components/section-paper/SectionPaperConfigPanel"), + // 🆕 탭 컴포넌트 + "tabs-widget": () => import("@/components/screen/config-panels/TabsConfigPanel"), }; // ConfigPanel 컴포넌트 캐시 @@ -76,6 +78,7 @@ export async function getComponentConfigPanel(componentId: string): Promise; }; +// TabsConfigPanel 래퍼 +const TabsConfigPanelWrapper: ConfigPanelComponent = ({ config, onConfigChange }) => { + const mockComponent = { + id: "temp", + type: "tabs" as const, + tabs: config.tabs || [], + defaultTab: config.defaultTab, + orientation: config.orientation || "horizontal", + variant: config.variant || "default", + allowCloseable: config.allowCloseable || false, + persistSelection: config.persistSelection || false, + }; + + const handleUpdate = (updates: any) => { + onConfigChange({ ...config, ...updates }); + }; + + return ; +}; + // 설정 패널 이름으로 직접 매핑하는 함수 (DB의 config_panel 필드용) export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent | null => { console.log(`🔧 getConfigPanelComponent 호출: panelName="${panelName}"`); @@ -128,6 +149,9 @@ export const getConfigPanelComponent = (panelName: string): ConfigPanelComponent case "DashboardConfigPanel": console.log(`🔧 DashboardConfigPanel 래퍼 컴포넌트 반환`); return DashboardConfigPanelWrapper; + case "TabsConfigPanel": + console.log(`🔧 TabsConfigPanel 래퍼 컴포넌트 반환`); + return TabsConfigPanelWrapper; default: console.warn(`🔧 알 수 없는 설정 패널: ${panelName}, 기본 설정 사용`); return null; // 기본 설정 (패널 없음) diff --git a/frontend/types/screen-management.ts b/frontend/types/screen-management.ts index 195b9b61..0320f303 100644 --- a/frontend/types/screen-management.ts +++ b/frontend/types/screen-management.ts @@ -190,6 +190,32 @@ export interface ComponentComponent extends BaseComponent { componentConfig: any; // 컴포넌트별 설정 } +/** + * 탭 아이템 인터페이스 + */ +export interface TabItem { + id: string; + label: string; + screenId?: number; // 연결된 화면 ID + screenName?: string; // 화면 이름 (표시용) + icon?: string; // 아이콘 (선택사항) + disabled?: boolean; // 비활성화 여부 + order: number; // 탭 순서 +} + +/** + * 탭 컴포넌트 + */ +export interface TabsComponent extends BaseComponent { + type: "tabs"; + tabs: TabItem[]; // 탭 목록 + defaultTab?: string; // 기본 선택 탭 ID + orientation?: "horizontal" | "vertical"; // 탭 방향 + variant?: "default" | "pills" | "underline"; // 탭 스타일 + allowCloseable?: boolean; // 탭 닫기 버튼 표시 여부 + persistSelection?: boolean; // 선택 상태 유지 (localStorage) +} + /** * 통합 컴포넌트 데이터 타입 */ @@ -200,7 +226,8 @@ export type ComponentData = | DataTableComponent | FileComponent | FlowComponent - | ComponentComponent; + | ComponentComponent + | TabsComponent; // ===== 웹타입별 설정 인터페이스 ===== @@ -791,6 +818,13 @@ export const isFlowComponent = (component: ComponentData): component is FlowComp return component.type === "flow"; }; +/** + * TabsComponent 타입 가드 + */ +export const isTabsComponent = (component: ComponentData): component is TabsComponent => { + return component.type === "tabs"; +}; + // ===== 안전한 타입 캐스팅 유틸리티 ===== /** @@ -852,3 +886,13 @@ export const asFlowComponent = (component: ComponentData): FlowComponent => { } return component; }; + +/** + * ComponentData를 TabsComponent로 안전하게 캐스팅 + */ +export const asTabsComponent = (component: ComponentData): TabsComponent => { + if (!isTabsComponent(component)) { + throw new Error(`Expected TabsComponent, got ${component.type}`); + } + return component; +}; diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index 4da2280a..f80c5c39 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -85,7 +85,8 @@ export type ComponentType = | "area" | "layout" | "flow" - | "component"; + | "component" + | "tabs"; /** * 기본 위치 정보 diff --git a/시연_시나리오.md b/시연_시나리오.md new file mode 100644 index 00000000..e67a7b09 --- /dev/null +++ b/시연_시나리오.md @@ -0,0 +1,542 @@ +# ERP-node 시스템 시연 시나리오 + +## 전체 개요 + +**주제**: 발주 → 입고 프로세스 자동화 +**목표**: 버튼 클릭 한 번으로 발주 데이터가 입고 테이블로 자동 이동하는 것을 보여주기 +**총 시간**: 10분 + +--- + +## Part 1: 테이블 2개 생성 (2분) + +### 1-1. 발주 테이블 생성 + +**화면 조작**: + +1. 테이블 관리 메뉴 접속 +2. "새 테이블" 버튼 클릭 +3. 테이블 정보 입력: + + - **테이블명(영문)**: `purchase_order` + - **테이블명(한글)**: `발주` + - **설명**: `발주 관리` + +4. 컬럼 추가 (4개): + +| 컬럼명(영문) | 컬럼명(한글) | 타입 | 필수 여부 | +| ------------ | ------------ | ------ | --------- | +| order_no | 발주번호 | text | ✓ | +| item_name | 품목명 | text | ✓ | +| quantity | 수량 | number | ✓ | +| unit_price | 단가 | number | ✓ | + +5. "테이블 생성" 버튼 클릭 +6. 성공 메시지 확인 + +--- + +### 1-2. 입고 테이블 생성 + +**화면 조작**: + +1. "새 테이블" 버튼 클릭 +2. 테이블 정보 입력: + + - **테이블명(영문)**: `receiving` + - **테이블명(한글)**: `입고` + - **설명**: `입고 관리` + +3. 컬럼 추가 (5개): + +| 컬럼명(영문) | 컬럼명(한글) | 타입 | 필수 여부 | 비고 | +| -------------- | ------------ | ------ | --------- | ------------------- | +| receiving_no | 입고번호 | text | ✓ | 자동 생성 | +| order_no | 발주번호 | text | ✓ | 발주 테이블 참조 | +| item_name | 품목명 | text | ✓ | | +| quantity | 수량 | number | ✓ | | +| receiving_date | 입고일자 | date | ✓ | 오늘 날짜 자동 입력 | + +4. "테이블 생성" 버튼 클릭 +5. 성공 메시지 확인 + +**포인트 강조**: + +- 클릭만으로 데이터베이스 테이블 자동 생성 +- Input Type에 따라 적절한 UI 자동 설정 + +--- + +## Part 2: 메뉴 2개 생성 (1분) + +### 2-1. 발주 관리 메뉴 생성 + +**화면 조작**: + +1. 관리자 메뉴 > 메뉴 관리 접속 +2. "새 메뉴 추가" 버튼 클릭 +3. 메뉴 정보 입력: + - **메뉴명**: `발주 관리` + - **순서**: 1 +4. "저장" 클릭 + +--- + +### 2-2. 입고 관리 메뉴 생성 + +**화면 조작**: + +1. "새 메뉴 추가" 버튼 클릭 +2. 메뉴 정보 입력: + - **메뉴명**: `입고 관리` + - **순서**: 2 +3. "저장" 클릭 +4. 좌측 메뉴바에서 새로 생성된 메뉴 2개 확인 + +**포인트 강조**: + +- URL 기반 자동 라우팅 +- 아이콘으로 직관적인 메뉴 구성 + +--- + +## Part 3: 플로우 생성 (2분) + +### 3-1. 플로우 생성 + +**화면 조작**: + +1. 제어 관리 메뉴 접속 +2. "새 플로우 생성" 버튼 클릭 +3. 플로우 생성 모달에서 입력: + - **플로우명**: `발주-입고 프로세스` + - **설명**: `발주에서 입고로 데이터 자동 이동` +4. "생성" 버튼 클릭 +5. 플로우 편집 화면(캔버스)으로 자동 이동 + +--- + +### 3-2. 노드 구성 + +**내레이션**: +"플로우는 소스 테이블과 액션 노드로 구성합니다. 발주 테이블에서 입고 테이블로 데이터를 INSERT하는 구조입니다." + +**노드 1: 발주 테이블 소스** + +**화면 조작**: + +1. 캔버스 좌측 팔레트에서 "테이블 소스" 에서 테이블 노드 드래그 +2. 캔버스에 드롭 +3. 생성된 노드 클릭 → 우측 속성 패널 표시 +4. 속성 패널에서 설정: + - **노드명**: `발주 테이블` + - **소스 테이블**: `purchase_order` 선택 + - **색상**: 파란색 (#3b82f6) +5. 데이터 소스 타입 컨텍스트 데이터 선택 + +--- + +**노드 2: 입고 INSERT 액션** + +**화면 조작**: + +1. 좌측 팔레트에서 "INSERT 액션" 노드 드래그 +2. 캔버스의 발주 테이블 오른쪽에 드롭 +3. 노드 클릭 → 우측 속성 패널 표시 +4. 속성 패널에서 설정: + - **노드명**: `입고 처리` + - **타겟 테이블**: `receiving`(입고) 선택 + - **액션 타입**: INSERT + - **색상**: 초록색 (#22c55e) + +--- + +### 3-3. 노드 연결 및 필드 매핑 + +**내레이션**: +"소스 테이블과 액션 노드를 연결하고 필드 매핑을 설정합니다." + +**화면 조작**: + +1. "발주 테이블" 노드의 오른쪽 연결점(핸들)에 마우스 올리기 +2. 연결점에서 드래그 시작 +3. "입고 처리" 노드의 왼쪽 연결점으로 드래그 +4. 연결선 자동 생성됨 + +5. "입고 처리" (INSERT 액션) 노드 클릭 +6. 우측 속성 패널에서 "필드 매핑" 탭 선택 +7. 필드 매핑 설정: + +| 소스 필드 (발주) | 타겟 필드 (입고) | 비고 | +| ---------------- | ---------------- | ------------- | +| order_no | order_no | 발주번호 복사 | +| item_name | item_name | 품목명 복사 | +| quantity | quantity | 수량 복사 | +| (자동 생성) | receiving_no | 입고번호 | +| (현재 날짜) | receiving_date | 입고일자 | + +8. 우측 상단 "저장" 버튼 클릭 +9. 성공 메시지: "플로우가 저장되었습니다" + +**포인트 강조**: + +- 테이블 소스 → 액션 노드 구조 +- 필드 매핑으로 데이터 자동 복사 설정 +- INSERT 액션으로 새 테이블에 데이터 생성 + +**참고**: + +- `receiving_no`와 `receiving_date`는 자동 생성 필드로 설정 +- 같은 이름의 필드는 자동 매핑됨 + +--- + +## Part 4: 화면 설계 (2분) + +### 4-1. 발주 관리 화면 설계 + +**화면 조작**: + +1. 화면 관리 > 화면 설계 메뉴 접속 +2. "발주 관리" 메뉴의 "화면 할당" 클릭 +3. "새 화면 생성" 선택 +4. 테이블 선택: `purchase_order` (발주) + +**화면 구성**: + +**전체: 테이블 리스트 컴포넌트 (CRUD 기능 포함)** + +1. 컴포넌트 팔레트에서 "테이블 리스트" 드래그 +2. 테이블 설정: + - **연결 테이블**: `purchase_order` + - **컬럼 표시**: + +| 컬럼 | 표시 | 정렬 가능 | 너비 | +| ---------- | ---- | --------- | ----- | +| order_no | ✓ | ✓ | 150px | +| item_name | ✓ | ✓ | 200px | +| quantity | ✓ | | 100px | +| unit_price | ✓ | | 120px | + +3. 기능 설정: + + - **조회**: 활성화 + - **등록**: 활성화 (신규 버튼) + - **수정**: 활성화 + - **삭제**: 활성화 + - **페이징**: 10개씩 + - **입고 처리 버튼**: 커스텀 액션 추가 + +4. 입고 처리 버튼 설정: + + - **버튼 라벨**: `입고 처리` + - **버튼 위치**: 행 액션 + - **연결 플로우**: `발주-입고 프로세스` 선택 + - **플로우 액션**: `입고 처리` (Connection에서 정의한 액션) + +5. "화면 저장" 버튼 클릭 + +--- + +### 4-2. 입고 관리 화면 설계 + +**화면 조작**: + +1. "입고 관리" 메뉴의 "화면 할당" 클릭 +2. "새 화면 생성" 선택 +3. 테이블 선택: `receiving` (입고) + +**화면 구성**: + +**전체: 테이블 리스트 컴포넌트 (조회 전용)** + +1. 컴포넌트 팔레트에서 "테이블 리스트" 드래그 +2. 테이블 설정: + - **연결 테이블**: `receiving` + - **컬럼 표시**: + +| 컬럼 | 표시 | 정렬 가능 | 너비 | +| -------------- | ---- | --------- | ----- | +| receiving_no | ✓ | ✓ | 150px | +| order_no | ✓ | ✓ | 150px | +| item_name | ✓ | ✓ | 200px | +| quantity | ✓ | | 100px | +| receiving_date | ✓ | ✓ | 120px | + +3. 기능 설정: + + - **조회**: 활성화 + - **등록**: 비활성화 (플로우로만 데이터 생성) + - **수정**: 비활성화 + - **삭제**: 비활성화 + - **페이징**: 20개씩 + - **정렬**: 입고일자 내림차순 + +4. "화면 저장" 버튼 클릭 + +**포인트 강조**: + +- 테이블 리스트 컴포넌트로 CRUD 자동 구성 +- 발주 화면에는 "입고 처리" 버튼으로 플로우 실행 +- 입고 화면은 조회 전용 (플로우로만 데이터 생성) + +--- + +## Part 5: 실행 및 동작 확인 (3분) + +### 5-1. 발주 등록 + +**화면 조작**: + +1. 좌측 메뉴에서 "발주 관리" 클릭 +2. 화면 구성 확인: + + - 테이블 리스트 컴포넌트 (빈 테이블) + - 상단에 "신규" 버튼 + +3. "신규" 버튼 클릭 +4. 입력 모달 창 표시 +5. 데이터 입력: + + - **발주번호**: PO-001 + - **품목명**: 노트북 (LG Gram 17) + - **수량**: 10 + - **단가**: 2,000,000 + +6. "저장" 버튼 클릭 +7. 성공 메시지 확인: "저장되었습니다" + +8. 결과 확인: + - 테이블에 새 행 추가됨 + - 행 우측에 "입고 처리" 버튼 표시됨 + +**추가 발주 등록 (옵션)**: + +9. "신규" 버튼 클릭 +10. 2번째 데이터 입력: + +- **발주번호**: PO-002 +- **품목명**: 모니터 (삼성 27인치) +- **수량**: 5 +- **단가**: 300,000 + +11. "저장" 클릭 +12. 테이블에 2개 행 확인 + +--- + +### 5-2. 입고 처리 실행 ⭐ (핵심 데모) + +**화면 조작**: + +1. 발주 테이블에서 첫 번째 행(PO-001 노트북) 확인 +2. 행 우측의 **"입고 처리"** 버튼 클릭 +3. 확인 대화상자: + + - "이 발주를 입고 처리하시겠습니까?" + - **"예"** 클릭 + +4. 성공 메시지: "입고 처리되었습니다" + +--- + +### 5-3. 자동 데이터 이동 확인 ⭐⭐⭐ + +**실시간 변화 확인**: + +**1) 발주 테이블 자동 업데이트** + +- PO-001 항목이 테이블에서 **즉시 사라짐** +- PO-002만 남아있음 (추가로 등록했다면) + +**2) 입고 관리 화면으로 이동** + +1. 좌측 메뉴에서 **"입고 관리"** 클릭 +2. 입고 테이블에 **자동으로 데이터 생성됨**: + +| 입고번호 | 발주번호 | 품목명 | 수량 | 입고일자 | +| ---------------- | -------- | ------------------- | ---- | ---------- | +| RCV-20250124-001 | PO-001 | 노트북 (LG Gram 17) | 10 | 2025-01-24 | + +3. **데이터 자동 생성 확인**: + - 입고번호: 자동 생성됨 (RCV-20250124-001) + - 발주번호: PO-001 복사됨 + - 품목명: 노트북 (LG Gram 17) 복사됨 + - 수량: 10 복사됨 + - 입고일자: 오늘 날짜 자동 입력 + +**3) 다시 발주 관리로 돌아가기** + +1. 좌측 메뉴 "발주 관리" 클릭 +2. PO-001은 여전히 사라진 상태 확인 +3. PO-002만 남아있음 + +**4) 제어 관리에서 확인** + +1. 제어 관리 > 플로우 목록 접속 +2. "발주-입고 프로세스" 클릭 +3. 플로우 현황 확인: + - **발주 완료**: 1건 (PO-002) + - **입고 완료**: 1건 (PO-001) + +--- + +### 5-4. 추가 입고 처리 (옵션) + +**화면 조작**: + +1. "발주 관리" 화면에서 PO-002 (모니터) 선택 +2. "입고 처리" 버튼 클릭 +3. 확인 후 입고 완료 + +4. 최종 확인: + - 발주 관리: 0건 (모두 입고 처리됨) + - 입고 관리: 2건 (PO-001, PO-002) + - 제어 관리 플로우: + - **발주 완료: 0건** + - **입고 완료: 2건** + +--- + +## 시연 마무리 (30초) + +**화면 정리 및 요약**: + +**보여준 핵심 기능**: + +- ✅ **코딩 없이 테이블 생성**: 클릭만으로 DB 테이블 자동 생성 +- ✅ **시각적 플로우 구성**: 드래그앤드롭으로 업무 흐름 설계 +- ✅ **자동 데이터 이동**: 버튼 클릭 한 번으로 테이블 간 데이터 자동 복사 및 이동 +- ✅ **실시간 상태 추적**: 제어 관리에서 플로우 현황 확인 +- ✅ **빠른 화면 구성**: 테이블 리스트 컴포넌트로 CRUD 자동 완성 + +**마지막 화면**: + +- 대시보드 또는 시스템 전체 구성도 +- 로고 및 연락처 정보 + +**자막**: +"개발자 없이도 비즈니스 담당자가 직접 업무 시스템을 구축할 수 있습니다." + +--- + +## 시간 배분 요약 + +| 파트 | 시간 | 주요 내용 | +| -------- | ---------- | ---------------------------- | +| Part 1 | 2분 | 테이블 2개 생성 (발주, 입고) | +| Part 2 | 1분 | 메뉴 2개 생성 | +| Part 3 | 2분 | 플로우 구성 및 연결 설정 | +| Part 4 | 2분 | 화면 2개 디자인 | +| Part 5 | 3분 | 발주 등록 → 입고 처리 실행 | +| 마무리 | 0.5분 | 요약 및 정리 | +| **합계** | **10.5분** | | + +--- + +## 시연 준비사항 + +### 사전 설정 + +1. 개발 서버 실행: `http://localhost:9771` +2. 로그인 정보: `wace / qlalfqjsgh11` +3. 데이터베이스 초기화 (테스트 데이터 제거) + +### 녹화 설정 + +- **해상도**: 1920x1080 (Full HD) +- **프레임**: 30fps +- **마우스 효과**: 클릭 하이라이트 활성화 +- **배경음악**: 부드러운 BGM (옵션) +- **자막**: 주요 포인트마다 표시 + +### 시연 팁 + +- 각 단계마다 2-3초 대기 (시청자 이해 시간) +- 중요한 버튼 클릭 시 화면 확대 효과 +- 플로우 위젯 카운트 변화는 빨간색 박스로 강조 +- 성공 메시지는 충분히 길게 보여주기 (최소 3초) +- 입고 테이블에 데이터 들어오는 순간 화면 확대 + +--- + +## 시연 스크립트 (참고용) + +### 오프닝 (10초) + +"안녕하세요. 오늘은 ERP-node 시스템의 핵심 기능을 시연하겠습니다. 발주에서 입고까지 데이터가 자동으로 이동하는 과정을 보여드립니다." + +### Part 1 (2분) + +"먼저 발주와 입고를 관리할 테이블을 생성합니다. 코딩 없이 클릭만으로 데이터베이스 테이블이 자동으로 만들어집니다." + +### Part 2 (1분) + +"이제 사용자가 접근할 메뉴를 추가합니다. URL만 지정하면 자동으로 라우팅이 연결됩니다." + +### Part 3 (2분) + +"발주에서 입고로 데이터가 이동하는 흐름을 제어 플로우로 정의합니다. 두 테이블을 연결하고 버튼을 누르면 자동으로 데이터가 복사 및 이동하도록 설정합니다." + +### Part 4 (2분) + +"실제 사용자가 볼 화면을 디자인합니다. 테이블 리스트 컴포넌트를 사용하면 CRUD 기능이 자동으로 구성되고, 각 행에 입고 처리 버튼을 추가하여 플로우를 실행할 수 있습니다." + +### Part 5 (3분) + +"이제 실제로 작동하는 모습을 보겠습니다. 발주를 등록하고... (데이터 입력) 저장하면 테이블에 추가됩니다. 입고 처리 버튼을 누르면... (클릭) 발주 테이블에서 데이터가 사라지고 입고 테이블에 자동으로 생성됩니다!" + +### 클로징 (10초) + +"이처럼 ERP-node는 코딩 없이 비즈니스 로직을 구현할 수 있는 노코드 플랫폼입니다. 감사합니다." + +--- + +## 체크리스트 + +### 시연 전 + +- [ ] 개발 서버 실행 확인 +- [ ] 로그인 테스트 +- [ ] 기존 테스트 데이터 삭제 +- [ ] 브라우저 창 크기 조정 (1920x1080) +- [ ] 녹화 프로그램 설정 +- [ ] 마이크 테스트 +- [ ] 시나리오 1회 이상 리허설 + +### 시연 중 + +- [ ] 천천히 명확하게 진행 +- [ ] 각 단계마다 결과 확인 +- [ ] 플로우 위젯 카운트 강조 +- [ ] 입고 테이블 데이터 자동 생성 강조 + +### 시연 후 + +- [ ] 녹화 파일 확인 +- [ ] 자막 추가 (필요 시) +- [ ] 배경음악 삽입 (옵션) +- [ ] 인트로/아웃트로 편집 +- [ ] 최종 영상 검수 + +--- + +## 추가 개선 아이디어 + +### 시연 버전 2 (고급) + +- 발주 승인 단계 추가 (발주 요청 → 승인 → 입고) +- 입고 수량 불일치 처리 (일부 입고) +- 대시보드에서 통계 차트 표시 + +### 시연 버전 3 (실전) + +- 실제 업무: 구매 요청 → 견적 → 발주 → 입고 → 검수 +- 권한 관리: 요청자, 승인자, 구매담당자 역할 분리 +- 알림: 각 단계 변경 시 담당자에게 알림 + +--- + +**작성일**: 2025-01-24 +**버전**: 1.0 +**작성자**: AI Assistant From 3f60f9ca3e5bfc21644c592dcdd7a5d57e525b81 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 09:33:36 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix(flow):=20=EC=A0=9C=EC=96=B4=20=EC=8B=A4?= =?UTF-8?q?=ED=96=89=20=EC=8B=9C=20writer=EC=99=80=20company=5Fcode=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=9E=85=EB=A0=A5=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 문제: - 제어(플로우) 실행으로 데이터 INSERT 시 writer, company_code 컬럼이 비어있는 문제 - 플로우 실행 API에 인증이 없어 사용자 정보를 사용할 수 없었음 ✅ 해결: 1. 플로우 실행 API에 authenticateToken 미들웨어 추가 2. 사용자 정보(userId, userName, companyCode)를 contextData에 포함 3. INSERT 노드 실행 시 writer와 company_code 자동 추가 - 필드 매핑에 없는 경우에만 자동 추가 - writer: 현재 로그인한 사용자 ID - company_code: 현재 사용자의 회사 코드 - 최고 관리자(companyCode = '*')는 제외 4. 플로우 제어 자동 감지 개선 - flowConfig가 있으면 controlMode 없이도 플로우 모드로 인식 - 데이터 미선택 시 명확한 오류 메시지 표시 🎯 영향: - 입고처리, 출고처리 등 제어 기반 데이터 생성 시 멀티테넌시 보장 - 데이터 추적성 향상 (누가 생성했는지 자동 기록) 📝 수정 파일: - frontend/lib/utils/buttonActions.ts - backend-node/src/routes/dataflow/node-flows.ts - backend-node/src/services/nodeFlowExecutionService.ts --- .../src/routes/dataflow/node-flows.ts | 15 ++++++++++-- .../src/services/nodeFlowExecutionService.ts | 24 +++++++++++++++++++ frontend/lib/utils/buttonActions.ts | 10 +++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/backend-node/src/routes/dataflow/node-flows.ts b/backend-node/src/routes/dataflow/node-flows.ts index 7ede970a..f13d65cf 100644 --- a/backend-node/src/routes/dataflow/node-flows.ts +++ b/backend-node/src/routes/dataflow/node-flows.ts @@ -7,6 +7,7 @@ import { query, queryOne } from "../../database/db"; import { logger } from "../../utils/logger"; import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService"; import { AuthenticatedRequest } from "../../types/auth"; +import { authenticateToken } from "../../middleware/authMiddleware"; const router = Router(); @@ -217,19 +218,29 @@ router.delete("/:flowId", async (req: Request, res: Response) => { * 플로우 실행 * POST /api/dataflow/node-flows/:flowId/execute */ -router.post("/:flowId/execute", async (req: Request, res: Response) => { +router.post("/:flowId/execute", authenticateToken, async (req: AuthenticatedRequest, res: Response) => { try { const { flowId } = req.params; const contextData = req.body; logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, { contextDataKeys: Object.keys(contextData), + userId: req.user?.userId, + companyCode: req.user?.companyCode, }); + // 사용자 정보를 contextData에 추가 + const enrichedContextData = { + ...contextData, + userId: req.user?.userId, + userName: req.user?.userName, + companyCode: req.user?.companyCode, + }; + // 플로우 실행 const result = await NodeFlowExecutionService.executeFlow( parseInt(flowId, 10), - contextData + enrichedContextData ); return res.json({ diff --git a/backend-node/src/services/nodeFlowExecutionService.ts b/backend-node/src/services/nodeFlowExecutionService.ts index 546b215a..9cdd85f3 100644 --- a/backend-node/src/services/nodeFlowExecutionService.ts +++ b/backend-node/src/services/nodeFlowExecutionService.ts @@ -938,6 +938,30 @@ export class NodeFlowExecutionService { insertedData[mapping.targetField] = value; }); + // 🆕 writer와 company_code 자동 추가 (필드 매핑에 없는 경우) + const hasWriterMapping = fieldMappings.some((m: any) => m.targetField === "writer"); + const hasCompanyCodeMapping = fieldMappings.some((m: any) => m.targetField === "company_code"); + + // 컨텍스트에서 사용자 정보 추출 + const userId = context.buttonContext?.userId; + const companyCode = context.buttonContext?.companyCode; + + // writer 자동 추가 (매핑에 없고, 컨텍스트에 userId가 있는 경우) + if (!hasWriterMapping && userId) { + fields.push("writer"); + values.push(userId); + insertedData.writer = userId; + console.log(` 🔧 자동 추가: writer = ${userId}`); + } + + // company_code 자동 추가 (매핑에 없고, 컨텍스트에 companyCode가 있는 경우) + if (!hasCompanyCodeMapping && companyCode && companyCode !== "*") { + fields.push("company_code"); + values.push(companyCode); + insertedData.company_code = companyCode; + console.log(` 🔧 자동 추가: company_code = ${companyCode}`); + } + const sql = ` INSERT INTO ${targetTable} (${fields.join(", ")}) VALUES (${fields.map((_, i) => `$${i + 1}`).join(", ")}) diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 5f825cdc..0d69254b 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -1673,7 +1673,11 @@ export class ButtonActionExecutor { }); // 🔥 새로운 버튼 액션 실행 시스템 사용 - if (config.dataflowConfig?.controlMode === "flow" && config.dataflowConfig?.flowConfig) { + // flowConfig가 있으면 controlMode가 명시되지 않아도 플로우 모드로 간주 + const hasFlowConfig = config.dataflowConfig?.flowConfig && config.dataflowConfig.flowConfig.flowId; + const isFlowMode = config.dataflowConfig?.controlMode === "flow" || hasFlowConfig; + + if (isFlowMode && config.dataflowConfig?.flowConfig) { console.log("🎯 노드 플로우 실행:", config.dataflowConfig.flowConfig); const { flowId, executionTiming } = config.dataflowConfig.flowConfig; @@ -1711,6 +1715,8 @@ export class ButtonActionExecutor { }); } else { console.warn("⚠️ flow-selection 모드이지만 선택된 플로우 데이터가 없습니다."); + toast.error("플로우에서 데이터를 먼저 선택해주세요."); + return false; } break; @@ -1723,6 +1729,8 @@ export class ButtonActionExecutor { }); } else { console.warn("⚠️ table-selection 모드이지만 선택된 행이 없습니다."); + toast.error("테이블에서 처리할 항목을 먼저 선택해주세요."); + return false; } break; From 9fda390c55132d89024546256e0e4a832b5bbd92 Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 09:34:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/screenManagementController.ts | 4 +- .../src/services/screenManagementService.ts | 14 +-- frontend/components/screen/ScreenList.tsx | 95 +++++++++++++++---- 3 files changed, 84 insertions(+), 29 deletions(-) diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index be3a16a3..0ff80988 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -148,11 +148,11 @@ export const updateScreenInfo = async ( try { const { id } = req.params; const { companyCode } = req.user as any; - const { screenName, description, isActive } = req.body; + const { screenName, tableName, description, isActive } = req.body; await screenManagementService.updateScreenInfo( parseInt(id), - { screenName, description, isActive }, + { screenName, tableName, description, isActive }, companyCode ); res.json({ success: true, message: "화면 정보가 수정되었습니다." }); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 6c3a3430..9dbe0270 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -321,7 +321,7 @@ export class ScreenManagementService { */ async updateScreenInfo( screenId: number, - updateData: { screenName: string; description?: string; isActive: string }, + updateData: { screenName: string; tableName?: string; description?: string; isActive: string }, userCompanyCode: string ): Promise { // 권한 확인 @@ -343,16 +343,18 @@ export class ScreenManagementService { throw new Error("이 화면을 수정할 권한이 없습니다."); } - // 화면 정보 업데이트 + // 화면 정보 업데이트 (tableName 포함) await query( `UPDATE screen_definitions SET screen_name = $1, - description = $2, - is_active = $3, - updated_date = $4 - WHERE screen_id = $5`, + table_name = $2, + description = $3, + is_active = $4, + updated_date = $5 + WHERE screen_id = $6`, [ updateData.screenName, + updateData.tableName || null, updateData.description || null, updateData.isActive, new Date(), diff --git a/frontend/components/screen/ScreenList.tsx b/frontend/components/screen/ScreenList.tsx index 51a09164..d2d3e367 100644 --- a/frontend/components/screen/ScreenList.tsx +++ b/frontend/components/screen/ScreenList.tsx @@ -35,7 +35,10 @@ import { DialogDescription, } from "@/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash } from "lucide-react"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; +import { cn } from "@/lib/utils"; +import { MoreHorizontal, Edit, Trash2, Copy, Eye, Plus, Search, Palette, RotateCcw, Trash, Check, ChevronsUpDown } from "lucide-react"; import { ScreenDefinition } from "@/types/screen"; import { screenApi } from "@/lib/api/screen"; import CreateScreenModal from "./CreateScreenModal"; @@ -127,8 +130,9 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr isActive: "Y", tableName: "", }); - const [tables, setTables] = useState([]); + const [tables, setTables] = useState>([]); const [loadingTables, setLoadingTables] = useState(false); + const [tableComboboxOpen, setTableComboboxOpen] = useState(false); // 미리보기 관련 상태 const [previewDialogOpen, setPreviewDialogOpen] = useState(false); @@ -279,9 +283,12 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr const { tableManagementApi } = await import("@/lib/api/tableManagement"); const response = await tableManagementApi.getTableList(); if (response.success && response.data) { - // tableName만 추출 (camelCase) - const tableNames = response.data.map((table: any) => table.tableName); - setTables(tableNames); + // tableName과 displayName 매핑 (백엔드에서 displayName으로 라벨을 반환함) + const tableList = response.data.map((table: any) => ({ + tableName: table.tableName, + tableLabel: table.displayName || table.tableName, + })); + setTables(tableList); } } catch (error) { console.error("테이블 목록 조회 실패:", error); @@ -297,6 +304,10 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr // 화면 정보 업데이트 API 호출 await screenApi.updateScreenInfo(screenToEdit.screenId, editFormData); + // 선택된 테이블의 라벨 찾기 + const selectedTable = tables.find((t) => t.tableName === editFormData.tableName); + const tableLabel = selectedTable?.tableLabel || editFormData.tableName; + // 목록에서 해당 화면 정보 업데이트 setScreens((prev) => prev.map((s) => @@ -304,6 +315,8 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr ? { ...s, screenName: editFormData.screenName, + tableName: editFormData.tableName, + tableLabel: tableLabel, description: editFormData.description, isActive: editFormData.isActive, } @@ -1202,22 +1215,62 @@ export default function ScreenList({ onScreenSelect, selectedScreen, onDesignScr
- + + + + + + + + + + 테이블을 찾을 수 없습니다. + + + {tables.map((table) => ( + { + setEditFormData({ ...editFormData, tableName: table.tableName }); + setTableComboboxOpen(false); + }} + className="text-xs sm:text-sm" + > + +
+ {table.tableLabel} + {table.tableName} +
+
+ ))} +
+
+
+
+
From 5e2392c417937b6f4f3a49af4f86503d7bae8c7d Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 10:06:56 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen/InteractiveScreenViewer.tsx | 12 +- .../components/screen/RealtimePreview.tsx | 91 ++++++--- .../components/screen/widgets/TabsWidget.tsx | 193 ++++++++++++------ .../components/tabs/tabs-component.tsx | 36 +++- 4 files changed, 238 insertions(+), 94 deletions(-) diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index 4b5d70e8..4c3e6506 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -346,6 +346,14 @@ export const InteractiveScreenViewer: React.FC = ( // 실제 사용 가능한 위젯 렌더링 const renderInteractiveWidget = (comp: ComponentData) => { + console.log("🎯 renderInteractiveWidget 호출:", { + type: comp.type, + id: comp.id, + componentId: (comp as any).componentId, + hasComponentConfig: !!(comp as any).componentConfig, + componentConfig: (comp as any).componentConfig, + }); + // 데이터 테이블 컴포넌트 처리 if (comp.type === "datatable") { return ( @@ -398,7 +406,8 @@ export const InteractiveScreenViewer: React.FC = ( } // 탭 컴포넌트 처리 - if (comp.type === "tabs" || (comp.type === "component" && comp.componentId === "tabs-widget")) { + const componentType = (comp as any).componentType || (comp as any).componentId; + if (comp.type === "tabs" || (comp.type === "component" && componentType === "tabs-widget")) { const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; // componentConfig에서 탭 정보 추출 @@ -416,6 +425,7 @@ export const InteractiveScreenViewer: React.FC = ( console.log("🔍 탭 컴포넌트 렌더링:", { originalType: comp.type, + componentType, componentId: (comp as any).componentId, tabs: tabsComponent.tabs, tabsConfig, diff --git a/frontend/components/screen/RealtimePreview.tsx b/frontend/components/screen/RealtimePreview.tsx index 7a0dbc34..0270ffa8 100644 --- a/frontend/components/screen/RealtimePreview.tsx +++ b/frontend/components/screen/RealtimePreview.tsx @@ -555,41 +555,70 @@ export const RealtimePreviewDynamic: React.FC = ({ })()} {/* 탭 컴포넌트 타입 */} - {(type === "tabs" || (type === "component" && (component as any).componentId === "tabs-widget")) && + {(type === "tabs" || (type === "component" && ((component as any).componentType === "tabs-widget" || (component as any).componentId === "tabs-widget"))) && (() => { - const tabsComponent = component as any; - // componentConfig에서 탭 정보 가져오기 - const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || []; + console.log("🎯 탭 컴포넌트 조건 충족:", { + type, + componentType: (component as any).componentType, + componentId: (component as any).componentId, + isDesignMode, + }); - return ( -
-
-
- -
-

탭 컴포넌트

-

- {tabs.length > 0 - ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` - : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} -

- {tabs.length > 0 && ( -
- {tabs.map((tab: any, index: number) => ( - - {tab.label || `탭 ${index + 1}`} - {tab.screenName && ( - - ({tab.screenName}) - - )} - - ))} + if (isDesignMode) { + // 디자인 모드: 미리보기 표시 + const tabsComponent = component as any; + const tabs = tabsComponent.componentConfig?.tabs || tabsComponent.tabs || []; + + return ( +
+
+
+
- )} +

탭 컴포넌트

+

+ {tabs.length > 0 + ? `${tabs.length}개의 탭 (실제 화면에서 표시됩니다)` + : "탭이 없습니다. 설정 패널에서 탭을 추가하세요"} +

+ {tabs.length > 0 && ( +
+ {tabs.map((tab: any, index: number) => ( + + {tab.label || `탭 ${index + 1}`} + {tab.screenName && ( + + ({tab.screenName}) + + )} + + ))} +
+ )} +
-
- ); + ); + } else { + // 실제 화면: TabsWidget 렌더링 + const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; + const tabsConfig = (component as any).componentConfig || {}; + const tabsComponent = { + ...component, + type: "tabs" as const, + tabs: tabsConfig.tabs || [], + defaultTab: tabsConfig.defaultTab, + orientation: tabsConfig.orientation || "horizontal", + variant: tabsConfig.variant || "default", + allowCloseable: tabsConfig.allowCloseable || false, + persistSelection: tabsConfig.persistSelection || false, + }; + + return ( +
+ +
+ ); + } })()} {/* 그룹 타입 */} diff --git a/frontend/components/screen/widgets/TabsWidget.tsx b/frontend/components/screen/widgets/TabsWidget.tsx index 90608a4b..683017cf 100644 --- a/frontend/components/screen/widgets/TabsWidget.tsx +++ b/frontend/components/screen/widgets/TabsWidget.tsx @@ -60,24 +60,45 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { } }, [selectedTab, persistSelection, storageKey]); + // 초기 로드 시 선택된 탭의 화면 불러오기 + useEffect(() => { + const currentTab = visibleTabs.find((t) => t.id === selectedTab); + console.log("🔄 초기 탭 로드:", { + selectedTab, + currentTab, + hasScreenId: !!currentTab?.screenId, + screenId: currentTab?.screenId, + }); + + if (currentTab && currentTab.screenId && !screenLayouts[currentTab.screenId]) { + console.log("📥 초기 화면 로딩 시작:", currentTab.screenId); + loadScreenLayout(currentTab.screenId); + } + }, [selectedTab, visibleTabs]); + // 화면 레이아웃 로드 const loadScreenLayout = async (screenId: number) => { if (screenLayouts[screenId]) { + console.log("✅ 이미 로드된 화면:", screenId); return; // 이미 로드됨 } + console.log("📥 화면 레이아웃 로딩 시작:", screenId); setLoadingScreens((prev) => ({ ...prev, [screenId]: true })); try { - const response = await fetch(`/api/screen-management/screens/${screenId}/layout`); - if (response.ok) { - const data = await response.json(); - if (data.success && data.data) { - setScreenLayouts((prev) => ({ ...prev, [screenId]: data.data })); - } + const { apiClient } = await import("@/lib/api/client"); + const response = await apiClient.get(`/screen-management/screens/${screenId}/layout`); + console.log("📦 API 응답:", { screenId, success: response.data.success, hasData: !!response.data.data }); + + if (response.data.success && response.data.data) { + console.log("✅ 화면 레이아웃 로드 완료:", screenId); + setScreenLayouts((prev) => ({ ...prev, [screenId]: response.data.data })); + } else { + console.error("❌ 화면 레이아웃 로드 실패 - success false"); } } catch (error) { - console.error(`Failed to load screen layout ${screenId}:`, error); + console.error(`❌ 화면 레이아웃 로드 실패 ${screenId}:`, error); } finally { setLoadingScreens((prev) => ({ ...prev, [screenId]: false })); } @@ -85,11 +106,15 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { // 탭 변경 핸들러 const handleTabChange = (tabId: string) => { + console.log("🔄 탭 변경:", tabId); setSelectedTab(tabId); // 해당 탭의 화면 로드 const tab = visibleTabs.find((t) => t.id === tabId); + console.log("🔍 선택된 탭 정보:", { tab, hasScreenId: !!tab?.screenId, screenId: tab?.screenId }); + if (tab && tab.screenId && !screenLayouts[tab.screenId]) { + console.log("📥 탭 변경 시 화면 로딩:", tab.screenId); loadScreenLayout(tab.screenId); } }; @@ -120,6 +145,7 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { }; if (visibleTabs.length === 0) { + console.log("⚠️ 보이는 탭이 없음"); return (

탭이 없습니다

@@ -127,61 +153,106 @@ export function TabsWidget({ component, className, style }: TabsWidgetProps) { ); } - return ( - - - {visibleTabs.map((tab) => ( -
- - {tab.label} - - {allowCloseable && ( - - )} -
- ))} -
+ console.log("🎨 TabsWidget 최종 렌더링:", { + visibleTabsCount: visibleTabs.length, + selectedTab, + screenLayoutsKeys: Object.keys(screenLayouts), + loadingScreensKeys: Object.keys(loadingScreens), + }); - {visibleTabs.map((tab) => ( - - {tab.screenId ? ( - loadingScreens[tab.screenId] ? ( -
- - 화면 로딩 중... + return ( +
+ +
+ + {visibleTabs.map((tab) => ( +
+ + {tab.label} + + {allowCloseable && ( + + )}
- ) : screenLayouts[tab.screenId] ? ( -
- -
- ) : ( -
-

화면을 불러올 수 없습니다

-
- ) - ) : ( -
-

연결된 화면이 없습니다

-
- )} - - ))} - + ))} +
+
+ +
+ {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) => ( + + ))} +
+
+ ); + })() + ) : ( +
+

화면을 불러올 수 없습니다

+
+ ) + ) : ( +
+

연결된 화면이 없습니다

+
+ )} +
+ ))} +
+
+
); } diff --git a/frontend/lib/registry/components/tabs/tabs-component.tsx b/frontend/lib/registry/components/tabs/tabs-component.tsx index 18fbf297..9006d78e 100644 --- a/frontend/lib/registry/components/tabs/tabs-component.tsx +++ b/frontend/lib/registry/components/tabs/tabs-component.tsx @@ -6,6 +6,40 @@ import { ComponentCategory } from "@/types/component"; import { Folder } from "lucide-react"; import type { TabsComponent, TabItem } from "@/types/screen-management"; +// TabsWidget 래퍼 컴포넌트 +const TabsWidgetWrapper: React.FC = (props) => { + const { component, ...restProps } = props; + + // componentConfig에서 탭 정보 추출 + const tabsConfig = component.componentConfig || {}; + const tabsComponent = { + ...component, + type: "tabs" as const, + tabs: tabsConfig.tabs || [], + defaultTab: tabsConfig.defaultTab, + orientation: tabsConfig.orientation || "horizontal", + variant: tabsConfig.variant || "default", + allowCloseable: tabsConfig.allowCloseable || false, + persistSelection: tabsConfig.persistSelection || false, + }; + + console.log("🎨 TabsWidget 렌더링:", { + componentId: component.id, + tabs: tabsComponent.tabs, + tabsLength: tabsComponent.tabs.length, + component, + }); + + // TabsWidget 동적 로드 + const TabsWidget = require("@/components/screen/widgets/TabsWidget").TabsWidget; + + return ( +
+ +
+ ); +}; + /** * 탭 컴포넌트 정의 * @@ -17,7 +51,7 @@ ComponentRegistry.registerComponent({ description: "화면을 탭으로 전환할 수 있는 컴포넌트입니다. 각 탭마다 다른 화면을 연결할 수 있습니다.", category: ComponentCategory.LAYOUT, webType: "text" as any, // 레이아웃 컴포넌트이므로 임시값 - component: () => null as any, // 레이아웃 컴포넌트이므로 임시값 + component: TabsWidgetWrapper, // ✅ 실제 TabsWidget 렌더러 defaultConfig: {}, tags: ["tabs", "navigation", "layout", "screen"], icon: Folder, From a0180d66a26fe0f1c1f5e702bba4bb331f0e9bae Mon Sep 17 00:00:00 2001 From: kjs Date: Tue, 25 Nov 2025 13:04:58 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=ED=8E=B8=EC=A7=91=EA=B8=B0=20=EC=9D=B8?= =?UTF-8?q?=ED=92=8B=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F?= =?UTF-8?q?=20=ED=83=AD=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=99=84?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/screen/ScreenDesigner.tsx | 24 +++- frontend/components/screen/StyleEditor.tsx | 49 +++---- .../config-panels/ButtonConfigPanel.tsx | 17 +-- .../config-panels/CheckboxConfigPanel.tsx | 24 ++-- .../screen/config-panels/CodeConfigPanel.tsx | 12 +- .../screen/config-panels/DateConfigPanel.tsx | 12 +- .../config-panels/EntityConfigPanel.tsx | 131 +++++++++++------- .../screen/config-panels/FileConfigPanel.tsx | 12 +- .../FlowVisibilityConfigPanel.tsx | 34 ++--- .../config-panels/FlowWidgetConfigPanel.tsx | 2 +- .../config-panels/NumberConfigPanel.tsx | 14 +- .../screen/config-panels/RadioConfigPanel.tsx | 22 +-- .../config-panels/SelectConfigPanel.tsx | 22 +-- .../screen/config-panels/TabsConfigPanel.tsx | 23 ++- .../screen/config-panels/TextConfigPanel.tsx | 12 +- .../config-panels/TextareaConfigPanel.tsx | 14 +- .../screen/dialogs/FlowButtonGroupDialog.tsx | 4 +- .../screen/panels/ComponentsPanel.tsx | 2 +- .../screen/panels/DataTableConfigPanel.tsx | 100 ++++++------- .../screen/panels/DetailSettingsPanel.tsx | 33 +---- .../screen/panels/FlowButtonGroupPanel.tsx | 4 +- .../components/screen/panels/LayoutsPanel.tsx | 2 +- .../screen/panels/PropertiesPanel.tsx | 6 +- .../screen/panels/ResolutionPanel.tsx | 11 +- .../screen/panels/RowSettingsPanel.tsx | 4 +- .../screen/panels/TemplatesPanel.tsx | 2 +- .../screen/panels/UnifiedPropertiesPanel.tsx | 29 +--- .../screen/panels/WebTypeConfigPanel.tsx | 8 +- .../CheckboxTypeConfigPanel.tsx | 8 +- .../webtype-configs/CodeTypeConfigPanel.tsx | 4 +- .../webtype-configs/DateTypeConfigPanel.tsx | 2 +- .../webtype-configs/EntityTypeConfigPanel.tsx | 6 +- .../webtype-configs/NumberTypeConfigPanel.tsx | 2 +- .../webtype-configs/RadioTypeConfigPanel.tsx | 2 +- .../webtype-configs/SelectTypeConfigPanel.tsx | 2 +- .../webtype-configs/TextTypeConfigPanel.tsx | 4 +- .../TextareaTypeConfigPanel.tsx | 2 +- .../components/screen/widgets/FlowWidget.tsx | 2 +- .../components/screen/widgets/InputWidget.tsx | 2 +- .../screen/widgets/SelectWidget.tsx | 2 +- 40 files changed, 342 insertions(+), 325 deletions(-) diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index dcd80a62..0127c9d1 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -981,7 +981,18 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 스페이스바 키 이벤트 처리 (Pan 모드) + 전역 마우스 이벤트 useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - // 입력 필드에서는 스페이스바 무시 + // 입력 필드에서는 스페이스바 무시 (activeElement로 정확하게 체크) + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute('contenteditable') === 'true' || + activeElement?.getAttribute('role') === 'textbox' + ) { + return; + } + + // e.target도 함께 체크 (이중 방어) if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } @@ -997,6 +1008,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }; const handleKeyUp = (e: KeyboardEvent) => { + // 입력 필드에서는 스페이스바 무시 + const activeElement = document.activeElement; + if ( + activeElement instanceof HTMLInputElement || + activeElement instanceof HTMLTextAreaElement || + activeElement?.getAttribute('contenteditable') === 'true' || + activeElement?.getAttribute('role') === 'textbox' + ) { + return; + } + if (e.code === "Space") { e.preventDefault(); // 스페이스바 기본 스크롤 동작 차단 setIsPanMode(false); diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index 054875f7..2e5dec7e 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -49,7 +49,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderWidth || ""} onChange={(e) => handleStyleChange("borderWidth", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -60,20 +59,20 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderStyle || "solid"} onValueChange={(value) => handleStyleChange("borderStyle", value)} > - + - + 실선 - + 파선 - + 점선 - + 없음 @@ -93,7 +92,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderColor || "#000000"} onChange={(e) => handleStyleChange("borderColor", e.target.value)} className="h-6 w-12 p-1" - style={{ fontSize: "12px" }} + className="text-xs" /> handleStyleChange("borderColor", e.target.value)} placeholder="#000000" className="h-6 flex-1 text-xs" - style={{ fontSize: "12px" }} />
@@ -116,7 +114,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.borderRadius || ""} onChange={(e) => handleStyleChange("borderRadius", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -142,7 +139,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.backgroundColor || "#ffffff"} onChange={(e) => handleStyleChange("backgroundColor", e.target.value)} className="h-6 w-12 p-1" - style={{ fontSize: "12px" }} + className="text-xs" /> handleStyleChange("backgroundColor", e.target.value)} placeholder="#ffffff" className="h-6 flex-1 text-xs" - style={{ fontSize: "12px" }} />
@@ -166,7 +162,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.backgroundImage || ""} onChange={(e) => handleStyleChange("backgroundImage", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />

위젯 배경 꾸미기용 (고급 사용자 전용) @@ -195,7 +190,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.color || "#000000"} onChange={(e) => handleStyleChange("color", e.target.value)} className="h-6 w-12 p-1" - style={{ fontSize: "12px" }} + className="text-xs" /> handleStyleChange("color", e.target.value)} placeholder="#000000" className="h-6 flex-1 text-xs" - style={{ fontSize: "12px" }} />

@@ -218,7 +212,6 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.fontSize || ""} onChange={(e) => handleStyleChange("fontSize", e.target.value)} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />
@@ -232,29 +225,29 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.fontWeight || "normal"} onValueChange={(value) => handleStyleChange("fontWeight", value)} > - + - + 보통 - + 굵게 - + 100 - + 400 - + 500 - + 600 - + 700 @@ -268,20 +261,20 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd value={localStyle.textAlign || "left"} onValueChange={(value) => handleStyleChange("textAlign", value)} > - + - + 왼쪽 - + 가운데 - + 오른쪽 - + 양쪽 diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 1ef8fee4..7af50458 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -509,7 +509,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -900,7 +900,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -978,7 +978,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1132,7 +1132,7 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={modalScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1286,7 +1286,6 @@ export const ButtonConfigPanel: React.FC = ({ role="combobox" aria-expanded={displayColumnOpen} className="mt-2 h-8 w-full justify-between text-xs" - style={{ fontSize: "12px" }} disabled={columnsLoading || tableColumns.length === 0} > {columnsLoading @@ -1301,9 +1300,9 @@ export const ButtonConfigPanel: React.FC = ({ - + - + 컬럼을 찾을 수 없습니다. @@ -1316,7 +1315,6 @@ export const ButtonConfigPanel: React.FC = ({ setDisplayColumnOpen(false); }} className="text-xs" - style={{ fontSize: "12px" }} > = ({ role="combobox" aria-expanded={navScreenOpen} className="h-6 w-full justify-between px-2 py-0" - style={{ fontSize: "12px" }} + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1424,7 +1422,6 @@ export const ButtonConfigPanel: React.FC = ({ onUpdateProperty("componentConfig.action.targetUrl", newValue); }} className="h-6 w-full px-2 py-0 text-xs" - style={{ fontSize: "12px" }} />

URL을 입력하면 화면 선택보다 우선 적용됩니다

diff --git a/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx b/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx index 85a78b7a..bff983dc 100644 --- a/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx +++ b/frontend/components/screen/config-panels/CheckboxConfigPanel.tsx @@ -117,7 +117,7 @@ export const CheckboxConfigPanel: React.FC = ({ return ( - + 체크박스 설정 @@ -173,7 +173,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.label || ""} onChange={(e) => updateConfig("label", e.target.value)} placeholder="체크박스 라벨" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -187,7 +187,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.checkedValue || ""} onChange={(e) => updateConfig("checkedValue", e.target.value)} placeholder="Y" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -199,7 +199,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.uncheckedValue || ""} onChange={(e) => updateConfig("uncheckedValue", e.target.value)} placeholder="N" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />
@@ -232,7 +232,7 @@ export const CheckboxConfigPanel: React.FC = ({ value={localConfig.groupLabel || ""} onChange={(e) => updateConfig("groupLabel", e.target.value)} placeholder="체크박스 그룹 제목" - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" /> @@ -244,19 +244,19 @@ export const CheckboxConfigPanel: React.FC = ({ value={newOptionLabel} onChange={(e) => setNewOptionLabel(e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> setNewOptionValue(e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> @@ -277,13 +277,13 @@ export const CheckboxConfigPanel: React.FC = ({ value={option.label} onChange={(e) => updateOption(index, "label", e.target.value)} placeholder="라벨" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> updateOption(index, "value", e.target.value)} placeholder="값" - className="flex-1 text-xs" style={{ fontSize: "12px" }} + className="flex-1 text-xs" /> = ({ disabled={localConfig.readonly} required={localConfig.required} defaultChecked={localConfig.defaultChecked} - className="text-xs" style={{ fontSize: "12px" }} + className="text-xs" />