diff --git a/frontend/components/screen/panels/ComponentsPanel.tsx b/frontend/components/screen/panels/ComponentsPanel.tsx index 9121a376..42f00dcb 100644 --- a/frontend/components/screen/panels/ComponentsPanel.tsx +++ b/frontend/components/screen/panels/ComponentsPanel.tsx @@ -92,6 +92,14 @@ export function ComponentsPanel({ tags: ["tree", "org", "bom", "cascading", "unified"], defaultSize: { width: 400, height: 300 }, }, + { + id: "unified-repeater", + name: "통합 반복 데이터", + description: "반복 데이터 관리 (인라인/모달/버튼 모드)", + category: "data" as ComponentCategory, + tags: ["repeater", "table", "modal", "button", "unified"], + defaultSize: { width: 600, height: 300 }, + }, ], []); // 카테고리별 컴포넌트 그룹화 diff --git a/frontend/components/unified/UnifiedRepeater.tsx b/frontend/components/unified/UnifiedRepeater.tsx index 81023c62..e7cc1c3c 100644 --- a/frontend/components/unified/UnifiedRepeater.tsx +++ b/frontend/components/unified/UnifiedRepeater.tsx @@ -3,22 +3,20 @@ /** * UnifiedRepeater 컴포넌트 * - * 기존 컴포넌트 통합: - * - simple-repeater-table: 인라인 모드 - * - modal-repeater-table: 모달 모드 - * - repeat-screen-modal: 화면 기반 모달 모드 - * - related-data-buttons: 버튼 모드 - * - * 모든 하드코딩을 제거하고 설정 기반으로 동작합니다. + * 렌더링 모드: + * - inline: 현재 테이블 컬럼 직접 입력 + * - modal: 엔티티 선택 (FK 저장) + 추가 입력 컬럼 + * - button: 버튼으로 관련 화면/모달 열기 + * - mixed: inline + modal 혼합 */ import React, { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; -import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Input } from "@/components/ui/input"; import { Checkbox } from "@/components/ui/checkbox"; -import { Plus, Trash2, Edit, Eye, GripVertical } from "lucide-react"; +import { Plus, Trash2, GripVertical, Search, X, Check } from "lucide-react"; import { cn } from "@/lib/utils"; import { UnifiedRepeaterConfig, @@ -61,71 +59,145 @@ export const UnifiedRepeater: React.FC = ({ [propConfig], ); - // 상태 + // 상태 - 메인 데이터 const [data, setData] = useState(initialData || []); - const [loading, setLoading] = useState(false); const [selectedRows, setSelectedRows] = useState>(new Set()); const [editingRow, setEditingRow] = useState(null); const [editedData, setEditedData] = useState>({}); - const [modalOpen, setModalOpen] = useState(false); - const [modalRow, setModalRow] = useState(null); + + // 상태 - 검색 모달 (modal 모드) + const [searchModalOpen, setSearchModalOpen] = useState(false); + const [searchKeyword, setSearchKeyword] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searchLoading, setSearchLoading] = useState(false); + const [selectedSearchItems, setSelectedSearchItems] = useState>(new Set()); + + // 상태 - 버튼 모드 const [codeButtons, setCodeButtons] = useState<{ label: string; value: string; variant?: string }[]>([]); - // 데이터 로드 - const loadData = useCallback(async () => { - if (!config.dataSource?.tableName || !parentId) return; + // 상태 - 엔티티 표시 정보 캐시 (FK값 → 표시 데이터) + const [entityDisplayCache, setEntityDisplayCache] = useState>>({}); + + // 상태 - 소스 테이블 컬럼 정보 (라벨 매핑용) + const [sourceTableColumnMap, setSourceTableColumnMap] = useState>({}); - setLoading(true); + // 외부 데이터 변경 시 동기화 + useEffect(() => { + if (initialData) { + setData(initialData); + } + }, [initialData]); + + // 소스 테이블 컬럼 정보 로드 (라벨 매핑용) + useEffect(() => { + const loadSourceTableColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) return; + + try { + const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); + console.log("소스 테이블 컬럼 API 응답:", response.data); + + const colMap: Record = {}; + + // 응답 구조에 따라 데이터 추출 - data.data.columns 형태 지원 + let columns: any[] = []; + if (response.data?.success && response.data?.data) { + // { success: true, data: { columns: [...] } } 구조 + if (Array.isArray(response.data.data.columns)) { + columns = response.data.data.columns; + } else if (Array.isArray(response.data.data)) { + columns = response.data.data; + } + } else if (Array.isArray(response.data)) { + columns = response.data; + } + + columns.forEach((col: any) => { + const colName = col.columnName || col.column_name; + const colLabel = col.displayName || col.columnLabel || col.column_label || colName; + if (colName) { + colMap[colName] = colLabel; + } + }); + + console.log("sourceTableColumnMap:", colMap); + setSourceTableColumnMap(colMap); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + } + }; + + if (config.renderMode === "modal" || config.renderMode === "mixed") { + loadSourceTableColumns(); + } + }, [config.dataSource?.sourceTable, config.renderMode]); + + // 공통코드 버튼 로드 + useEffect(() => { + const loadCodeButtons = async () => { + if (config.button?.sourceType !== "commonCode" || !config.button?.commonCode?.categoryCode) return; + + try { + const response = await commonCodeApi.codes.getList(config.button.commonCode.categoryCode); + if (response.success && response.data) { + const labelField = config.button.commonCode.labelField || "codeName"; + setCodeButtons( + response.data.map((code) => ({ + label: labelField === "codeName" ? code.codeName : code.codeValue, + value: code.codeValue, + variant: config.button?.commonCode?.variantMapping?.[code.codeValue], + })), + ); + } + } catch (error) { + console.error("공통코드 버튼 로드 실패:", error); + } + }; + loadCodeButtons(); + }, [config.button?.sourceType, config.button?.commonCode]); + + // 소스 테이블 검색 (modal 모드) + const searchSourceTable = useCallback(async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) return; + + setSearchLoading(true); try { - const response = await apiClient.get(`/dynamic-form/${config.dataSource.tableName}`, { - params: { - [config.dataSource.foreignKey]: parentId, - }, + const searchParams: any = {}; + + // 검색어가 있고, 검색 필드가 설정되어 있으면 검색 + if (searchKeyword && config.modal?.searchFields?.length) { + // 검색 필드들에 검색어 적용 + config.modal.searchFields.forEach((field) => { + searchParams[field] = searchKeyword; + }); + } + + const response = await apiClient.post(`/table-management/tables/${sourceTable}/data`, { + search: Object.keys(searchParams).length > 0 ? searchParams : undefined, + page: 1, + size: 50, }); if (response.data?.success && response.data?.data) { - const items = Array.isArray(response.data.data) ? response.data.data : [response.data.data]; - setData(items); - onDataChange?.(items); + const items = Array.isArray(response.data.data) ? response.data.data : response.data.data.data || []; + setSearchResults(items); } } catch (error) { - console.error("UnifiedRepeater 데이터 로드 실패:", error); + console.error("검색 실패:", error); + setSearchResults([]); } finally { - setLoading(false); + setSearchLoading(false); } - }, [config.dataSource?.tableName, config.dataSource?.foreignKey, parentId, onDataChange]); + }, [config.dataSource?.sourceTable, config.modal?.searchFields, searchKeyword]); - // 공통코드 버튼 로드 - const loadCodeButtons = useCallback(async () => { - if (config.button?.sourceType !== "commonCode" || !config.button?.commonCode?.categoryCode) return; - - try { - const response = await commonCodeApi.codes.getList(config.button.commonCode.categoryCode); - if (response.success && response.data) { - const labelField = config.button.commonCode.labelField || "codeName"; - setCodeButtons( - response.data.map((code) => ({ - label: labelField === "codeName" ? code.codeName : code.codeValue, - value: code.codeValue, - variant: config.button?.commonCode?.variantMapping?.[code.codeValue], - })), - ); - } - } catch (error) { - console.error("공통코드 버튼 로드 실패:", error); - } - }, [config.button?.sourceType, config.button?.commonCode]); - - // 초기 로드 + // 검색 모달 열릴 때 자동 검색 useEffect(() => { - if (!initialData) { - loadData(); + if (searchModalOpen) { + searchSourceTable(); } - }, [loadData, initialData]); - - useEffect(() => { - loadCodeButtons(); - }, [loadCodeButtons]); + }, [searchModalOpen, searchSourceTable]); // 행 선택 토글 const toggleRowSelection = (index: number) => { @@ -145,42 +217,128 @@ export const UnifiedRepeater: React.FC = ({ }); }; + // 검색 결과 항목 선택 토글 (검색 모달에서는 항상 다중 선택 허용) + const toggleSearchItemSelection = (index: number) => { + setSelectedSearchItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + // 검색 항목 선택 확인 - FK만 저장하고 추가 입력 컬럼은 빈 값으로 + const confirmSearchSelection = () => { + const selectedItems = Array.from(selectedSearchItems).map((index) => searchResults[index]).filter(Boolean); + + console.log("confirmSearchSelection 호출:", { + selectedSearchItems: Array.from(selectedSearchItems), + selectedItems, + searchResults: searchResults.length, + foreignKey: config.dataSource?.foreignKey, + referenceKey: config.dataSource?.referenceKey, + }); + + if (selectedItems.length === 0) { + console.warn("선택된 항목이 없습니다"); + return; + } + + const newRows = selectedItems.map((sourceItem) => { + const newRow: any = {}; + + // 1. FK 저장 (엔티티 참조 ID만!) + if (config.dataSource?.foreignKey && config.dataSource?.referenceKey) { + newRow[config.dataSource.foreignKey] = sourceItem[config.dataSource.referenceKey]; + console.log("FK 저장:", config.dataSource.foreignKey, "=", sourceItem[config.dataSource.referenceKey]); + } + + // 2. 추가 입력 컬럼은 빈 값으로 초기화 + config.columns.forEach((col) => { + if (newRow[col.key] === undefined) { + newRow[col.key] = ""; + } + }); + + return newRow; + }); + + // 선택한 항목의 표시 정보를 캐시에 저장 (렌더링용) + const newDisplayCache = { ...entityDisplayCache }; + selectedItems.forEach((sourceItem) => { + const fkValue = sourceItem[config.dataSource?.referenceKey || "id"]; + if (fkValue) { + const displayData: Record = {}; + // 모달 표시 컬럼들의 값을 캐시 - sourceDisplayColumns (useMemo로 변환된 것) 사용 + sourceDisplayColumns.forEach((col) => { + displayData[col.key] = sourceItem[col.key]; + }); + // displayColumn도 캐시 + if (config.dataSource?.displayColumn) { + displayData[config.dataSource.displayColumn] = sourceItem[config.dataSource.displayColumn]; + } + newDisplayCache[fkValue] = displayData; + console.log("캐시 저장:", fkValue, displayData); + } + }); + setEntityDisplayCache(newDisplayCache); + + const newData = [...data, ...newRows]; + const firstNewRowIndex = data.length; // 기존 data 길이가 첫 새 행의 인덱스 + + setData(newData); + onDataChange?.(newData); + + // 모달 닫기 및 초기화 + setSearchModalOpen(false); + setSelectedSearchItems(new Set()); + setSearchKeyword(""); + + // 마지막 추가된 행을 편집 모드로 (setTimeout으로 state 업데이트 후 실행) + const hasEditableColumns = config.columns.length > 0; + console.log("편집 모드 전환 체크:", { + newRowsLength: newRows.length, + inlineEdit: config.features?.inlineEdit, + hasEditableColumns, + columnsLength: config.columns.length, + columns: config.columns, + }); + + // 추가 입력 컬럼이 있으면 편집 모드 시작 + if (newRows.length > 0 && hasEditableColumns) { + setTimeout(() => { + console.log("편집 모드 시작:", firstNewRowIndex, newRows[0]); + setEditingRow(firstNewRowIndex); + setEditedData({ ...newRows[0] }); + }, 0); + } + }; + // 행 추가 - const handleAddRow = async () => { + const handleAddRow = () => { if (config.renderMode === "modal" || config.renderMode === "mixed") { - setModalRow(null); - setModalOpen(true); + // 검색 모달 열기 + setSearchModalOpen(true); } else { // 인라인 추가 const newRow: any = {}; config.columns.forEach((col) => { newRow[col.key] = ""; }); - if (config.dataSource?.foreignKey && parentId) { - newRow[config.dataSource.foreignKey] = parentId; - } const newData = [...data, newRow]; setData(newData); onDataChange?.(newData); setEditingRow(newData.length - 1); + setEditedData(newRow); } }; - // 행 삭제 - const handleDeleteRow = async (index: number) => { - const row = data[index]; - const rowId = row?.id || row?.objid; - - if (rowId && config.dataSource?.tableName) { - try { - await apiClient.delete(`/dynamic-form/${config.dataSource.tableName}/${rowId}`); - } catch (error) { - console.error("행 삭제 실패:", error); - return; - } - } - + // 행 삭제 (로컬 상태만) + const handleDeleteRow = (index: number) => { const newData = data.filter((_, i) => i !== index); setData(newData); onDataChange?.(newData); @@ -189,49 +347,38 @@ export const UnifiedRepeater: React.FC = ({ newSet.delete(index); return newSet; }); + + // 편집 중이던 행이 삭제되면 편집 취소 + if (editingRow === index) { + setEditingRow(null); + setEditedData({}); + } }; // 선택된 행 일괄 삭제 - const handleDeleteSelected = async () => { + const handleDeleteSelected = () => { if (selectedRows.size === 0) return; - const indices = Array.from(selectedRows).sort((a, b) => b - a); // 역순 정렬 + const indices = Array.from(selectedRows).sort((a, b) => b - a); + let newData = [...data]; for (const index of indices) { - await handleDeleteRow(index); + newData = newData.filter((_, i) => i !== index); } + setData(newData); + onDataChange?.(newData); setSelectedRows(new Set()); + setEditingRow(null); + setEditedData({}); }; - // 인라인 편집 시작 - const handleEditRow = (index: number) => { - if (!config.features?.inlineEdit) return; - setEditingRow(index); - setEditedData({ ...data[index] }); - }; - - // 인라인 편집 저장 - const handleSaveEdit = async () => { + // 인라인 편집 확인 + const handleConfirmEdit = () => { if (editingRow === null) return; - const rowId = editedData?.id || editedData?.objid; - - try { - if (rowId && config.dataSource?.tableName) { - await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, editedData); - } else if (config.dataSource?.tableName) { - const response = await apiClient.post(`/dynamic-form/${config.dataSource.tableName}`, editedData); - if (response.data?.data?.id) { - editedData.id = response.data.data.id; - } - } - - const newData = [...data]; - newData[editingRow] = editedData; - setData(newData); - onDataChange?.(newData); - } catch (error) { - console.error("저장 실패:", error); - } + const newData = [...data]; + newData[editingRow] = editedData; + setData(newData); + onDataChange?.(newData); setEditingRow(null); setEditedData({}); @@ -245,61 +392,51 @@ export const UnifiedRepeater: React.FC = ({ // 행 클릭 const handleRowClick = (row: any, index: number) => { + console.log("handleRowClick 호출:", { + index, + row, + currentEditingRow: editingRow, + inlineEdit: config.features?.inlineEdit, + columns: config.columns + }); + + if (editingRow === index) return; + + if (editingRow !== null) { + handleConfirmEdit(); + } + if (config.features?.selectable) { toggleRowSelection(index); } + onRowClick?.(row); - if (config.renderMode === "modal" || config.renderMode === "mixed") { - setModalRow(row); - setModalOpen(true); + // 인라인 편집 모드면 편집 시작 (모달 모드에서도 추가 입력 컬럼이 있으면 편집 가능) + const hasEditableColumns = config.columns.length > 0; + const isModalRenderMode = config.renderMode === "modal" || config.renderMode === "mixed"; + if (config.features?.inlineEdit || (isModalRenderMode && hasEditableColumns)) { + console.log("편집 모드 활성화:", { index, row }); + setEditingRow(index); + setEditedData({ ...row }); } }; // 버튼 클릭 핸들러 const handleButtonAction = (action: ButtonActionType, row?: any, buttonConfig?: RepeaterButtonConfig) => { onButtonClick?.(action, row, buttonConfig); - - if (action === "view" && row) { - setModalRow(row); - setModalOpen(true); - } }; // 공통코드 버튼 클릭 - const handleCodeButtonClick = async (codeValue: string, row: any, index: number) => { + const handleCodeButtonClick = (codeValue: string, row: any, index: number) => { const valueField = config.button?.commonCode?.valueField; if (!valueField) return; const updatedRow = { ...row, [valueField]: codeValue }; - const rowId = row?.id || row?.objid; - - try { - if (rowId && config.dataSource?.tableName) { - await apiClient.put(`/dynamic-form/${config.dataSource.tableName}/${rowId}`, { [valueField]: codeValue }); - } - - const newData = [...data]; - newData[index] = updatedRow; - setData(newData); - onDataChange?.(newData); - } catch (error) { - console.error("상태 변경 실패:", error); - } - }; - - // 모달 제목 생성 - const getModalTitle = (row?: any) => { - const template = config.modal?.titleTemplate; - if (!template) return row ? "상세 보기" : "새 항목"; - - let title = template.prefix || ""; - if (template.columnKey && row?.[template.columnKey]) { - title += row[template.columnKey]; - } - title += template.suffix || ""; - - return title || (row ? "상세 보기" : "새 항목"); + const newData = [...data]; + newData[index] = updatedRow; + setData(newData); + onDataChange?.(newData); }; // 버튼 렌더링 @@ -328,7 +465,6 @@ export const UnifiedRepeater: React.FC = ({ ); } - // 수동 버튼 return (
{(config.button?.manualButtons || []).map((btn) => ( @@ -349,27 +485,60 @@ export const UnifiedRepeater: React.FC = ({ ); }; - // 테이블 렌더링 (inline, mixed 모드) + // FK 값으로 캐시에서 표시 정보 가져오기 + const getEntityDisplayInfo = (fkValue: any): Record | null => { + if (!fkValue) return null; + return entityDisplayCache[fkValue] || null; + }; + + // 테이블 렌더링 const renderTable = () => { if (config.renderMode === "button") return null; + const fkColumn = config.dataSource?.foreignKey; + const isModalMode = config.renderMode === "modal" || config.renderMode === "mixed"; + + // 모달 표시 컬럼들 (호환 처리된 sourceDisplayColumns 사용) + const displayColumns = sourceDisplayColumns; + + // 전체 컬럼 수 계산 + const totalColumns = + (config.features?.selectable ? 1 : 0) + + (config.features?.showRowNumber ? 1 : 0) + + (isModalMode ? displayColumns.length : 0) + + config.columns.length + + (config.features?.showDeleteButton ? 1 : 0); + return (
- {config.features?.dragSort && } {config.features?.selectable && } {config.features?.showRowNumber && #} + + {/* 모달 표시 컬럼들 (선택한 엔티티 정보 - 읽기 전용) */} + {isModalMode && displayColumns.map((col) => ( + + {col.label} + + ))} + + {/* 추가 입력 컬럼 */} {config.columns .filter((col) => col.visible !== false) .map((col) => ( - + {col.title} ))} - {(config.features?.inlineEdit || config.features?.showDeleteButton || config.renderMode === "mixed") && ( - 액션 + + {config.features?.showDeleteButton && ( + 액션 )} @@ -377,124 +546,105 @@ export const UnifiedRepeater: React.FC = ({ {data.length === 0 ? ( - 데이터가 없습니다 + {isModalMode + ? `"${config.modal?.buttonText || "검색"}" 버튼을 클릭하여 항목을 추가하세요` + : "\"추가\" 버튼을 클릭하여 데이터를 입력하세요"} ) : ( - data.map((row, index) => ( - handleRowClick(row, index)} - > - {config.features?.dragSort && ( - - - - )} - {config.features?.selectable && ( - - toggleRowSelection(index)} - onClick={(e) => e.stopPropagation()} - /> - - )} - {config.features?.showRowNumber && ( - {index + 1} - )} - {config.columns - .filter((col) => col.visible !== false) - .map((col) => ( - - {editingRow === index ? ( - - setEditedData((prev) => ({ - ...prev, - [col.key]: e.target.value, - })) - } - className="h-7 text-xs" - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {row[col.key]} - )} + data.map((row, index) => { + // FK 값으로 캐시된 표시 정보 가져오기 + const fkValue = fkColumn ? row[fkColumn] : null; + const displayInfo = getEntityDisplayInfo(fkValue); + + return ( + handleRowClick(row, index)} + > + {config.features?.selectable && ( + + toggleRowSelection(index)} + onClick={(e) => e.stopPropagation()} + /> + + )} + {config.features?.showRowNumber && ( + {index + 1} + )} + + {/* 모달 표시 컬럼들 (읽기 전용 - 캐시에서 조회) */} + {isModalMode && displayColumns.map((col) => ( + + + {displayInfo?.[col.key] || "-"} + ))} - -
- {editingRow === index ? ( - <> - - - - ) : ( - <> - {config.features?.inlineEdit && ( - + + {/* 추가 입력 컬럼 */} + {config.columns + .filter((col) => col.visible !== false) + .map((col) => ( + + {editingRow === index && col.editable !== false ? ( + + setEditedData((prev) => ({ + ...prev, + [col.key]: e.target.value, + })) + } + className="h-7 text-xs min-w-[80px] w-full" + onClick={(e) => e.stopPropagation()} + /> + ) : ( + {row[col.key] || "-"} )} - {config.renderMode === "mixed" && ( - + + ))} + + {config.features?.showDeleteButton && ( + +
+ {editingRow === index && ( + <> + + + )} - {config.features?.showDeleteButton && ( + {editingRow !== index && ( )} - - )} -
-
- - )) +
+
+ )} +
+ ); + }) )}
@@ -537,16 +687,47 @@ export const UnifiedRepeater: React.FC = ({ ); }; + // 소스 테이블 표시 컬럼 (라벨 포함) - 이전/새 형식 호환 + 동적 라벨 매핑 + const sourceDisplayColumns = useMemo(() => { + const rawCols = config.modal?.sourceDisplayColumns || []; + + // string[] 형식과 {key, label}[] 형식 모두 지원 + const result = rawCols + .map((col: any, idx: number) => { + if (typeof col === "string") { + // 컬럼명만 있는 경우: sourceTableColumnMap에서 라벨 조회 + return { + key: col, + label: sourceTableColumnMap[col] || col + }; + } + // 라벨이 컬럼명과 같으면 sourceTableColumnMap에서 더 좋은 라벨 찾기 + const dynamicLabel = sourceTableColumnMap[col.key]; + return { + key: col.key || `col_${idx}`, + label: (col.label && col.label !== col.key) ? col.label : (dynamicLabel || col.label || col.key || `컬럼${idx + 1}`) + }; + }) + // 잘못된 컬럼 필터링 (none, 빈값, undefined 등) + .filter((col) => col.key && col.key !== "none" && col.key !== "undefined"); + + return result; + }, [config.modal?.sourceDisplayColumns, sourceTableColumnMap]); + + const displayColumn = config.dataSource?.displayColumn; + return (
- {/* 헤더 (추가/삭제 버튼) */} + {/* 헤더 */} {(config.features?.showAddButton || (config.features?.selectable && selectedRows.size > 0)) && (
{config.features?.showAddButton && ( )} {config.features?.selectable && selectedRows.size > 0 && config.features?.showDeleteButton && ( @@ -560,62 +741,106 @@ export const UnifiedRepeater: React.FC = ({
)} - {/* 로딩 */} - {loading &&
로딩 중...
} - {/* 메인 컨텐츠 */} - {!loading && ( - <> - {renderTable()} - {renderButtonMode()} + {renderTable()} + {renderButtonMode()} - {/* mixed 모드에서 버튼도 표시 */} - {config.renderMode === "mixed" && data.length > 0 && ( -
- {data.map((row, index) => ( -
- {renderButtons(row, index)} -
- ))} -
- )} - - )} - - {/* 모달 */} - - + {/* 검색 모달 (modal 모드) */} + + - {getModalTitle(modalRow)} + {config.modal?.title || "항목 검색"} -
- {config.modal?.screenId ? ( - // 화면 기반 모달 - 동적 화면 로드 -
- 화면 ID: {config.modal.screenId} - {/* TODO: DynamicScreen 컴포넌트로 교체 */} -
- ) : ( - // 기본 폼 표시 -
- {config.columns.map((col) => ( -
- - - setModalRow((prev: any) => ({ - ...prev, - [col.key]: e.target.value, - })) - } - className="h-9" - /> -
- ))} -
- )} + + {/* 검색 입력 */} +
+ setSearchKeyword(e.target.value)} + placeholder="검색어 입력..." + className="flex-1" + onKeyDown={(e) => e.key === "Enter" && searchSourceTable()} + /> +
+ + {/* 검색 결과 테이블 */} +
+ + + + + {sourceDisplayColumns.length > 0 ? ( + sourceDisplayColumns.map((col) => ( + {col.label} + )) + ) : displayColumn ? ( + {displayColumn} + ) : ( + 항목 + )} + + + + {searchLoading ? ( + + + 검색 중... + + + ) : searchResults.length === 0 ? ( + + + 검색 결과가 없습니다 + + + ) : ( + searchResults.map((item, index) => ( + toggleSearchItemSelection(index)} + > + + toggleSearchItemSelection(index)} + onClick={(e) => e.stopPropagation()} + /> + + {sourceDisplayColumns.length > 0 ? ( + sourceDisplayColumns.map((col) => ( + {item[col.key]} + )) + ) : displayColumn ? ( + {item[displayColumn]} + ) : ( + {JSON.stringify(item).substring(0, 50)}... + )} + + )) + )} + +
+
+ + + + +
@@ -625,4 +850,3 @@ export const UnifiedRepeater: React.FC = ({ UnifiedRepeater.displayName = "UnifiedRepeater"; export default UnifiedRepeater; - diff --git a/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx index c812feee..152757f9 100644 --- a/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx +++ b/frontend/components/unified/config-panels/UnifiedRepeaterConfigPanel.tsx @@ -3,11 +3,11 @@ /** * UnifiedRepeater 설정 패널 * - * 모든 설정을 콤보박스 중심으로 구현하여 직접 입력을 최소화합니다. - * - 테이블/컬럼: API에서 동적으로 로드 - * - 화면 목록: API에서 동적으로 로드 - * - 공통코드 카테고리: API에서 동적으로 로드 - * - 기타 옵션: 고정 옵션에서 선택 + * 렌더링 모드별 설정: + * - inline: 현재 화면 테이블 컬럼 직접 입력 + * - modal: 엔티티 선택 + 추가 입력 (FK 저장 + 추가 컬럼 입력) + * - button: 버튼 클릭으로 관련 화면/모달 열기 + * - mixed: inline + modal 기능 결합 */ import React, { useState, useEffect, useMemo, useCallback } from "react"; @@ -22,13 +22,11 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Database, Link2, - ChevronDown, - ChevronRight, Plus, Trash2, GripVertical, - Monitor, - Settings2, + ArrowRight, + Calculator, } from "lucide-react"; import { tableTypeApi, screenApi } from "@/lib/api/screen"; import { commonCodeApi } from "@/lib/api/commonCode"; @@ -55,21 +53,31 @@ interface UnifiedRepeaterConfigPanelProps { config: UnifiedRepeaterConfig; onChange: (config: UnifiedRepeaterConfig) => void; currentTableName?: string; -} - -interface TableOption { - tableName: string; - tableLabel: string; + screenTableName?: string; + tableColumns?: any[]; } interface ColumnOption { columnName: string; displayName: string; - isJoinColumn?: boolean; - sourceTable?: string; inputType?: string; } +interface EntityColumnOption { + columnName: string; + displayName: string; + referenceTable?: string; + referenceColumn?: string; + displayColumn?: string; +} + +interface CalculationRule { + id: string; + targetColumn: string; + formula: string; + label?: string; +} + interface ScreenOption { screenId: number; screenName: string; @@ -82,20 +90,47 @@ interface CategoryOption { } export const UnifiedRepeaterConfigPanel: React.FC = ({ - config, + config: propConfig, onChange, - currentTableName, + currentTableName: propCurrentTableName, + screenTableName, }) => { + const currentTableName = screenTableName || propCurrentTableName; + + // config 안전하게 초기화 + const config: UnifiedRepeaterConfig = useMemo(() => ({ + ...DEFAULT_REPEATER_CONFIG, + ...propConfig, + renderMode: propConfig?.renderMode || DEFAULT_REPEATER_CONFIG.renderMode, + dataSource: { + ...DEFAULT_REPEATER_CONFIG.dataSource, + ...propConfig?.dataSource, + }, + columns: propConfig?.columns || [], + modal: { + ...DEFAULT_REPEATER_CONFIG.modal, + ...propConfig?.modal, + }, + button: { + ...DEFAULT_REPEATER_CONFIG.button, + ...propConfig?.button, + }, + features: { + ...DEFAULT_REPEATER_CONFIG.features, + ...propConfig?.features, + }, + }), [propConfig]); + // 상태 관리 - const [tables, setTables] = useState([]); - const [dataTableColumns, setDataTableColumns] = useState([]); - const [currentTableColumns, setCurrentTableColumns] = useState([]); + const [currentTableColumns, setCurrentTableColumns] = useState([]); // 현재 테이블 컬럼 + const [entityColumns, setEntityColumns] = useState([]); // 엔티티 타입 컬럼 + const [sourceTableColumns, setSourceTableColumns] = useState([]); // 소스(엔티티) 테이블 컬럼 const [screens, setScreens] = useState([]); const [categories, setCategories] = useState([]); - const [expandedJoinSections, setExpandedJoinSections] = useState>(new Set()); + const [calculationRules, setCalculationRules] = useState([]); - const [loadingTables, setLoadingTables] = useState(false); const [loadingColumns, setLoadingColumns] = useState(false); + const [loadingSourceColumns, setLoadingSourceColumns] = useState(false); const [loadingScreens, setLoadingScreens] = useState(false); const [loadingCategories, setLoadingCategories] = useState(false); @@ -143,84 +178,106 @@ export const UnifiedRepeaterConfigPanel: React.FC { - const loadTables = async () => { - setLoadingTables(true); - try { - const { tableManagementApi } = await import("@/lib/api/tableManagement"); - const response = await tableManagementApi.getTableList(); - if (response.success && response.data) { - setTables( - response.data.map((t: any) => ({ - tableName: t.tableName || t.table_name, - tableLabel: t.tableLabel || t.table_label || t.tableName || t.table_name, - })), - ); - } - } catch (error) { - console.error("테이블 목록 로드 실패:", error); - } finally { - setLoadingTables(false); - } - }; - loadTables(); - }, []); - - // 데이터 테이블 컬럼 로드 - useEffect(() => { - const loadDataTableColumns = async () => { - const tableName = config.dataSource?.tableName; - if (!tableName) { - setDataTableColumns([]); + const loadCurrentTableColumns = async () => { + if (!currentTableName) { + setCurrentTableColumns([]); + setEntityColumns([]); return; } setLoadingColumns(true); - try { - const columnData = await tableTypeApi.getColumns(tableName); - const cols: ColumnOption[] = columnData.map((c: any) => ({ - columnName: c.columnName || c.column_name, - displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, - inputType: c.inputType || c.input_type, - })); - setDataTableColumns(cols); - } catch (error) { - console.error("데이터 테이블 컬럼 로드 실패:", error); - setDataTableColumns([]); - } finally { - setLoadingColumns(false); - } - }; - loadDataTableColumns(); - }, [config.dataSource?.tableName]); - - // 현재 화면 테이블 컬럼 로드 - useEffect(() => { - const loadCurrentTableColumns = async () => { - if (!currentTableName) { - setCurrentTableColumns([]); - return; - } - try { const columnData = await tableTypeApi.getColumns(currentTableName); - const cols: ColumnOption[] = columnData.map((c: any) => ({ - columnName: c.columnName || c.column_name, - displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, - })); + const cols: ColumnOption[] = []; + const entityCols: EntityColumnOption[] = []; + + for (const c of columnData) { + const col: ColumnOption = { + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + }; + cols.push(col); + + // 엔티티 타입 컬럼 감지 + if (col.inputType === "entity") { + let detailSettings: any = null; + + if (c.detailSettings) { + try { + detailSettings = typeof c.detailSettings === "string" + ? JSON.parse(c.detailSettings) + : c.detailSettings; + } catch (e) { + console.warn("detailSettings 파싱 실패:", c.detailSettings); + } + } + + const referenceTable = detailSettings?.referenceTable || c.referenceTable; + const referenceColumn = detailSettings?.referenceColumn || c.referenceColumn || "id"; + const displayColumn = detailSettings?.displayColumn || c.displayColumn; + + if (referenceTable) { + entityCols.push({ + columnName: col.columnName, + displayName: col.displayName, + referenceTable, + referenceColumn, + displayColumn, + }); + } + } + } + setCurrentTableColumns(cols); + setEntityColumns(entityCols); } catch (error) { console.error("현재 테이블 컬럼 로드 실패:", error); setCurrentTableColumns([]); + setEntityColumns([]); + } finally { + setLoadingColumns(false); } }; loadCurrentTableColumns(); }, [currentTableName]); - // 화면 목록 로드 (모달 모드일 때) + // 소스(엔티티) 테이블 컬럼 로드 (모달 모드일 때) useEffect(() => { - const needScreens = config.renderMode === "modal" || config.renderMode === "mixed"; + const loadSourceTableColumns = async () => { + const sourceTable = config.dataSource?.sourceTable; + if (!sourceTable) { + setSourceTableColumns([]); + return; + } + + setLoadingSourceColumns(true); + try { + const columnData = await tableTypeApi.getColumns(sourceTable); + const cols: ColumnOption[] = columnData.map((c: any) => ({ + columnName: c.columnName || c.column_name, + displayName: c.displayName || c.columnLabel || c.columnName || c.column_name, + inputType: c.inputType || c.input_type, + })); + setSourceTableColumns(cols); + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + setSourceTableColumns([]); + } finally { + setLoadingSourceColumns(false); + } + }; + + if (config.renderMode === "modal" || config.renderMode === "mixed") { + loadSourceTableColumns(); + } + }, [config.dataSource?.sourceTable, config.renderMode]); + + // 화면 목록 로드 + useEffect(() => { + const needScreens = config.renderMode === "button" || config.renderMode === "mixed"; if (!needScreens) return; const loadScreens = async () => { @@ -243,7 +300,7 @@ export const UnifiedRepeaterConfigPanel: React.FC { const needCategories = (config.renderMode === "button" || config.renderMode === "mixed") && @@ -271,38 +328,105 @@ export const UnifiedRepeaterConfigPanel: React.FC { + // 컬럼 토글 (현재 테이블 컬럼 - 입력용) + const toggleInputColumn = (column: ColumnOption) => { const existingIndex = config.columns.findIndex((c) => c.key === column.columnName); if (existingIndex >= 0) { - // 제거 const newColumns = config.columns.filter((c) => c.key !== column.columnName); updateConfig({ columns: newColumns }); } else { - // 추가 const newColumn: RepeaterColumnConfig = { key: column.columnName, title: column.displayName, width: "auto", visible: true, - isJoinColumn: column.isJoinColumn || false, - sourceTable: column.sourceTable, + editable: true, }; updateConfig({ columns: [...config.columns, newColumn] }); } }; + // 소스 컬럼 토글 (모달에 표시용 - 라벨 포함) + const toggleSourceDisplayColumn = (column: ColumnOption) => { + const sourceDisplayColumns = config.modal?.sourceDisplayColumns || []; + const exists = sourceDisplayColumns.some(c => c.key === column.columnName); + + if (exists) { + updateModal("sourceDisplayColumns", sourceDisplayColumns.filter(c => c.key !== column.columnName)); + } else { + updateModal("sourceDisplayColumns", [ + ...sourceDisplayColumns, + { key: column.columnName, label: column.displayName } + ]); + } + }; + const isColumnAdded = (columnName: string) => { return config.columns.some((c) => c.key === columnName); }; + const isSourceColumnSelected = (columnName: string) => { + return (config.modal?.sourceDisplayColumns || []).some(c => c.key === columnName); + }; + // 컬럼 속성 업데이트 const updateColumnProp = (key: string, field: keyof RepeaterColumnConfig, value: any) => { const newColumns = config.columns.map((col) => (col.key === key ? { ...col, [field]: value } : col)); updateConfig({ columns: newColumns }); }; - // 수동 버튼 추가 + // 계산 규칙 추가 + const addCalculationRule = () => { + setCalculationRules(prev => [ + ...prev, + { id: `calc_${Date.now()}`, targetColumn: "", formula: "" } + ]); + }; + + // 계산 규칙 삭제 + const removeCalculationRule = (id: string) => { + setCalculationRules(prev => prev.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) + ); + }; + + // 엔티티 컬럼 선택 시 소스 테이블 자동 설정 + const handleEntityColumnSelect = (columnName: string) => { + const selectedEntity = entityColumns.find(c => c.columnName === columnName); + + if (selectedEntity) { + console.log("엔티티 컬럼 선택:", selectedEntity); + + // 소스 테이블 컬럼에서 라벨 정보 찾기 + const displayColInfo = sourceTableColumns.find(c => c.columnName === selectedEntity.displayColumn); + const displayLabel = displayColInfo?.displayName || selectedEntity.displayColumn || ""; + + updateConfig({ + dataSource: { + ...config.dataSource, + sourceTable: selectedEntity.referenceTable || "", + foreignKey: selectedEntity.columnName, + referenceKey: selectedEntity.referenceColumn || "id", + displayColumn: selectedEntity.displayColumn, + }, + modal: { + ...config.modal, + searchFields: selectedEntity.displayColumn ? [selectedEntity.displayColumn] : [], + // 라벨 포함 형식으로 저장 + sourceDisplayColumns: selectedEntity.displayColumn + ? [{ key: selectedEntity.displayColumn, label: displayLabel }] + : [], + }, + }); + } + }; + + // 수동 버튼 관리 const addManualButton = () => { const newButton: RepeaterButtonConfig = { id: `btn_${Date.now()}`, @@ -314,70 +438,39 @@ export const UnifiedRepeaterConfigPanel: React.FC { const currentButtons = config.button?.manualButtons || []; - updateButton( - "manualButtons", - currentButtons.filter((b) => b.id !== id), - ); + updateButton("manualButtons", currentButtons.filter((b) => b.id !== id)); }; - // 수동 버튼 속성 업데이트 const updateManualButton = (id: string, field: keyof RepeaterButtonConfig, value: any) => { const currentButtons = config.button?.manualButtons || []; const updated = currentButtons.map((b) => (b.id === id ? { ...b, [field]: value } : b)); updateButton("manualButtons", updated); }; - // 조인 섹션 토글 - const toggleJoinSection = (tableName: string) => { - setExpandedJoinSections((prev) => { - const newSet = new Set(prev); - if (newSet.has(tableName)) { - newSet.delete(tableName); - } else { - newSet.add(tableName); - } - return newSet; - }); - }; + // 모드 여부 + const isInlineMode = config.renderMode === "inline"; + const isModalMode = config.renderMode === "modal" || config.renderMode === "mixed"; + const isButtonMode = config.renderMode === "button" || config.renderMode === "mixed"; - // 엔티티 조인 컬럼 그룹화 - const joinColumnsByTable = useMemo(() => { - const grouped: Record = {}; - dataTableColumns - .filter((col) => col.isJoinColumn) - .forEach((col) => { - const table = col.sourceTable || "unknown"; - if (!grouped[table]) grouped[table] = []; - grouped[table].push(col); - }); - return grouped; - }, [dataTableColumns]); - - const baseColumns = useMemo(() => dataTableColumns.filter((col) => !col.isJoinColumn), [dataTableColumns]); - - // 모달/버튼 모드 여부 - const showModalSettings = config.renderMode === "modal" || config.renderMode === "mixed"; - const showButtonSettings = config.renderMode === "button" || config.renderMode === "mixed"; + // 엔티티 컬럼 제외한 입력 가능 컬럼 (FK 컬럼 제외) + const inputableColumns = useMemo(() => { + const fkColumn = config.dataSource?.foreignKey; + return currentTableColumns.filter(col => + col.columnName !== fkColumn && // FK 컬럼 제외 + col.inputType !== "entity" // 다른 엔티티 컬럼도 제외 (필요시) + ); + }, [currentTableColumns, config.dataSource?.foreignKey]); return (
- - 기본 - - - 컬럼 - - - 모달 - - - 버튼 - + 기본 + 컬럼 + 모달 + 버튼 {/* 기본 설정 탭 */} @@ -392,7 +485,15 @@ export const UnifiedRepeaterConfigPanel: React.FC {RENDER_MODE_OPTIONS.map((opt) => ( - {opt.label} +
+ {opt.label} + + {opt.value === "inline" && "현재 테이블 컬럼 직접 입력"} + {opt.value === "modal" && "엔티티 선택 후 추가 정보 입력"} + {opt.value === "button" && "버튼으로 관련 화면 열기"} + {opt.value === "mixed" && "직접 입력 + 엔티티 검색"} + +
))} @@ -401,94 +502,82 @@ export const UnifiedRepeaterConfigPanel: React.FC - {/* 데이터 소스 설정 */} -
- - - {/* 데이터 테이블 선택 */} -
- - -
- - {/* 연결 키 (FK) - 데이터 테이블 컬럼 */} -
- - -

데이터 테이블에서 부모를 참조하는 컬럼

-
- - {/* 상위 키 - 현재 화면 테이블 컬럼 */} -
- - {currentTableName ? ( - - ) : ( -

화면에 테이블이 설정되지 않았습니다

- )} -

현재 화면 테이블의 PK/ID 컬럼

-
+ {/* 현재 화면 정보 */} +
+ + {currentTableName ? ( +
+

{currentTableName}

+

+ 컬럼 {currentTableColumns.length}개 / 엔티티 {entityColumns.length}개 +

+
+ ) : ( +
+

화면에 테이블을 먼저 설정해주세요

+
+ )}
+ {/* 모달 모드: 엔티티 컬럼 선택 */} + {isModalMode && ( + <> + +
+ +

+ 모달에서 검색할 엔티티를 선택하세요 (FK만 저장됨) +

+ + {entityColumns.length > 0 ? ( + + ) : ( +
+

+ {loadingColumns ? "로딩 중..." : "엔티티 타입 컬럼이 없습니다"} +

+
+ )} + + {/* 선택된 엔티티 정보 */} + {config.dataSource?.sourceTable && ( +
+

선택된 엔티티

+
+

검색 테이블: {config.dataSource.sourceTable}

+

저장 컬럼: {config.dataSource.foreignKey} (FK)

+
+
+ )} +
+ + )} + {/* 기능 옵션 */}
-
updateFeatures("showAddButton", !!checked)} /> - +
@@ -507,9 +594,7 @@ export const UnifiedRepeaterConfigPanel: React.FC updateFeatures("showDeleteButton", !!checked)} /> - +
@@ -518,20 +603,16 @@ export const UnifiedRepeaterConfigPanel: React.FC updateFeatures("inlineEdit", !!checked)} /> - +
updateFeatures("dragSort", !!checked)} + id="multiSelect" + checked={config.features?.multiSelect ?? true} + onCheckedChange={(checked) => updateFeatures("multiSelect", !!checked)} /> - +
@@ -540,9 +621,7 @@ export const UnifiedRepeaterConfigPanel: React.FC updateFeatures("showRowNumber", !!checked)} /> - +
@@ -551,9 +630,7 @@ export const UnifiedRepeaterConfigPanel: React.FC updateFeatures("selectable", !!checked)} /> - +
@@ -561,80 +638,84 @@ export const UnifiedRepeaterConfigPanel: React.FC -
- + {/* 모달 모드: 모달에 표시할 컬럼 */} + {isModalMode && config.dataSource?.sourceTable && ( + <> +
+ +

+ 검색 모달에서 보여줄 컬럼 (보기용) +

+ + {loadingSourceColumns ? ( +

로딩 중...

+ ) : sourceTableColumns.length === 0 ? ( +

컬럼 정보가 없습니다

+ ) : ( +
+ {sourceTableColumns.map((column) => ( +
toggleSourceDisplayColumn(column)} + > + toggleSourceDisplayColumn(column)} + className="pointer-events-none h-3.5 w-3.5" + /> + {column.displayName} +
+ ))} +
+ )} +
+ + + )} + {/* 추가 입력 컬럼 (현재 테이블에서 FK 제외) */} +
+ +

