From f7384cb450fdcb2e2820d9f5d3d436c3fcd3ce10 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 15 Dec 2025 09:25:14 +0900 Subject: [PATCH 01/11] =?UTF-8?q?fix(modal-repeater-table):=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=A1=B0=EC=9D=B8=20?= =?UTF-8?q?=EC=8B=9C=20ID=20=ED=83=80=EC=9E=85=20=EB=B3=80=ED=99=98=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/registry/components/split-panel-layout2/README.md | 1 + .../components/split-panel-layout2/SplitPanelLayout2Renderer.tsx | 1 + .../universal-form-modal/modals/FieldDetailSettingsModal.tsx | 1 + .../components/universal-form-modal/modals/SaveSettingsModal.tsx | 1 + .../universal-form-modal/modals/SectionLayoutModal.tsx | 1 + 5 files changed, 5 insertions(+) diff --git a/frontend/lib/registry/components/split-panel-layout2/README.md b/frontend/lib/registry/components/split-panel-layout2/README.md index f1d8544b..4e5debe8 100644 --- a/frontend/lib/registry/components/split-panel-layout2/README.md +++ b/frontend/lib/registry/components/split-panel-layout2/README.md @@ -100,3 +100,4 @@ - [컴포넌트 시스템 가이드](../../docs/컴포넌트_시스템_가이드.md) - [split-panel-layout (v1)](../split-panel-layout/README.md) + diff --git a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx index f582646e..21e70b13 100644 --- a/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx +++ b/frontend/lib/registry/components/split-panel-layout2/SplitPanelLayout2Renderer.tsx @@ -40,3 +40,4 @@ export class SplitPanelLayout2Renderer extends AutoRegisteringComponentRenderer // 자동 등록 실행 SplitPanelLayout2Renderer.registerSelf(); + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx index 719a99e3..751ac2c6 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/FieldDetailSettingsModal.tsx @@ -840,3 +840,4 @@ export function FieldDetailSettingsModal({ ); } + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx index 9d269c62..27ee00ff 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SaveSettingsModal.tsx @@ -794,3 +794,4 @@ export function SaveSettingsModal({ ); } + diff --git a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx index fe981260..dfdecbc0 100644 --- a/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx +++ b/frontend/lib/registry/components/universal-form-modal/modals/SectionLayoutModal.tsx @@ -514,3 +514,4 @@ export function SectionLayoutModal({ ); } + From 16885225a020d7a53de556a486b9af2260ac8e07 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Mon, 15 Dec 2025 14:46:32 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat(edit-modal):=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=9B=84=20=EC=A0=9C=EC=96=B4=EB=A1=9C?= =?UTF-8?q?=EC=A7=81(=EB=85=B8=EB=93=9C=20=ED=94=8C=EB=A1=9C=EC=9A=B0)=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=8B=A4=ED=96=89=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EditModal에서 INSERT/UPDATE/그룹 저장 완료 후 제어로직 자동 실행 - loadSaveButtonConfig(): 모달 내부 저장 버튼의 제어로직 설정 조회 - findSaveButtonInComponents(): 재귀적으로 저장 버튼 탐색 (conditional-container 내부 포함) - buttonActions.ts: openEditModal 이벤트에 buttonConfig, buttonContext 전달 - executeAfterSaveControl()을 public으로 변경하여 외부 호출 가능 - 제어로직 실행 오류 시 저장 성공 유지, 경고 토스트만 표시 --- frontend/components/screen/EditModal.tsx | 234 +++++++++++++++++- .../button-primary/ButtonPrimaryComponent.tsx | 12 + frontend/lib/utils/buttonActions.ts | 5 +- 3 files changed, 248 insertions(+), 3 deletions(-) diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 294bca7f..5a123b3f 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -26,12 +26,56 @@ interface EditModalState { onSave?: () => 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,10 +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, @@ -130,6 +255,9 @@ export const EditModal: React.FC = ({ className }) => { onSave, groupByColumns, // 🆕 그룹핑 컬럼 tableName, // 🆕 테이블명 + buttonConfig, // 🆕 버튼 설정 + buttonContext, // 🆕 버튼 컨텍스트 + saveButtonConfig, // 🆕 모달 내부 저장 버튼의 제어로직 설정 }); // 편집 데이터로 폼 데이터 초기화 @@ -581,6 +709,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("변경된 내용이 없습니다."); @@ -615,6 +783,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 || "생성에 실패했습니다."); @@ -657,6 +856,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/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index 1942d268..4b88c565 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -376,6 +376,7 @@ export const ButtonPrimaryComponent: React.FC = ({ // 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴) enableDataflowControl: component.webTypeConfig?.enableDataflowControl, dataflowConfig: component.webTypeConfig?.dataflowConfig, + dataflowTiming: component.webTypeConfig?.dataflowTiming, }; } else if (componentConfig.action && typeof componentConfig.action === "object") { // 🔥 이미 객체인 경우에도 제어관리 설정 추가 @@ -383,8 +384,19 @@ export const ButtonPrimaryComponent: React.FC = ({ ...componentConfig.action, enableDataflowControl: component.webTypeConfig?.enableDataflowControl, dataflowConfig: component.webTypeConfig?.dataflowConfig, + dataflowTiming: component.webTypeConfig?.dataflowTiming, }; } + + // 🔍 디버깅: processedConfig.action 확인 + console.log("[ButtonPrimaryComponent] processedConfig.action 생성 완료", { + actionType: processedConfig.action?.type, + enableDataflowControl: processedConfig.action?.enableDataflowControl, + dataflowTiming: processedConfig.action?.dataflowTiming, + dataflowConfig: processedConfig.action?.dataflowConfig, + webTypeConfigRaw: component.webTypeConfig, + componentText: component.text, + }); // 스타일 계산 // height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감 diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 1ced2836..ede92868 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -2086,6 +2086,8 @@ export class ButtonActionExecutor { editData: rowData, groupByColumns: groupByColumns.length > 0 ? groupByColumns : undefined, // 🆕 그룹핑 컬럼 전달 tableName: context.tableName, // 🆕 테이블명 전달 + buttonConfig: config, // 🆕 버튼 설정 전달 (제어로직 실행용) + buttonContext: context, // 🆕 버튼 컨텍스트 전달 (screenId, userId 등) onSave: () => { context.onRefresh?.(); }, @@ -2621,8 +2623,9 @@ export class ButtonActionExecutor { /** * 저장 후 제어 실행 (After Timing) + * EditModal 등 외부에서도 호출 가능하도록 public으로 변경 */ - private static async executeAfterSaveControl( + public static async executeAfterSaveControl( config: ButtonActionConfig, context: ButtonActionContext, ): Promise { From 93443c98eec483074d701d6474c23d17a539d7be Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 15:40:29 +0900 Subject: [PATCH 03/11] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20RepeaterFieldGroup=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20DB=20w?= =?UTF-8?q?ebType=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=ED=95=91=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../externalDbConnectionPoolService.ts | 4 +- .../components/webtypes/RepeaterInput.tsx | 334 +++++------ .../webtypes/config/RepeaterConfigPanel.tsx | 255 ++++++--- frontend/contexts/ScreenContext.tsx | 93 ++-- .../button-primary/ButtonPrimaryComponent.tsx | 21 +- .../RepeaterFieldGroupRenderer.tsx | 517 ++++++++++++++---- frontend/lib/utils/buttonActions.ts | 165 +++++- frontend/types/repeater.ts | 37 +- 8 files changed, 1034 insertions(+), 392 deletions(-) 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/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index 3116b2c6..0b5a1328 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]); @@ -164,14 +185,14 @@ export const RepeaterInput: React.FC = ({ if (items.length <= minItems) { return; } - + // 🆕 삭제되는 항목의 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 +200,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 +226,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 +248,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 +309,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 +350,7 @@ export const RepeaterInput: React.FC = ({ default: result = value1; } - + return result; }; @@ -341,42 +360,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 +406,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 +429,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 +478,12 @@ export const RepeaterInput: React.FC = ({ ); } // 색상이 없으면 텍스트로 표시 - return {categoryData.label}; + return {categoryData.label}; } } - + // 일반 텍스트 - return ( - - {value || "-"} - - ); + return {value || "-"}; } switch (field.type) { @@ -500,35 +513,46 @@ 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")) { + dateValue = dateValue.split("T")[0]; + } + // 유효한 날짜인지 확인 + 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 +564,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 +649,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -641,29 +661,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 +691,10 @@ export const RepeaterInput: React.FC = ({ color: item.color || "#64748b", }; }); - + console.log(`✅ [RepeaterInput] 조인 테이블 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); - - setCategoryMappings(prev => ({ + + setCategoryMappings((prev) => ({ ...prev, [columnName]: mapping, })); @@ -694,9 +714,9 @@ export const RepeaterInput: React.FC = ({ if (fields.length === 0) { return (
-
-

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

