diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index 138f560c..b6660709 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -554,6 +554,16 @@ export const ScreenModal: React.FC = ({ className }) => { // 화면 관리에서 설정한 해상도 사용 (우선순위) const screenResolution = (layoutData as any).screenResolution || (screenInfo as any).screenResolution; + console.log("🔍 [ScreenModal] 해상도 디버그:", { + screenId, + v2ScreenResolution: v2LayoutData?.screenResolution, + layoutScreenResolution: (layoutData as any).screenResolution, + screenInfoResolution: (screenInfo as any).screenResolution, + finalScreenResolution: screenResolution, + hasWidth: screenResolution?.width, + hasHeight: screenResolution?.height, + }); + let dimensions; if (screenResolution && screenResolution.width && screenResolution.height) { // 화면 관리에서 설정한 해상도 사용 @@ -563,9 +573,11 @@ export const ScreenModal: React.FC = ({ className }) => { offsetX: 0, offsetY: 0, }; + console.log("✅ [ScreenModal] 화면관리 해상도 적용:", dimensions); } else { // 해상도 정보가 없으면 자동 계산 dimensions = calculateScreenDimensions(components); + console.log("⚠️ [ScreenModal] 해상도 없음 - 자동 계산:", dimensions); } setScreenDimensions(dimensions); @@ -869,16 +881,24 @@ export const ScreenModal: React.FC = ({ className }) => { // 모달 크기 설정 - 화면관리 설정 크기 + 헤더/푸터 const getModalStyle = () => { if (!screenDimensions) { + console.log("⚠️ [ScreenModal] getModalStyle: screenDimensions가 null - 기본 스타일 사용"); return { className: "w-fit min-w-[400px] max-w-4xl overflow-hidden", style: { padding: 0, gap: 0, maxHeight: "calc(100dvh - 8px)" }, }; } + const finalWidth = Math.min(screenDimensions.width, window.innerWidth * 0.98); + console.log("✅ [ScreenModal] getModalStyle: 해상도 적용됨", { + screenDimensions, + finalWidth: `${finalWidth}px`, + viewportWidth: window.innerWidth, + }); + return { className: "overflow-hidden", style: { - width: `${Math.min(screenDimensions.width, window.innerWidth * 0.98)}px`, + width: `${finalWidth}px`, // CSS가 알아서 처리: 뷰포트 안에 들어가면 auto-height, 넘치면 max-height로 제한 maxHeight: "calc(100dvh - 8px)", maxWidth: "98vw", diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index d8ce8e7a..0fd0cfec 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -565,12 +565,32 @@ export const EditModal: React.FC = ({ className }) => { return newActiveIds; }, [formData, groupData, conditionalLayers, screenData?.components]); - // 🆕 활성화된 조건부 레이어의 컴포넌트 가져오기 + // 활성화된 조건부 레이어의 컴포넌트 가져오기 (Zone 오프셋 적용) const activeConditionalComponents = useMemo(() => { return conditionalLayers .filter((layer) => activeConditionalLayerIds.includes(layer.id)) - .flatMap((layer) => (layer as LayerDefinition & { components: ComponentData[] }).components || []); - }, [conditionalLayers, activeConditionalLayerIds]); + .flatMap((layer) => { + const layerWithComps = layer as LayerDefinition & { components: ComponentData[] }; + const comps = layerWithComps.components || []; + + // Zone 오프셋 적용: 조건부 레이어 컴포넌트는 Zone 내부 상대 좌표로 저장되므로 + // Zone의 절대 좌표를 더해줘야 EditModal에서 올바른 위치에 렌더링됨 + const associatedZone = zones.find((z) => z.zone_id === (layer as any).zoneId); + if (!associatedZone) return comps; + + const zoneOffsetX = associatedZone.x || 0; + const zoneOffsetY = associatedZone.y || 0; + + return comps.map((comp) => ({ + ...comp, + position: { + ...comp.position, + x: parseFloat(comp.position?.x?.toString() || "0") + zoneOffsetX, + y: parseFloat(comp.position?.y?.toString() || "0") + zoneOffsetY, + }, + })); + }); + }, [conditionalLayers, activeConditionalLayerIds, zones]); const handleClose = () => { setModalState({ @@ -881,14 +901,31 @@ export const EditModal: React.FC = ({ className }) => { } } + // V2Repeater 저장 이벤트 발생 (디테일 테이블 데이터 저장) + const hasRepeaterInstances = window.__v2RepeaterInstances && window.__v2RepeaterInstances.size > 0; + if (hasRepeaterInstances) { + const masterRecordId = groupData[0]?.id || formData.id; + window.dispatchEvent( + new CustomEvent("repeaterSave", { + detail: { + parentId: masterRecordId, + masterRecordId, + mainFormData: formData, + tableName: screenData.screenInfo.tableName, + }, + }), + ); + console.log("📋 [EditModal] 그룹 저장 후 repeaterSave 이벤트 발생:", { masterRecordId }); + } + // 결과 메시지 const messages: string[] = []; if (insertedCount > 0) messages.push(`${insertedCount}개 추가`); if (updatedCount > 0) messages.push(`${updatedCount}개 수정`); if (deletedCount > 0) messages.push(`${deletedCount}개 삭제`); - if (messages.length > 0) { - toast.success(`품목이 저장되었습니다 (${messages.join(", ")})`); + if (messages.length > 0 || hasRepeaterInstances) { + toast.success(messages.length > 0 ? `품목이 저장되었습니다 (${messages.join(", ")})` : "저장되었습니다."); // 부모 컴포넌트의 onSave 콜백 실행 (테이블 새로고침) if (modalState.onSave) { diff --git a/frontend/components/screen/InteractiveScreenViewer.tsx b/frontend/components/screen/InteractiveScreenViewer.tsx index c4b2ad0a..252f5c2b 100644 --- a/frontend/components/screen/InteractiveScreenViewer.tsx +++ b/frontend/components/screen/InteractiveScreenViewer.tsx @@ -2494,7 +2494,13 @@ export const InteractiveScreenViewer: React.FC = ( setPopupScreen(null); setPopupFormData({}); // 팝업 닫을 때 formData도 초기화 }}> - + {popupScreen?.title || "상세 정보"} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 76bd8973..af4fc96b 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -5555,8 +5555,12 @@ export default function ScreenDesigner({ return false; } - // 6. 삭제 (단일/다중 선택 지원) - if (e.key === "Delete" && (selectedComponent || groupState.selectedComponents.length > 0)) { + // 6. 삭제 (단일/다중 선택 지원) - Delete 또는 Backspace(Mac) + const isInputFocused = document.activeElement instanceof HTMLInputElement || + document.activeElement instanceof HTMLTextAreaElement || + document.activeElement instanceof HTMLSelectElement || + (document.activeElement as HTMLElement)?.isContentEditable; + if ((e.key === "Delete" || (e.key === "Backspace" && !isInputFocused)) && (selectedComponent || groupState.selectedComponents.length > 0)) { // console.log("🗑️ 컴포넌트 삭제 (단축키)"); e.preventDefault(); e.stopPropagation(); @@ -7418,7 +7422,7 @@ export default function ScreenDesigner({

편집: Ctrl+C(복사), Ctrl+V(붙여넣기), Ctrl+S(저장), - Ctrl+Z(실행취소), Delete(삭제) + Ctrl+Z(실행취소), Delete/Backspace(삭제)

⚠️ diff --git a/frontend/components/v2/V2Repeater.tsx b/frontend/components/v2/V2Repeater.tsx index 0f16cd31..734032f3 100644 --- a/frontend/components/v2/V2Repeater.tsx +++ b/frontend/components/v2/V2Repeater.tsx @@ -43,6 +43,7 @@ export const V2Repeater: React.FC = ({ onDataChange, onRowClick, className, + formData: parentFormData, }) => { // 설정 병합 const config: V2RepeaterConfig = useMemo( @@ -153,21 +154,15 @@ export const V2Repeater: React.FC = ({ // 메인 폼 데이터 병합 (커스텀 테이블 사용 시에는 메인 폼 데이터 병합 안함) let mergedData: Record; if (config.useCustomTable && config.mainTableName) { - // 커스텀 테이블: 리피터 데이터만 저장 mergedData = { ...cleanRow }; - // 🆕 FK 자동 연결 - foreignKeySourceColumn이 설정된 경우 해당 컬럼 값 사용 if (config.foreignKeyColumn) { - // foreignKeySourceColumn이 있으면 mainFormData에서 해당 컬럼 값 사용 - // 없으면 마스터 레코드 ID 사용 (기존 동작) const sourceColumn = config.foreignKeySourceColumn; let fkValue: any; if (sourceColumn && mainFormData && mainFormData[sourceColumn] !== undefined) { - // mainFormData에서 참조 컬럼 값 가져오기 fkValue = mainFormData[sourceColumn]; } else { - // 기본: 마스터 레코드 ID 사용 fkValue = masterRecordId; } @@ -176,7 +171,6 @@ export const V2Repeater: React.FC = ({ } } } else { - // 기존 방식: 메인 폼 데이터 병합 const { id: _mainId, ...mainFormDataWithoutId } = mainFormData || {}; mergedData = { ...mainFormDataWithoutId, @@ -192,7 +186,19 @@ export const V2Repeater: React.FC = ({ } } - await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); + // 기존 행(id 존재)은 UPDATE, 새 행은 INSERT + const rowId = row.id; + if (rowId && typeof rowId === "string" && rowId.includes("-")) { + // UUID 형태의 id가 있으면 기존 데이터 → UPDATE + const { id: _, created_date: _cd, updated_date: _ud, ...updateFields } = filteredData; + await apiClient.put(`/table-management/tables/${tableName}/edit`, { + originalData: { id: rowId }, + updatedData: updateFields, + }); + } else { + // 새 행 → INSERT + await apiClient.post(`/table-management/tables/${tableName}/add`, filteredData); + } } } catch (error) { console.error("❌ V2Repeater 저장 실패:", error); @@ -228,6 +234,108 @@ export const V2Repeater: React.FC = ({ parentId, ]); + // 수정 모드: useCustomTable + FK 기반으로 기존 디테일 데이터 자동 로드 + const dataLoadedRef = useRef(false); + useEffect(() => { + if (dataLoadedRef.current) return; + if (!config.useCustomTable || !config.mainTableName || !config.foreignKeyColumn) return; + if (!parentFormData) return; + + const fkSourceColumn = config.foreignKeySourceColumn || config.foreignKeyColumn; + const fkValue = parentFormData[fkSourceColumn]; + if (!fkValue) return; + + // 이미 데이터가 있으면 로드하지 않음 + if (data.length > 0) return; + + const loadExistingData = async () => { + try { + console.log("📥 [V2Repeater] 수정 모드 데이터 로드:", { + tableName: config.mainTableName, + fkColumn: config.foreignKeyColumn, + fkValue, + }); + + const response = await apiClient.post( + `/table-management/tables/${config.mainTableName}/data`, + { + page: 1, + size: 1000, + search: { [config.foreignKeyColumn]: fkValue }, + autoFilter: true, + } + ); + + const rows = response.data?.data?.data || response.data?.data?.rows || response.data?.rows || []; + if (Array.isArray(rows) && rows.length > 0) { + console.log(`✅ [V2Repeater] 기존 데이터 ${rows.length}건 로드 완료`); + + // isSourceDisplay 컬럼이 있으면 소스 테이블에서 표시 데이터 보강 + const sourceDisplayColumns = config.columns.filter((col) => col.isSourceDisplay); + const sourceTable = config.dataSource?.sourceTable; + const fkColumn = config.dataSource?.foreignKey; + const refKey = config.dataSource?.referenceKey || "id"; + + if (sourceDisplayColumns.length > 0 && sourceTable && fkColumn) { + try { + const fkValues = rows.map((row) => row[fkColumn]).filter(Boolean); + const uniqueValues = [...new Set(fkValues)]; + + if (uniqueValues.length > 0) { + // FK 값 기반으로 소스 테이블에서 해당 레코드만 조회 + const sourcePromises = uniqueValues.map((val) => + apiClient.post(`/table-management/tables/${sourceTable}/data`, { + page: 1, size: 1, + search: { [refKey]: val }, + autoFilter: true, + }).then((r) => r.data?.data?.data || r.data?.data?.rows || []) + .catch(() => []) + ); + const sourceResults = await Promise.all(sourcePromises); + const sourceMap = new Map(); + sourceResults.flat().forEach((sr: any) => { + if (sr[refKey]) sourceMap.set(String(sr[refKey]), sr); + }); + + // 각 행에 소스 테이블의 표시 데이터 병합 + // RepeaterTable은 isSourceDisplay 컬럼을 `_display_${col.key}` 필드로 렌더링함 + rows.forEach((row: any) => { + const sourceRecord = sourceMap.get(String(row[fkColumn])); + if (sourceRecord) { + sourceDisplayColumns.forEach((col) => { + const displayValue = sourceRecord[col.key] ?? null; + row[col.key] = displayValue; + row[`_display_${col.key}`] = displayValue; + }); + } + }); + console.log("✅ [V2Repeater] 소스 테이블 표시 데이터 보강 완료"); + } + } catch (sourceError) { + console.warn("⚠️ [V2Repeater] 소스 테이블 조회 실패 (표시만 영향):", sourceError); + } + } + + setData(rows); + dataLoadedRef.current = true; + if (onDataChange) onDataChange(rows); + } + } catch (error) { + console.error("❌ [V2Repeater] 기존 데이터 로드 실패:", error); + } + }; + + loadExistingData(); + }, [ + config.useCustomTable, + config.mainTableName, + config.foreignKeyColumn, + config.foreignKeySourceColumn, + parentFormData, + data.length, + onDataChange, + ]); + // 현재 테이블 컬럼 정보 로드 useEffect(() => { const loadCurrentTableColumnInfo = async () => { @@ -451,58 +559,71 @@ export const V2Repeater: React.FC = ({ loadCategoryLabels(); }, [data, sourceCategoryColumns]); + // 계산 규칙 적용 (소스 테이블의 _display_* 필드도 참조 가능) + const applyCalculationRules = useCallback( + (row: any): any => { + const rules = config.calculationRules; + if (!rules || rules.length === 0) return row; + + const updatedRow = { ...row }; + for (const rule of rules) { + if (!rule.targetColumn || !rule.formula) continue; + try { + let formula = rule.formula; + const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || []; + for (const field of fieldMatches) { + if (field === rule.targetColumn) continue; + // 직접 필드 → _display_* 필드 순으로 값 탐색 + const raw = updatedRow[field] ?? updatedRow[`_display_${field}`]; + const value = parseFloat(raw) || 0; + formula = formula.replace(new RegExp(`\\b${field}\\b`, "g"), value.toString()); + } + updatedRow[rule.targetColumn] = new Function(`return ${formula}`)(); + } catch { + updatedRow[rule.targetColumn] = 0; + } + } + return updatedRow; + }, + [config.calculationRules], + ); + + // _targetTable 메타데이터 포함하여 onDataChange 호출 + const notifyDataChange = useCallback( + (newData: any[]) => { + if (!onDataChange) return; + const targetTable = + config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; + if (targetTable) { + onDataChange(newData.map((row) => ({ ...row, _targetTable: targetTable }))); + } else { + onDataChange(newData); + } + }, + [onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], + ); + // 데이터 변경 핸들러 const handleDataChange = useCallback( (newData: any[]) => { - setData(newData); - - // 🆕 _targetTable 메타데이터 포함하여 전달 (백엔드에서 테이블 분리용) - if (onDataChange) { - const targetTable = - config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - - if (targetTable) { - // 각 행에 _targetTable 추가 - const dataWithTarget = newData.map((row) => ({ - ...row, - _targetTable: targetTable, - })); - onDataChange(dataWithTarget); - } else { - onDataChange(newData); - } - } - - // 🆕 데이터 변경 시 자동으로 컬럼 너비 조정 + const calculated = newData.map(applyCalculationRules); + setData(calculated); + notifyDataChange(calculated); setAutoWidthTrigger((prev) => prev + 1); }, - [onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], + [applyCalculationRules, notifyDataChange], ); // 행 변경 핸들러 const handleRowChange = useCallback( (index: number, newRow: any) => { + const calculated = applyCalculationRules(newRow); const newData = [...data]; - newData[index] = newRow; + newData[index] = calculated; setData(newData); - - // 🆕 _targetTable 메타데이터 포함 - if (onDataChange) { - const targetTable = - config.useCustomTable && config.mainTableName ? config.mainTableName : config.dataSource?.tableName; - - if (targetTable) { - const dataWithTarget = newData.map((row) => ({ - ...row, - _targetTable: targetTable, - })); - onDataChange(dataWithTarget); - } else { - onDataChange(newData); - } - } + notifyDataChange(newData); }, - [data, onDataChange, config.useCustomTable, config.mainTableName, config.dataSource?.tableName], + [data, applyCalculationRules, notifyDataChange], ); // 행 삭제 핸들러 diff --git a/frontend/components/v2/V2Select.tsx b/frontend/components/v2/V2Select.tsx index c7ea8c94..b13d450e 100644 --- a/frontend/components/v2/V2Select.tsx +++ b/frontend/components/v2/V2Select.tsx @@ -153,13 +153,11 @@ const DropdownSelect = forwardRef { - // value는 CommandItem의 value (라벨) - // search는 검색어 + filter={(itemValue, search) => { if (!search) return 1; - const normalizedValue = value.toLowerCase(); - const normalizedSearch = search.toLowerCase(); - if (normalizedValue.includes(normalizedSearch)) return 1; + const option = options.find((o) => o.value === itemValue); + const label = (option?.label || option?.value || "").toLowerCase(); + if (label.includes(search.toLowerCase())) return 1; return 0; }} > @@ -172,7 +170,7 @@ const DropdownSelect = forwardRef handleSelect(option.value)} > = ({ const [currentTableColumns, setCurrentTableColumns] = useState([]); // 현재 테이블 컬럼 const [entityColumns, setEntityColumns] = useState([]); // 엔티티 타입 컬럼 const [sourceTableColumns, setSourceTableColumns] = useState([]); // 소스(엔티티) 테이블 컬럼 - const [calculationRules, setCalculationRules] = useState([]); + const [calculationRules, setCalculationRules] = useState( + config.calculationRules || [] + ); const [loadingColumns, setLoadingColumns] = useState(false); const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); @@ -553,26 +555,56 @@ export const V2RepeaterConfigPanel: React.FC = ({ updateConfig({ columns: newColumns }); }; + // 계산 규칙을 config에 반영하는 헬퍼 + const syncCalculationRules = (rules: CalculationRule[]) => { + setCalculationRules(rules); + updateConfig({ calculationRules: rules }); + }; + // 계산 규칙 추가 const addCalculationRule = () => { - setCalculationRules(prev => [ - ...prev, + const newRules = [ + ...calculationRules, { id: `calc_${Date.now()}`, targetColumn: "", formula: "" } - ]); + ]; + syncCalculationRules(newRules); }; // 계산 규칙 삭제 const removeCalculationRule = (id: string) => { - setCalculationRules(prev => prev.filter(r => r.id !== id)); + syncCalculationRules(calculationRules.filter(r => r.id !== id)); }; // 계산 규칙 업데이트 const updateCalculationRule = (id: string, field: keyof CalculationRule, value: string) => { - setCalculationRules(prev => - prev.map(r => r.id === id ? { ...r, [field]: value } : r) + syncCalculationRules( + calculationRules.map(r => r.id === id ? { ...r, [field]: value } : r) ); }; + // 수식 입력 필드에 컬럼명 삽입 + const insertColumnToFormula = (ruleId: string, columnKey: string) => { + const rule = calculationRules.find(r => r.id === ruleId); + if (!rule) return; + const newFormula = rule.formula ? `${rule.formula} ${columnKey}` : columnKey; + updateCalculationRule(ruleId, "formula", newFormula); + }; + + // 수식의 영어 컬럼명을 한글 제목으로 변환 + const formulaToKorean = (formula: string): string => { + if (!formula) return ""; + let result = formula; + const allCols = config.columns || []; + // 긴 컬럼명부터 치환 (부분 매칭 방지) + const sorted = [...allCols].sort((a, b) => b.key.length - a.key.length); + for (const col of sorted) { + if (col.title && col.key) { + result = result.replace(new RegExp(`\\b${col.key}\\b`, "g"), col.title); + } + } + return result; + }; + // 엔티티 컬럼 선택 시 소스 테이블 자동 설정 const handleEntityColumnSelect = (columnName: string) => { const selectedEntity = entityColumns.find(c => c.columnName === columnName); @@ -1374,7 +1406,7 @@ export const V2RepeaterConfigPanel: React.FC = ({ {(isModalMode || isInlineMode) && config.columns.length > 0 && ( <> -

+
-

- 예: 금액 = 수량 * 단가 -

-
+
{calculationRules.map((rule) => ( -
- - - = - - updateCalculationRule(rule.id, "formula", e.target.value)} - placeholder="quantity * unit_price" - className="h-7 flex-1 text-xs" - /> - - +
+
+ + = + updateCalculationRule(rule.id, "formula", e.target.value)} + placeholder="컬럼 클릭 또는 직접 입력" + className="h-6 flex-1 font-mono text-[10px]" + /> + +
+ + {/* 한글 수식 미리보기 */} + {rule.formula && ( +

+ {config.columns.find(c => c.key === rule.targetColumn)?.title || rule.targetColumn || "결과"} = {formulaToKorean(rule.formula)} +

+ )} + + {/* 컬럼 칩: 디테일 컬럼 + 소스(품목) 컬럼 + 연산자 */} +
+ {config.columns + .filter(col => col.key !== rule.targetColumn && !col.isSourceDisplay) + .map((col) => ( + + ))} + {config.columns + .filter(col => col.isSourceDisplay) + .map((col) => ( + + ))} + {["+", "-", "*", "/", "(", ")"].map((op) => ( + + ))} +
))} {calculationRules.length === 0 && ( -

+

계산 규칙이 없습니다

)} diff --git a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx index 908bc4f1..e531b655 100644 --- a/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx +++ b/frontend/lib/registry/components/v2-repeater/V2RepeaterRenderer.tsx @@ -20,6 +20,7 @@ interface V2RepeaterRendererProps { onRowClick?: (row: any) => void; onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; parentId?: string | number; + formData?: Record; } const V2RepeaterRenderer: React.FC = ({ @@ -31,6 +32,7 @@ const V2RepeaterRenderer: React.FC = ({ onRowClick, onButtonClick, parentId, + formData, }) => { // component.componentConfig 또는 component.config에서 V2RepeaterConfig 추출 const config: V2RepeaterConfig = React.useMemo(() => { @@ -101,6 +103,7 @@ const V2RepeaterRenderer: React.FC = ({ onRowClick={onRowClick} onButtonClick={onButtonClick} className={component?.className} + formData={formData} /> ); }; diff --git a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx index ebecedb3..f56b0fb3 100644 --- a/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx +++ b/frontend/lib/registry/components/v2-split-panel-layout/SplitPanelLayoutComponent.tsx @@ -1526,22 +1526,30 @@ export const SplitPanelLayoutComponent: React.FC [componentConfig.rightPanel?.additionalTabs, isDesignMode, toast], ); - // 탭 변경 핸들러 (좌측 미선택 시에도 전체 데이터 로드) + // 탭 변경 핸들러 const handleTabChange = useCallback( (newTabIndex: number) => { setActiveTabIndex(newTabIndex); + // 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 데이터 로드하지 않음 + const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; + const requireSelection = mainRelationType === "detail"; + if (newTabIndex === 0) { if (!rightData || (Array.isArray(rightData) && rightData.length === 0)) { - loadRightData(selectedLeftItem); + if (!requireSelection || selectedLeftItem) { + loadRightData(selectedLeftItem); + } } } else { if (!tabsData[newTabIndex]) { - loadTabData(newTabIndex, selectedLeftItem); + if (!requireSelection || selectedLeftItem) { + loadTabData(newTabIndex, selectedLeftItem); + } } } }, - [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData], + [selectedLeftItem, rightData, tabsData, loadRightData, loadTabData, componentConfig.rightPanel?.relation?.type], ); // 좌측 항목 선택 핸들러 (동일 항목 재클릭 시 선택 해제 → 전체 데이터 표시) @@ -1554,24 +1562,31 @@ export const SplitPanelLayoutComponent: React.FC selectedLeftItem[leftPk] === item[leftPk]; if (isSameItem) { - // 선택 해제 → 전체 데이터 로드 + // 선택 해제 setSelectedLeftItem(null); - setCustomLeftSelectedData({}); // 커스텀 모드 우측 폼 데이터 초기화 + setCustomLeftSelectedData({}); setExpandedRightItems(new Set()); setTabsData({}); - if (activeTabIndex === 0) { - loadRightData(null); + + const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; + if (mainRelationType === "detail") { + // "선택 시 표시" 모드: 선택 해제 시 데이터 비움 + setRightData(null); } else { - loadTabData(activeTabIndex, null); - } - // 추가 탭들도 전체 데이터 로드 - const tabs = componentConfig.rightPanel?.additionalTabs; - if (tabs && tabs.length > 0) { - tabs.forEach((_: any, idx: number) => { - if (idx + 1 !== activeTabIndex) { - loadTabData(idx + 1, null); - } - }); + // "연관 목록" 모드: 선택 해제 시 전체 데이터 로드 + if (activeTabIndex === 0) { + loadRightData(null); + } else { + loadTabData(activeTabIndex, null); + } + const tabs = componentConfig.rightPanel?.additionalTabs; + if (tabs && tabs.length > 0) { + tabs.forEach((_: any, idx: number) => { + if (idx + 1 !== activeTabIndex) { + loadTabData(idx + 1, null); + } + }); + } } return; } @@ -2778,15 +2793,17 @@ export const SplitPanelLayoutComponent: React.FC if (relationshipType === "join") { loadRightData(null); } - // 추가 탭: 각 탭의 relation.type에 따라 초기 로드 결정 - const tabs = componentConfig.rightPanel?.additionalTabs; - if (tabs && tabs.length > 0) { - tabs.forEach((tab: any, idx: number) => { - const tabRelType = tab.relation?.type || "join"; - if (tabRelType === "join") { - loadTabData(idx + 1, null); - } - }); + // 추가 탭: 메인 패널이 "detail"(선택 시 표시)이면 추가 탭도 초기 로드하지 않음 + if (relationshipType !== "detail") { + const tabs = componentConfig.rightPanel?.additionalTabs; + if (tabs && tabs.length > 0) { + tabs.forEach((tab: any, idx: number) => { + const tabRelType = tab.relation?.type || "join"; + if (tabRelType === "join") { + loadTabData(idx + 1, null); + } + }); + } } } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -3734,6 +3751,17 @@ export const SplitPanelLayoutComponent: React.FC const currentTabData = tabsData[activeTabIndex] || []; const isTabLoading = tabsLoading[activeTabIndex]; + // 메인 패널이 "detail"(선택 시 표시)이면 좌측 미선택 시 안내 메시지 + const mainRelationType = componentConfig.rightPanel?.relation?.type || "detail"; + if (mainRelationType === "detail" && !selectedLeftItem && !isDesignMode) { + return ( +
+

좌측에서 항목을 선택하세요

+

선택한 항목의 관련 데이터가 여기에 표시됩니다

+
+ ); + } + if (isTabLoading) { return (
diff --git a/frontend/types/v2-repeater.ts b/frontend/types/v2-repeater.ts index d09ac9e9..fab7a523 100644 --- a/frontend/types/v2-repeater.ts +++ b/frontend/types/v2-repeater.ts @@ -180,7 +180,9 @@ export interface V2RepeaterProps { data?: any[]; // 초기 데이터 (없으면 API로 로드) onDataChange?: (data: any[]) => void; onRowClick?: (row: any) => void; + onButtonClick?: (action: string, row?: any, buttonConfig?: any) => void; className?: string; + formData?: Record; // 수정 모드에서 FK 기반 데이터 로드용 } // 기본 설정값