+ {isModalMode + ? "엔티티 선택 후 추가로 입력받을 컬럼 (수량, 단가 등)" + : "직접 입력받을 컬럼을 선택하세요" + } +

+ {loadingColumns ? ( -

컬럼 로딩 중...

- ) : !config.dataSource?.tableName ? ( -

먼저 데이터 테이블을 선택해주세요

+

로딩 중...

+ ) : inputableColumns.length === 0 ? ( +

+ {isModalMode ? "추가 입력 가능한 컬럼이 없습니다" : "컬럼 정보가 없습니다"} +

) : (
- {baseColumns.map((column) => ( + {inputableColumns.map((column) => (
toggleColumn(column)} + onClick={() => toggleInputColumn(column)} > toggleColumn(column)} + onCheckedChange={() => toggleInputColumn(column)} className="pointer-events-none h-3.5 w-3.5" /> {column.displayName} + {column.inputType}
))} - - {/* 조인 컬럼 */} - {Object.keys(joinColumnsByTable).length > 0 && ( -
-
- - 엔티티 조인 컬럼 -
- {Object.entries(joinColumnsByTable).map(([refTable, refColumns]) => ( -
-
toggleJoinSection(refTable)} - > - {expandedJoinSections.has(refTable) ? ( - - ) : ( - - )} - {refTable} -
- - {expandedJoinSections.has(refTable) && ( -
- {refColumns.map((column) => ( -
toggleColumn(column)} - > - toggleColumn(column)} - className="pointer-events-none h-3.5 w-3.5" - /> - {column.displayName} -
- ))} -
- )} -
- ))} -
- )}
)}
@@ -649,18 +730,13 @@ export const UnifiedRepeaterConfigPanel: React.FC (
- {col.isJoinColumn ? ( - - ) : ( - - )} + updateColumnProp(col.key, "title", e.target.value)} placeholder="제목" className="h-6 flex-1 text-xs" /> - {/* 너비: 콤보박스 */} + updateColumnProp(col.key, "editable", !!checked)} + title="편집 가능" + /> +
+

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