-

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

+
+

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

+

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

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

{emptyMessage}

+
+

{emptyMessage}

{!readonly && !disabled && items.length < maxItems && (
diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index 4d5915e9..e1573998 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -4130,14 +4130,51 @@ export class ButtonActionExecutor { const tripId = this.currentTripId; + // 🆕 DB에서 출발지/목적지 조회 (운전자가 중간에 바꿔도 원래 값 사용) + let dbDeparture: string | null = null; + let dbArrival: string | null = null; + let dbVehicleId: string | null = null; + + const userId = context.userId || this.trackingUserId; + if (userId) { + try { + const { apiClient } = await import("@/lib/api/client"); + const statusTableName = config.trackingStatusTableName || this.trackingConfig?.trackingStatusTableName || context.tableName || "vehicles"; + const keyField = config.trackingStatusKeyField || this.trackingConfig?.trackingStatusKeyField || "user_id"; + + // DB에서 현재 차량 정보 조회 + const vehicleResponse = await apiClient.post( + `/table-management/tables/${statusTableName}/data`, + { + page: 1, + size: 1, + search: { [keyField]: userId }, + autoFilter: true, + }, + ); + + const vehicleData = vehicleResponse.data?.data?.data?.[0] || vehicleResponse.data?.data?.rows?.[0]; + if (vehicleData) { + dbDeparture = vehicleData.departure || null; + dbArrival = vehicleData.arrival || null; + dbVehicleId = vehicleData.id || vehicleData.vehicle_id || null; + console.log("📍 [handleTrackingStop] DB에서 출발지/목적지 조회:", { dbDeparture, dbArrival, dbVehicleId }); + } + } catch (dbError) { + console.warn("⚠️ [handleTrackingStop] DB 조회 실패, formData 사용:", dbError); + } + } + // 마지막 위치 저장 (추적 중이었던 경우에만) if (isTrackingActive) { - const departure = + // DB 값 우선, 없으면 formData 사용 + const departure = dbDeparture || this.trackingContext?.formData?.[this.trackingConfig?.trackingDepartureField || "departure"] || null; - const arrival = this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; + const arrival = dbArrival || + this.trackingContext?.formData?.[this.trackingConfig?.trackingArrivalField || "arrival"] || null; const departureName = this.trackingContext?.formData?.["departure_name"] || null; const destinationName = this.trackingContext?.formData?.["destination_name"] || null; - const vehicleId = + const vehicleId = dbVehicleId || this.trackingContext?.formData?.[this.trackingConfig?.trackingVehicleIdField || "vehicle_id"] || null; await this.saveLocationToHistory( From 8425dece7f73a9a0d2691b374f463d69d0bcf201 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 15 Dec 2025 17:47:16 +0900 Subject: [PATCH 06/11] =?UTF-8?q?=EB=B6=84=ED=95=A0=20=ED=8C=A8=EB=84=90?= =?UTF-8?q?=20=EC=A2=8C=EC=B8=A1=20=EC=84=A0=ED=83=9D=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=ED=8F=BC=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EA=B0=80=20=EA=B0=B1=EC=8B=A0=EB=90=98=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../screen-embedding/EmbeddedScreen.tsx | 82 +++++---- .../button-primary/ButtonPrimaryComponent.tsx | 18 +- .../text-input/TextInputComponent.tsx | 165 ++++++++++-------- 3 files changed, 156 insertions(+), 109 deletions(-) diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx index d8e62c00..17cd240f 100644 --- a/frontend/components/screen-embedding/EmbeddedScreen.tsx +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -40,32 +40,33 @@ 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(); - + // 🆕 사용자 정보 가져오기 (저장 액션에 필요) 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 +93,49 @@ 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]); + + // 🆕 현재 화면의 모든 컴포넌트에서 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 +401,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 +421,9 @@ export const EmbeddedScreen = forwardRef +
= ({ } } - // 🆕 분할 패널 우측이면 screenContext.formData와 props.formData를 병합 - // screenContext.formData: RepeaterFieldGroup 등 컴포넌트가 직접 업데이트한 데이터 - // props.formData: 부모에서 전달된 폼 데이터 + // 🆕 분할 패널 우측이면 여러 소스에서 formData를 병합 + // 우선순위: props.formData > screenContext.formData > splitPanelParentData const screenContextFormData = screenContext?.formData || {}; const propsFormData = formData || {}; - // 병합: props.formData를 기본으로 하고, screenContext.formData로 오버라이드 - // (RepeaterFieldGroup 데이터는 screenContext에만 있음) - const effectiveFormData = { ...propsFormData, ...screenContextFormData }; + // 병합: splitPanelParentData를 기본으로, props.formData, screenContext.formData 순으로 오버라이드 + // (일반 폼 필드는 props.formData, RepeaterFieldGroup은 screenContext.formData에 있음) + let effectiveFormData = { ...propsFormData, ...screenContextFormData }; + + // 🆕 분할 패널 우측이고 formData가 비어있으면 splitPanelParentData 사용 + if (splitPanelPosition === "right" && Object.keys(effectiveFormData).length === 0 && splitPanelParentData) { + effectiveFormData = { ...splitPanelParentData }; + console.log("🔍 [ButtonPrimary] 분할 패널 우측 - splitPanelParentData 사용:", Object.keys(effectiveFormData)); + } console.log("🔍 [ButtonPrimary] formData 선택:", { hasScreenContextFormData: Object.keys(screenContextFormData).length > 0, screenContextKeys: Object.keys(screenContextFormData), hasPropsFormData: Object.keys(propsFormData).length > 0, propsFormDataKeys: Object.keys(propsFormData), + hasSplitPanelParentData: !!splitPanelParentData && Object.keys(splitPanelParentData).length > 0, splitPanelPosition, effectiveFormDataKeys: Object.keys(effectiveFormData), }); diff --git a/frontend/lib/registry/components/text-input/TextInputComponent.tsx b/frontend/lib/registry/components/text-input/TextInputComponent.tsx index ad37f19f..8ffa8afe 100644 --- a/frontend/lib/registry/components/text-input/TextInputComponent.tsx +++ b/frontend/lib/registry/components/text-input/TextInputComponent.tsx @@ -53,7 +53,7 @@ export const TextInputComponent: React.FC = ({ // 자동생성된 값 상태 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 && ( -