From 80be7c5a7600c080f897823837890fbdef58f565 Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Mar 2026 15:01:05 +0900 Subject: [PATCH] feat: enhance component configuration and rendering - Updated the RealtimePreviewDynamic component to display selected component information more clearly. - Added dynamic field type labels in the RealtimePreviewDynamic component for better user understanding. - Introduced a table refresh counter in the ScreenDesigner component to handle table column updates effectively. - Improved the V2PropertiesPanel and V2SelectConfigPanel to support additional properties and enhance usability. - Refactored the DynamicComponentRenderer to better handle field types and improve component configuration merging. Made-with: Cursor --- .../screen/RealtimePreviewDynamic.tsx | 15 +- frontend/components/screen/ScreenDesigner.tsx | 9 + .../screen/panels/V2PropertiesPanel.tsx | 22 +- .../v2/config-panels/V2FieldConfigPanel.tsx | 749 ++++++++++++++++++ .../v2/config-panels/V2InputConfigPanel.tsx | 144 +++- .../v2/config-panels/V2SelectConfigPanel.tsx | 59 +- .../lib/registry/DynamicComponentRenderer.tsx | 40 +- .../lib/registry/components/v2-input/index.ts | 4 +- .../registry/components/v2-select/index.ts | 4 +- .../lib/utils/getComponentConfigPanel.tsx | 9 +- ...fied-field-type-config-panel-test-guide.md | 178 +++++ 11 files changed, 1163 insertions(+), 70 deletions(-) create mode 100644 frontend/components/v2/config-panels/V2FieldConfigPanel.tsx create mode 100644 test-output/unified-field-type-config-panel-test-guide.md diff --git a/frontend/components/screen/RealtimePreviewDynamic.tsx b/frontend/components/screen/RealtimePreviewDynamic.tsx index 17615147..f8a46f1d 100644 --- a/frontend/components/screen/RealtimePreviewDynamic.tsx +++ b/frontend/components/screen/RealtimePreviewDynamic.tsx @@ -771,7 +771,7 @@ const RealtimePreviewDynamicComponent: React.FC = ({ /> - {/* 선택된 컴포넌트 정보 표시 - 🔧 오른쪽으로 이동 (라벨과 겹치지 않도록) */} + {/* 선택된 컴포넌트 정보 표시 */} {isSelected && (
{type === "widget" && ( @@ -782,7 +782,18 @@ const RealtimePreviewDynamicComponent: React.FC = ({ )} {type !== "widget" && (
- {component.componentConfig?.type || type} + {(() => { + const ft = (component as any).componentConfig?.fieldType; + if (ft) { + const labels: Record = { + text: "텍스트", number: "숫자", textarea: "여러줄", + select: "셀렉트", category: "카테고리", entity: "엔티티", + numbering: "채번", + }; + return labels[ft] || ft; + } + return (component as any).componentConfig?.type || componentType || type; + })()}
)}
diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 10e1153d..70102da2 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -475,6 +475,7 @@ export default function ScreenDesigner({ // 테이블 데이터 const [tables, setTables] = useState([]); + const [tableRefreshCounter, setTableRefreshCounter] = useState(0); const [searchTerm, setSearchTerm] = useState(""); // 🆕 검색어로 필터링된 테이블 목록 @@ -1434,8 +1435,16 @@ export default function ScreenDesigner({ selectedScreen?.restApiConnectionId, selectedScreen?.restApiEndpoint, selectedScreen?.restApiJsonPath, + tableRefreshCounter, ]); + // 필드 타입 변경 시 테이블 컬럼 정보 갱신 (화면 디자이너에서 input_type 변경 반영) + useEffect(() => { + const handler = () => setTableRefreshCounter((c) => c + 1); + window.addEventListener("table-columns-refresh", handler); + return () => window.removeEventListener("table-columns-refresh", handler); + }, []); + // 테이블 선택 핸들러 - 사이드바에서 테이블 선택 시 호출 const handleTableSelect = useCallback( async (tableName: string) => { diff --git a/frontend/components/screen/panels/V2PropertiesPanel.tsx b/frontend/components/screen/panels/V2PropertiesPanel.tsx index c2c34114..1f97230c 100644 --- a/frontend/components/screen/panels/V2PropertiesPanel.tsx +++ b/frontend/components/screen/panels/V2PropertiesPanel.tsx @@ -214,15 +214,18 @@ export const V2PropertiesPanel: React.FC = ({ ({ id: comp.id, componentType: comp.componentType || comp.type, @@ -250,6 +253,7 @@ export const V2PropertiesPanel: React.FC = ({ return ( ", label: "초과 (>)" }, + { value: "<", label: "미만 (<)" }, + { value: ">=", label: "이상 (>=)" }, + { value: "<=", label: "이하 (<=)" }, + { value: "in", label: "포함 (IN)" }, + { value: "notIn", label: "미포함 (NOT IN)" }, + { value: "like", label: "유사 (LIKE)" }, + { value: "isNull", label: "NULL" }, + { value: "isNotNull", label: "NOT NULL" }, +] as const; + +const VALUE_TYPE_OPTIONS = [ + { value: "static", label: "고정값" }, + { value: "field", label: "폼 필드 참조" }, + { value: "user", label: "로그인 사용자" }, +] as const; + +const USER_FIELD_OPTIONS = [ + { value: "companyCode", label: "회사코드" }, + { value: "userId", label: "사용자ID" }, + { value: "deptCode", label: "부서코드" }, + { value: "userName", label: "사용자명" }, +] as const; + +interface ColumnOption { + columnName: string; + columnLabel: string; +} + +interface CategoryValueOption { + valueCode: string; + valueLabel: string; +} + +// ─── 하위 호환: 기존 config에서 fieldType 추론 ─── +function resolveFieldType(config: Record, componentType?: string): FieldType { + if (config.fieldType) return config.fieldType as FieldType; + + // v2-select 계열 + if (componentType === "v2-select" || config.source) { + const source = config.source === "code" ? "category" : config.source; + if (source === "entity") return "entity"; + if (source === "category") return "category"; + return "select"; + } + + // v2-input 계열 + const it = config.inputType || config.type; + if (it === "number") return "number"; + if (it === "textarea") return "textarea"; + if (it === "numbering") return "numbering"; + return "text"; +} + +// ─── 필터 조건 서브 컴포넌트 ─── +const FilterConditionsSection: React.FC<{ + filters: V2SelectFilter[]; + columns: ColumnOption[]; + loadingColumns: boolean; + targetTable: string; + onFiltersChange: (filters: V2SelectFilter[]) => void; +}> = ({ filters, columns, loadingColumns, targetTable, onFiltersChange }) => { + const addFilter = () => { + onFiltersChange([...filters, { column: "", operator: "=", valueType: "static", value: "" }]); + }; + const updateFilter = (index: number, patch: Partial) => { + const updated = [...filters]; + updated[index] = { ...updated[index], ...patch }; + if (patch.valueType) { + if (patch.valueType === "static") { updated[index].fieldRef = undefined; updated[index].userField = undefined; } + else if (patch.valueType === "field") { updated[index].value = undefined; updated[index].userField = undefined; } + else if (patch.valueType === "user") { updated[index].value = undefined; updated[index].fieldRef = undefined; } + } + if (patch.operator === "isNull" || patch.operator === "isNotNull") { + updated[index].value = undefined; updated[index].fieldRef = undefined; + updated[index].userField = undefined; updated[index].valueType = "static"; + } + onFiltersChange(updated); + }; + const removeFilter = (index: number) => onFiltersChange(filters.filter((_, i) => i !== index)); + const needsValue = (op: string) => op !== "isNull" && op !== "isNotNull"; + + return ( +
+
+
+ + 데이터 필터 +
+ +
+

{targetTable} 테이블에서 옵션을 불러올 때 적용할 조건

+ {loadingColumns && ( +
컬럼 목록 로딩 중...
+ )} + {filters.length === 0 &&

필터 조건이 없습니다

} +
+ {filters.map((filter, index) => ( +
+
+ + + +
+ {needsValue(filter.operator) && ( +
+ + {(filter.valueType || "static") === "static" && ( + updateFilter(index, { value: e.target.value })} + placeholder={filter.operator === "in" || filter.operator === "notIn" ? "값1, 값2, ..." : "값 입력"} className="h-7 flex-1 text-[11px]" /> + )} + {filter.valueType === "field" && ( + updateFilter(index, { fieldRef: e.target.value })} placeholder="참조할 필드명" className="h-7 flex-1 text-[11px]" /> + )} + {filter.valueType === "user" && ( + + )} +
+ )} +
+ ))} +
+
+ ); +}; + +// ─── 메인 컴포넌트 ─── + +interface V2FieldConfigPanelProps { + config: Record; + onChange: (config: Record) => void; + tableName?: string; + columnName?: string; + tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>; + menuObjid?: number; + screenTableName?: string; + inputType?: string; + componentType?: string; +} + +export const V2FieldConfigPanel: React.FC = ({ + config, + onChange, + tableName, + columnName, + tables = [], + screenTableName, + inputType: metaInputType, + componentType, +}) => { + const fieldType = resolveFieldType(config, componentType); + const isSelectGroup = ["select", "category", "entity"].includes(fieldType); + + // ─── 채번 관련 상태 (테이블 기반) ─── + const [numberingRules, setNumberingRules] = useState([]); + const [loadingRules, setLoadingRules] = useState(false); + const numberingTableName = screenTableName || tableName; + + // ─── 셀렉트 관련 상태 ─── + const [entityColumns, setEntityColumns] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [categoryValues, setCategoryValues] = useState([]); + const [loadingCategoryValues, setLoadingCategoryValues] = useState(false); + const [filterColumns, setFilterColumns] = useState([]); + const [loadingFilterColumns, setLoadingFilterColumns] = useState(false); + + const [advancedOpen, setAdvancedOpen] = useState(false); + + const updateConfig = (field: string, value: any) => { + onChange({ ...config, [field]: value }); + }; + + // ─── 필드 타입 전환 핸들러 ─── + const handleFieldTypeChange = (newType: FieldType) => { + const newIsSelect = ["select", "category", "entity"].includes(newType); + const base: Record = { ...config, fieldType: newType }; + + if (newIsSelect) { + base.source = newType === "category" ? "category" : newType === "entity" ? "entity" : "static"; + delete base.inputType; + } else { + base.inputType = newType; + // 선택형 -> 입력형 전환 시 source 잔류 제거 (안 지우면 '카테고리 값이 없습니다' 같은 오류 표시) + delete base.source; + } + + if (newType === "numbering") { + base.autoGeneration = { + ...config.autoGeneration, + type: "numbering_rule" as AutoGenerationType, + tableName: numberingTableName, + }; + base.readonly = config.readonly ?? true; + } + + onChange(base); + + // table_type_columns.input_type 동기화 (카테고리/엔티티 등 설정 가능하도록) + const syncTableName = screenTableName || tableName; + const syncColumnName = columnName || config.columnName || config.fieldName; + if (syncTableName && syncColumnName) { + apiClient.put(`/table-management/tables/${syncTableName}/columns/${syncColumnName}/input-type`, { + inputType: newType, + }).then(() => { + // 왼쪽 테이블 패널의 컬럼 타입 뱃지 갱신 + window.dispatchEvent(new CustomEvent("table-columns-refresh")); + }).catch(() => { /* 동기화 실패해도 화면 설정은 유지 */ }); + } + }; + + // ─── 채번 규칙 로드 (테이블 기반) ─── + useEffect(() => { + if (fieldType !== "numbering") return; + if (!numberingTableName) { setNumberingRules([]); return; } + const load = async () => { + setLoadingRules(true); + try { + const resp = await getAvailableNumberingRulesForScreen(numberingTableName); + if (resp.success && resp.data) setNumberingRules(resp.data); + else setNumberingRules([]); + } catch { setNumberingRules([]); } finally { setLoadingRules(false); } + }; + load(); + }, [numberingTableName, fieldType]); + + // ─── 엔티티 컬럼 로드 ─── + const loadEntityColumns = useCallback(async (tblName: string) => { + if (!tblName) { setEntityColumns([]); return; } + setLoadingColumns(true); + try { + const resp = await apiClient.get(`/table-management/tables/${tblName}/columns?size=500`); + const data = resp.data.data || resp.data; + const cols = data.columns || data || []; + setEntityColumns(cols.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, + }))); + } catch { setEntityColumns([]); } finally { setLoadingColumns(false); } + }, []); + + useEffect(() => { + if (fieldType === "entity" && config.entityTable) loadEntityColumns(config.entityTable); + }, [fieldType, config.entityTable, loadEntityColumns]); + + // ─── 카테고리 값 로드 ─── + const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => { + if (!catTable || !catColumn) { setCategoryValues([]); return; } + setLoadingCategoryValues(true); + try { + const resp = await apiClient.get(`/table-categories/${catTable}/${catColumn}/values`); + if (resp.data.success && resp.data.data) { + const flattenTree = (items: any[], depth = 0): CategoryValueOption[] => { + const result: CategoryValueOption[] = []; + for (const item of items) { + result.push({ valueCode: item.valueCode, valueLabel: depth > 0 ? `${" ".repeat(depth)}${item.valueLabel}` : item.valueLabel }); + if (item.children?.length) result.push(...flattenTree(item.children, depth + 1)); + } + return result; + }; + setCategoryValues(flattenTree(resp.data.data)); + } + } catch { setCategoryValues([]); } finally { setLoadingCategoryValues(false); } + }, []); + + useEffect(() => { + if (fieldType === "category") { + const catTable = config.categoryTable || tableName; + const catColumn = config.categoryColumn || columnName; + if (catTable && catColumn) loadCategoryValues(catTable, catColumn); + } + }, [fieldType, config.categoryTable, config.categoryColumn, tableName, columnName, loadCategoryValues]); + + // ─── 필터 컬럼 로드 ─── + const filterTargetTable = useMemo(() => { + if (fieldType === "entity") return config.entityTable; + if (fieldType === "category") return config.categoryTable || tableName; + return null; + }, [fieldType, config.entityTable, config.categoryTable, tableName]); + + useEffect(() => { + if (!filterTargetTable) { setFilterColumns([]); return; } + const load = async () => { + setLoadingFilterColumns(true); + try { + const resp = await apiClient.get(`/table-management/tables/${filterTargetTable}/columns?size=500`); + const data = resp.data.data || resp.data; + const cols = data.columns || data || []; + setFilterColumns(cols.map((col: any) => ({ + columnName: col.columnName || col.column_name || col.name, + columnLabel: col.displayName || col.display_name || col.columnLabel || col.column_label || col.columnName || col.column_name || col.name, + }))); + } catch { setFilterColumns([]); } finally { setLoadingFilterColumns(false); } + }; + load(); + }, [filterTargetTable]); + + // ─── 옵션 관리 (select static) ─── + const options = config.options || []; + const addOption = () => updateConfig("options", [...options, { value: "", label: "" }]); + const updateOptionValue = (index: number, value: string) => { + const newOpts = [...options]; + newOpts[index] = { ...newOpts[index], value, label: value }; + updateConfig("options", newOpts); + }; + const removeOption = (index: number) => updateConfig("options", options.filter((_: any, i: number) => i !== index)); + + return ( +
+ {/* ═══ 1단계: 필드 유형 선택 ═══ */} +
+

이 필드는 어떤 유형인가요?

+

유형에 따라 입력 방식이 바뀌어요

+
+ +
+ {FIELD_TYPE_CARDS.map((card) => { + const Icon = card.icon; + const isSelected = fieldType === card.value; + return ( + + ); + })} +
+ + {/* ═══ 2단계: 유형별 상세 설정 ═══ */} + + {/* ─── 텍스트/숫자/여러줄: 기본 설정 ─── */} + {(fieldType === "text" || fieldType === "number" || fieldType === "textarea") && ( +
+
+ 안내 텍스트 + updateConfig("placeholder", e.target.value)} placeholder="입력 안내" className="h-7 w-[160px] text-xs" /> +
+ + {fieldType === "text" && ( +
+ 입력 형식 + +
+ )} + + {fieldType === "number" && ( +
+

값 범위

+
+
+ + updateConfig("min", e.target.value ? Number(e.target.value) : undefined)} placeholder="0" className="h-7 text-xs" /> +
+
+ + updateConfig("max", e.target.value ? Number(e.target.value) : undefined)} placeholder="100" className="h-7 text-xs" /> +
+
+ + updateConfig("step", e.target.value ? Number(e.target.value) : undefined)} placeholder="1" className="h-7 text-xs" /> +
+
+
+ )} + + {fieldType === "textarea" && ( +
+ 줄 수 + updateConfig("rows", parseInt(e.target.value) || 3)} min={2} max={20} className="h-7 w-[160px] text-xs" /> +
+ )} +
+ )} + + {/* ─── 셀렉트 (직접 입력): 옵션 관리 ─── */} + {fieldType === "select" && ( +
+
+ 옵션 목록 + +
+ {options.length > 0 ? ( +
+ {options.map((option: any, index: number) => ( +
+ updateOptionValue(index, e.target.value)} placeholder={`옵션 ${index + 1}`} className="h-8 flex-1 text-sm" /> + +
+ ))} +
+ ) : ( +
+ +

아직 옵션이 없어요

+

위의 추가 버튼으로 옵션을 만들어보세요

+
+ )} + {options.length > 0 && ( +
+
+ 기본 선택값 + +
+
+ )} +
+ )} + + {/* ─── 카테고리 ─── */} + {fieldType === "category" && ( +
+
+ + 카테고리 +
+ {config.source === "code" && config.codeGroup && ( +
+

코드 그룹

+

{config.codeGroup}

+
+ )} +
+
+

테이블

{config.categoryTable || tableName || "-"}

+

컬럼

{config.categoryColumn || columnName || "-"}

+
+
+ {loadingCategoryValues &&
카테고리 값 로딩 중...
} + {categoryValues.length > 0 && ( +
+

{categoryValues.length}개의 값이 있어요

+
+ {categoryValues.map((cv) => ( +
+ {cv.valueCode} + {cv.valueLabel} +
+ ))} +
+
+ 기본 선택값 + +
+
+ )} + {!loadingCategoryValues && categoryValues.length === 0 && ( +

카테고리 값이 없습니다. 테이블 카테고리 관리에서 값을 추가해주세요.

+ )} +
+ )} + + {/* ─── 테이블 참조 (entity) ─── */} + {fieldType === "entity" && ( +
+
+ + 테이블 참조 +
+
+

참조 테이블

+ +
+ {loadingColumns &&
컬럼 목록 로딩 중...
} + {entityColumns.length > 0 && ( +
+
+

실제 저장되는 값

+ +
+
+

사용자에게 보여지는 텍스트

+ +
+

엔티티 선택 시 같은 폼의 관련 필드가 자동으로 채워져요

+
+ )} + {config.entityTable && !loadingColumns && entityColumns.length === 0 && ( +

선택한 테이블의 컬럼 정보를 불러올 수 없어요.

+ )} +
+ )} + + {/* ─── 채번 (테이블 기반) ─── */} + {fieldType === "numbering" && ( +
+
+ + 채번 규칙 +
+ {numberingTableName ? ( +
+

대상 테이블

+

{numberingTableName}

+
+ ) : ( +

화면에 연결된 테이블이 없어서 채번 규칙을 불러올 수 없어요.

+ )} + {numberingTableName && ( +
+

채번 규칙

+ {loadingRules ? ( +
채번 규칙 로딩 중...
+ ) : numberingRules.length > 0 ? ( + + ) : ( +

이 테이블에 등록된 채번 규칙이 없어요

+ )} +
+ )} +
+
+

읽기전용

+

채번 필드는 자동 생성되므로 읽기전용을 권장해요

+
+ updateConfig("readonly", checked)} /> +
+
+ )} + + {/* ─── 데이터 필터 (선택형 + 테이블 있을 때만) ─── */} + {isSelectGroup && fieldType !== "select" && filterTargetTable && ( +
+ updateConfig("filters", filters)} + /> +
+ )} + + {/* ═══ 3단계: 고급 설정 ═══ */} + + + + + +
+ {/* 선택형: 선택 방식, 복수 선택, 검색 등 */} + {isSelectGroup && ( + <> +
+

선택 방식

+ +
+
+
+

여러 개 선택

한 번에 여러 값을 선택할 수 있어요

+ updateConfig("multiple", v)} /> +
+ {config.multiple && ( +
+
+ 최대 선택 개수 + updateConfig("maxSelect", e.target.value ? Number(e.target.value) : undefined)} placeholder="제한 없음" min={1} className="h-7 w-[100px] text-xs" /> +
+
+ )} +
+