+ +
+ {calculationRules.map((rule) => ( +
+ + + = + + updateCalculationRule(rule.id, "formula", e.target.value)} + placeholder="quantity * unit_price" + className="h-7 flex-1 text-xs" + /> + + +
+ ))} + + {calculationRules.length === 0 && ( +

+ 계산 규칙이 없습니다 +

+ )} +
+
+ + )} {/* 모달 설정 탭 */} - {showModalSettings ? ( + {isModalMode ? ( <> - {/* 모달 화면 선택 */} -
- - -
- {/* 모달 크기 */}
updateModal("title", e.target.value)} + placeholder="예: 품목 검색" + className="h-8 text-xs" + /> +
+ + {/* 버튼 텍스트 */} +
+ + updateModal("buttonText", e.target.value)} + placeholder="예: 품목 검색" + className="h-8 text-xs" + /> +
+ - {/* 모달 제목 템플릿 */} + {/* 검색 필드 */}
- - -
-
- - - updateModal("titleTemplate", { - ...config.modal?.titleTemplate, - prefix: e.target.value, - }) - } - placeholder="예: 수정 -" - className="h-7 text-xs" - /> -
- -
- - -
- -
- - - updateModal("titleTemplate", { - ...config.modal?.titleTemplate, - suffix: e.target.value, - }) - } - placeholder="예: 상세" - className="h-7 text-xs" - /> -
+ +

