From d4b5bdd835244c6e9b43101611ed62ef38787477 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 19 Jan 2026 13:18:17 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20RepeaterInput=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A1=B0=ED=9A=8C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표시 컬럼 순서 변경 기능 추가 (columnOrder) - 조회 컬럼 -> 저장 컬럼 매핑 기능 추가 (fieldMappings) - 컬럼별 라벨, 순서, 저장 여부 통합 설정 UI 구현 - 하위 호환성 유지 (fieldMappings 없으면 기존 로직 사용) --- .../components/webtypes/RepeaterInput.tsx | 32 ++- .../webtypes/config/RepeaterConfigPanel.tsx | 196 +++++++++++++++++- .../SubDataLookupPanel.tsx | 16 +- .../repeater-field-group/useSubDataLookup.ts | 12 +- frontend/types/repeater.ts | 10 + 5 files changed, 252 insertions(+), 14 deletions(-) diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 7cd4b279..49751699 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -309,18 +309,32 @@ export const RepeaterInput: React.FC = ({ _subDataMaxValue: maxValue, }; - // 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우) - // 예: warehouse_code, location_code 등 - if (subDataLookup.lookup.displayColumns) { - subDataLookup.lookup.displayColumns.forEach((col) => { - if (selectedItem[col] !== undefined) { - // 필드가 정의되어 있으면 복사 - const fieldDef = fields.find((f) => f.name === col); - if (fieldDef || col.includes("_code") || col.includes("_id")) { - newItems[itemIndex][col] = selectedItem[col]; + // fieldMappings가 설정되어 있으면 매핑에 따라 값 복사 + if (subDataLookup.lookup.fieldMappings && subDataLookup.lookup.fieldMappings.length > 0) { + subDataLookup.lookup.fieldMappings.forEach((mapping) => { + if (mapping.targetField && mapping.targetField !== "") { + // 매핑된 타겟 필드에 소스 컬럼 값 복사 + const sourceValue = selectedItem[mapping.sourceColumn]; + if (sourceValue !== undefined) { + newItems[itemIndex][mapping.targetField] = sourceValue; } } }); + } else { + // fieldMappings가 없으면 기존 로직 (하위 호환성) + // 선택된 하위 데이터의 필드 값을 상위 item에 복사 (설정된 경우) + // 예: warehouse_code, location_code 등 + if (subDataLookup.lookup.displayColumns) { + subDataLookup.lookup.displayColumns.forEach((col) => { + if (selectedItem[col] !== undefined) { + // 필드가 정의되어 있으면 복사 + const fieldDef = fields.find((f) => f.name === col); + if (fieldDef || col.includes("_code") || col.includes("_id")) { + newItems[itemIndex][col] = selectedItem[col]; + } + } + }); + } } setItems(newItems); diff --git a/frontend/components/webtypes/config/RepeaterConfigPanel.tsx b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx index 97e20574..857ece17 100644 --- a/frontend/components/webtypes/config/RepeaterConfigPanel.tsx +++ b/frontend/components/webtypes/config/RepeaterConfigPanel.tsx @@ -319,6 +319,103 @@ export const RepeaterConfigPanel: React.FC = ({ }); }; + // 표시 컬럼 순서 가져오기 (columnOrder가 있으면 사용, 없으면 displayColumns 순서) + const getOrderedDisplayColumns = (): string[] => { + const displayColumns = config.subDataLookup?.lookup?.displayColumns || []; + const columnOrder = config.subDataLookup?.lookup?.columnOrder; + + if (columnOrder && columnOrder.length > 0) { + // columnOrder에 있는 컬럼만, 순서대로 반환 (displayColumns에 있는 것만) + const orderedCols = columnOrder.filter(col => displayColumns.includes(col)); + // columnOrder에 없지만 displayColumns에 있는 컬럼 추가 + const remainingCols = displayColumns.filter(col => !columnOrder.includes(col)); + return [...orderedCols, ...remainingCols]; + } + return displayColumns; + }; + + // 표시 컬럼 순서 변경 핸들러 (위로) + const handleDisplayColumnMoveUp = (columnName: string) => { + const orderedColumns = getOrderedDisplayColumns(); + const index = orderedColumns.indexOf(columnName); + if (index <= 0) return; + + const newOrder = [...orderedColumns]; + [newOrder[index - 1], newOrder[index]] = [newOrder[index], newOrder[index - 1]]; + handleSubDataLookupChange("lookup.columnOrder", newOrder); + }; + + // 표시 컬럼 순서 변경 핸들러 (아래로) + const handleDisplayColumnMoveDown = (columnName: string) => { + const orderedColumns = getOrderedDisplayColumns(); + const index = orderedColumns.indexOf(columnName); + if (index < 0 || index >= orderedColumns.length - 1) return; + + const newOrder = [...orderedColumns]; + [newOrder[index], newOrder[index + 1]] = [newOrder[index + 1], newOrder[index]]; + handleSubDataLookupChange("lookup.columnOrder", newOrder); + }; + + // 표시 컬럼 토글 시 columnOrder도 업데이트 + const handleDisplayColumnToggleWithOrder = (columnName: string, checked: boolean) => { + const currentColumns = config.subDataLookup?.lookup?.displayColumns || []; + const currentOrder = config.subDataLookup?.lookup?.columnOrder || []; + const currentMappings = config.subDataLookup?.lookup?.fieldMappings || []; + + let newColumns: string[]; + let newOrder: string[]; + let newMappings: { sourceColumn: string; targetField: string }[]; + + if (checked) { + newColumns = [...currentColumns, columnName]; + newOrder = [...currentOrder, columnName]; + // 기본 매핑 추가: 동일한 컬럼명이 targetTable에 있으면 자동 매핑, 없으면 빈 문자열 + const targetColumn = tableColumns.find((c) => c.columnName === columnName); + newMappings = [...currentMappings, { sourceColumn: columnName, targetField: targetColumn ? columnName : "" }]; + } else { + newColumns = currentColumns.filter((c) => c !== columnName); + newOrder = currentOrder.filter((c) => c !== columnName); + newMappings = currentMappings.filter((m) => m.sourceColumn !== columnName); + } + + // displayColumns, columnOrder, fieldMappings 함께 업데이트 + const newConfig = { ...config.subDataLookup } as SubDataLookupConfig; + if (!newConfig.lookup) { + newConfig.lookup = { tableName: "", linkColumn: "", displayColumns: [] }; + } + newConfig.lookup.displayColumns = newColumns; + newConfig.lookup.columnOrder = newOrder; + newConfig.lookup.fieldMappings = newMappings; + + onChange({ + ...config, + subDataLookup: newConfig, + }); + }; + + // 필드 매핑 변경 핸들러 + const handleFieldMappingChange = (sourceColumn: string, targetField: string) => { + const currentMappings = config.subDataLookup?.lookup?.fieldMappings || []; + const existingIndex = currentMappings.findIndex((m) => m.sourceColumn === sourceColumn); + + let newMappings: { sourceColumn: string; targetField: string }[]; + if (existingIndex >= 0) { + newMappings = [...currentMappings]; + newMappings[existingIndex] = { sourceColumn, targetField }; + } else { + newMappings = [...currentMappings, { sourceColumn, targetField }]; + } + + handleSubDataLookupChange("lookup.fieldMappings", newMappings); + }; + + // 특정 컬럼의 현재 매핑된 타겟 필드 가져오기 + const getFieldMapping = (sourceColumn: string): string => { + const mappings = config.subDataLookup?.lookup?.fieldMappings || []; + const mapping = mappings.find((m) => m.sourceColumn === sourceColumn); + return mapping?.targetField || ""; + }; + return (
{/* 대상 테이블 선택 */} @@ -588,7 +685,7 @@ export const RepeaterConfigPanel: React.FC = ({ handleDisplayColumnToggle(col.columnName, checked as boolean)} + onCheckedChange={(checked) => handleDisplayColumnToggleWithOrder(col.columnName, checked as boolean)} />
)} + {/* 컬럼 설정 (순서 + 라벨 + 저장 컬럼) */} + {(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && ( +
+ +

순서, 라벨, 저장 여부를 설정하세요

+
+ {getOrderedDisplayColumns().map((colName, index) => { + const col = subDataTableColumns.find((c) => c.columnName === colName); + const currentLabel = config.subDataLookup?.lookup?.columnLabels?.[colName] || ""; + const currentMapping = getFieldMapping(colName); + const orderedColumns = getOrderedDisplayColumns(); + const isFirst = index === 0; + const isLast = index === orderedColumns.length - 1; + + return ( +
+ {/* 상단: 순서 버튼 + 번호 + 컬럼명 */} +
+ {/* 순서 변경 버튼 */} +
+ + +
+ + {/* 순서 번호 */} + {index + 1} + + {/* 컬럼명 */} +
+ {col?.columnLabel || colName} + ({colName}) +
+
+ + {/* 중단: 라벨 입력 */} +
+ 표시 라벨: + handleColumnLabelChange(colName, e.target.value)} + placeholder={col?.columnLabel || colName} + className="h-6 flex-1 text-xs" + /> +
+ + {/* 하단: 저장 컬럼 선택 */} +
+ 저장 컬럼: + +
+
+ ); + })} +
+ {config.targetTable && ( +

+ * 저장 대상: {config.targetTable} +

+ )} +
+ )} + {/* 선택 설정 */} {(config.subDataLookup?.lookup?.displayColumns?.length || 0) > 0 && (
diff --git a/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx b/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx index 5baf0fe0..51be5a64 100644 --- a/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx +++ b/frontend/lib/registry/components/repeater-field-group/SubDataLookupPanel.tsx @@ -78,8 +78,20 @@ export const SubDataLookupPanel: React.FC = ({ return config.lookup.columnLabels?.[columnName] || columnName; }; - // 표시할 컬럼 목록 - const displayColumns = config.lookup.displayColumns || []; + // 표시할 컬럼 목록 (columnOrder가 있으면 순서 적용) + const displayColumns = useMemo(() => { + const columns = config.lookup.displayColumns || []; + const columnOrder = config.lookup.columnOrder; + + if (columnOrder && columnOrder.length > 0) { + // columnOrder 순서대로 정렬 (displayColumns에 있는 것만) + const orderedCols = columnOrder.filter(col => columns.includes(col)); + // columnOrder에 없지만 displayColumns에 있는 컬럼 추가 + const remainingCols = columns.filter(col => !columnOrder.includes(col)); + return [...orderedCols, ...remainingCols]; + } + return columns; + }, [config.lookup.displayColumns, config.lookup.columnOrder]); // 요약 정보 표시용 선택 상태 const summaryText = useMemo(() => { diff --git a/frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts b/frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts index b2c44e3d..2753dd16 100644 --- a/frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts +++ b/frontend/lib/registry/components/repeater-field-group/useSubDataLookup.ts @@ -197,10 +197,18 @@ export function useSubDataLookup(props: UseSubDataLookupProps): UseSubDataLookup return "선택 안됨"; } - const { displayColumns, columnLabels } = config.lookup; + const { displayColumns, columnLabels, columnOrder } = config.lookup; const parts: string[] = []; - displayColumns.forEach((col) => { + // columnOrder가 있으면 순서 적용, 없으면 displayColumns 순서 + let orderedColumns = displayColumns; + if (columnOrder && columnOrder.length > 0) { + const orderedCols = columnOrder.filter(col => displayColumns.includes(col)); + const remainingCols = displayColumns.filter(col => !columnOrder.includes(col)); + orderedColumns = [...orderedCols, ...remainingCols]; + } + + orderedColumns.forEach((col) => { const value = selectedItem[col]; if (value !== undefined && value !== null && value !== "") { const label = columnLabels?.[col] || col; diff --git a/frontend/types/repeater.ts b/frontend/types/repeater.ts index 2362210b..bbdc8727 100644 --- a/frontend/types/repeater.ts +++ b/frontend/types/repeater.ts @@ -113,6 +113,14 @@ export type RepeaterData = RepeaterItemData[]; // 품목 선택 시 재고/단가 등 관련 데이터를 조회하고 선택하는 기능 // ============================================================ +/** + * 선택 데이터 필드 매핑 설정 + */ +export interface SubDataFieldMapping { + sourceColumn: string; // 조회 테이블 컬럼 (예: lot_number) + targetField: string; // 저장 테이블 컬럼 (예: lot_number) 또는 "" (선택안함) +} + /** * 하위 데이터 조회 테이블 설정 */ @@ -121,6 +129,8 @@ export interface SubDataLookupSettings { linkColumn: string; // 상위 데이터와 연결할 컬럼 (예: item_code) displayColumns: string[]; // 표시할 컬럼들 (예: ["warehouse_code", "location_code", "quantity"]) columnLabels?: Record; // 컬럼 라벨 (예: { warehouse_code: "창고" }) + columnOrder?: string[]; // 컬럼 표시 순서 (없으면 displayColumns 순서 사용) + fieldMappings?: SubDataFieldMapping[]; // 선택 데이터 저장 매핑 (조회 컬럼 → 저장 컬럼) additionalFilters?: Record; // 추가 필터 조건 }