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 기반 데이터 로드용
}
// 기본 설정값