diff --git a/backend-node/src/services/externalDbConnectionPoolService.ts b/backend-node/src/services/externalDbConnectionPoolService.ts index 73077ef1..f35150ac 100644 --- a/backend-node/src/services/externalDbConnectionPoolService.ts +++ b/backend-node/src/services/externalDbConnectionPoolService.ts @@ -164,8 +164,8 @@ class MySQLPoolWrapper implements ConnectionPoolWrapper { } try { - const [rows] = await this.pool.execute(sql, params); - return rows; + const [rows] = await this.pool.execute(sql, params); + return rows; } catch (error: any) { // 연결 닫힘 오류 감지 if ( diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index d8e62c00..b0d39d22 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -40,32 +40,37 @@ export const EmbeddedScreen = forwardRef(null); const [screenInfo, setScreenInfo] = useState(null); const [formData, setFormData] = useState>(initialFormData || {}); // 🆕 초기 데이터로 시작 + const [formDataVersion, setFormDataVersion] = useState(0); // 🆕 폼 데이터 버전 (강제 리렌더링용) // 컴포넌트 참조 맵 const componentRefs = useRef>(new Map()); - + // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) const splitPanelContext = useSplitPanelContext(); - + + // 🆕 selectedLeftData 참조 안정화 (실제 값이 바뀔 때만 업데이트) + const selectedLeftData = splitPanelContext?.selectedLeftData; + const prevSelectedLeftDataRef = useRef(""); + // 🆕 사용자 정보 가져오기 (저장 액션에 필요) const { userId, userName, companyCode } = useAuth(); // 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해) const contentBounds = React.useMemo(() => { if (layout.length === 0) return { width: 0, height: 0 }; - + let maxRight = 0; let maxBottom = 0; - + layout.forEach((component) => { const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component; const right = (compPosition.x || 0) + (size.width || 200); const bottom = (compPosition.y || 0) + (size.height || 40); - + if (right > maxRight) maxRight = right; if (bottom > maxBottom) maxBottom = bottom; }); - + return { width: maxRight, height: maxBottom }; }, [layout]); @@ -92,26 +97,53 @@ export const EmbeddedScreen = forwardRef { // 우측 화면인 경우에만 적용 - if (position !== "right" || !splitPanelContext) return; - - // 자동 데이터 전달이 비활성화된 경우 스킵 - if (splitPanelContext.disableAutoDataTransfer) { - console.log("🔗 [EmbeddedScreen] 자동 데이터 전달 비활성화됨 - 버튼 클릭으로만 전달"); + if (position !== "right" || !splitPanelContext) { return; } - - const mappedData = splitPanelContext.getMappedParentData(); - if (Object.keys(mappedData).length > 0) { - console.log("🔗 [EmbeddedScreen] 분할 패널 부모 데이터 자동 반영:", mappedData); - setFormData((prev) => ({ - ...prev, - ...mappedData, - })); + + // 자동 데이터 전달이 비활성화된 경우 스킵 + if (splitPanelContext.disableAutoDataTransfer) { + return; } - }, [position, splitPanelContext, splitPanelContext?.selectedLeftData]); + + // 🆕 값 비교로 실제 변경 여부 확인 (불필요한 리렌더링 방지) + const currentDataStr = JSON.stringify(selectedLeftData || {}); + if (prevSelectedLeftDataRef.current === currentDataStr) { + return; // 실제 값이 같으면 스킵 + } + prevSelectedLeftDataRef.current = currentDataStr; + + // 🆕 현재 화면의 모든 컴포넌트에서 columnName 수집 + const allColumnNames = layout.filter((comp) => comp.columnName).map((comp) => comp.columnName as string); + + // 🆕 모든 필드를 빈 값으로 초기화한 후, selectedLeftData로 덮어쓰기 + const initializedFormData: Record = {}; + + // 먼저 모든 컬럼을 빈 문자열로 초기화 + allColumnNames.forEach((colName) => { + initializedFormData[colName] = ""; + }); + + // selectedLeftData가 있으면 해당 값으로 덮어쓰기 + if (selectedLeftData && Object.keys(selectedLeftData).length > 0) { + Object.keys(selectedLeftData).forEach((key) => { + // null/undefined는 빈 문자열로, 나머지는 그대로 + initializedFormData[key] = selectedLeftData[key] ?? ""; + }); + } + + console.log("🔗 [EmbeddedScreen] 우측 폼 데이터 교체:", { + allColumnNames, + selectedLeftDataKeys: selectedLeftData ? Object.keys(selectedLeftData) : [], + initializedFormDataKeys: Object.keys(initializedFormData), + }); + + setFormData(initializedFormData); + setFormDataVersion((v) => v + 1); // 🆕 버전 증가로 컴포넌트 강제 리렌더링 + }, [position, splitPanelContext, selectedLeftData, layout]); // 선택 변경 이벤트 전파 useEffect(() => { @@ -377,15 +409,15 @@ export const EmbeddedScreen = forwardRef화면에 컴포넌트가 없습니다.

) : ( -
{layout.map((component) => { const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; - + // 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정 // 부모 컨테이너의 100%를 기준으로 계산 const componentStyle: React.CSSProperties = { @@ -397,13 +429,9 @@ export const EmbeddedScreen = forwardRef +
void; groupByColumns?: string[]; // 🆕 그룹핑 컬럼 (예: ["order_no"]) tableName?: string; // 🆕 테이블명 (그룹 조회용) + buttonConfig?: any; // 🆕 버튼 설정 (제어로직 실행용) + buttonContext?: any; // 🆕 버튼 컨텍스트 (screenId, userId 등) + saveButtonConfig?: { + enableDataflowControl?: boolean; + dataflowConfig?: any; + dataflowTiming?: string; + }; // 🆕 모달 내부 저장 버튼의 제어로직 설정 } interface EditModalProps { className?: string; } +/** + * 모달 내부에서 저장 버튼 찾기 (재귀적으로 탐색) + * action.type이 "save"인 button-primary 컴포넌트를 찾음 + */ +const findSaveButtonInComponents = (components: any[]): any | null => { + if (!components || !Array.isArray(components)) return null; + + for (const comp of components) { + // button-primary이고 action.type이 save인 경우 + if ( + comp.componentType === "button-primary" && + comp.componentConfig?.action?.type === "save" + ) { + return comp; + } + + // conditional-container의 sections 내부 탐색 + if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) { + for (const section of comp.componentConfig.sections) { + if (section.screenId) { + // 조건부 컨테이너의 내부 화면은 별도로 로드해야 함 + // 여기서는 null 반환하고, loadSaveButtonConfig에서 처리 + continue; + } + } + } + + // 자식 컴포넌트가 있으면 재귀 탐색 + if (comp.children && Array.isArray(comp.children)) { + const found = findSaveButtonInComponents(comp.children); + if (found) return found; + } + } + + return null; +}; + export const EditModal: React.FC = ({ className }) => { const { user } = useAuth(); const [modalState, setModalState] = useState({ @@ -44,6 +88,9 @@ export const EditModal: React.FC = ({ className }) => { onSave: undefined, groupByColumns: undefined, tableName: undefined, + buttonConfig: undefined, + buttonContext: undefined, + saveButtonConfig: undefined, }); const [screenData, setScreenData] = useState<{ @@ -115,11 +162,88 @@ export const EditModal: React.FC = ({ className }) => { }; }; + // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 + const loadSaveButtonConfig = async (targetScreenId: number): Promise<{ + enableDataflowControl?: boolean; + dataflowConfig?: any; + dataflowTiming?: string; + } | null> => { + try { + // 1. 대상 화면의 레이아웃 조회 + const layoutData = await screenApi.getLayout(targetScreenId); + + if (!layoutData?.components) { + console.log("[EditModal] 레이아웃 컴포넌트 없음:", targetScreenId); + return null; + } + + // 2. 저장 버튼 찾기 + let saveButton = findSaveButtonInComponents(layoutData.components); + + // 3. conditional-container가 있는 경우 내부 화면도 탐색 + if (!saveButton) { + for (const comp of layoutData.components) { + if (comp.componentType === "conditional-container" && comp.componentConfig?.sections) { + for (const section of comp.componentConfig.sections) { + if (section.screenId) { + try { + const innerLayoutData = await screenApi.getLayout(section.screenId); + saveButton = findSaveButtonInComponents(innerLayoutData?.components || []); + if (saveButton) { + console.log("[EditModal] 조건부 컨테이너 내부에서 저장 버튼 발견:", { + sectionScreenId: section.screenId, + sectionLabel: section.label, + }); + break; + } + } catch (innerError) { + console.warn("[EditModal] 내부 화면 레이아웃 조회 실패:", section.screenId); + } + } + } + if (saveButton) break; + } + } + } + + if (!saveButton) { + console.log("[EditModal] 저장 버튼을 찾을 수 없음:", targetScreenId); + return null; + } + + // 4. webTypeConfig에서 제어로직 설정 추출 + const webTypeConfig = saveButton.webTypeConfig; + if (webTypeConfig?.enableDataflowControl) { + const config = { + enableDataflowControl: webTypeConfig.enableDataflowControl, + dataflowConfig: webTypeConfig.dataflowConfig, + dataflowTiming: webTypeConfig.dataflowConfig?.flowConfig?.executionTiming || "after", + }; + console.log("[EditModal] 저장 버튼 제어로직 설정 발견:", config); + return config; + } + + console.log("[EditModal] 저장 버튼에 제어로직 설정 없음"); + return null; + } catch (error) { + console.warn("[EditModal] 저장 버튼 설정 조회 실패:", error); + return null; + } + }; + // 전역 모달 이벤트 리스너 useEffect(() => { - const handleOpenEditModal = (event: CustomEvent) => { - const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode } = - event.detail; + const handleOpenEditModal = async (event: CustomEvent) => { + const { screenId, title, description, modalSize, editData, onSave, groupByColumns, tableName, isCreateMode, buttonConfig, buttonContext } = event.detail; + + // 🆕 모달 내부 저장 버튼의 제어로직 설정 조회 + let saveButtonConfig: EditModalState["saveButtonConfig"] = undefined; + if (screenId) { + const config = await loadSaveButtonConfig(screenId); + if (config) { + saveButtonConfig = config; + } + } setModalState({ isOpen: true, @@ -131,6 +255,9 @@ export const EditModal: React.FC = ({ className }) => { onSave, groupByColumns, // 🆕 그룹핑 컬럼 tableName, // 🆕 테이블명 + buttonConfig, // 🆕 버튼 설정 + buttonContext, // 🆕 버튼 컨텍스트 + saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정 }); // 편집 데이터로 폼 데이터 초기화 @@ -578,6 +705,46 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] 그룹 저장 완료 후 제어로직 실행 시도", { + hasSaveButtonConfig: !!modalState.saveButtonConfig, + hasButtonConfig: !!modalState.buttonConfig, + controlConfig, + }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + // buttonActions의 executeAfterSaveControl 동적 import + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + // 제어로직 실행 + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData: modalState.editData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } else { + console.log("ℹ️ [EditModal] 저장 후 실행할 제어로직 없음"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + // 제어로직 오류는 저장 성공을 방해하지 않음 (경고만 표시) + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { toast.info("변경된 내용이 없습니다."); @@ -612,6 +779,37 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] INSERT 완료 후 제어로직 실행 시도", { controlConfig }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { throw new Error(response.message || "생성에 실패했습니다."); @@ -654,6 +852,37 @@ export const EditModal: React.FC = ({ className }) => { } } + // 🆕 저장 후 제어로직 실행 (버튼의 After 타이밍 제어) + // 우선순위: 모달 내부 저장 버튼 설정(saveButtonConfig) > 수정 버튼에서 전달받은 설정(buttonConfig) + try { + const controlConfig = modalState.saveButtonConfig || modalState.buttonConfig; + + console.log("[EditModal] UPDATE 완료 후 제어로직 실행 시도", { controlConfig }); + + if (controlConfig?.enableDataflowControl && controlConfig?.dataflowTiming === "after") { + console.log("🎯 [EditModal] 저장 후 제어로직 발견:", controlConfig.dataflowConfig); + + const { ButtonActionExecutor } = await import("@/lib/utils/buttonActions"); + + await ButtonActionExecutor.executeAfterSaveControl( + controlConfig, + { + formData, + screenId: modalState.buttonContext?.screenId || modalState.screenId, + tableName: modalState.buttonContext?.tableName || screenData?.screenInfo?.tableName, + userId: user?.userId, + companyCode: user?.companyCode, + onRefresh: modalState.onSave, + } + ); + + console.log("✅ [EditModal] 제어로직 실행 완료"); + } + } catch (controlError) { + console.error("❌ [EditModal] 제어로직 실행 오류:", controlError); + toast.warning("저장은 완료되었으나 연결된 제어 실행 중 오류가 발생했습니다."); + } + handleClose(); } else { throw new Error(response.message || "수정에 실패했습니다."); diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 39f32a73..54315683 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -333,22 +333,72 @@ export const ButtonConfigPanel: React.FC = ({ const loadModalMappingColumns = async () => { // 소스 테이블: 현재 화면의 분할 패널 또는 테이블에서 감지 - // allComponents에서 split-panel-layout 또는 table-list 찾기 let sourceTableName: string | null = null; + console.log("[openModalWithData] 컬럼 로드 시작:", { + allComponentsCount: allComponents.length, + currentTableName, + targetScreenId: config.action?.targetScreenId, + }); + + // 모든 컴포넌트 타입 로그 + allComponents.forEach((comp, idx) => { + const compType = comp.componentType || (comp as any).componentConfig?.type; + console.log(` [${idx}] componentType: ${compType}, tableName: ${(comp as any).componentConfig?.tableName || (comp as any).componentConfig?.leftPanel?.tableName || 'N/A'}`); + }); + for (const comp of allComponents) { const compType = comp.componentType || (comp as any).componentConfig?.type; + const compConfig = (comp as any).componentConfig || {}; + + // 분할 패널 타입들 (다양한 경로에서 테이블명 추출) if (compType === "split-panel-layout" || compType === "screen-split-panel") { - // 분할 패널의 좌측 테이블명 - sourceTableName = (comp as any).componentConfig?.leftPanel?.tableName || - (comp as any).componentConfig?.leftTableName; - break; + sourceTableName = compConfig?.leftPanel?.tableName || + compConfig?.leftTableName || + compConfig?.tableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] split-panel-layout에서 소스 테이블 감지: ${sourceTableName}`); + break; + } } + + // split-panel-layout2 타입 (새로운 분할 패널) + if (compType === "split-panel-layout2") { + sourceTableName = compConfig?.leftPanel?.tableName || + compConfig?.tableName || + compConfig?.leftTableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] split-panel-layout2에서 소스 테이블 감지: ${sourceTableName}`); + break; + } + } + + // 테이블 리스트 타입 if (compType === "table-list") { - sourceTableName = (comp as any).componentConfig?.tableName; + sourceTableName = compConfig?.tableName; + if (sourceTableName) { + console.log(`✅ [openModalWithData] table-list에서 소스 테이블 감지: ${sourceTableName}`); + break; + } + } + + // 🆕 모든 컴포넌트에서 tableName 찾기 (폴백) + if (!sourceTableName && compConfig?.tableName) { + sourceTableName = compConfig.tableName; + console.log(`✅ [openModalWithData] ${compType}에서 소스 테이블 감지 (폴백): ${sourceTableName}`); break; } } + + // 여전히 없으면 currentTableName 사용 (화면 레벨 테이블명) + if (!sourceTableName && currentTableName) { + sourceTableName = currentTableName; + console.log(`✅ [openModalWithData] currentTableName에서 소스 테이블 사용: ${sourceTableName}`); + } + + if (!sourceTableName) { + console.warn("[openModalWithData] 소스 테이블을 찾을 수 없습니다."); + } // 소스 테이블 컬럼 로드 if (sourceTableName) { @@ -361,11 +411,11 @@ export const ButtonConfigPanel: React.FC = ({ if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + name: col.name || col.columnName || col.column_name, + label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalSourceColumns(columns); - console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드:`, columns.length); + console.log(`✅ [openModalWithData] 소스 테이블(${sourceTableName}) 컬럼 로드 완료:`, columns.length); } } } catch (error) { @@ -379,8 +429,12 @@ export const ButtonConfigPanel: React.FC = ({ try { // 타겟 화면 정보 가져오기 const screenResponse = await apiClient.get(`/screen-management/screens/${targetScreenId}`); + console.log("[openModalWithData] 타겟 화면 응답:", screenResponse.data); + if (screenResponse.data.success && screenResponse.data.data) { const targetTableName = screenResponse.data.data.tableName; + console.log("[openModalWithData] 타겟 화면 테이블명:", targetTableName); + if (targetTableName) { const columnResponse = await apiClient.get(`/table-management/tables/${targetTableName}/columns`); if (columnResponse.data.success) { @@ -390,23 +444,27 @@ export const ButtonConfigPanel: React.FC = ({ if (Array.isArray(columnData)) { const columns = columnData.map((col: any) => ({ - name: col.name || col.columnName, - label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + name: col.name || col.columnName || col.column_name, + label: col.displayName || col.label || col.columnLabel || col.display_name || col.name || col.columnName || col.column_name, })); setModalTargetColumns(columns); - console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드:`, columns.length); + console.log(`✅ [openModalWithData] 타겟 테이블(${targetTableName}) 컬럼 로드 완료:`, columns.length); } } + } else { + console.warn("[openModalWithData] 타겟 화면에 테이블명이 없습니다."); } } } catch (error) { console.error("타겟 화면 테이블 컬럼 로드 실패:", error); } + } else { + console.warn("[openModalWithData] 타겟 화면 ID가 없습니다."); } }; loadModalMappingColumns(); - }, [config.action?.type, config.action?.targetScreenId, allComponents]); + }, [config.action?.type, config.action?.targetScreenId, allComponents, currentTableName]); // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { @@ -1158,11 +1216,12 @@ export const ButtonConfigPanel: React.FC = ({

) : ( -
+
{(config.action?.fieldMappings || []).map((mapping: any, index: number) => ( -
- {/* 소스 필드 선택 (Combobox) */} -
+
+ {/* 소스 필드 선택 (Combobox) - 세로 배치 */} +
+ setModalSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} @@ -1171,15 +1230,17 @@ export const ButtonConfigPanel: React.FC = ({ - + = ({ value={modalSourceSearch[index] || ""} onValueChange={(value) => setModalSourceSearch((prev) => ({ ...prev, [index]: value }))} /> - + 컬럼을 찾을 수 없습니다 {modalSourceColumns.map((col) => ( @@ -1208,9 +1269,9 @@ export const ButtonConfigPanel: React.FC = ({ mapping.sourceField === col.name ? "opacity-100" : "opacity-0" )} /> - {col.label} + {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -1221,10 +1282,14 @@ export const ButtonConfigPanel: React.FC = ({
- + {/* 화살표 표시 */} +
+ +
- {/* 타겟 필드 선택 (Combobox) */} -
+ {/* 타겟 필드 선택 (Combobox) - 세로 배치 */} +
+ setModalTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} @@ -1233,15 +1298,17 @@ export const ButtonConfigPanel: React.FC = ({ - + = ({ value={modalTargetSearch[index] || ""} onValueChange={(value) => setModalTargetSearch((prev) => ({ ...prev, [index]: value }))} /> - + 컬럼을 찾을 수 없습니다 {modalTargetColumns.map((col) => ( @@ -1270,9 +1337,9 @@ export const ButtonConfigPanel: React.FC = ({ mapping.targetField === col.name ? "opacity-100" : "opacity-0" )} /> - {col.label} + {col.label} {col.label !== col.name && ( - ({col.name}) + ({col.name}) )} ))} @@ -1284,19 +1351,22 @@ export const ButtonConfigPanel: React.FC = ({
{/* 삭제 버튼 */} - +
+ +
))}
diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 3116b2c6..6118e073 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -10,7 +10,13 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Separator } from "@/components/ui/separator"; import { Badge } from "@/components/ui/badge"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; -import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater"; +import { + RepeaterFieldGroupConfig, + RepeaterData, + RepeaterItemData, + RepeaterFieldDefinition, + CalculationFormula, +} from "@/types/repeater"; import { cn } from "@/lib/utils"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; @@ -46,7 +52,9 @@ export const RepeaterInput: React.FC = ({ const breakpoint = previewBreakpoint || globalBreakpoint; // 카테고리 매핑 데이터 (값 -> {label, color}) - const [categoryMappings, setCategoryMappings] = useState>>({}); + const [categoryMappings, setCategoryMappings] = useState< + Record> + >({}); // 설정 기본값 const { @@ -78,10 +86,10 @@ export const RepeaterInput: React.FC = ({ // 접힌 상태 관리 (각 항목별) const [collapsedItems, setCollapsedItems] = useState>(new Set()); - + // 🆕 초기 계산 완료 여부 추적 (무한 루프 방지) const initialCalcDoneRef = useRef(false); - + // 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영) const deletedItemIdsRef = useRef([]); @@ -98,47 +106,60 @@ export const RepeaterInput: React.FC = ({ // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트 useEffect(() => { - if (value.length > 0) { - // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) - const calculatedFields = fields.filter(f => f.type === "calculated"); - - if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { - const updatedValue = value.map(item => { - const updatedItem = { ...item }; - let hasChange = false; - - calculatedFields.forEach(calcField => { - const calculatedValue = calculateValue(calcField.formula, updatedItem); - if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { - updatedItem[calcField.name] = calculatedValue; - hasChange = true; - } - }); - - // 🆕 기존 레코드임을 표시 (id가 있는 경우) - if (updatedItem.id) { - updatedItem._existingRecord = true; - } - - return hasChange ? updatedItem : item; - }); - - setItems(updatedValue); - initialCalcDoneRef.current = true; - - // 계산된 값이 있으면 onChange 호출 (초기 1회만) - const dataWithMeta = config.targetTable - ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) - : updatedValue; - onChange?.(dataWithMeta); + // 🆕 빈 배열도 처리 (FK 기반 필터링 시 데이터가 없을 수 있음) + if (value.length === 0) { + // minItems가 설정되어 있으면 빈 항목 생성, 아니면 빈 배열로 초기화 + if (minItems > 0) { + const emptyItems = Array(minItems) + .fill(null) + .map(() => createEmptyItem()); + setItems(emptyItems); } else { - // 🆕 기존 레코드 플래그 추가 - const valueWithFlag = value.map(item => ({ - ...item, - _existingRecord: !!item.id, - })); - setItems(valueWithFlag); + setItems([]); } + initialCalcDoneRef.current = false; // 다음 데이터 로드 시 계산식 재실행 + return; + } + + // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) + const calculatedFields = fields.filter((f) => f.type === "calculated"); + + if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { + const updatedValue = value.map((item) => { + const updatedItem = { ...item }; + let hasChange = false; + + calculatedFields.forEach((calcField) => { + const calculatedValue = calculateValue(calcField.formula, updatedItem); + if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { + updatedItem[calcField.name] = calculatedValue; + hasChange = true; + } + }); + + // 🆕 기존 레코드임을 표시 (id가 있는 경우) + if (updatedItem.id) { + updatedItem._existingRecord = true; + } + + return hasChange ? updatedItem : item; + }); + + setItems(updatedValue); + initialCalcDoneRef.current = true; + + // 계산된 값이 있으면 onChange 호출 (초기 1회만) + const dataWithMeta = config.targetTable + ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) + : updatedValue; + onChange?.(dataWithMeta); + } else { + // 🆕 기존 레코드 플래그 추가 + const valueWithFlag = value.map((item) => ({ + ...item, + _existingRecord: !!item.id, + })); + setItems(valueWithFlag); } }, [value]); @@ -161,17 +182,16 @@ export const RepeaterInput: React.FC = ({ // 항목 제거 const handleRemoveItem = (index: number) => { - if (items.length <= minItems) { - return; - } - + // 🆕 항목이 1개 이하일 때도 삭제 가능 (빈 상태 허용) + // minItems 체크 제거 - 모든 항목 삭제 허용 + // 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요) const removedItem = items[index]; if (removedItem?.id) { console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id); deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id]; } - + const newItems = items.filter((_, i) => i !== index); setItems(newItems); @@ -179,10 +199,10 @@ export const RepeaterInput: React.FC = ({ // 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용) const currentDeletedIds = deletedItemIdsRef.current; console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds); - + const dataWithMeta = config.targetTable - ? newItems.map((item, idx) => ({ - ...item, + ? newItems.map((item, idx) => ({ + ...item, _targetTable: config.targetTable, // 첫 번째 항목에만 삭제 ID 목록 포함 ...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}), @@ -205,16 +225,16 @@ export const RepeaterInput: React.FC = ({ ...newItems[itemIndex], [fieldName]: value, }; - + // 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산 - const calculatedFields = fields.filter(f => f.type === "calculated"); - calculatedFields.forEach(calcField => { + const calculatedFields = fields.filter((f) => f.type === "calculated"); + calculatedFields.forEach((calcField) => { const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]); if (calculatedValue !== null) { newItems[itemIndex][calcField.name] = calculatedValue; } }); - + setItems(newItems); console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { itemIndex, @@ -227,8 +247,8 @@ export const RepeaterInput: React.FC = ({ // 🆕 삭제된 항목 ID 목록도 유지 const currentDeletedIds = deletedItemIdsRef.current; const dataWithMeta = config.targetTable - ? newItems.map((item, idx) => ({ - ...item, + ? newItems.map((item, idx) => ({ + ...item, _targetTable: config.targetTable, // 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만) ...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}), @@ -288,14 +308,12 @@ export const RepeaterInput: React.FC = ({ */ const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => { if (!formula || !formula.field1) return null; - + const value1 = parseFloat(item[formula.field1]) || 0; - const value2 = formula.field2 - ? (parseFloat(item[formula.field2]) || 0) - : (formula.constantValue ?? 0); - + const value2 = formula.field2 ? parseFloat(item[formula.field2]) || 0 : (formula.constantValue ?? 0); + let result: number; - + switch (formula.operator) { case "+": result = value1 + value2; @@ -331,7 +349,7 @@ export const RepeaterInput: React.FC = ({ default: result = value1; } - + return result; }; @@ -341,42 +359,44 @@ export const RepeaterInput: React.FC = ({ * @param format 포맷 설정 * @returns 포맷된 문자열 */ - const formatNumber = ( - value: number | null, - format?: RepeaterFieldDefinition["numberFormat"] - ): string => { + const formatNumber = (value: number | null, format?: RepeaterFieldDefinition["numberFormat"]): string => { if (value === null || isNaN(value)) return "-"; - + let formattedValue = value; - + // 소수점 자릿수 적용 if (format?.decimalPlaces !== undefined) { formattedValue = parseFloat(value.toFixed(format.decimalPlaces)); } - + // 천 단위 구분자 - let result = format?.useThousandSeparator !== false - ? formattedValue.toLocaleString("ko-KR", { - minimumFractionDigits: format?.minimumFractionDigits ?? 0, - maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, - }) - : formattedValue.toString(); - + let result = + format?.useThousandSeparator !== false + ? formattedValue.toLocaleString("ko-KR", { + minimumFractionDigits: format?.minimumFractionDigits ?? 0, + maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, + }) + : formattedValue.toString(); + // 접두사/접미사 추가 if (format?.prefix) result = format.prefix + result; if (format?.suffix) result = result + format.suffix; - + return result; }; // 개별 필드 렌더링 const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { const isReadonly = disabled || readonly || field.readonly; - + + // 🆕 placeholder 기본값: 필드에 설정된 값 > 필드 라벨 기반 자동 생성 + // "id(를) 입력하세요" 같은 잘못된 기본값 방지 + const defaultPlaceholder = field.placeholder || `${field.label || field.name}`; + const commonProps = { value: value || "", disabled: isReadonly, - placeholder: field.placeholder, + placeholder: defaultPlaceholder, required: field.required, }; @@ -385,25 +405,21 @@ export const RepeaterInput: React.FC = ({ const item = items[itemIndex]; const calculatedValue = calculateValue(field.formula, item); const formattedValue = formatNumber(calculatedValue, field.numberFormat); - - return ( - - {formattedValue} - - ); + + return {formattedValue}; } // 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용) if (field.type === "category") { if (!value) return -; - + // field.name을 키로 사용 (테이블 리스트와 동일) const mapping = categoryMappings[field.name]; const valueStr = String(value); // 값을 문자열로 변환 const categoryData = mapping?.[valueStr]; const displayLabel = categoryData?.label || valueStr; const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate) - + console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, { fieldName: field.name, value: valueStr, @@ -412,12 +428,12 @@ export const RepeaterInput: React.FC = ({ displayLabel, displayColor, }); - + // 색상이 "none"이면 일반 텍스트로 표시 if (displayColor === "none") { return {displayLabel}; } - + return ( = ({ if (field.displayMode === "readonly") { // select 타입인 경우 옵션에서 라벨 찾기 if (field.type === "select" && value && field.options) { - const option = field.options.find(opt => opt.value === value); + const option = field.options.find((opt) => opt.value === value); return {option?.label || value}; } - + // 🆕 카테고리 매핑이 있는 경우 라벨로 변환 (조인된 테이블의 카테고리 필드) const mapping = categoryMappings[field.name]; if (mapping && value) { @@ -461,16 +477,12 @@ export const RepeaterInput: React.FC = ({ ); } // 색상이 없으면 텍스트로 표시 - return {categoryData.label}; + return {categoryData.label}; } } - + // 일반 텍스트 - return ( - - {value || "-"} - - ); + return {value || "-"}; } switch (field.type) { @@ -500,35 +512,55 @@ export const RepeaterInput: React.FC = ({ {...commonProps} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} rows={3} - className="resize-none min-w-[100px]" + className="min-w-[100px] resize-none" /> ); - case "date": + case "date": { + // 날짜 값 정규화: ISO 형식이면 YYYY-MM-DD로 변환 (타임존 이슈 해결) + let dateValue = value || ""; + if (dateValue && typeof dateValue === "string") { + // ISO 형식(YYYY-MM-DDTHH:mm:ss)이면 로컬 시간으로 변환하여 날짜 추출 + if (dateValue.includes("T")) { + const date = new Date(dateValue); + if (!isNaN(date.getTime())) { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + dateValue = `${year}-${month}-${day}`; + } else { + dateValue = ""; + } + } else { + // 유효한 날짜인지 확인 + const parsedDate = new Date(dateValue); + if (isNaN(parsedDate.getTime())) { + dateValue = ""; // 유효하지 않은 날짜면 빈 값 + } + } + } return ( handleFieldChange(itemIndex, field.name, e.target.value)} + onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value || null)} className="min-w-[120px]" /> ); + } case "number": // 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시 if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) { const numValue = parseFloat(value) || 0; const formattedDisplay = formatNumber(numValue, field.numberFormat); - + // 읽기 전용이면 포맷팅된 텍스트만 표시 if (isReadonly) { - return ( - - {formattedDisplay} - - ); + return {formattedDisplay}; } - + // 편집 가능: 입력은 숫자로, 표시는 포맷팅 return (
@@ -540,15 +572,11 @@ export const RepeaterInput: React.FC = ({ max={field.validation?.max} className="pr-1" /> - {value && ( -
- {formattedDisplay} -
- )} + {value &&
{formattedDisplay}
}
); } - + return ( = ({ // 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values useEffect(() => { // 카테고리 타입 필드 + readonly 필드 (조인된 테이블에서 온 데이터일 가능성) - const categoryFields = fields.filter(f => f.type === "category"); - const readonlyFields = fields.filter(f => f.displayMode === "readonly" && f.type === "text"); - + const categoryFields = fields.filter((f) => f.type === "category"); + const readonlyFields = fields.filter((f) => f.displayMode === "readonly" && f.type === "text"); + if (categoryFields.length === 0 && readonlyFields.length === 0) return; const loadCategoryMappings = async () => { const apiClient = (await import("@/lib/api/client")).apiClient; - + // 1. 카테고리 타입 필드 매핑 로드 for (const field of categoryFields) { const columnName = field.name; - + if (categoryMappings[columnName]) continue; - + try { const tableName = config.targetTable; if (!tableName) continue; - + console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`); - + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -629,10 +657,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -641,29 +669,29 @@ export const RepeaterInput: React.FC = ({ console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error); } } - + // 2. 🆕 readonly 필드에 대해 조인된 테이블 (item_info)에서 카테고리 매핑 로드 // material, division 등 조인된 테이블의 카테고리 필드 - const joinedTableFields = ['material', 'division', 'status', 'currency_code']; - const fieldsToLoadFromJoinedTable = readonlyFields.filter(f => joinedTableFields.includes(f.name)); - + const joinedTableFields = ["material", "division", "status", "currency_code"]; + const fieldsToLoadFromJoinedTable = readonlyFields.filter((f) => joinedTableFields.includes(f.name)); + if (fieldsToLoadFromJoinedTable.length > 0) { // item_info 테이블에서 카테고리 매핑 로드 - const joinedTableName = 'item_info'; - + const joinedTableName = "item_info"; + for (const field of fieldsToLoadFromJoinedTable) { const columnName = field.name; - + if (categoryMappings[columnName]) continue; - + try { console.log(`📡 [RepeaterInput] 조인 테이블 카테고리 매핑 로드: ${joinedTableName}/${columnName}`); - + const response = await apiClient.get(`/table-categories/${joinedTableName}/${columnName}/values`); - + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { const mapping: Record = {}; - + response.data.data.forEach((item: any) => { const key = String(item.valueCode); mapping[key] = { @@ -671,10 +699,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -694,9 +722,9 @@ export const RepeaterInput: React.FC = ({ if (fields.length === 0) { return (
-
-

필드가 정의되지 않았습니다

-

속성 패널에서 필드를 추가하세요.

+
+

필드가 정의되지 않았습니다

+

속성 패널에서 필드를 추가하세요.

); @@ -706,8 +734,8 @@ export const RepeaterInput: React.FC = ({ if (items.length === 0) { return (
-
-

{emptyMessage}

+
+

{emptyMessage}

{!readonly && !disabled && items.length < maxItems && (
diff --git a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx index 12219280..c0327303 100644 --- a/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx +++ b/frontend/lib/registry/components/repeater-field-group/RepeaterFieldGroupRenderer.tsx @@ -20,24 +20,56 @@ const RepeaterFieldGroupComponent: React.FC = (props) => const screenContext = useScreenContextOptional(); const splitPanelContext = useSplitPanelContext(); const receiverRef = useRef(null); - + // 🆕 그룹화된 데이터를 저장하는 상태 const [groupedData, setGroupedData] = useState(null); const [isLoadingGroupData, setIsLoadingGroupData] = useState(false); const groupDataLoadedRef = useRef(false); - + // 🆕 원본 데이터 ID 목록 (삭제 추적용) const [originalItemIds, setOriginalItemIds] = useState([]); + // 🆕 DB에서 로드한 컬럼 정보 (webType 등) + const [columnInfo, setColumnInfo] = useState>({}); + // 컴포넌트의 필드명 (formData 키) const fieldName = (component as any).columnName || component.id; // repeaterConfig 또는 componentConfig에서 설정 가져오기 - const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] }; - + const rawConfig = (component as any).repeaterConfig || component.componentConfig || { fields: [] }; + // 🆕 그룹화 설정 (예: groupByColumn: "inbound_number") - const groupByColumn = config.groupByColumn; - const targetTable = config.targetTable; + const groupByColumn = rawConfig.groupByColumn; + const targetTable = rawConfig.targetTable; + + // 🆕 DB 컬럼 정보를 적용한 config 생성 (webType → type 매핑) + const config = useMemo(() => { + const rawFields = rawConfig.fields || []; + console.log("📋 [RepeaterFieldGroup] config 생성:", { + rawFieldsCount: rawFields.length, + rawFieldNames: rawFields.map((f: any) => f.name), + columnInfoKeys: Object.keys(columnInfo), + hasColumnInfo: Object.keys(columnInfo).length > 0, + }); + + const fields = rawFields.map((field: any) => { + const colInfo = columnInfo[field.name]; + // DB의 webType 또는 web_type을 field.type으로 적용 + const dbWebType = colInfo?.webType || colInfo?.web_type; + + // 타입 오버라이드 조건: + // 1. field.type이 없거나 + // 2. field.type이 'direct'(기본값)이고 DB에 더 구체적인 타입이 있는 경우 + const shouldOverride = !field.type || (field.type === "direct" && dbWebType && dbWebType !== "text"); + + if (colInfo && dbWebType && shouldOverride) { + console.log(`✅ [RepeaterFieldGroup] 필드 타입 매핑: ${field.name} → ${dbWebType}`); + return { ...field, type: dbWebType }; + } + return field; + }); + return { ...rawConfig, fields }; + }, [rawConfig, columnInfo]); // formData에서 값 가져오기 (value prop보다 우선) const rawValue = formData?.[fieldName] ?? value; @@ -45,21 +77,127 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우 // formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시) const isEditMode = formData?.id && !rawValue && !value; - + // 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인 const configFields = config.fields || []; - const hasRepeaterFieldsInFormData = configFields.length > 0 && - configFields.some((field: any) => formData?.[field.name] !== undefined); + const hasRepeaterFieldsInFormData = + configFields.length > 0 && configFields.some((field: any) => formData?.[field.name] !== undefined); // 🆕 formData와 config.fields의 필드 이름 매칭 확인 const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined); - + // 🆕 그룹 키 값 (예: formData.inbound_number) const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null; - - console.log("🔄 [RepeaterFieldGroup] 렌더링:", { - fieldName, - hasFormData: !!formData, + + // 🆕 분할 패널 위치 및 좌측 선택 데이터 확인 + const splitPanelPosition = screenContext?.splitPanelPosition; + const isRightPanel = splitPanelPosition === "right"; + const selectedLeftData = splitPanelContext?.selectedLeftData; + + // 🆕 연결 필터 설정에서 FK 컬럼 정보 가져오기 + // screen-split-panel에서 설정한 linkedFilters 사용 + const linkedFilters = splitPanelContext?.linkedFilters || []; + const getLinkedFilterValues = splitPanelContext?.getLinkedFilterValues; + + // 🆕 FK 컬럼 설정 우선순위: + // 1. linkedFilters에서 targetTable에 해당하는 설정 찾기 + // 2. config.fkColumn (컴포넌트 설정) + // 3. config.groupByColumn (그룹화 컬럼) + let fkSourceColumn: string | null = null; + let fkTargetColumn: string | null = null; + let linkedFilterTargetTable: string | null = null; + + // linkedFilters에서 FK 컬럼 찾기 + if (linkedFilters.length > 0 && selectedLeftData) { + // 첫 번째 linkedFilter 사용 (일반적으로 하나만 설정됨) + const linkedFilter = linkedFilters[0]; + fkSourceColumn = linkedFilter.sourceColumn; + + // targetColumn이 "테이블명.컬럼명" 형식일 수 있음 → 분리 + // 예: "dtg_maintenance_history.serial_no" → table: "dtg_maintenance_history", column: "serial_no" + const targetColumnParts = linkedFilter.targetColumn.split("."); + if (targetColumnParts.length === 2) { + linkedFilterTargetTable = targetColumnParts[0]; + fkTargetColumn = targetColumnParts[1]; + } else { + fkTargetColumn = linkedFilter.targetColumn; + } + } + + // 🆕 targetTable 우선순위: config.targetTable > linkedFilters에서 추출한 테이블 + const effectiveTargetTable = targetTable || linkedFilterTargetTable; + + // 🆕 DB에서 컬럼 정보 로드 (webType 등) + useEffect(() => { + const loadColumnInfo = async () => { + if (!effectiveTargetTable) return; + + try { + const response = await apiClient.get(`/table-management/tables/${effectiveTargetTable}/columns`); + console.log("📋 [RepeaterFieldGroup] 컬럼 정보 응답:", response.data); + + // 응답 구조에 따라 데이터 추출 + // 실제 응답: { success: true, data: { columns: [...], page, size, total, totalPages } } + let columns: any[] = []; + if (response.data?.success && response.data?.data) { + // data.columns가 배열인 경우 (실제 응답 구조) + if (Array.isArray(response.data.data.columns)) { + columns = response.data.data.columns; + } + // data가 배열인 경우 + else if (Array.isArray(response.data.data)) { + columns = response.data.data; + } + // data 자체가 객체이고 배열이 아닌 경우 (키-값 형태) + else if (typeof response.data.data === "object") { + columns = Object.values(response.data.data); + } + } + // success 없이 바로 배열인 경우 + else if (Array.isArray(response.data)) { + columns = response.data; + } + + console.log("📋 [RepeaterFieldGroup] 파싱된 컬럼 배열:", columns.length, "개"); + + if (columns.length > 0) { + const colMap: Record = {}; + columns.forEach((col: any) => { + // columnName 또는 column_name 또는 name 키 사용 + const colName = col.columnName || col.column_name || col.name; + if (colName) { + colMap[colName] = col; + } + }); + setColumnInfo(colMap); + console.log("📋 [RepeaterFieldGroup] 컬럼 정보 로드 완료:", { + table: effectiveTargetTable, + columns: Object.keys(colMap), + webTypes: Object.entries(colMap).map( + ([name, info]: [string, any]) => `${name}: ${info.webType || info.web_type || "unknown"}`, + ), + }); + } + } catch (error) { + console.error("❌ [RepeaterFieldGroup] 컬럼 정보 로드 실패:", error); + } + }; + + loadColumnInfo(); + }, [effectiveTargetTable]); + + // linkedFilters가 없으면 config에서 가져오기 + const fkColumn = fkTargetColumn || config.fkColumn || config.groupByColumn; + const fkValue = + fkSourceColumn && selectedLeftData + ? selectedLeftData[fkSourceColumn] + : fkColumn && selectedLeftData + ? selectedLeftData[fkColumn] + : null; + + console.log("🔄 [RepeaterFieldGroup] 렌더링:", { + fieldName, + hasFormData: !!formData, formDataId: formData?.id, formDataValue: formData?.[fieldName], propsValue: value, @@ -72,8 +210,24 @@ const RepeaterFieldGroupComponent: React.FC = (props) => groupByColumn, groupKeyValue, targetTable, + linkedFilterTargetTable, + effectiveTargetTable, hasGroupedData: groupedData !== null, groupedDataLength: groupedData?.length, + // 🆕 분할 패널 관련 정보 + linkedFiltersCount: linkedFilters.length, + linkedFilters: linkedFilters.map((f) => `${f.sourceColumn} → ${f.targetColumn}`), + fkSourceColumn, + fkTargetColumn, + splitPanelPosition, + isRightPanel, + hasSelectedLeftData: !!selectedLeftData, + // 🆕 selectedLeftData 상세 정보 (디버깅용) + selectedLeftDataId: selectedLeftData?.id, + selectedLeftDataFkValue: fkSourceColumn ? selectedLeftData?.[fkSourceColumn] : "N/A", + selectedLeftData: selectedLeftData ? JSON.stringify(selectedLeftData).slice(0, 200) : null, + fkColumn, + fkValue, }); // 🆕 수정 모드에서 그룹화된 데이터 로드 @@ -82,16 +236,16 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 이미 로드했거나 조건이 맞지 않으면 스킵 if (groupDataLoadedRef.current) return; if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return; - + console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", { groupByColumn, groupKeyValue, targetTable, }); - + setIsLoadingGroupData(true); groupDataLoadedRef.current = true; - + try { // API 호출: 같은 그룹 키를 가진 모든 데이터 조회 // search 파라미터 사용 (filters가 아닌 search) @@ -100,14 +254,14 @@ const RepeaterFieldGroupComponent: React.FC = (props) => size: 100, // 충분히 큰 값 search: { [groupByColumn]: groupKeyValue }, }); - + console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", { success: response.data?.success, hasData: !!response.data?.data, dataType: typeof response.data?.data, dataKeys: response.data?.data ? Object.keys(response.data.data) : [], }); - + // 응답 구조: { success, data: { data: [...], total, page, totalPages } } if (response.data?.success && response.data?.data?.data) { const items = response.data.data.data; // 실제 데이터 배열 @@ -118,17 +272,17 @@ const RepeaterFieldGroupComponent: React.FC = (props) => firstItem: items[0], }); setGroupedData(items); - + // 🆕 원본 데이터 ID 목록 저장 (삭제 추적용) const itemIds = items.map((item: any) => String(item.id || item.po_item_id || item.item_id)).filter(Boolean); setOriginalItemIds(itemIds); console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds); - + // 🆕 SplitPanelContext에 기존 항목 ID 등록 (좌측 테이블 필터링용) if (splitPanelContext?.addItemIds && itemIds.length > 0) { splitPanelContext.addItemIds(itemIds); } - + // onChange 호출하여 부모에게 알림 if (onChange && items.length > 0) { const dataWithMeta = items.map((item: any) => ({ @@ -150,15 +304,126 @@ const RepeaterFieldGroupComponent: React.FC = (props) => setIsLoadingGroupData(false); } }; - + loadGroupedData(); }, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]); + // 🆕 분할 패널에서 좌측 데이터 선택 시 FK 기반으로 데이터 로드 + // 좌측 테이블의 serial_no 등을 기준으로 우측 repeater 데이터 필터링 + const prevFkValueRef = useRef(null); + + useEffect(() => { + const loadDataByFK = async () => { + // 우측 패널이 아니면 스킵 + if (!isRightPanel) { + return; + } + + // 🆕 fkValue가 없거나 빈 값이면 빈 상태로 초기화 + if (!fkValue || fkValue === "" || fkValue === null || fkValue === undefined) { + console.log("🔄 [RepeaterFieldGroup] FK 값 없음 - 빈 상태로 초기화:", { + fkColumn, + fkValue, + prevFkValue: prevFkValueRef.current, + }); + // 이전에 데이터가 있었다면 초기화 + if (prevFkValueRef.current !== null) { + setGroupedData([]); + setOriginalItemIds([]); + onChange?.([]); + prevFkValueRef.current = null; + } + return; + } + + // FK 컬럼이나 타겟 테이블이 없으면 스킵 + if (!fkColumn || !effectiveTargetTable) { + console.log("⏭️ [RepeaterFieldGroup] FK 기반 로드 스킵 (설정 부족):", { + fkColumn, + effectiveTargetTable, + }); + return; + } + + // 같은 FK 값으로 이미 로드했으면 스킵 + const currentFkValueStr = String(fkValue); + if (prevFkValueRef.current === currentFkValueStr) { + console.log("⏭️ [RepeaterFieldGroup] 같은 FK 값 - 스킵:", currentFkValueStr); + return; + } + prevFkValueRef.current = currentFkValueStr; + + console.log("📥 [RepeaterFieldGroup] 분할 패널 FK 기반 데이터 로드:", { + fkColumn, + fkValue, + effectiveTargetTable, + }); + + setIsLoadingGroupData(true); + + try { + // API 호출: FK 값을 기준으로 데이터 조회 + const response = await apiClient.post(`/table-management/tables/${effectiveTargetTable}/data`, { + page: 1, + size: 100, + search: { [fkColumn]: fkValue }, + }); + + if (response.data?.success) { + const items = response.data?.data?.data || []; + console.log("✅ [RepeaterFieldGroup] FK 기반 데이터 로드 완료:", { + count: items.length, + fkColumn, + fkValue, + effectiveTargetTable, + }); + + // 🆕 데이터가 있든 없든 항상 상태 업데이트 (빈 배열도 명확히 설정) + setGroupedData(items); + + // 원본 데이터 ID 목록 저장 + const itemIds = items.map((item: any) => String(item.id)).filter(Boolean); + setOriginalItemIds(itemIds); + + // onChange 호출 (effectiveTargetTable 사용) + if (onChange) { + if (items.length > 0) { + const dataWithMeta = items.map((item: any) => ({ + ...item, + _targetTable: effectiveTargetTable, + _existingRecord: !!item.id, + })); + onChange(dataWithMeta); + } else { + // 🆕 데이터가 없으면 빈 배열 전달 (이전 데이터 클리어) + console.log("ℹ️ [RepeaterFieldGroup] FK 기반 데이터 없음 - 빈 상태로 초기화"); + onChange([]); + } + } + } else { + // API 실패 시 빈 배열로 설정 + console.log("⚠️ [RepeaterFieldGroup] FK 기반 데이터 로드 실패 - 빈 상태로 초기화"); + setGroupedData([]); + setOriginalItemIds([]); + onChange?.([]); + } + } catch (error) { + console.error("❌ [RepeaterFieldGroup] FK 기반 데이터 로드 오류:", error); + setGroupedData([]); + } finally { + setIsLoadingGroupData(false); + } + }; + + loadDataByFK(); + }, [isRightPanel, fkColumn, fkValue, effectiveTargetTable, onChange]); + // 값이 JSON 문자열인 경우 파싱 let parsedValue: any[] = []; - - // 🆕 그룹화된 데이터가 있으면 우선 사용 - if (groupedData !== null && groupedData.length > 0) { + + // 🆕 그룹화된 데이터가 설정되어 있으면 우선 사용 (빈 배열 포함!) + // groupedData가 null이 아니면 (빈 배열이라도) 해당 값을 사용 + if (groupedData !== null) { parsedValue = groupedData; } else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) { // 그룹화 설정이 없는 경우에만 단일 행 사용 @@ -201,7 +466,7 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 데이터 수신 핸들러 const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => { console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode }); - + if (!data || data.length === 0) { toast.warning("전달할 데이터가 없습니다"); return; @@ -230,13 +495,20 @@ const RepeaterFieldGroupComponent: React.FC = (props) => const definedFields = configRef.current.fields || []; const definedFieldNames = new Set(definedFields.map((f: any) => f.name)); // 시스템 필드 및 필수 필드 추가 (id는 제외 - 새 레코드로 처리하기 위해) - const systemFields = new Set(['_targetTable', '_isNewItem', 'created_date', 'updated_date', 'writer', 'company_code']); - + const systemFields = new Set([ + "_targetTable", + "_isNewItem", + "created_date", + "updated_date", + "writer", + "company_code", + ]); + const filteredData = normalizedData.map((item: any) => { const filteredItem: Record = {}; - Object.keys(item).forEach(key => { + Object.keys(item).forEach((key) => { // 🆕 id 필드는 제외 (새 레코드로 저장되도록) - if (key === 'id') { + if (key === "id") { return; // id 필드 제외 } // 정의된 필드이거나 시스템 필드인 경우만 포함 @@ -254,25 +526,21 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 기존 데이터에 새 데이터 추가 (기본 모드: append) const currentValue = parsedValueRef.current; - + // mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가 const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append"; - + let newItems: any[]; let addedCount = 0; let duplicateCount = 0; - + if (mode === "replace") { newItems = filteredData; addedCount = filteredData.length; } else { // 🆕 중복 체크: item_code를 기준으로 이미 존재하는 항목 제외 (id는 사용하지 않음) - const existingItemCodes = new Set( - currentValue - .map((item: any) => item.item_code) - .filter(Boolean) - ); - + const existingItemCodes = new Set(currentValue.map((item: any) => item.item_code).filter(Boolean)); + const uniqueNewItems = filteredData.filter((item: any) => { const itemCode = item.item_code; if (itemCode && existingItemCodes.has(itemCode)) { @@ -281,14 +549,14 @@ const RepeaterFieldGroupComponent: React.FC = (props) => } return true; }); - + newItems = [...currentValue, ...uniqueNewItems]; addedCount = uniqueNewItems.length; } - console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { - currentValue, - newItems, + console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { + currentValue, + newItems, mode, addedCount, duplicateCount, @@ -300,21 +568,19 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // 🆕 SplitPanelContext에 추가된 항목 ID 등록 (좌측 테이블 필터링용) // item_code를 기준으로 등록 (id는 새 레코드라 없을 수 있음) if (splitPanelContext?.addItemIds && addedCount > 0) { - const newItemCodes = newItems - .map((item: any) => String(item.item_code)) - .filter(Boolean); + const newItemCodes = newItems.map((item: any) => String(item.item_code)).filter(Boolean); splitPanelContext.addItemIds(newItemCodes); } // JSON 문자열로 변환하여 저장 const jsonValue = JSON.stringify(newItems); - console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", { - jsonValue, + console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", { + jsonValue, hasOnChange: !!onChangeRef.current, hasOnFormDataChange: !!onFormDataChangeRef.current, fieldName: fieldNameRef.current, }); - + // onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트) if (onFormDataChangeRef.current) { onFormDataChangeRef.current(fieldNameRef.current, jsonValue); @@ -337,18 +603,21 @@ const RepeaterFieldGroupComponent: React.FC = (props) => }, []); // DataReceivable 인터페이스 구현 - const dataReceiver = useMemo(() => ({ - componentId: component.id, - componentType: "repeater-field-group", - receiveData: handleReceiveData, - }), [component.id, handleReceiveData]); + const dataReceiver = useMemo( + () => ({ + componentId: component.id, + componentType: "repeater-field-group", + receiveData: handleReceiveData, + }), + [component.id, handleReceiveData], + ); // ScreenContext에 데이터 수신자로 등록 useEffect(() => { if (screenContext && component.id) { console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id); screenContext.registerDataReceiver(component.id, dataReceiver); - + return () => { screenContext.unregisterDataReceiver(component.id); }; @@ -358,16 +627,16 @@ const RepeaterFieldGroupComponent: React.FC = (props) => // SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만) useEffect(() => { const splitPanelPosition = screenContext?.splitPanelPosition; - + if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) { console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", { componentId: component.id, position: splitPanelPosition, }); - + splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver); receiverRef.current = dataReceiver; - + return () => { console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id); splitPanelContext.unregisterReceiver(splitPanelPosition, component.id); @@ -380,13 +649,13 @@ const RepeaterFieldGroupComponent: React.FC = (props) => useEffect(() => { const handleSplitPanelDataTransfer = (event: CustomEvent) => { const { data, mode, mappingRules } = event.detail; - + console.log("📥 [RepeaterFieldGroup] splitPanelDataTransfer 이벤트 수신:", { dataCount: data?.length, mode, componentId: component.id, }); - + // 우측 패널의 리피터 필드 그룹만 데이터를 수신 const splitPanelPosition = screenContext?.splitPanelPosition; if (splitPanelPosition === "right" && data && data.length > 0) { @@ -395,51 +664,113 @@ const RepeaterFieldGroupComponent: React.FC = (props) => }; window.addEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); - + return () => { window.removeEventListener("splitPanelDataTransfer", handleSplitPanelDataTransfer as EventListener); }; }, [screenContext?.splitPanelPosition, handleReceiveData, component.id]); // 🆕 RepeaterInput에서 항목 변경 시 SplitPanelContext의 addedItemIds 동기화 - const handleRepeaterChange = useCallback((newValue: any[]) => { - // 배열을 JSON 문자열로 변환하여 저장 - const jsonValue = JSON.stringify(newValue); - onChange?.(jsonValue); - - // 🆕 groupedData 상태도 업데이트 - setGroupedData(newValue); - - // 🆕 SplitPanelContext의 addedItemIds 동기화 - if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") { - // 현재 항목들의 ID 목록 - const currentIds = newValue - .map((item: any) => String(item.id || item.po_item_id || item.item_id)) - .filter(Boolean); - - // 기존 addedItemIds와 비교하여 삭제된 ID 찾기 - const addedIds = splitPanelContext.addedItemIds; - const removedIds = Array.from(addedIds).filter(id => !currentIds.includes(id)); - - if (removedIds.length > 0) { - console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds); - splitPanelContext.removeItemIds(removedIds); + const handleRepeaterChange = useCallback( + (newValue: any[]) => { + // 🆕 분할 패널에서 우측인 경우, 새 항목에 FK 값과 targetTable 추가 + let valueWithMeta = newValue; + + if (isRightPanel && effectiveTargetTable) { + valueWithMeta = newValue.map((item: any) => { + const itemWithMeta = { + ...item, + _targetTable: effectiveTargetTable, + }; + + // 🆕 FK 값이 있고 새 항목이면 FK 컬럼에 값 추가 + if (fkColumn && fkValue && item._isNewItem) { + itemWithMeta[fkColumn] = fkValue; + console.log("🔗 [RepeaterFieldGroup] 새 항목에 FK 값 추가:", { + fkColumn, + fkValue, + }); + } + + return itemWithMeta; + }); } - - // 새로 추가된 ID가 있으면 등록 - const newIds = currentIds.filter((id: string) => !addedIds.has(id)); - if (newIds.length > 0) { - console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds); - splitPanelContext.addItemIds(newIds); + + // 배열을 JSON 문자열로 변환하여 저장 + const jsonValue = JSON.stringify(valueWithMeta); + console.log("📤 [RepeaterFieldGroup] 데이터 변경:", { + fieldName, + itemCount: valueWithMeta.length, + isRightPanel, + hasScreenContextUpdateFormData: !!screenContext?.updateFormData, + }); + + // 🆕 분할 패널 우측에서는 ScreenContext.updateFormData만 사용 + // (중복 저장 방지: onChange/onFormDataChange는 부모에게 전달되어 다시 formData로 돌아옴) + if (isRightPanel && screenContext?.updateFormData) { + screenContext.updateFormData(fieldName, jsonValue); + console.log("📤 [RepeaterFieldGroup] screenContext.updateFormData 호출 (우측 패널):", { fieldName }); + } else { + // 분할 패널이 아니거나 좌측 패널인 경우 기존 방식 사용 + onChange?.(jsonValue); + if (onFormDataChange) { + onFormDataChange(fieldName, jsonValue); + console.log("📤 [RepeaterFieldGroup] onFormDataChange(props) 호출:", { fieldName }); + } } - } - }, [onChange, splitPanelContext, screenContext?.splitPanelPosition]); + + // 🆕 groupedData 상태도 업데이트 + setGroupedData(valueWithMeta); + + // 🆕 SplitPanelContext의 addedItemIds 동기화 + if (splitPanelContext?.isInSplitPanel && screenContext?.splitPanelPosition === "right") { + // 현재 항목들의 ID 목록 + const currentIds = newValue + .map((item: any) => String(item.id || item.po_item_id || item.item_id)) + .filter(Boolean); + + // 기존 addedItemIds와 비교하여 삭제된 ID 찾기 + const addedIds = splitPanelContext.addedItemIds; + const removedIds = Array.from(addedIds).filter((id) => !currentIds.includes(id)); + + if (removedIds.length > 0) { + console.log("🗑️ [RepeaterFieldGroup] 삭제된 항목 ID 제거:", removedIds); + splitPanelContext.removeItemIds(removedIds); + } + + // 새로 추가된 ID가 있으면 등록 + const newIds = currentIds.filter((id: string) => !addedIds.has(id)); + if (newIds.length > 0) { + console.log("➕ [RepeaterFieldGroup] 새 항목 ID 추가:", newIds); + splitPanelContext.addItemIds(newIds); + } + } + }, + [ + onChange, + onFormDataChange, + splitPanelContext, + screenContext?.splitPanelPosition, + screenContext?.updateFormData, + isRightPanel, + effectiveTargetTable, + fkColumn, + fkValue, + fieldName, + ], + ); + + // 🆕 config에 effectiveTargetTable 병합 (linkedFilters에서 추출된 테이블도 포함) + const effectiveConfig = { + ...config, + targetTable: effectiveTargetTable || config.targetTable, + }; return ( = ({ // 자동생성된 값 상태 const [autoGeneratedValue, setAutoGeneratedValue] = useState(""); - + // API 호출 중복 방지를 위한 ref const isGeneratingRef = React.useRef(false); const hasGeneratedRef = React.useRef(false); @@ -104,7 +104,6 @@ export const TextInputComponent: React.FC = ({ const currentFormValue = formData?.[component.columnName]; const currentComponentValue = component.value; - // 자동생성된 값이 없고, 현재 값도 없을 때만 생성 if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) { isGeneratingRef.current = true; // 생성 시작 플래그 @@ -145,7 +144,7 @@ export const TextInputComponent: React.FC = ({ if (isInteractive && onFormDataChange && component.columnName) { console.log("📝 formData 업데이트:", component.columnName, generatedValue); onFormDataChange(component.columnName, generatedValue); - + // 채번 규칙 ID도 함께 저장 (저장 시점에 실제 할당하기 위함) if (testAutoGeneration.type === "numbering_rule" && testAutoGeneration.options?.numberingRuleId) { const ruleIdKey = `${component.columnName}_numberingRuleId`; @@ -181,12 +180,12 @@ export const TextInputComponent: React.FC = ({ // width는 항상 100%로 고정 (부모 컨테이너가 gridColumns로 크기 제어) width: "100%", // 숨김 기능: 편집 모드에서만 연하게 표시 - ...(isHidden && - isDesignMode && { - opacity: 0.4, - backgroundColor: "hsl(var(--muted))", - pointerEvents: "auto", - }), + ...(isHidden && + isDesignMode && { + opacity: 0.4, + backgroundColor: "hsl(var(--muted))", + pointerEvents: "auto", + }), }; // 디자인 모드 스타일 @@ -361,7 +360,7 @@ export const TextInputComponent: React.FC = ({
{/* 라벨 렌더링 */} {component.label && component.style?.labelDisplay !== false && ( -