From cbd54316cf4d8e2bd5e9965e389e913665a9cbd3 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 13:26:42 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0:=20INSERT?= =?UTF-8?q?=EA=B0=80=20=EC=95=84=EB=8B=8C=20=EC=95=A1=EC=85=98=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EC=8B=A4=ED=96=89=EC=A1=B0=EA=B1=B4=20?= =?UTF-8?q?=ED=95=84=EC=88=98=20=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20DELETE=20=EC=95=A1=EC=85=98=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=ED=95=84=EB=93=9C=20=EB=A7=A4=ED=95=91=20=EB=B0=8F?= =?UTF-8?q?=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=B6=84=ED=95=A0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=83=9D=EB=9E=B5=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84.=20=EC=A1=B0=EA=B1=B4=20=EB=AF=B8=EB=B9=84=20?= =?UTF-8?q?=EC=8B=9C=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=95=88=EB=82=B4=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=B6=94=EA=B0=80.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dataflow/ConnectionSetupModal.tsx | 82 +++++++++++++++++-- .../connection/ActionConditionsSection.tsx | 64 ++++++++++++++- .../connection/ActionFieldMappings.tsx | 21 ++++- .../dataflow/connection/DataSaveSettings.tsx | 63 ++++++++++---- 4 files changed, 203 insertions(+), 27 deletions(-) diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index 7baac19c..b8d37f8a 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -343,6 +343,37 @@ export const ConnectionSetupModal: React.FC = ({ break; case "data-save": settings = dataSaveSettings; + + // INSERT가 아닌 액션 타입에 대한 실행조건 필수 검증 + for (const action of dataSaveSettings.actions) { + if (action.actionType !== "insert") { + if (!action.conditions || action.conditions.length === 0) { + toast.error( + `${action.actionType.toUpperCase()} 액션은 실행조건이 필수입니다. '${action.name}' 액션에 실행조건을 추가해주세요.`, + ); + return; + } + + // 실제 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외) + const hasValidConditions = action.conditions.some((condition) => { + if (condition.type !== "condition") return false; + if (!condition.field || !condition.operator) return false; + + // value가 null, undefined, 빈 문자열이면 유효하지 않음 + const value = condition.value; + if (value === null || value === undefined || value === "") return false; + + return true; + }); + + if (!hasValidConditions) { + toast.error( + `${action.actionType.toUpperCase()} 액션은 완전한 실행조건이 필요합니다. '${action.name}' 액션에 필드, 연산자, 값을 모두 설정해주세요.`, + ); + return; + } + } + } break; case "external-call": // 외부 호출은 plan에 저장 @@ -508,9 +539,21 @@ export const ConnectionSetupModal: React.FC = ({ case "data-save": // 데이터 저장: 액션과 필드 매핑이 완성되어야 함 const hasActions = dataSaveSettings.actions.length > 0; - const allActionsHaveMappings = dataSaveSettings.actions.every((action) => action.fieldMappings.length > 0); - const allMappingsComplete = dataSaveSettings.actions.every((action) => - action.fieldMappings.every((mapping) => { + + // DELETE 액션은 필드 매핑이 필요 없음 + const allActionsHaveMappings = dataSaveSettings.actions.every((action) => { + if (action.actionType === "delete") { + return true; // DELETE는 필드 매핑 불필요 + } + return action.fieldMappings.length > 0; + }); + + const allMappingsComplete = dataSaveSettings.actions.every((action) => { + if (action.actionType === "delete") { + return true; // DELETE는 필드 매핑 검증 생략 + } + + return action.fieldMappings.every((mapping) => { // 타겟은 항상 필요 if (!mapping.targetTable || !mapping.targetField) return false; @@ -525,9 +568,36 @@ export const ConnectionSetupModal: React.FC = ({ // FROM 테이블이 있으면 소스 매핑 완성 또는 기본값 필요 return hasSource || hasDefault; - }), - ); - return !hasActions || !allActionsHaveMappings || !allMappingsComplete; + }); + }); + + // INSERT가 아닌 액션 타입에 대한 실행조건 필수 검증 + const allRequiredConditionsMet = dataSaveSettings.actions.every((action) => { + if (action.actionType === "insert") { + return true; // INSERT는 조건 불필요 + } + + // INSERT가 아닌 액션은 유효한 조건이 있어야 함 + if (!action.conditions || action.conditions.length === 0) { + return false; + } + + // 실제 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외) + const hasValidConditions = action.conditions.some((condition) => { + if (condition.type !== "condition") return false; + if (!condition.field || !condition.operator) return false; + + // value가 null, undefined, 빈 문자열이면 유효하지 않음 + const value = condition.value; + if (value === null || value === undefined || value === "") return false; + + return true; + }); + + return hasValidConditions; + }); + + return !hasActions || !allActionsHaveMappings || !allMappingsComplete || !allRequiredConditionsMet; case "external-call": // 외부 호출: 설정 ID와 메시지가 있어야 함 diff --git a/frontend/components/dataflow/connection/ActionConditionsSection.tsx b/frontend/components/dataflow/connection/ActionConditionsSection.tsx index 9e40cade..ff498dc0 100644 --- a/frontend/components/dataflow/connection/ActionConditionsSection.tsx +++ b/frontend/components/dataflow/connection/ActionConditionsSection.tsx @@ -32,6 +32,22 @@ export const ActionConditionsSection: React.FC = ( }) => { const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers(); + // INSERT가 아닌 액션 타입인지 확인 + const isConditionRequired = action.actionType !== "insert"; + + // 유효한 조건이 있는지 확인 (group-start, group-end만 있는 경우 제외) + const hasValidConditions = + action.conditions?.some((condition) => { + if (condition.type !== "condition") return false; + if (!condition.field || !condition.operator) return false; + + // value가 null, undefined, 빈 문자열이면 유효하지 않음 + const value = condition.value; + if (value === null || value === undefined || value === "") return false; + + return true; + }) || false; + const addActionCondition = () => { const newActions = [...settings.actions]; if (!newActions[actionIndex].conditions) { @@ -65,14 +81,28 @@ export const ActionConditionsSection: React.FC = ( return (
- +
- 🔍 이 액션의 실행 조건 (선택사항) + 🔍 이 액션의 실행 조건 + {isConditionRequired ? ( + 필수 + ) : ( + (선택사항) + )} {action.conditions && action.conditions.length > 0 && ( {action.conditions.length}개 )} + {isConditionRequired && !hasValidConditions && ( + ⚠️ 조건 필요 + )}
{action.conditions && action.conditions.length > 0 && (
+ + {/* 조건이 없을 때 안내 메시지 */} + {(!action.conditions || action.conditions.length === 0) && ( +
+ {isConditionRequired ? ( +
+ ⚠️ +
+
실행조건이 필요합니다
+
+ {action.actionType.toUpperCase()} 액션은 언제 실행될지 결정하는 조건이 반드시 필요합니다. +
+ 필드, 연산자, 값을 모두 입력해야 합니다. +
+ 예: user_name = '관리자우저' AND status = 'active' +
+
+
+ ) : ( +
조건이 설정되지 않았습니다. INSERT 액션은 조건 없이도 실행 가능합니다.
+ )} +
+ )} + {action.conditions && action.conditions.length > 0 && (
{action.conditions.map((condition, condIndex) => ( diff --git a/frontend/components/dataflow/connection/ActionFieldMappings.tsx b/frontend/components/dataflow/connection/ActionFieldMappings.tsx index 1a4f19da..d75d804b 100644 --- a/frontend/components/dataflow/connection/ActionFieldMappings.tsx +++ b/frontend/components/dataflow/connection/ActionFieldMappings.tsx @@ -54,7 +54,10 @@ export const ActionFieldMappings: React.FC = ({ return (
- +
+ + (필수) +
))} + + {/* 필드 매핑이 없을 때 안내 메시지 */} + {action.fieldMappings.length === 0 && ( +
+
+ ⚠️ +
+
필드 매핑이 필요합니다
+
+ {action.actionType.toUpperCase()} 액션은 어떤 데이터를 어떻게 처리할지 결정하는 필드 매핑이 + 필요합니다. +
+
+
+
+ )}
); diff --git a/frontend/components/dataflow/connection/DataSaveSettings.tsx b/frontend/components/dataflow/connection/DataSaveSettings.tsx index 15047f6a..b9483e8a 100644 --- a/frontend/components/dataflow/connection/DataSaveSettings.tsx +++ b/frontend/components/dataflow/connection/DataSaveSettings.tsx @@ -135,25 +135,52 @@ export const DataSaveSettings: React.FC = ({ toTableName={toTableName} /> - {/* 데이터 분할 설정 */} - + {/* 데이터 분할 설정 - DELETE 액션은 제외 */} + {action.actionType !== "delete" && ( + + )} - {/* 필드 매핑 */} - + {/* 필드 매핑 - DELETE 액션은 제외 */} + {action.actionType !== "delete" && ( + + )} + + {/* DELETE 액션일 때 안내 메시지 */} + {action.actionType === "delete" && ( +
+
+
+ ℹ️ +
+
DELETE 액션 정보
+
+ DELETE 액션은 실행조건만 필요합니다. +
+ • 데이터 분할 설정: 불필요 (삭제 작업에는 분할이 의미 없음) +
+ • 필드 매핑: 불필요 (조건에 맞는 데이터를 통째로 삭제) +
+ 위에서 설정한 실행조건에 맞는 모든 데이터가 삭제됩니다. +
+
+
+
+
+ )} ))} From e6cd8806e3683943754b97fbd6075db44957cc75 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 17:17:06 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0:=20INSERT?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=EC=97=90=20=EB=8C=80=ED=95=9C=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EB=A7=A4=ED=95=91=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EC=9A=B4=20InsertFieldMappingPanel=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=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 --- .../dataflow/ConnectionSetupModal.tsx | 22 + .../connection/ActionFieldMappings.tsx | 24 ++ .../connection/ColumnTableSection.tsx | 334 +++++++++++++++ .../dataflow/connection/DataSaveSettings.tsx | 4 + .../connection/InsertFieldMappingPanel.tsx | 395 ++++++++++++++++++ frontend/lib/api/dataflow.ts | 4 +- frontend/lib/api/screen.ts | 4 +- 7 files changed, 783 insertions(+), 4 deletions(-) create mode 100644 frontend/components/dataflow/connection/ColumnTableSection.tsx create mode 100644 frontend/components/dataflow/connection/InsertFieldMappingPanel.tsx diff --git a/frontend/components/dataflow/ConnectionSetupModal.tsx b/frontend/components/dataflow/ConnectionSetupModal.tsx index b8d37f8a..b0c27fe9 100644 --- a/frontend/components/dataflow/ConnectionSetupModal.tsx +++ b/frontend/components/dataflow/ConnectionSetupModal.tsx @@ -553,6 +553,28 @@ export const ConnectionSetupModal: React.FC = ({ return true; // DELETE는 필드 매핑 검증 생략 } + // INSERT 액션의 경우 모든 TO 테이블 컬럼이 매핑되거나 기본값이 있어야 함 + if (action.actionType === "insert") { + // TO 테이블의 모든 컬럼을 찾기 + const toTableName = action.fieldMappings[0]?.targetTable; + if (!toTableName) return false; + + const toTableColumns = tableColumnsCache[toTableName] || []; + if (toTableColumns.length === 0) return false; + + // 모든 TO 컬럼이 매핑되거나 기본값이 있는지 확인 + return toTableColumns.every((column) => { + const mapping = action.fieldMappings.find((m) => m.targetField === column.columnName); + if (!mapping) return false; + + // 소스 매핑 또는 기본값 중 하나는 있어야 함 + const hasSource = mapping.sourceTable && mapping.sourceField; + const hasDefault = mapping.defaultValue && mapping.defaultValue.trim(); + + return hasSource || hasDefault; + }); + } + return action.fieldMappings.every((mapping) => { // 타겟은 항상 필요 if (!mapping.targetTable || !mapping.targetField) return false; diff --git a/frontend/components/dataflow/connection/ActionFieldMappings.tsx b/frontend/components/dataflow/connection/ActionFieldMappings.tsx index d75d804b..dcd56cf6 100644 --- a/frontend/components/dataflow/connection/ActionFieldMappings.tsx +++ b/frontend/components/dataflow/connection/ActionFieldMappings.tsx @@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Plus, Trash2 } from "lucide-react"; import { TableInfo, ColumnInfo } from "@/lib/api/dataflow"; import { DataSaveSettings } from "@/types/connectionTypes"; +import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel"; interface ActionFieldMappingsProps { action: DataSaveSettings["actions"][0]; @@ -16,6 +17,10 @@ interface ActionFieldMappingsProps { onSettingsChange: (settings: DataSaveSettings) => void; availableTables: TableInfo[]; tableColumnsCache: { [tableName: string]: ColumnInfo[] }; + fromTableColumns?: ColumnInfo[]; + toTableColumns?: ColumnInfo[]; + fromTableName?: string; + toTableName?: string; } export const ActionFieldMappings: React.FC = ({ @@ -25,7 +30,26 @@ export const ActionFieldMappings: React.FC = ({ onSettingsChange, availableTables, tableColumnsCache, + fromTableColumns = [], + toTableColumns = [], + fromTableName, + toTableName, }) => { + // INSERT 액션일 때는 새로운 패널 사용 + if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) { + return ( + + ); + } const addFieldMapping = () => { const newActions = [...settings.actions]; newActions[actionIndex].fieldMappings.push({ diff --git a/frontend/components/dataflow/connection/ColumnTableSection.tsx b/frontend/components/dataflow/connection/ColumnTableSection.tsx new file mode 100644 index 00000000..0edbd0a6 --- /dev/null +++ b/frontend/components/dataflow/connection/ColumnTableSection.tsx @@ -0,0 +1,334 @@ +"use client"; + +import React, { useMemo } from "react"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { ColumnInfo } from "@/lib/api/dataflow"; +import { getInputTypeForDataType } from "@/utils/connectionUtils"; + +interface ColumnMapping { + toColumnName: string; + fromColumnName?: string; + defaultValue?: string; +} + +interface ColumnTableSectionProps { + type: "from" | "to"; + tableName: string; + columns: ColumnInfo[]; + selectedColumn: string | null; + onColumnClick: (columnName: string) => void; + searchTerm: string; + onSearchChange: (term: string) => void; + dataTypeFilter: string; + onDataTypeFilterChange: (filter: string) => void; + showMappedOnly: boolean; + onShowMappedOnlyChange: (show: boolean) => void; + showUnmappedOnly: boolean; + onShowUnmappedOnlyChange: (show: boolean) => void; + columnMappings: ColumnMapping[]; + onDefaultValueChange?: (columnName: string, value: string) => void; + onRemoveMapping?: (columnName: string) => void; + isColumnClickable: (column: ColumnInfo) => boolean; + oppositeSelectedColumn?: string | null; + oppositeColumns?: ColumnInfo[]; +} + +export const ColumnTableSection: React.FC = ({ + type, + tableName, + columns, + selectedColumn, + onColumnClick, + searchTerm, + onSearchChange, + dataTypeFilter, + onDataTypeFilterChange, + showMappedOnly, + onShowMappedOnlyChange, + showUnmappedOnly, + onShowUnmappedOnlyChange, + columnMappings, + onDefaultValueChange, + onRemoveMapping, + isColumnClickable, + oppositeSelectedColumn, + oppositeColumns, +}) => { + const isFromTable = type === "from"; + + // 데이터 타입 목록 추출 + const dataTypes = useMemo(() => { + const types = new Set(columns.map((col) => col.dataType).filter((type): type is string => !!type)); + return Array.from(types).sort(); + }, [columns]); + + // 필터링된 컬럼 목록 + const filteredColumns = useMemo(() => { + return columns.filter((column) => { + // 검색어 필터 + const matchesSearch = searchTerm === "" || column.columnName.toLowerCase().includes(searchTerm.toLowerCase()); + + // 데이터 타입 필터 + const matchesDataType = dataTypeFilter === "" || column.dataType === dataTypeFilter; + + // 매핑 상태 필터 + const isMapped = isFromTable + ? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName) + : (() => { + const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName); + return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim()); + })(); + + const matchesMappingFilter = + (!showMappedOnly && !showUnmappedOnly) || (showMappedOnly && isMapped) || (showUnmappedOnly && !isMapped); + + return matchesSearch && matchesDataType && matchesMappingFilter; + }); + }, [columns, searchTerm, dataTypeFilter, showMappedOnly, showUnmappedOnly, columnMappings, isFromTable]); + + const mappedCount = columns.filter((column) => + isFromTable + ? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName) + : (() => { + const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName); + return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim()); + })(), + ).length; + + return ( +
+ {/* 헤더 */} +
+

+ {isFromTable ? "From" : "To"}: {tableName} ({columns.length}/{columns.length}) +

+
+ + {/* 검색 및 필터 */} +
+
+ onSearchChange(e.target.value)} + className="h-8 text-xs" + /> +
+ +
+ + +
+
+
+
+ + {/* 컬럼 리스트 */} +
+ {filteredColumns.map((column) => { + const isSelected = selectedColumn === column.columnName; + const isClickable = isColumnClickable(column); + + if (isFromTable) { + // FROM 테이블 렌더링 + const isMapped = columnMappings.some((mapping) => mapping.fromColumnName === column.columnName); + const mappedToColumn = columnMappings.find( + (mapping) => mapping.fromColumnName === column.columnName, + )?.toColumnName; + + // 선택된 TO 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외) + const isTypeCompatible = + !oppositeSelectedColumn || + isMapped || + oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType; + + return ( +
onColumnClick(column.columnName) : undefined} + className={`border-b border-gray-200 px-3 py-2 text-xs transition-colors ${ + isSelected + ? "bg-gray-200 text-gray-800" + : isMapped + ? "bg-gray-100 text-gray-700" + : oppositeSelectedColumn && !isTypeCompatible + ? "cursor-not-allowed bg-red-50 text-red-400 opacity-60" + : isClickable + ? "cursor-pointer hover:bg-gray-50" + : "cursor-not-allowed bg-gray-100 text-gray-400" + }`} + > +
+
+
+ {column.columnName} + {isSelected && } + {isMapped && } + {oppositeSelectedColumn && !isTypeCompatible && ( + + ⚠ + + )} +
+
+ {column.dataType} + {oppositeSelectedColumn && !isTypeCompatible && ( + (호환 불가) + )} +
+ {isMapped && mappedToColumn && ( +
→ {mappedToColumn}
+ )} +
+
+
+ ); + } else { + // TO 테이블 렌더링 + const mapping = columnMappings.find((m) => m.toColumnName === column.columnName); + const isMapped = !!mapping?.fromColumnName; + const hasDefaultValue = !!(mapping?.defaultValue && mapping.defaultValue.trim()); + + // 선택된 FROM 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외) + const isTypeCompatible = + !oppositeSelectedColumn || + isMapped || + oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType; + + return ( +
+ {/* 컬럼 정보 행 */} +
onColumnClick(column.columnName) : undefined} + className={`px-3 py-2 text-xs ${ + isClickable && isTypeCompatible + ? "cursor-pointer hover:bg-gray-50" + : oppositeSelectedColumn && !isTypeCompatible + ? "cursor-not-allowed" + : hasDefaultValue + ? "cursor-not-allowed" + : "" + }`} + > +
+
+ {column.columnName} + {isSelected && } + {oppositeSelectedColumn && !isTypeCompatible && ( + + ⚠ + + )} +
+
+ {column.dataType} + {oppositeSelectedColumn && !isTypeCompatible && ( + (호환 불가) + )} +
+ + {isMapped && ( +
+ ← {mapping.fromColumnName} + +
+ )} + + {!isMapped && onDefaultValueChange && ( +
+ onDefaultValueChange(column.columnName, e.target.value)} + className="h-6 border-gray-200 text-xs focus:border-green-400 focus:ring-0" + onClick={(e) => e.stopPropagation()} + disabled={isSelected || !!oppositeSelectedColumn} + /> +
+ )} +
+
+
+ ); + } + })} +
+ + {/* 하단 통계 */} +
+
+ + {isFromTable ? "매핑됨" : "설정됨"}: {mappedCount}/{columns.length} + + + 표시: {filteredColumns.length} + +
+
+
+ ); +}; diff --git a/frontend/components/dataflow/connection/DataSaveSettings.tsx b/frontend/components/dataflow/connection/DataSaveSettings.tsx index b9483e8a..8fd3937e 100644 --- a/frontend/components/dataflow/connection/DataSaveSettings.tsx +++ b/frontend/components/dataflow/connection/DataSaveSettings.tsx @@ -156,6 +156,10 @@ export const DataSaveSettings: React.FC = ({ onSettingsChange={onSettingsChange} availableTables={availableTables} tableColumnsCache={tableColumnsCache} + fromTableColumns={fromTableColumns} + toTableColumns={toTableColumns} + fromTableName={fromTableName} + toTableName={toTableName} /> )} diff --git a/frontend/components/dataflow/connection/InsertFieldMappingPanel.tsx b/frontend/components/dataflow/connection/InsertFieldMappingPanel.tsx new file mode 100644 index 00000000..8118147c --- /dev/null +++ b/frontend/components/dataflow/connection/InsertFieldMappingPanel.tsx @@ -0,0 +1,395 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Progress } from "@/components/ui/progress"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { ColumnInfo } from "@/lib/api/dataflow"; +import { DataSaveSettings } from "@/types/connectionTypes"; +import { ColumnTableSection } from "./ColumnTableSection"; + +interface InsertFieldMappingPanelProps { + action: DataSaveSettings["actions"][0]; + actionIndex: number; + settings: DataSaveSettings; + onSettingsChange: (settings: DataSaveSettings) => void; + fromTableColumns: ColumnInfo[]; + toTableColumns: ColumnInfo[]; + fromTableName?: string; + toTableName?: string; +} + +interface ColumnMapping { + toColumnName: string; + fromColumnName?: string; + defaultValue?: string; +} + +export const InsertFieldMappingPanel: React.FC = ({ + action, + actionIndex, + settings, + onSettingsChange, + fromTableColumns, + toTableColumns, + fromTableName, + toTableName, +}) => { + const [selectedFromColumn, setSelectedFromColumn] = useState(null); + const [selectedToColumn, setSelectedToColumn] = useState(null); + const [columnMappings, setColumnMappings] = useState([]); + + // 검색 및 필터링 상태 (FROM과 TO 독립적) + const [fromSearchTerm, setFromSearchTerm] = useState(""); + const [toSearchTerm, setToSearchTerm] = useState(""); + const [fromDataTypeFilter, setFromDataTypeFilter] = useState(""); + const [toDataTypeFilter, setToDataTypeFilter] = useState(""); + + // FROM 테이블 필터 + const [fromShowMappedOnly, setFromShowMappedOnly] = useState(false); + const [fromShowUnmappedOnly, setFromShowUnmappedOnly] = useState(false); + + // TO 테이블 필터 + const [toShowMappedOnly, setToShowMappedOnly] = useState(false); + const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false); + + // 기존 매핑 데이터를 columnMappings로 변환 + useEffect(() => { + const mappings: ColumnMapping[] = toTableColumns.map((toCol) => { + const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName); + + return { + toColumnName: toCol.columnName, + fromColumnName: existingMapping?.sourceField || undefined, + defaultValue: existingMapping?.defaultValue || "", + }; + }); + + setColumnMappings(mappings); + }, [action.fieldMappings, toTableColumns]); + + // columnMappings 변경 시 settings 업데이트 + const updateSettings = (newMappings: ColumnMapping[]) => { + const newActions = [...settings.actions]; + + // 새로운 fieldMappings 생성 + const fieldMappings = newMappings + .filter((mapping) => mapping.fromColumnName || (mapping.defaultValue && mapping.defaultValue.trim())) + .map((mapping) => ({ + sourceTable: mapping.fromColumnName ? fromTableName || "" : "", + sourceField: mapping.fromColumnName || "", + targetTable: toTableName || "", + targetField: mapping.toColumnName, + defaultValue: mapping.defaultValue || "", + transformFunction: "", + })); + + newActions[actionIndex].fieldMappings = fieldMappings; + onSettingsChange({ ...settings, actions: newActions }); + }; + + // FROM 컬럼 클릭 핸들러 + const handleFromColumnClick = (columnName: string) => { + if (selectedFromColumn === columnName) { + setSelectedFromColumn(null); + } else { + setSelectedFromColumn(columnName); + + // TO 컬럼이 이미 선택되어 있으면 매핑 시도 + if (selectedToColumn) { + const fromColumn = fromTableColumns.find((col) => col.columnName === columnName); + const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn); + + if (fromColumn && toColumn) { + // 데이터 타입 호환성 체크 + if (fromColumn.dataType !== toColumn.dataType) { + alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`); + return; + } + + // 매핑 생성 + createMapping(columnName, selectedToColumn); + setSelectedFromColumn(null); + setSelectedToColumn(null); + } + } + } + }; + + // 공통 매핑 생성 함수 + const createMapping = (fromColumnName: string, toColumnName: string) => { + const newMappings = columnMappings.map((mapping) => { + if (mapping.toColumnName === toColumnName) { + return { + ...mapping, + fromColumnName: fromColumnName, + defaultValue: "", // 매핑이 설정되면 기본값 초기화 + }; + } + return mapping; + }); + + setColumnMappings(newMappings); + updateSettings(newMappings); + }; + + // TO 컬럼 클릭 핸들러 + const handleToColumnClick = (toColumnName: string) => { + const currentMapping = columnMappings.find((m) => m.toColumnName === toColumnName); + + // 이미 매핑된 컬럼인 경우 처리하지 않음 + if (currentMapping?.fromColumnName) return; + + if (selectedToColumn === toColumnName) { + setSelectedToColumn(null); + } else { + setSelectedToColumn(toColumnName); + + // FROM 컬럼이 이미 선택되어 있으면 매핑 시도 + if (selectedFromColumn) { + const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn); + const toColumn = toTableColumns.find((col) => col.columnName === toColumnName); + + if (fromColumn && toColumn) { + // 데이터 타입 호환성 체크 + if (fromColumn.dataType !== toColumn.dataType) { + alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`); + return; + } + + // 매핑 생성 + createMapping(selectedFromColumn, toColumnName); + setSelectedFromColumn(null); + setSelectedToColumn(null); + } + } + } + }; + + // 기본값 변경 핸들러 + const handleDefaultValueChange = (toColumnName: string, value: string) => { + const newMappings = columnMappings.map((mapping) => { + if (mapping.toColumnName === toColumnName) { + return { + ...mapping, + fromColumnName: value.trim() ? undefined : mapping.fromColumnName, // 기본값이 있으면 매핑 제거 + defaultValue: value, + }; + } + return mapping; + }); + + setColumnMappings(newMappings); + updateSettings(newMappings); + }; + + // 매핑 제거 핸들러 + const handleRemoveMapping = (toColumnName: string) => { + const newMappings = columnMappings.map((mapping) => { + if (mapping.toColumnName === toColumnName) { + return { + ...mapping, + fromColumnName: undefined, + defaultValue: "", + }; + } + return mapping; + }); + + setColumnMappings(newMappings); + updateSettings(newMappings); + }; + + // FROM 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성 + 1대1 매핑 제약) + const isFromColumnClickable = (fromColumn: ColumnInfo) => { + // 이미 다른 TO 컬럼과 매핑된 FROM 컬럼은 클릭 불가 + const isAlreadyMapped = columnMappings.some((mapping) => mapping.fromColumnName === fromColumn.columnName); + if (isAlreadyMapped) return false; + + if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능 + + const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn); + if (!toColumn) return true; + + return fromColumn.dataType === toColumn.dataType; + }; + + // TO 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성) + const isToColumnClickable = (toColumn: ColumnInfo) => { + const currentMapping = columnMappings.find((m) => m.toColumnName === toColumn.columnName); + + // 이미 매핑된 컬럼은 클릭 불가 + if (currentMapping?.fromColumnName) return false; + + // 기본값이 설정된 컬럼은 클릭 불가 + if (currentMapping?.defaultValue && currentMapping.defaultValue.trim()) return false; + + if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능 + + const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn); + if (!fromColumn) return true; + + return fromColumn.dataType === toColumn.dataType; + }; + + return ( +
+ {/* 헤더 섹션 */} + + +

+ 양쪽 테이블의 컬럼을 클릭하여 매핑하거나, 대상 컬럼에 기본값을 입력하세요. 같은 데이터 타입의 컬럼만 매핑 + 가능합니다. 하나의 FROM 컬럼은 하나의 TO 컬럼에만 매핑 가능합니다. +

+
+
+ +
+ + + +
+ + {/* 빠른 필터 액션 */} + + + 빠른 필터: + + + +
+ + FROM: {fromTableColumns.length} + + + TO: {toTableColumns.length} + +
+
+
+ + {/* 매핑 통계 */} + + +
+
+
+
매핑 진행 상황
+
+ 총 {toTableColumns.length}개 컬럼 중{" "} + + {columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length} + 개 + {" "} + 완료 +
+
+
+
+
+ {Math.round( + (columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length / + toTableColumns.length) * + 100, + )} + % +
+
완료율
+
+
+
+ m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length / + toTableColumns.length) * + 100 + } + className="h-2" + /> +
+
+
+
+ ); +}; diff --git a/frontend/lib/api/dataflow.ts b/frontend/lib/api/dataflow.ts index 9c702ca2..c40f1a3b 100644 --- a/frontend/lib/api/dataflow.ts +++ b/frontend/lib/api/dataflow.ts @@ -358,7 +358,7 @@ export class DataFlowAPI { } /** - * 테이블 컬럼 정보 조회 + * 테이블 컬럼 정보 조회 (모든 컬럼) */ static async getTableColumns(tableName: string): Promise { try { @@ -369,7 +369,7 @@ export class DataFlowAPI { total: number; totalPages: number; }> - >(`/table-management/tables/${tableName}/columns`); + >(`/table-management/tables/${tableName}/columns?size=1000`); if (!response.data.success) { throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다."); diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 4accf9da..11a91479 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -220,9 +220,9 @@ export const tableTypeApi = { } }, - // 테이블 컬럼 정보 조회 + // 테이블 컬럼 정보 조회 (모든 컬럼) getColumns: async (tableName: string): Promise => { - const response = await apiClient.get(`/table-management/tables/${tableName}/columns`); + const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`); // 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages } const data = response.data.data || response.data; return data.columns || data || []; From ea1e46e52cc80d2ec23a7cc38735b33aea2f9ade Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 17:45:47 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/prod/start-all-linux-force.sh | 288 ++++++++++++++++++++++++++ scripts/prod/start-all-linux.sh | 4 +- 2 files changed, 290 insertions(+), 2 deletions(-) create mode 100755 scripts/prod/start-all-linux-force.sh diff --git a/scripts/prod/start-all-linux-force.sh b/scripts/prod/start-all-linux-force.sh new file mode 100755 index 00000000..908d3fd5 --- /dev/null +++ b/scripts/prod/start-all-linux-force.sh @@ -0,0 +1,288 @@ +#!/bin/bash + +echo "============================================" +echo "WACE 솔루션 - 전체 서비스 시작 (강제 업데이트) - Linux" +echo "============================================" + +echo "" +echo "🚀 최신 코드로 백엔드와 프론트엔드를 강제 업데이트하여 시작합니다..." +echo "" + +# 시스템 정보 출력 +echo "시스템 정보:" +echo " OS: $(uname -s)" +echo " Architecture: $(uname -m)" +echo " Kernel: $(uname -r)" +echo "" + +# Docker 및 Docker Compose 버전 확인 +echo "Docker 환경 확인:" +docker --version 2>/dev/null || echo " ❌ Docker가 설치되지 않았습니다." +docker-compose --version 2>/dev/null || echo " ❌ Docker Compose가 설치되지 않았습니다." +echo "" + +# Git 상태 확인 +echo "============================================" +echo "0. Git 상태 확인 및 최신 코드 동기화" +echo "============================================" + +echo "현재 Git 상태:" +git status --porcelain + +echo "" +echo "현재 브랜치: $(git branch --show-current)" +echo "최신 커밋: $(git log -1 --oneline)" + +# 수정된 파일이 있는지 확인 +if [ -n "$(git status --porcelain)" ]; then + echo "" + echo "⚠️ 수정된 파일이 있습니다. 다음 중 선택하세요:" + echo "1. 수정사항을 커밋하고 계속" + echo "2. 수정사항을 임시 저장(stash)하고 계속" + echo "3. 수정사항을 무시하고 강제 리셋 후 계속" + echo "4. 현재 상태로 빌드 계속" + echo "" + read -p "선택하세요 (1-4): " choice + + case $choice in + 1) + echo "변경사항을 커밋합니다..." + git add . + git commit -m "Auto commit before deployment - $(date)" + ;; + 2) + echo "변경사항을 임시 저장합니다..." + git stash push -m "Auto stash before deployment - $(date)" + ;; + 3) + echo "변경사항을 무시하고 강제 리셋합니다..." + git reset --hard HEAD + git clean -fd + ;; + 4) + echo "현재 상태로 계속합니다..." + ;; + *) + echo "잘못된 선택입니다. 현재 상태로 계속합니다..." + ;; + esac +fi + +# 원격 저장소에서 최신 코드 가져오기 +echo "" +echo "원격 저장소에서 최신 코드를 가져옵니다..." +git fetch origin +current_branch=$(git branch --show-current) +git pull origin $current_branch || echo "⚠️ Pull 실패 - 현재 상태로 계속합니다." + +# 기존 컨테이너 및 이미지 완전 정리 +echo "" +echo "============================================" +echo "1. 기존 서비스 완전 정리 중..." +echo "============================================" + +# 모든 관련 컨테이너 중지 및 제거 +echo "기존 컨테이너 중지 및 제거 중..." +docker-compose -f docker/prod/docker-compose.backend.prod.yml down -v --remove-orphans 2>/dev/null || true +docker-compose -f docker/prod/docker-compose.frontend.prod.yml down -v --remove-orphans 2>/dev/null || true + +# 관련 컨테이너 강제 제거 +docker stop pms-backend-prod pms-frontend-linux 2>/dev/null || true +docker rm pms-backend-prod pms-frontend-linux 2>/dev/null || true + +# 관련 이미지 제거 (캐시 무효화) +echo "기존 이미지 제거 중..." +docker rmi $(docker images | grep -E "(pms-|erp-node)" | awk '{print $3}') 2>/dev/null || true + +# Docker 시스템 정리 +echo "Docker 시스템 정리 중..." +docker system prune -f --volumes 2>/dev/null || true +docker builder prune -f 2>/dev/null || true + +# 네트워크 재생성 +echo "네트워크 재생성..." +docker network rm pms-network 2>/dev/null || true +docker network create pms-network 2>/dev/null || echo "네트워크 생성 실패 - 기본 네트워크 사용" + +# 백엔드 먼저 시작 +echo "" +echo "============================================" +echo "2. 백엔드 서비스 시작 중..." +echo "============================================" + +# 백엔드 의존성 체크 +echo "백엔드 의존성 확인 중..." +if [ ! -f "backend-node/package.json" ]; then + echo "❌ backend-node/package.json을 찾을 수 없습니다." + exit 1 +fi + +if [ ! -f "backend-node/src/app.ts" ]; then + echo "❌ backend-node/src/app.ts를 찾을 수 없습니다." + exit 1 +fi + +# 백엔드 빌드 (캐시 무시) +echo "백엔드 이미지 빌드 중... (캐시 무시)" +docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache --pull + +if [ $? -ne 0 ]; then + echo "❌ 백엔드 빌드 실패" + exit 1 +fi + +echo "백엔드 서비스 시작 중..." +docker-compose -f docker/prod/docker-compose.backend.prod.yml up -d + +echo "" +echo "⏳ 백엔드 서비스 안정화 대기 중... (45초)" +sleep 45 + +# 백엔드 상태 확인 +echo "백엔드 서비스 상태 확인:" +docker-compose -f docker/prod/docker-compose.backend.prod.yml ps + +# 백엔드 로그 확인 +echo "" +echo "백엔드 최근 로그:" +docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=10 + +# 프론트엔드 시작 +echo "" +echo "============================================" +echo "3. 프론트엔드 서비스 시작 중..." +echo "============================================" + +# 프론트엔드 의존성 체크 +echo "프론트엔드 의존성 확인 중..." +if [ ! -f "frontend/package.json" ]; then + echo "❌ frontend/package.json을 찾을 수 없습니다." + exit 1 +fi + +if [ ! -f "frontend/next.config.mjs" ]; then + echo "❌ frontend/next.config.mjs를 찾을 수 없습니다." + exit 1 +fi + +echo "프론트엔드 이미지 빌드 중... (캐시 무시)" +docker-compose -f docker/prod/docker-compose.frontend.prod.yml build --no-cache --pull + +if [ $? -ne 0 ]; then + echo "❌ 프론트엔드 빌드 실패" + exit 1 +fi + +echo "프론트엔드 서비스 시작 중..." +docker-compose -f docker/prod/docker-compose.frontend.prod.yml up -d + +echo "" +echo "⏳ 프론트엔드 서비스 안정화 대기 중... (30초)" +sleep 30 + +# 프론트엔드 상태 확인 +echo "프론트엔드 서비스 상태 확인:" +docker-compose -f docker/prod/docker-compose.frontend.prod.yml ps + +# 프론트엔드 로그 확인 +echo "" +echo "프론트엔드 최근 로그:" +docker-compose -f docker/prod/docker-compose.frontend.prod.yml logs --tail=10 + +echo "" +echo "============================================" +echo "🎉 모든 서비스가 시작되었습니다!" +echo "============================================" +echo "" +echo "📊 서비스 접속 정보:" +echo " [DATABASE] PostgreSQL: http://39.117.244.52:11132" +echo " [BACKEND] Spring Boot: http://localhost:8080/api" +echo " [FRONTEND] Next.js: http://localhost:5555" +echo "" + +# 서비스 헬스체크 +echo "" +echo "🏥 서비스 헬스체크 수행 중..." +echo "" + +# 백엔드 헬스체크 (최대 2분 대기) +echo "백엔드 헬스체크..." +backend_healthy=false +for i in {1..24}; do + if curl -s -f http://localhost:8080/health >/dev/null 2>&1; then + echo " ✅ 백엔드 서비스 정상" + backend_healthy=true + break + else + echo " ⏳ 백엔드 응답 대기 중... ($i/24)" + sleep 5 + fi +done + +if [ "$backend_healthy" = false ]; then + echo " ❌ 백엔드 헬스체크 실패" + echo " 백엔드 로그:" + docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=20 +fi + +# 프론트엔드 헬스체크 (최대 1분 대기) +echo "프론트엔드 헬스체크..." +frontend_healthy=false +for i in {1..12}; do + if curl -s -f http://localhost:5555 >/dev/null 2>&1; then + echo " ✅ 프론트엔드 서비스 정상" + frontend_healthy=true + break + else + echo " ⏳ 프론트엔드 응답 대기 중... ($i/12)" + sleep 5 + fi +done + +if [ "$frontend_healthy" = false ]; then + echo " ❌ 프론트엔드 헬스체크 실패" + echo " 프론트엔드 로그:" + docker-compose -f docker/prod/docker-compose.frontend.prod.yml logs --tail=20 +fi + +# 최종 상태 요약 +echo "" +echo "============================================" +echo "🎯 배포 완료 요약" +echo "============================================" +echo "" +echo "Git 정보:" +echo " 브랜치: $(git branch --show-current)" +echo " 커밋: $(git log -1 --oneline)" +echo " 상태: $(git status --porcelain | wc -l) 개의 수정된 파일" +echo "" +echo "서비스 상태:" +if [ "$backend_healthy" = true ]; then + echo " ✅ 백엔드: 정상" +else + echo " ❌ 백엔드: 오류" +fi + +if [ "$frontend_healthy" = true ]; then + echo " ✅ 프론트엔드: 정상" +else + echo " ❌ 프론트엔드: 오류" +fi + +echo "" +echo "🔧 유용한 명령어:" +echo " 전체 로그 확인: docker-compose -f docker/prod/docker-compose.backend.prod.yml -f docker/prod/docker-compose.frontend.prod.yml logs -f" +echo " 백엔드 로그: docker-compose -f docker/prod/docker-compose.backend.prod.yml logs -f" +echo " 프론트엔드 로그: docker-compose -f docker/prod/docker-compose.frontend.prod.yml logs -f" +echo " 컨테이너 상태: docker ps" +echo " 서비스 중지: ./stop-all-linux.sh" +echo "" + +if [ "$backend_healthy" = true ] && [ "$frontend_healthy" = true ]; then + echo "🎯 성공! 브라우저에서 http://localhost:5555 을 확인하세요." +else + echo "⚠️ 일부 서비스에 문제가 있습니다. 로그를 확인해주세요." +fi + +echo "" +read -p "계속하려면 Enter 키를 누르세요..." diff --git a/scripts/prod/start-all-linux.sh b/scripts/prod/start-all-linux.sh index cf2d90fb..a2de431f 100644 --- a/scripts/prod/start-all-linux.sh +++ b/scripts/prod/start-all-linux.sh @@ -45,7 +45,7 @@ docker network create pms-network 2>/dev/null || echo "네트워크가 이미 # 백엔드 빌드 및 시작 echo "백엔드 이미지 빌드 중..." -docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache +docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache --pull echo "백엔드 서비스 시작 중..." docker-compose -f docker/prod/docker-compose.backend.prod.yml up -d @@ -65,7 +65,7 @@ echo "2. 프론트엔드 서비스 시작 중..." echo "============================================" echo "프론트엔드 이미지 빌드 중..." -docker-compose -f docker/prod/docker-compose.frontend.prod.yml build --no-cache +docker-compose -f docker/prod/docker-compose.frontend.prod.yml build --no-cache --pull echo "프론트엔드 서비스 시작 중..." docker-compose -f docker/prod/docker-compose.frontend.prod.yml up -d From e062aa09ffd2077edb5316ec2a87063fc818b655 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 17:55:00 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=EC=9B=90=EC=83=81=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/prod/start-all-linux-force.sh | 288 -------------------------- scripts/prod/start-all-linux.sh | 4 +- 2 files changed, 2 insertions(+), 290 deletions(-) delete mode 100755 scripts/prod/start-all-linux-force.sh diff --git a/scripts/prod/start-all-linux-force.sh b/scripts/prod/start-all-linux-force.sh deleted file mode 100755 index 908d3fd5..00000000 --- a/scripts/prod/start-all-linux-force.sh +++ /dev/null @@ -1,288 +0,0 @@ -#!/bin/bash - -echo "============================================" -echo "WACE 솔루션 - 전체 서비스 시작 (강제 업데이트) - Linux" -echo "============================================" - -echo "" -echo "🚀 최신 코드로 백엔드와 프론트엔드를 강제 업데이트하여 시작합니다..." -echo "" - -# 시스템 정보 출력 -echo "시스템 정보:" -echo " OS: $(uname -s)" -echo " Architecture: $(uname -m)" -echo " Kernel: $(uname -r)" -echo "" - -# Docker 및 Docker Compose 버전 확인 -echo "Docker 환경 확인:" -docker --version 2>/dev/null || echo " ❌ Docker가 설치되지 않았습니다." -docker-compose --version 2>/dev/null || echo " ❌ Docker Compose가 설치되지 않았습니다." -echo "" - -# Git 상태 확인 -echo "============================================" -echo "0. Git 상태 확인 및 최신 코드 동기화" -echo "============================================" - -echo "현재 Git 상태:" -git status --porcelain - -echo "" -echo "현재 브랜치: $(git branch --show-current)" -echo "최신 커밋: $(git log -1 --oneline)" - -# 수정된 파일이 있는지 확인 -if [ -n "$(git status --porcelain)" ]; then - echo "" - echo "⚠️ 수정된 파일이 있습니다. 다음 중 선택하세요:" - echo "1. 수정사항을 커밋하고 계속" - echo "2. 수정사항을 임시 저장(stash)하고 계속" - echo "3. 수정사항을 무시하고 강제 리셋 후 계속" - echo "4. 현재 상태로 빌드 계속" - echo "" - read -p "선택하세요 (1-4): " choice - - case $choice in - 1) - echo "변경사항을 커밋합니다..." - git add . - git commit -m "Auto commit before deployment - $(date)" - ;; - 2) - echo "변경사항을 임시 저장합니다..." - git stash push -m "Auto stash before deployment - $(date)" - ;; - 3) - echo "변경사항을 무시하고 강제 리셋합니다..." - git reset --hard HEAD - git clean -fd - ;; - 4) - echo "현재 상태로 계속합니다..." - ;; - *) - echo "잘못된 선택입니다. 현재 상태로 계속합니다..." - ;; - esac -fi - -# 원격 저장소에서 최신 코드 가져오기 -echo "" -echo "원격 저장소에서 최신 코드를 가져옵니다..." -git fetch origin -current_branch=$(git branch --show-current) -git pull origin $current_branch || echo "⚠️ Pull 실패 - 현재 상태로 계속합니다." - -# 기존 컨테이너 및 이미지 완전 정리 -echo "" -echo "============================================" -echo "1. 기존 서비스 완전 정리 중..." -echo "============================================" - -# 모든 관련 컨테이너 중지 및 제거 -echo "기존 컨테이너 중지 및 제거 중..." -docker-compose -f docker/prod/docker-compose.backend.prod.yml down -v --remove-orphans 2>/dev/null || true -docker-compose -f docker/prod/docker-compose.frontend.prod.yml down -v --remove-orphans 2>/dev/null || true - -# 관련 컨테이너 강제 제거 -docker stop pms-backend-prod pms-frontend-linux 2>/dev/null || true -docker rm pms-backend-prod pms-frontend-linux 2>/dev/null || true - -# 관련 이미지 제거 (캐시 무효화) -echo "기존 이미지 제거 중..." -docker rmi $(docker images | grep -E "(pms-|erp-node)" | awk '{print $3}') 2>/dev/null || true - -# Docker 시스템 정리 -echo "Docker 시스템 정리 중..." -docker system prune -f --volumes 2>/dev/null || true -docker builder prune -f 2>/dev/null || true - -# 네트워크 재생성 -echo "네트워크 재생성..." -docker network rm pms-network 2>/dev/null || true -docker network create pms-network 2>/dev/null || echo "네트워크 생성 실패 - 기본 네트워크 사용" - -# 백엔드 먼저 시작 -echo "" -echo "============================================" -echo "2. 백엔드 서비스 시작 중..." -echo "============================================" - -# 백엔드 의존성 체크 -echo "백엔드 의존성 확인 중..." -if [ ! -f "backend-node/package.json" ]; then - echo "❌ backend-node/package.json을 찾을 수 없습니다." - exit 1 -fi - -if [ ! -f "backend-node/src/app.ts" ]; then - echo "❌ backend-node/src/app.ts를 찾을 수 없습니다." - exit 1 -fi - -# 백엔드 빌드 (캐시 무시) -echo "백엔드 이미지 빌드 중... (캐시 무시)" -docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache --pull - -if [ $? -ne 0 ]; then - echo "❌ 백엔드 빌드 실패" - exit 1 -fi - -echo "백엔드 서비스 시작 중..." -docker-compose -f docker/prod/docker-compose.backend.prod.yml up -d - -echo "" -echo "⏳ 백엔드 서비스 안정화 대기 중... (45초)" -sleep 45 - -# 백엔드 상태 확인 -echo "백엔드 서비스 상태 확인:" -docker-compose -f docker/prod/docker-compose.backend.prod.yml ps - -# 백엔드 로그 확인 -echo "" -echo "백엔드 최근 로그:" -docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=10 - -# 프론트엔드 시작 -echo "" -echo "============================================" -echo "3. 프론트엔드 서비스 시작 중..." -echo "============================================" - -# 프론트엔드 의존성 체크 -echo "프론트엔드 의존성 확인 중..." -if [ ! -f "frontend/package.json" ]; then - echo "❌ frontend/package.json을 찾을 수 없습니다." - exit 1 -fi - -if [ ! -f "frontend/next.config.mjs" ]; then - echo "❌ frontend/next.config.mjs를 찾을 수 없습니다." - exit 1 -fi - -echo "프론트엔드 이미지 빌드 중... (캐시 무시)" -docker-compose -f docker/prod/docker-compose.frontend.prod.yml build --no-cache --pull - -if [ $? -ne 0 ]; then - echo "❌ 프론트엔드 빌드 실패" - exit 1 -fi - -echo "프론트엔드 서비스 시작 중..." -docker-compose -f docker/prod/docker-compose.frontend.prod.yml up -d - -echo "" -echo "⏳ 프론트엔드 서비스 안정화 대기 중... (30초)" -sleep 30 - -# 프론트엔드 상태 확인 -echo "프론트엔드 서비스 상태 확인:" -docker-compose -f docker/prod/docker-compose.frontend.prod.yml ps - -# 프론트엔드 로그 확인 -echo "" -echo "프론트엔드 최근 로그:" -docker-compose -f docker/prod/docker-compose.frontend.prod.yml logs --tail=10 - -echo "" -echo "============================================" -echo "🎉 모든 서비스가 시작되었습니다!" -echo "============================================" -echo "" -echo "📊 서비스 접속 정보:" -echo " [DATABASE] PostgreSQL: http://39.117.244.52:11132" -echo " [BACKEND] Spring Boot: http://localhost:8080/api" -echo " [FRONTEND] Next.js: http://localhost:5555" -echo "" - -# 서비스 헬스체크 -echo "" -echo "🏥 서비스 헬스체크 수행 중..." -echo "" - -# 백엔드 헬스체크 (최대 2분 대기) -echo "백엔드 헬스체크..." -backend_healthy=false -for i in {1..24}; do - if curl -s -f http://localhost:8080/health >/dev/null 2>&1; then - echo " ✅ 백엔드 서비스 정상" - backend_healthy=true - break - else - echo " ⏳ 백엔드 응답 대기 중... ($i/24)" - sleep 5 - fi -done - -if [ "$backend_healthy" = false ]; then - echo " ❌ 백엔드 헬스체크 실패" - echo " 백엔드 로그:" - docker-compose -f docker/prod/docker-compose.backend.prod.yml logs --tail=20 -fi - -# 프론트엔드 헬스체크 (최대 1분 대기) -echo "프론트엔드 헬스체크..." -frontend_healthy=false -for i in {1..12}; do - if curl -s -f http://localhost:5555 >/dev/null 2>&1; then - echo " ✅ 프론트엔드 서비스 정상" - frontend_healthy=true - break - else - echo " ⏳ 프론트엔드 응답 대기 중... ($i/12)" - sleep 5 - fi -done - -if [ "$frontend_healthy" = false ]; then - echo " ❌ 프론트엔드 헬스체크 실패" - echo " 프론트엔드 로그:" - docker-compose -f docker/prod/docker-compose.frontend.prod.yml logs --tail=20 -fi - -# 최종 상태 요약 -echo "" -echo "============================================" -echo "🎯 배포 완료 요약" -echo "============================================" -echo "" -echo "Git 정보:" -echo " 브랜치: $(git branch --show-current)" -echo " 커밋: $(git log -1 --oneline)" -echo " 상태: $(git status --porcelain | wc -l) 개의 수정된 파일" -echo "" -echo "서비스 상태:" -if [ "$backend_healthy" = true ]; then - echo " ✅ 백엔드: 정상" -else - echo " ❌ 백엔드: 오류" -fi - -if [ "$frontend_healthy" = true ]; then - echo " ✅ 프론트엔드: 정상" -else - echo " ❌ 프론트엔드: 오류" -fi - -echo "" -echo "🔧 유용한 명령어:" -echo " 전체 로그 확인: docker-compose -f docker/prod/docker-compose.backend.prod.yml -f docker/prod/docker-compose.frontend.prod.yml logs -f" -echo " 백엔드 로그: docker-compose -f docker/prod/docker-compose.backend.prod.yml logs -f" -echo " 프론트엔드 로그: docker-compose -f docker/prod/docker-compose.frontend.prod.yml logs -f" -echo " 컨테이너 상태: docker ps" -echo " 서비스 중지: ./stop-all-linux.sh" -echo "" - -if [ "$backend_healthy" = true ] && [ "$frontend_healthy" = true ]; then - echo "🎯 성공! 브라우저에서 http://localhost:5555 을 확인하세요." -else - echo "⚠️ 일부 서비스에 문제가 있습니다. 로그를 확인해주세요." -fi - -echo "" -read -p "계속하려면 Enter 키를 누르세요..." diff --git a/scripts/prod/start-all-linux.sh b/scripts/prod/start-all-linux.sh index a2de431f..cf2d90fb 100644 --- a/scripts/prod/start-all-linux.sh +++ b/scripts/prod/start-all-linux.sh @@ -45,7 +45,7 @@ docker network create pms-network 2>/dev/null || echo "네트워크가 이미 # 백엔드 빌드 및 시작 echo "백엔드 이미지 빌드 중..." -docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache --pull +docker-compose -f docker/prod/docker-compose.backend.prod.yml build --no-cache echo "백엔드 서비스 시작 중..." docker-compose -f docker/prod/docker-compose.backend.prod.yml up -d @@ -65,7 +65,7 @@ echo "2. 프론트엔드 서비스 시작 중..." echo "============================================" echo "프론트엔드 이미지 빌드 중..." -docker-compose -f docker/prod/docker-compose.frontend.prod.yml build --no-cache --pull +docker-compose -f docker/prod/docker-compose.frontend.prod.yml build --no-cache echo "프론트엔드 서비스 시작 중..." docker-compose -f docker/prod/docker-compose.frontend.prod.yml up -d From 2a313c5ca20e7e0196a5b9aebc3da1cde210dfc1 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 18 Sep 2025 18:18:18 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/app/(main)/admin/components/page.tsx | 361 ----------- .../components/admin/ComponentFormModal.tsx | 565 ------------------ .../hooks/admin/useComponentDuplicateCheck.ts | 16 - 3 files changed, 942 deletions(-) delete mode 100644 frontend/app/(main)/admin/components/page.tsx delete mode 100644 frontend/components/admin/ComponentFormModal.tsx delete mode 100644 frontend/hooks/admin/useComponentDuplicateCheck.ts diff --git a/frontend/app/(main)/admin/components/page.tsx b/frontend/app/(main)/admin/components/page.tsx deleted file mode 100644 index dbd63be8..00000000 --- a/frontend/app/(main)/admin/components/page.tsx +++ /dev/null @@ -1,361 +0,0 @@ -"use client"; - -import React, { useState, useMemo } from "react"; -import { Search, Plus, Edit, Trash2, RefreshCw, Package, Filter } from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Badge } from "@/components/ui/badge"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; -import { AlertModal } from "@/components/common/AlertModal"; -import { - useComponents, - useComponentCategories, - useComponentStatistics, - useDeleteComponent, - useCreateComponent, - useUpdateComponent, -} from "@/hooks/admin/useComponents"; -import { ComponentFormModal } from "@/components/admin/ComponentFormModal"; - -// 컴포넌트 카테고리 정의 -const COMPONENT_CATEGORIES = [ - { id: "input", name: "입력", color: "blue" }, - { id: "action", name: "액션", color: "green" }, - { id: "display", name: "표시", color: "purple" }, - { id: "layout", name: "레이아웃", color: "orange" }, - { id: "other", name: "기타", color: "gray" }, -]; - -export default function ComponentManagementPage() { - const [searchTerm, setSearchTerm] = useState(""); - const [selectedCategory, setSelectedCategory] = useState("all"); - const [sortBy, setSortBy] = useState("sort_order"); - const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc"); - const [selectedComponent, setSelectedComponent] = useState(null); - const [showDeleteModal, setShowDeleteModal] = useState(false); - const [showNewComponentModal, setShowNewComponentModal] = useState(false); - const [showEditComponentModal, setShowEditComponentModal] = useState(false); - - // 컴포넌트 데이터 가져오기 - const { - data: componentsData, - isLoading: loading, - error, - refetch, - } = useComponents({ - category: selectedCategory === "all" ? undefined : selectedCategory, - active: "Y", - search: searchTerm, - sort: sortBy, - order: sortOrder, - }); - - // 카테고리와 통계 데이터 - const { data: categories } = useComponentCategories(); - const { data: statistics } = useComponentStatistics(); - - // 뮤테이션 - const deleteComponentMutation = useDeleteComponent(); - const createComponentMutation = useCreateComponent(); - const updateComponentMutation = useUpdateComponent(); - - // 컴포넌트 목록 (이미 필터링과 정렬이 적용된 상태) - const components = componentsData?.components || []; - - // 카테고리별 통계 (백엔드에서 가져온 데이터 사용) - const categoryStats = useMemo(() => { - if (!statistics?.byCategory) return {}; - - const stats: Record = {}; - statistics.byCategory.forEach(({ category, count }) => { - stats[category] = count; - }); - - return stats; - }, [statistics]); - - // 카테고리 이름 및 색상 가져오기 - const getCategoryInfo = (categoryId: string) => { - const category = COMPONENT_CATEGORIES.find((cat) => cat.id === categoryId); - return category || { id: "other", name: "기타", color: "gray" }; - }; - - // 삭제 처리 - const handleDelete = async () => { - if (!selectedComponent) return; - - try { - await deleteComponentMutation.mutateAsync(selectedComponent.component_code); - setShowDeleteModal(false); - setSelectedComponent(null); - } catch (error) { - console.error("컴포넌트 삭제 실패:", error); - } - }; - - // 컴포넌트 생성 처리 - const handleCreate = async (data: any) => { - await createComponentMutation.mutateAsync(data); - setShowNewComponentModal(false); - }; - - // 컴포넌트 수정 처리 - const handleUpdate = async (data: any) => { - if (!selectedComponent) return; - await updateComponentMutation.mutateAsync({ - component_code: selectedComponent.component_code, - data, - }); - setShowEditComponentModal(false); - setSelectedComponent(null); - }; - - if (loading) { - return ( -
-
- -

컴포넌트 목록을 불러오는 중...

-
-
- ); - } - - if (error) { - return ( -
-
- -

컴포넌트 목록을 불러오는데 실패했습니다.

- -
-
- ); - } - - return ( -
- {/* 헤더 */} -
-
-
-

컴포넌트 관리

-

화면 설계에 사용되는 컴포넌트들을 관리합니다

-
-
- -
-
-
- - {/* 카테고리 통계 */} -
- {COMPONENT_CATEGORIES.map((category) => { - const count = categoryStats[category.id] || 0; - return ( - setSelectedCategory(category.id)} - > - -
{count}
-
{category.name}
-
-
- ); - })} -
- - {/* 검색 및 필터 */} - - -
- {/* 검색 */} -
- - setSearchTerm(e.target.value)} - className="pl-9" - /> -
- - {/* 카테고리 필터 */} - - - {/* 정렬 */} - - - - - -
-
-
- - {/* 컴포넌트 목록 테이블 */} - - - - 컴포넌트 목록 ({components.length}개) - - - -
- - - - 컴포넌트 이름 - 컴포넌트 코드 - 카테고리 - 타입 - 상태 - 수정일 - 작업 - - - - {components.map((component) => { - const categoryInfo = getCategoryInfo(component.category || "other"); - - return ( - - -
-
{component.component_name}
- {component.component_name_eng && ( -
{component.component_name_eng}
- )} -
-
- - {component.component_code} - - - - {categoryInfo.name} - - - - {component.component_config ? ( - - {component.component_config.type || component.component_code} - - ) : ( - 없음 - )} - - - - {component.is_active === "Y" ? "활성" : "비활성"} - - - - {component.updated_date ? new Date(component.updated_date).toLocaleDateString() : "-"} - - -
- - -
-
-
- ); - })} -
-
-
-
-
- - {/* 삭제 확인 모달 */} - setShowDeleteModal(false)} - onConfirm={handleDelete} - type="warning" - title="컴포넌트 삭제" - message={`정말로 "${selectedComponent?.component_name}" 컴포넌트를 삭제하시겠습니까?`} - confirmText="삭제" - /> - - {/* 새 컴포넌트 추가 모달 */} - setShowNewComponentModal(false)} - onSubmit={handleCreate} - mode="create" - /> - - {/* 컴포넌트 편집 모달 */} - { - setShowEditComponentModal(false); - setSelectedComponent(null); - }} - onSubmit={handleUpdate} - initialData={selectedComponent} - mode="edit" - /> -
- ); -} diff --git a/frontend/components/admin/ComponentFormModal.tsx b/frontend/components/admin/ComponentFormModal.tsx deleted file mode 100644 index c64b62c9..00000000 --- a/frontend/components/admin/ComponentFormModal.tsx +++ /dev/null @@ -1,565 +0,0 @@ -"use client"; - -import React, { useState, useEffect } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; -import { Switch } from "@/components/ui/switch"; -import { Badge } from "@/components/ui/badge"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Plus, X, Save, RotateCcw, AlertTriangle, CheckCircle } from "lucide-react"; -import { toast } from "sonner"; -import { useComponentDuplicateCheck } from "@/hooks/admin/useComponentDuplicateCheck"; -import { Alert, AlertDescription } from "@/components/ui/alert"; - -// 컴포넌트 카테고리 정의 -const COMPONENT_CATEGORIES = [ - { id: "input", name: "입력", description: "사용자 입력을 받는 컴포넌트" }, - { id: "action", name: "액션", description: "사용자 액션을 처리하는 컴포넌트" }, - { id: "display", name: "표시", description: "정보를 표시하는 컴포넌트" }, - { id: "layout", name: "레이아웃", description: "레이아웃을 구성하는 컴포넌트" }, - { id: "other", name: "기타", description: "기타 컴포넌트" }, -]; - -// 컴포넌트 타입 정의 -const COMPONENT_TYPES = [ - { id: "widget", name: "위젯", description: "입력 양식 위젯" }, - { id: "button", name: "버튼", description: "액션 버튼" }, - { id: "card", name: "카드", description: "카드 컨테이너" }, - { id: "container", name: "컨테이너", description: "일반 컨테이너" }, - { id: "dashboard", name: "대시보드", description: "대시보드 그리드" }, - { id: "alert", name: "알림", description: "알림 메시지" }, - { id: "badge", name: "배지", description: "상태 배지" }, - { id: "progress", name: "진행률", description: "진행률 표시" }, - { id: "chart", name: "차트", description: "데이터 차트" }, -]; - -// 웹타입 정의 (위젯인 경우만) -const WEB_TYPES = [ - "text", - "number", - "decimal", - "date", - "datetime", - "select", - "dropdown", - "textarea", - "boolean", - "checkbox", - "radio", - "code", - "entity", - "file", - "email", - "tel", - "color", - "range", - "time", - "week", - "month", -]; - -interface ComponentFormData { - component_code: string; - component_name: string; - description: string; - category: string; - component_config: { - type: string; - webType?: string; - config_panel?: string; - }; - default_size: { - width: number; - height: number; - }; - icon_name: string; - active: string; - sort_order: number; -} - -interface ComponentFormModalProps { - isOpen: boolean; - onClose: () => void; - onSubmit: (data: ComponentFormData) => Promise; - initialData?: any; - mode?: "create" | "edit"; -} - -export const ComponentFormModal: React.FC = ({ - isOpen, - onClose, - onSubmit, - initialData, - mode = "create", -}) => { - const [formData, setFormData] = useState({ - component_code: "", - component_name: "", - description: "", - category: "other", - component_config: { - type: "widget", - }, - default_size: { - width: 200, - height: 40, - }, - icon_name: "", - is_active: "Y", - sort_order: 100, - }); - - const [isSubmitting, setIsSubmitting] = useState(false); - const [shouldCheckDuplicate, setShouldCheckDuplicate] = useState(false); - - // 중복 체크 쿼리 (생성 모드에서만 활성화) - const duplicateCheck = useComponentDuplicateCheck( - formData.component_code, - mode === "create" && shouldCheckDuplicate && formData.component_code.length > 0, - ); - - // 초기 데이터 설정 - useEffect(() => { - if (isOpen) { - if (mode === "edit" && initialData) { - setFormData({ - component_code: initialData.component_code || "", - component_name: initialData.component_name || "", - description: initialData.description || "", - category: initialData.category || "other", - component_config: initialData.component_config || { type: "widget" }, - default_size: initialData.default_size || { width: 200, height: 40 }, - icon_name: initialData.icon_name || "", - is_active: initialData.is_active || "Y", - sort_order: initialData.sort_order || 100, - }); - } else { - // 새 컴포넌트 생성 시 초기값 - setFormData({ - component_code: "", - component_name: "", - description: "", - category: "other", - component_config: { - type: "widget", - }, - default_size: { - width: 200, - height: 40, - }, - icon_name: "", - is_active: "Y", - sort_order: 100, - }); - } - } - }, [isOpen, mode, initialData]); - - // 컴포넌트 코드 자동 생성 - const generateComponentCode = (name: string, type: string) => { - if (!name) return ""; - - // 한글을 영문으로 매핑 - const koreanToEnglish: { [key: string]: string } = { - 도움말: "help", - 툴팁: "tooltip", - 안내: "guide", - 알림: "alert", - 버튼: "button", - 카드: "card", - 대시보드: "dashboard", - 패널: "panel", - 입력: "input", - 텍스트: "text", - 선택: "select", - 체크: "check", - 라디오: "radio", - 파일: "file", - 이미지: "image", - 테이블: "table", - 리스트: "list", - 폼: "form", - }; - - // 한글을 영문으로 변환 - let englishName = name; - Object.entries(koreanToEnglish).forEach(([korean, english]) => { - englishName = englishName.replace(new RegExp(korean, "g"), english); - }); - - const cleanName = englishName - .toLowerCase() - .replace(/[^a-z0-9\s]/g, "") - .replace(/\s+/g, "-") - .replace(/-+/g, "-") - .replace(/^-|-$/g, ""); - - // 빈 문자열이거나 숫자로 시작하는 경우 기본값 설정 - const finalName = cleanName || "component"; - const validName = /^[0-9]/.test(finalName) ? `comp-${finalName}` : finalName; - - return type === "widget" ? validName : `${validName}-${type}`; - }; - - // 폼 필드 변경 처리 - const handleChange = (field: string, value: any) => { - setFormData((prev) => { - const newData = { ...prev }; - - if (field.includes(".")) { - const [parent, child] = field.split("."); - newData[parent as keyof ComponentFormData] = { - ...(newData[parent as keyof ComponentFormData] as any), - [child]: value, - }; - } else { - (newData as any)[field] = value; - } - - // 컴포넌트 이름이 변경되면 코드 자동 생성 - if (field === "component_name" || field === "component_config.type") { - const name = field === "component_name" ? value : newData.component_name; - const type = field === "component_config.type" ? value : newData.component_config.type; - - if (name && mode === "create") { - newData.component_code = generateComponentCode(name, type); - // 자동 생성된 코드에 대해서도 중복 체크 활성화 - setShouldCheckDuplicate(true); - } - } - - // 컴포넌트 코드가 직접 변경되면 중복 체크 활성화 - if (field === "component_code" && mode === "create") { - setShouldCheckDuplicate(true); - } - - return newData; - }); - }; - - // 폼 제출 - const handleSubmit = async () => { - // 유효성 검사 - if (!formData.component_code || !formData.component_name) { - toast.error("컴포넌트 코드와 이름은 필수입니다."); - return; - } - - if (!formData.component_config.type) { - toast.error("컴포넌트 타입을 선택해주세요."); - return; - } - - // 생성 모드에서 중복 체크 - if (mode === "create" && duplicateCheck.data?.isDuplicate) { - toast.error("이미 사용 중인 컴포넌트 코드입니다. 다른 코드를 사용해주세요."); - return; - } - - setIsSubmitting(true); - try { - await onSubmit(formData); - toast.success(mode === "create" ? "컴포넌트가 생성되었습니다." : "컴포넌트가 수정되었습니다."); - onClose(); - } catch (error) { - toast.error(mode === "create" ? "컴포넌트 생성에 실패했습니다." : "컴포넌트 수정에 실패했습니다."); - } finally { - setIsSubmitting(false); - } - }; - - // 폼 초기화 - const handleReset = () => { - if (mode === "edit" && initialData) { - setFormData({ - component_code: initialData.component_code || "", - component_name: initialData.component_name || "", - description: initialData.description || "", - category: initialData.category || "other", - component_config: initialData.component_config || { type: "widget" }, - default_size: initialData.default_size || { width: 200, height: 40 }, - icon_name: initialData.icon_name || "", - is_active: initialData.is_active || "Y", - sort_order: initialData.sort_order || 100, - }); - } else { - setFormData({ - component_code: "", - component_name: "", - description: "", - category: "other", - component_config: { - type: "widget", - }, - default_size: { - width: 200, - height: 40, - }, - icon_name: "", - is_active: "Y", - sort_order: 100, - }); - } - }; - - return ( - - - - {mode === "create" ? "새 컴포넌트 추가" : "컴포넌트 편집"} - - {mode === "create" - ? "화면 설계에 사용할 새로운 컴포넌트를 추가합니다." - : "선택한 컴포넌트의 정보를 수정합니다."} - - - -
- {/* 기본 정보 */} - - - 기본 정보 - - -
-
- - handleChange("component_name", e.target.value)} - placeholder="예: 정보 알림" - /> -
-
- -
- handleChange("component_code", e.target.value)} - placeholder="예: alert-info" - disabled={mode === "edit"} - className={ - mode === "create" && duplicateCheck.data?.isDuplicate - ? "border-red-500 pr-10" - : mode === "create" && duplicateCheck.data && !duplicateCheck.data.isDuplicate - ? "border-green-500 pr-10" - : "" - } - /> - {mode === "create" && formData.component_code && duplicateCheck.data && ( -
- {duplicateCheck.data.isDuplicate ? ( - - ) : ( - - )} -
- )} -
- {mode === "create" && formData.component_code && duplicateCheck.data && ( - - - {duplicateCheck.data.isDuplicate - ? "⚠️ 이미 사용 중인 컴포넌트 코드입니다." - : "✅ 사용 가능한 컴포넌트 코드입니다."} - - - )} -
-
- -
- -