+ 검색어 입력 시 검색할 필드 +

+ +
+ {sourceTableColumns.map((column) => { + const searchFields = config.modal?.searchFields || []; + const isChecked = searchFields.includes(column.columnName); + + return ( +
{ + if (isChecked) { + updateModal("searchFields", searchFields.filter(f => f !== column.columnName)); + } else { + updateModal("searchFields", [...searchFields, column.columnName]); + } + }} + > + + {column.displayName} +
+ ); + })}
@@ -813,7 +932,7 @@ export const UnifiedRepeaterConfigPanel: React.FC - {showButtonSettings ? ( + {isButtonMode ? ( <> {/* 버튼 소스 선택 */}
@@ -826,9 +945,7 @@ export const UnifiedRepeaterConfigPanel: React.FC (
- +
))} @@ -841,7 +958,6 @@ export const UnifiedRepeaterConfigPanel: React.FC - {/* 카테고리 선택 */}
- {/* 값 컬럼 */}
-

버튼 클릭 시 값이 저장될 컬럼

)} @@ -951,7 +1064,6 @@ export const UnifiedRepeaterConfigPanel: React.FC
- {/* 액션 */} - {/* 스타일 */} = /> ); + case "unified-repeater": + return ( + { + console.log("UnifiedRepeater data changed:", data); + }} + onRowClick={(row) => { + console.log("UnifiedRepeater row clicked:", row); + }} + onButtonClick={(action, row, buttonConfig) => { + console.log("UnifiedRepeater button clicked:", action, row, buttonConfig); + }} + /> + ); + default: return (
diff --git a/frontend/types/unified-repeater.ts b/frontend/types/unified-repeater.ts index c1f32000..11aa1324 100644 --- a/frontend/types/unified-repeater.ts +++ b/frontend/types/unified-repeater.ts @@ -1,11 +1,11 @@ /** * UnifiedRepeater 컴포넌트 타입 정의 * - * 기존 컴포넌트 통합: - * - simple-repeater-table: 인라인 모드 - * - modal-repeater-table: 모달 모드 - * - repeat-screen-modal: 화면 기반 모달 모드 - * - related-data-buttons: 버튼 모드 + * 렌더링 모드: + * - inline: 현재 테이블 컬럼 직접 입력 (simple-repeater-table) + * - modal: 소스 테이블에서 검색/선택 후 복사 (modal-repeater-table) + * - button: 버튼으로 관련 화면 열기 (related-data-buttons) + * - mixed: inline + modal 혼합 */ // 렌더링 모드 @@ -35,6 +35,7 @@ export interface RepeaterColumnConfig { title: string; width: ColumnWidthOption; visible: boolean; + editable?: boolean; // 편집 가능 여부 (inline 모드) isJoinColumn?: boolean; sourceTable?: string; } @@ -62,10 +63,25 @@ export interface CommonCodeButtonConfig { variantMapping?: Record; // 코드값별 색상 매핑 } +// 모달 표시 컬럼 (라벨 포함) +export interface ModalDisplayColumn { + key: string; + label: string; +} + // 모달 설정 export interface RepeaterModalConfig { - screenId?: number; + // 기본 설정 size: ModalSize; + title?: string; // 모달 제목 + buttonText?: string; // 검색 버튼 텍스트 + + // 소스 테이블 표시 설정 (modal 모드) + sourceDisplayColumns?: ModalDisplayColumn[]; // 모달에 표시할 소스 테이블 컬럼 (라벨 포함) + searchFields?: string[]; // 검색에 사용할 필드 + + // 화면 기반 모달 (옵션) + screenId?: number; titleTemplate?: { prefix?: string; columnKey?: string; @@ -84,25 +100,55 @@ export interface RepeaterFeatureOptions { multiSelect: boolean; } +// 데이터 소스 설정 +export interface RepeaterDataSource { + // inline 모드: 현재 테이블 설정은 필요 없음 (컬럼만 선택) + + // modal 모드: 소스 테이블 설정 + sourceTable?: string; // 검색할 테이블 (엔티티 참조 테이블) + foreignKey?: string; // 현재 테이블의 FK 컬럼 (part_objid 등) + referenceKey?: string; // 소스 테이블의 PK 컬럼 (id 등) + displayColumn?: string; // 표시할 컬럼 (item_name 등) + + // 추가 필터 + filter?: { + column: string; + value: string; + }; +} + +// 컬럼 매핑 (modal 모드에서 소스→타겟 복사용) +export interface ColumnMapping { + sourceColumn: string; + targetColumn: string; + transform?: "copy" | "reference"; // copy: 값 복사, reference: ID 참조 +} + +// 계산 규칙 +export interface CalculationRule { + id: string; + targetColumn: string; + formula: string; // 예: "quantity * unit_price" + label?: string; +} + // 메인 설정 타입 export interface UnifiedRepeaterConfig { // 렌더링 모드 renderMode: RepeaterRenderMode; // 데이터 소스 설정 - dataSource: { - tableName: string; // 데이터 테이블 - foreignKey: string; // 연결 키 (FK) - 데이터 테이블의 컬럼 - referenceKey: string; // 상위 키 - 현재 화면 테이블의 컬럼 (부모 ID) - filter?: { // 추가 필터 조건 - column: string; - value: string; - }; - }; + dataSource: RepeaterDataSource; - // 컬럼 설정 + // 컬럼 설정 (inline: 입력 컬럼, modal: 표시 컬럼) columns: RepeaterColumnConfig[]; + // 컬럼 매핑 (modal 모드) + columnMappings?: ColumnMapping[]; + + // 계산 규칙 (modal 모드) + calculationRules?: CalculationRule[]; + // 모달 설정 (modal, mixed 모드) modal?: RepeaterModalConfig; @@ -141,14 +187,12 @@ export interface UnifiedRepeaterProps { // 기본 설정값 export const DEFAULT_REPEATER_CONFIG: UnifiedRepeaterConfig = { renderMode: "inline", - dataSource: { - tableName: "", - foreignKey: "", - referenceKey: "", - }, + dataSource: {}, columns: [], modal: { - size: "md", + size: "lg", + sourceDisplayColumns: [], + searchFields: [], }, button: { sourceType: "manual", @@ -159,20 +203,20 @@ export const DEFAULT_REPEATER_CONFIG: UnifiedRepeaterConfig = { features: { showAddButton: true, showDeleteButton: true, - inlineEdit: false, + inlineEdit: true, dragSort: false, showRowNumber: false, selectable: false, - multiSelect: false, + multiSelect: true, }, }; // 고정 옵션들 (콤보박스용) export const RENDER_MODE_OPTIONS = [ - { value: "inline", label: "인라인 (테이블)" }, - { value: "modal", label: "모달" }, + { value: "inline", label: "인라인 (직접 입력)" }, + { value: "modal", label: "모달 (검색 선택)" }, { value: "button", label: "버튼" }, - { value: "mixed", label: "혼합 (테이블 + 버튼)" }, + { value: "mixed", label: "혼합 (입력 + 검색)" }, ] as const; export const MODAL_SIZE_OPTIONS = [ @@ -227,4 +271,3 @@ export const LABEL_FIELD_OPTIONS = [ { value: "codeName", label: "코드명" }, { value: "codeValue", label: "코드값" }, ] as const; -