검색 기능

옵션이 많을 때 검색으로 찾을 수 있어요

+ updateConfig("searchable", v)} /> +
+
+

선택 초기화

선택한 값을 지울 수 있는 X 버튼이 표시돼요

+ updateConfig("allowClear", v)} /> +
+
+ + )} + + {/* 입력형: 자동 생성 */} + {!isSelectGroup && fieldType !== "numbering" && ( +
+
+

자동 생성

값이 자동으로 채워져요

+ updateConfig("autoGeneration", { ...config.autoGeneration || { type: "none", enabled: false }, enabled: checked })} /> +
+ {config.autoGeneration?.enabled && ( +
+
+

생성 방식

+ +
+ {config.autoGeneration?.type && config.autoGeneration.type !== "none" && ( +

{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}

+ )} +
+ )} + + {/* 입력 마스크 */} +
+
+ 입력 마스크 +

# = 숫자, A = 문자, * = 모두

+
+ updateConfig("mask", e.target.value)} placeholder="###-####-####" className="h-7 w-[140px] text-xs" /> +
+
+ )} +
+
+
+
+ ); +}; + +V2FieldConfigPanel.displayName = "V2FieldConfigPanel"; + +export default V2FieldConfigPanel; diff --git a/frontend/components/v2/config-panels/V2InputConfigPanel.tsx b/frontend/components/v2/config-panels/V2InputConfigPanel.tsx index 1b4462dd..44d7bd2a 100644 --- a/frontend/components/v2/config-panels/V2InputConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2InputConfigPanel.tsx @@ -46,10 +46,12 @@ export const V2InputConfigPanel: React.FC = ({ config, const response = await apiClient.get("/admin/menus"); if (response.data.success && response.data.data) { const allMenus = response.data.data; - const level2UserMenus = allMenus.filter((menu: any) => - menu.menu_type === '1' && menu.lev === 2 - ); - setParentMenus(level2UserMenus); + const userMenus = allMenus.filter((menu: any) => { + const menuType = menu.menu_type || menu.menuType; + const level = menu.level || menu.lev || menu.LEVEL; + return menuType === '1' && (level === 2 || level === 3 || level === '2' || level === '3'); + }); + setParentMenus(userMenus); } } catch (error) { console.error("부모 메뉴 로드 실패:", error); @@ -60,9 +62,12 @@ export const V2InputConfigPanel: React.FC = ({ config, loadMenus(); }, []); + const inputType = config.inputType || config.type || "text"; + useEffect(() => { const loadRules = async () => { - if (config.autoGeneration?.type !== "numbering_rule") return; + const isNumbering = inputType === "numbering" || config.autoGeneration?.type === "numbering_rule"; + if (!isNumbering) return; if (!selectedMenuObjid) { setNumberingRules([]); return; } setLoadingRules(true); try { @@ -78,9 +83,7 @@ export const V2InputConfigPanel: React.FC = ({ config, } }; loadRules(); - }, [selectedMenuObjid, config.autoGeneration?.type]); - - const inputType = config.inputType || config.type || "text"; + }, [selectedMenuObjid, config.autoGeneration?.type, inputType]); return (
@@ -106,7 +109,24 @@ export const V2InputConfigPanel: React.FC = ({ config,
- {/* ─── 채번 타입 전용 안내 ─── */} + {/* ─── 채번 타입 전용 설정 ─── */} {inputType === "numbering" && (
-
-

- 채번 규칙은 테이블 관리에서 컬럼별로 설정돼요. - 화면에 배치된 컬럼의 채번 규칙이 자동으로 적용돼요. -

+
+ + 채번 규칙
+ +
+

적용할 메뉴

+ {menuObjid && selectedMenuObjid === menuObjid ? ( +
+

현재 화면 메뉴 사용 중

+
+

+ {parentMenus.find((m: any) => m.objid === menuObjid)?.menu_name_kor + || parentMenus.find((m: any) => m.objid === menuObjid)?.translated_name + || `메뉴 #${menuObjid}`} +

+ +
+
+ ) : loadingMenus ? ( +
+ + 메뉴 목록 로딩 중... +
+ ) : ( + + )} +
+ + {selectedMenuObjid && ( +
+

채번 규칙

+ {loadingRules ? ( +
+ + 채번 규칙 로딩 중... +
+ ) : numberingRules.length > 0 ? ( + + ) : ( +

선택한 메뉴에 등록된 채번 규칙이 없어요

+ )} +
+ )} +

읽기전용

diff --git a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx index d6ba3d94..1a396a27 100644 --- a/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx +++ b/frontend/components/v2/config-panels/V2SelectConfigPanel.tsx @@ -73,7 +73,6 @@ const SOURCE_CARDS = [ icon: Database, title: "테이블 참조", description: "다른 테이블에서 가져와요", - entityOnly: true, }, ] as const; @@ -279,6 +278,8 @@ interface V2SelectConfigPanelProps { inputType?: string; tableName?: string; columnName?: string; + tables?: Array<{ tableName: string; displayName?: string; tableComment?: string }>; + screenTableName?: string; } export const V2SelectConfigPanel: React.FC = ({ @@ -287,6 +288,8 @@ export const V2SelectConfigPanel: React.FC = ({ inputType, tableName, columnName, + tables = [], + screenTableName, }) => { const isEntityType = inputType === "entity" || config.source === "entity" || !!config.entityTable; const isCategoryType = inputType === "category"; @@ -342,11 +345,12 @@ export const V2SelectConfigPanel: React.FC = ({ loadFilterColumns(); }, [filterTargetTable]); + // 초기 source가 설정 안 된 경우에만 기본값 설정 useEffect(() => { - if (isCategoryType && config.source !== "category") { + if (!config.source && isCategoryType) { onChange({ ...config, source: "category" }); } - }, [isCategoryType]); + }, []); const loadCategoryValues = useCallback(async (catTable: string, catColumn: string) => { if (!catTable || !catColumn) { @@ -447,23 +451,13 @@ export const V2SelectConfigPanel: React.FC = ({ updateConfig("options", newOptions); }; - const effectiveSource = isCategoryType + const effectiveSource = config.source === "code" ? "category" - : config.source === "code" - ? "category" - : config.source || "static"; + : config.source || (isCategoryType ? "category" : "static"); - const visibleCards = useMemo(() => { - if (isCategoryType) { - return SOURCE_CARDS.filter((c) => c.value === "category"); - } - return SOURCE_CARDS.filter((c) => { - if (c.entityOnly && !isEntityType) return false; - return true; - }); - }, [isCategoryType, isEntityType]); + const visibleCards = SOURCE_CARDS; - const gridCols = isEntityType ? "grid-cols-3" : "grid-cols-2"; + const gridCols = "grid-cols-3"; return (
@@ -572,9 +566,25 @@ export const V2SelectConfigPanel: React.FC = ({ 테이블 참조
-
-

참조 테이블

-

{config.entityTable || "미설정"}

+
+

참조 테이블

+
{loadingColumns && ( @@ -628,16 +638,9 @@ export const V2SelectConfigPanel: React.FC = ({
)} - {!loadingColumns && entityColumns.length === 0 && !config.entityTable && ( -
-

참조 테이블이 설정되지 않았어요

-

테이블 타입 관리에서 참조 테이블을 설정해주세요

-
- )} - {config.entityTable && !loadingColumns && entityColumns.length === 0 && (

- 테이블 컬럼을 조회할 수 없습니다. 테이블 타입 관리에서 참조 테이블을 설정해주세요. + 선택한 테이블의 컬럼 정보를 불러올 수 없어요. 테이블 타입 관리에서 컬럼 정보를 확인해주세요.

)}
diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 8ee1ba20..b34ba2b3 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -302,9 +302,16 @@ export const DynamicComponentRenderer: React.FC = return type; }; - const componentType = mapToV2ComponentType(rawComponentType); + const mappedComponentType = mapToV2ComponentType(rawComponentType); - // 컴포넌트 타입 변환 완료 + // fieldType 기반 동적 컴포넌트 전환 (통합 필드 설정 패널에서 설정된 값) + const componentType = (() => { + const ft = (component as any).componentConfig?.fieldType; + if (!ft) return mappedComponentType; + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft)) return "v2-input"; + if (["select", "category", "entity"].includes(ft)) return "v2-select"; + return mappedComponentType; + })(); // 🆕 조건부 렌더링 체크 (conditionalConfig) // componentConfig 또는 overrides에서 conditionalConfig를 가져와서 formData와 비교 @@ -738,7 +745,21 @@ export const DynamicComponentRenderer: React.FC = // 컬럼 메타데이터 기반 componentConfig 병합 (DB 최신 설정 우선) const isEntityJoinColumn = fieldName?.includes("."); const baseColumnName = isEntityJoinColumn ? undefined : fieldName; - const mergedComponentConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {}); + const rawMergedConfig = mergeColumnMeta(screenTableName, baseColumnName, component.componentConfig || {}); + + // fieldType이 설정된 경우, source/inputType 보조 속성 자동 보완 + const mergedComponentConfig = (() => { + const ft = rawMergedConfig?.fieldType; + if (!ft) return rawMergedConfig; + const patch: Record = {}; + if (["select", "category", "entity"].includes(ft) && !rawMergedConfig.source) { + patch.source = ft === "category" ? "category" : ft === "entity" ? "entity" : "static"; + } + if (["text", "number", "password", "textarea", "slider", "color", "numbering"].includes(ft) && !rawMergedConfig.inputType) { + patch.inputType = ft; + } + return Object.keys(patch).length > 0 ? { ...rawMergedConfig, ...patch } : rawMergedConfig; + })(); // NOT NULL 기반 필수 여부를 component.required에 반영 const notNullRequired = isColumnRequiredByMeta(screenTableName, baseColumnName); @@ -755,17 +776,16 @@ export const DynamicComponentRenderer: React.FC = onClick, onDragStart, onDragEnd, - size: needsExternalHorizLabel - ? { ...(component.size || newComponent.defaultSize), width: undefined, height: undefined } - : component.size || newComponent.defaultSize, - position: component.position, config: mergedComponentConfig, componentConfig: mergedComponentConfig, - // componentConfig의 모든 속성을 props로 spread (tableName, displayField 등) + // componentConfig spread를 먼저 → 이후 명시적 속성이 override ...(mergedComponentConfig || {}), - // 🔧 style은 맨 마지막에! (componentConfig.style이 있어도 mergedStyle이 우선) + // size/position/style/label은 componentConfig spread 이후에 설정 (덮어쓰기 방지) + size: needsExternalHorizLabel + ? { ...(component.size || newComponent.defaultSize), width: undefined } + : component.size || newComponent.defaultSize, + position: component.position, style: mergedStyle, - // 수평 라벨 → 외부에서 처리하므로 label 전달 안 함 label: needsExternalHorizLabel ? undefined : effectiveLabel, // NOT NULL 메타데이터 포함된 필수 여부 (V2Hierarchy 등 직접 props.required 참조하는 컴포넌트용) required: effectiveRequired, diff --git a/frontend/lib/registry/components/v2-input/index.ts b/frontend/lib/registry/components/v2-input/index.ts index c6650717..4b7bbc78 100644 --- a/frontend/lib/registry/components/v2-input/index.ts +++ b/frontend/lib/registry/components/v2-input/index.ts @@ -6,7 +6,7 @@ import { ComponentCategory } from "@/types/component"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { V2InputConfigPanel } from "@/components/v2/config-panels/V2InputConfigPanel"; +import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel"; import { V2Input } from "@/components/v2/V2Input"; export const V2InputDefinition = createComponentDefinition({ @@ -72,7 +72,7 @@ export const V2InputDefinition = createComponentDefinition({ tags: ["input", "text", "number", "v2"], // 설정 패널 - configPanel: V2InputConfigPanel, + configPanel: V2FieldConfigPanel, }); export default V2InputDefinition; diff --git a/frontend/lib/registry/components/v2-select/index.ts b/frontend/lib/registry/components/v2-select/index.ts index bf866a15..24ec7686 100644 --- a/frontend/lib/registry/components/v2-select/index.ts +++ b/frontend/lib/registry/components/v2-select/index.ts @@ -6,7 +6,7 @@ import { ComponentCategory } from "@/types/component"; import { createComponentDefinition } from "../../utils/createComponentDefinition"; -import { V2SelectConfigPanel } from "@/components/v2/config-panels/V2SelectConfigPanel"; +import { V2FieldConfigPanel } from "@/components/v2/config-panels/V2FieldConfigPanel"; import { V2Select } from "@/components/v2/V2Select"; export const V2SelectDefinition = createComponentDefinition({ @@ -82,7 +82,7 @@ export const V2SelectDefinition = createComponentDefinition({ tags: ["select", "dropdown", "combobox", "v2"], // 설정 패널 - configPanel: V2SelectConfigPanel, + configPanel: V2FieldConfigPanel, }); export default V2SelectDefinition; diff --git a/frontend/lib/utils/getComponentConfigPanel.tsx b/frontend/lib/utils/getComponentConfigPanel.tsx index cc9263b2..c813e77d 100644 --- a/frontend/lib/utils/getComponentConfigPanel.tsx +++ b/frontend/lib/utils/getComponentConfigPanel.tsx @@ -8,8 +8,8 @@ import type { ConfigPanelContext } from "@/lib/registry/components/common/Config // 컴포넌트별 ConfigPanel 동적 import 맵 const CONFIG_PANEL_MAP: Record Promise> = { // ========== V2 컴포넌트 ========== - "v2-input": () => import("@/components/v2/config-panels/V2InputConfigPanel"), - "v2-select": () => import("@/components/v2/config-panels/V2SelectConfigPanel"), + "v2-input": () => import("@/components/v2/config-panels/V2FieldConfigPanel"), + "v2-select": () => import("@/components/v2/config-panels/V2FieldConfigPanel"), "v2-date": () => import("@/components/v2/config-panels/V2DateConfigPanel"), "v2-list": () => import("@/components/v2/config-panels/V2ListConfigPanel"), "v2-media": () => import("@/components/v2/config-panels/V2MediaConfigPanel"), @@ -205,6 +205,7 @@ export interface ComponentConfigPanelProps { menuObjid?: number; allComponents?: any[]; currentComponent?: any; + componentType?: string; } export const DynamicComponentConfigPanel: React.FC = ({ @@ -217,6 +218,7 @@ export const DynamicComponentConfigPanel: React.FC = menuObjid, allComponents, currentComponent, + componentType, }) => { const [ConfigPanelComponent, setConfigPanelComponent] = React.useState | null>(null); const [loading, setLoading] = React.useState(true); @@ -484,6 +486,8 @@ export const DynamicComponentConfigPanel: React.FC = onConfigChange={onChange} context={context} screenTableName={screenTableName} + tableName={screenTableName} + columnName={currentComponent?.columnName || config?.columnName || config?.fieldName} tableColumns={selectedTableColumns} tables={tables} allTables={allTablesList.length > 0 ? allTablesList : tables} @@ -493,6 +497,7 @@ export const DynamicComponentConfigPanel: React.FC = currentComponent={currentComponent} screenComponents={screenComponents} inputType={currentComponent?.inputType || config?.inputType} + componentType={componentType || componentId} sourceTableColumns={sourceTableColumns} targetTableColumns={targetTableColumns} onSourceTableChange={handleSourceTableChange} diff --git a/test-output/unified-field-type-config-panel-test-guide.md b/test-output/unified-field-type-config-panel-test-guide.md new file mode 100644 index 00000000..11ed2711 --- /dev/null +++ b/test-output/unified-field-type-config-panel-test-guide.md @@ -0,0 +1,178 @@ +# 통합 필드 타입 Config Panel 테스트 가이드 + +## 테스트 대상 +- **컴포넌트:** V2FieldConfigPanel (통합 필드 설정 패널) +- **위치:** `frontend/components/v2/config-panels/V2FieldConfigPanel.tsx` +- **연결:** v2-input, v2-select 컴포넌트 선택 시 오른쪽 속성 패널에 표시 + +## 7개 필드 타입 카드 +| 카드 | value | 설명 | +|------|-------|------| +| 텍스트 | text | 일반 텍스트 입력 | +| 숫자 | number | 숫자만 입력 | +| 여러 줄 | textarea | 긴 텍스트 입력 | +| 셀렉트 | select | 직접 옵션 선택 | +| 카테고리 | category | 등록된 선택지 | +| 테이블 참조 | entity | 다른 테이블 참조 | +| 채번 | numbering | 자동 번호 생성 | + +--- + +## 테스트 절차 + +### 1단계: 로그인 +1. 브라우저에서 `http://localhost:9771/login` 접속 +2. 로그인 정보: + - **User ID:** `admin` + - **Password:** `wace1234!` +3. "로그인" 버튼 클릭 +4. 페이지 로드 완료까지 대기 (약 3~5초) + +**확인:** 로그인 성공 시 메인 대시보드 또는 홈 화면으로 이동 + +--- + +### 2단계: 화면 디자이너 열기 + +**경로 1 (권장):** +- 사이드바에서 **"화면 관리"** 또는 **"화면 설정"** 메뉴 클릭 +- 화면 목록에서 기존 화면 선택 후 **"설계"** 또는 **"편집"** 버튼 클릭 + +**경로 2 (URL 직접):** +- `http://localhost:9771/admin/screenMng/screenMngList` 접속 +- 화면 목록에서 화면 선택 후 설계 버튼 클릭 + +**경로 3 (특정 화면 직접):** +- `http://localhost:9771/admin/screenMng/screenMngList?openDesigner=60` 접속 +- (60은 화면 ID - 존재하는 화면 ID로 변경) + +**확인:** 화면 디자이너가 전체 화면으로 열림 (캔버스, 좌측 컴포넌트 목록, 오른쪽 속성 패널) + +--- + +### 3단계: v2-input 컴포넌트 찾기 및 선택 + +**v2-input 컴포넌트 찾기:** +- v2-input은 주로 **테이블 컬럼을 캔버스로 드래그**할 때 자동 생성됨 +- 또는 좌측 패널에서 "입력" 관련 컴포넌트를 드래그하여 배치 +- 캔버스에 있는 **텍스트 입력 필드**를 클릭 + +**v2-input 식별:** +- 선택 시 오른쪽 패널에 "이 필드는 어떤 유형인가요?" 문구와 7개 카드가 보이면 v2-input +- 레거시 위젯은 "입력 타입", "세부 타입 선택" 등 다른 UI + +**확인:** v2-input 선택 시 오른쪽 패널에 7개 카드 표시 + +--- + +### 4단계: 7개 카드 확인 + +오른쪽 속성 패널에서 다음 7개 카드가 **3열 그리드**로 표시되는지 확인: + +1. **텍스트** - "일반 텍스트 입력" +2. **숫자** - "숫자만 입력" +3. **여러 줄** - "긴 텍스트 입력" +4. **셀렉트** - "직접 옵션 선택" +5. **카테고리** - "등록된 선택지" +6. **테이블 참조** - "다른 테이블 참조" +7. **채번** - "자동 번호 생성" + +**확인:** 7개 카드 모두 표시, 선택된 카드에 파란색 테두리/배경 + +--- + +### 5단계: 셀렉트 카드 클릭 테스트 + +1. **텍스트** 카드가 선택된 상태에서 시작 (기본값) +2. **셀렉트** 카드 클릭 +3. 아래 상세 설정이 **텍스트용**에서 **셀렉트용**으로 바뀌는지 확인 + +**텍스트 선택 시 표시:** +- "안내 텍스트" (placeholder) 입력 필드 +- "입력 형식" (선택사항) +- "최대 길이" 등 + +**셀렉트 선택 시 표시:** +- "옵션 목록" 섹션 +- "추가" 버튼 +- "아직 옵션이 없어요" 또는 옵션 목록 +- "기본 선택값" 드롭다운 + +**확인:** 카드 클릭 시 상세 설정 영역이 해당 타입에 맞게 전환됨 + +--- + +### 6단계: 기타 카드 전환 테스트 + +| 클릭 카드 | 예상 상세 설정 | +|-----------|----------------| +| 숫자 | 최소/최대값, 단위, 슬라이더 등 | +| 여러 줄 | 줄 수 설정 | +| 카테고리 | 테이블/컬럼 정보, 카테고리 값 목록 | +| 테이블 참조 | 참조 테이블 선택, 값/라벨 컬럼 | +| 채번 | 메뉴 선택, 채번 규칙 선택 | + +**확인:** 각 카드 클릭 시 해당 타입의 상세 설정이 표시됨 + +--- + +## 예상 결과 요약 + +### 정상 동작 +- [x] 로그인 성공 +- [x] 화면 디자이너 접근 가능 +- [x] v2-input 컴포넌트 선택 시 오른쪽 패널에 7개 카드 표시 +- [x] "셀렉트" 카드 클릭 시 옵션 목록 관리 UI로 전환 +- [x] 다른 카드 클릭 시 해당 타입의 상세 설정으로 전환 + +### 오류 상황 +- **"로드 실패"** 메시지: ConfigPanel 동적 로드 실패 +- **"설정 패널 없음"** 메시지: v2-input 매핑 누락 +- **7개 카드 미표시:** V2FieldConfigPanel 대신 다른 패널이 렌더링됨 +- **카드 클릭 무반응:** handleFieldTypeChange 미동작 또는 config 업데이트 실패 + +--- + +## 코드 흐름 (참고) + +``` +ScreenDesigner + └─ V2PropertiesPanel (selectedComponent) + └─ hasComponentConfigPanel("v2-input") === true + └─ DynamicComponentConfigPanel (componentId="v2-input") + └─ getComponentConfigPanel("v2-input") + └─ import("@/components/v2/config-panels/V2FieldConfigPanel") + └─ V2FieldConfigPanel (7 cards + handleFieldTypeChange) +``` + +--- + +## 스크린샷 체크리스트 + +1. **로그인 화면** - 로그인 전 +2. **화면 관리 목록** - 화면 디자이너 진입 전 +3. **화면 디자이너** - v2-input 미선택 상태 +4. **7개 카드 표시** - v2-input 선택 후 오른쪽 패널 +5. **셀렉트 상세 설정** - 셀렉트 카드 클릭 후 +6. **다른 타입 상세 설정** - 예: 채번 또는 테이블 참조 + +--- + +## 문제 발생 시 디버깅 + +### Console 확인 +```javascript +// 선택된 컴포넌트 타입 확인 +// React DevTools 또는 전역 상태에서 +console.log(selectedComponent?.componentConfig?.type); +// "v2-input" 이어야 함 +``` + +### Network 확인 +- `/api/admin/webTypes` - 200 OK +- 화면 레이아웃 API - 200 OK + +### 컴포넌트가 v2-input이 아닌 경우 +- 테이블 컬럼을 캔버스로 드래그하면 v2-input 생성 +- 기존 화면에 레거시 text-input 등이 있으면 V2InputConfigPanel(5개 카드)이 표시될 수 있음 +- V2FieldConfigPanel은 **7개 카드** (텍스트, 숫자, 여러 줄, 셀렉트, 카테고리, 테이블 참조, 채번)