From df04afa5deec022fe4d5c031ed099e2edd69d0ab Mon Sep 17 00:00:00 2001 From: DDD1542 Date: Thu, 12 Feb 2026 16:20:26 +0900 Subject: [PATCH 1/2] feat: Refactor EditModal for improved INSERT/UPDATE handling - Introduced a new state flag `isCreateModeFlag` to determine the mode (INSERT or UPDATE) directly from the event, enhancing clarity in the modal's behavior. - Updated the logic for initializing `originalData` and determining the mode, ensuring that the modal correctly identifies whether to create or update based on the provided data. - Refactored the update logic to send the entire `formData` without relying on `originalData`, streamlining the update process. - Enhanced logging for better debugging and understanding of the modal's state during operations. --- .../src/controllers/screenGroupController.ts | 18 +-- frontend/components/screen/EditModal.tsx | 151 ++++++++++-------- frontend/components/screen/StyleEditor.tsx | 12 ++ frontend/components/v2/V2Input.tsx | 27 +++- .../date-input/DateInputComponent.tsx | 7 + .../number-input/NumberInputComponent.tsx | 6 + .../SelectedItemsDetailInputComponent.tsx | 19 ++- .../text-input/TextInputComponent.tsx | 10 ++ .../textarea-basic/TextareaBasicComponent.tsx | 2 +- .../SplitPanelLayoutComponent.tsx | 6 +- .../SplitPanelLayoutConfigPanel.tsx | 19 +-- 11 files changed, 180 insertions(+), 97 deletions(-) diff --git a/backend-node/src/controllers/screenGroupController.ts b/backend-node/src/controllers/screenGroupController.ts index b53454b9..0e97e2e2 100644 --- a/backend-node/src/controllers/screenGroupController.ts +++ b/backend-node/src/controllers/screenGroupController.ts @@ -1488,13 +1488,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, - jsonb_array_elements_text( + sd.table_name::text as main_table, + jsonb_array_elements( COALESCE( sl.properties->'componentConfig'->'columns', '[]'::jsonb ) - )::jsonb->>'columnName' as column_name + )->>'columnName' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id WHERE sd.screen_id = ANY($1) @@ -1507,7 +1507,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, COALESCE( sl.properties->'componentConfig'->>'bindField', sl.properties->>'bindField', @@ -1530,7 +1530,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'valueField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1543,7 +1543,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'parentFieldId' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1556,7 +1556,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'cascadingParentField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1569,7 +1569,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons SELECT sd.screen_id, sd.screen_name, - sd.table_name as main_table, + sd.table_name::text as main_table, sl.properties->'componentConfig'->>'controlField' as column_name FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id @@ -1750,7 +1750,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons sd.table_name as main_table, sl.properties->>'componentType' as component_type, sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, - sl.properties->'componentConfig'->'rightPanel'->'tableName' as right_panel_table, + sl.properties->'componentConfig'->'rightPanel'->>'tableName' as right_panel_table, sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns FROM screen_definitions sd JOIN screen_layouts sl ON sd.screen_id = sl.screen_id diff --git a/frontend/components/screen/EditModal.tsx b/frontend/components/screen/EditModal.tsx index 38b6da5a..d8ce8e7a 100644 --- a/frontend/components/screen/EditModal.tsx +++ b/frontend/components/screen/EditModal.tsx @@ -113,6 +113,10 @@ export const EditModal: React.FC = ({ className }) => { // 폼 데이터 상태 (편집 데이터로 초기화됨) const [formData, setFormData] = useState>({}); const [originalData, setOriginalData] = useState>({}); + // INSERT/UPDATE 판단용 플래그 (이벤트에서 명시적으로 전달받음) + // true = INSERT (등록/복사), false = UPDATE (수정) + // originalData 상태에 의존하지 않고 이벤트의 isCreateMode 값을 직접 사용 + const [isCreateModeFlag, setIsCreateModeFlag] = useState(true); // 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목) const [groupData, setGroupData] = useState[]>([]); @@ -271,13 +275,19 @@ export const EditModal: React.FC = ({ className }) => { // 편집 데이터로 폼 데이터 초기화 setFormData(editData || {}); - // 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) - // originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 + // originalData: changedData 계산(PATCH)에만 사용 + // INSERT/UPDATE 판단에는 사용하지 않음 setOriginalData(isCreateMode ? {} : editData || {}); + // INSERT/UPDATE 판단: 이벤트의 isCreateMode 플래그를 직접 저장 + // isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE + setIsCreateModeFlag(!!isCreateMode); - if (isCreateMode) { - console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); - } + console.log("[EditModal] 모달 열림:", { + mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)", + hasEditData: !!editData, + editDataId: editData?.id, + isCreateMode, + }); }; const handleCloseEditModal = () => { @@ -579,6 +589,7 @@ export const EditModal: React.FC = ({ className }) => { setZones([]); setConditionalLayers([]); setOriginalData({}); + setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향) setGroupData([]); // 🆕 setOriginalGroupData([]); // 🆕 }; @@ -942,8 +953,31 @@ export const EditModal: React.FC = ({ className }) => { return; } - // originalData가 비어있으면 INSERT, 있으면 UPDATE - const isCreateMode = Object.keys(originalData).length === 0; + // ======================================== + // INSERT/UPDATE 판단 (재설계) + // ======================================== + // 판단 기준: + // 1. isCreateModeFlag === true → 무조건 INSERT (복사/등록 모드 보호) + // 2. isCreateModeFlag === false → formData.id 있으면 UPDATE, 없으면 INSERT + // originalData는 INSERT/UPDATE 판단에 사용하지 않음 (changedData 계산에만 사용) + // ======================================== + let isCreateMode: boolean; + + if (isCreateModeFlag) { + // 이벤트에서 명시적으로 INSERT 모드로 지정됨 (등록/복사) + isCreateMode = true; + } else { + // 수정 모드: formData에 id가 있으면 UPDATE, 없으면 INSERT + isCreateMode = !formData.id; + } + + console.log("[EditModal] 저장 모드 판단:", { + isCreateMode, + isCreateModeFlag, + formDataId: formData.id, + originalDataLength: Object.keys(originalData).length, + tableName: screenData.screenInfo.tableName, + }); if (isCreateMode) { // INSERT 모드 @@ -1134,70 +1168,57 @@ export const EditModal: React.FC = ({ className }) => { throw new Error(response.message || "생성에 실패했습니다."); } } else { - // UPDATE 모드 - 기존 로직 - const changedData: Record = {}; - Object.keys(formData).forEach((key) => { - if (formData[key] !== originalData[key]) { - let value = formData[key]; - - // 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외) - if (Array.isArray(value)) { - // 리피터 데이터인지 확인 (객체 배열이고 _targetTable 또는 _isNewItem이 있으면 리피터) - const isRepeaterData = value.length > 0 && - typeof value[0] === "object" && - value[0] !== null && - ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); - - if (!isRepeaterData) { - // 🔧 손상된 값 필터링 헬퍼 (중괄호, 따옴표, 백슬래시 포함 시 무효) - const isValidValue = (v: any): boolean => { - if (typeof v === "number" && !isNaN(v)) return true; - if (typeof v !== "string") return false; - if (!v || v.trim() === "") return false; - // 손상된 PostgreSQL 배열 형식 감지 - if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false; - return true; - }; - - // 🔧 다중 선택 배열 → 쉼표 구분 문자열로 변환 (손상된 값 필터링) - const validValues = value - .map((v: any) => typeof v === "number" ? String(v) : v) - .filter(isValidValue); - - if (validValues.length !== value.length) { - console.warn(`⚠️ [EditModal UPDATE] 손상된 값 필터링: ${key}`, { - before: value.length, - after: validValues.length, - removed: value.filter((v: any) => !isValidValue(v)) - }); - } - - const stringValue = validValues.join(","); - console.log(`🔧 [EditModal UPDATE] 배열→문자열 변환: ${key}`, { original: value.length, valid: validValues.length, converted: stringValue }); - value = stringValue; - } - } - - changedData[key] = value; - } - }); + // UPDATE 모드 - PUT (전체 업데이트) + // originalData 비교 없이 formData 전체를 보냄 + const recordId = formData.id; - if (Object.keys(changedData).length === 0) { - toast.info("변경된 내용이 없습니다."); - handleClose(); + if (!recordId) { + console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", { + formDataKeys: Object.keys(formData), + }); + toast.error("수정할 레코드의 ID를 찾을 수 없습니다."); return; } - // 기본키 확인 (id 또는 첫 번째 키) - const recordId = originalData.id || Object.values(originalData)[0]; + // 배열 값 → 쉼표 구분 문자열 변환 (리피터 데이터 제외) + const dataToSave: Record = {}; + Object.entries(formData).forEach(([key, value]) => { + if (Array.isArray(value)) { + const isRepeaterData = value.length > 0 && + typeof value[0] === "object" && + value[0] !== null && + ("_targetTable" in value[0] || "_isNewItem" in value[0] || "_existingRecord" in value[0]); + + if (isRepeaterData) { + // 리피터 데이터는 제외 (별도 저장) + return; + } + // 다중 선택 배열 → 쉼표 구분 문자열 + const validValues = value + .map((v: any) => typeof v === "number" ? String(v) : v) + .filter((v: any) => { + if (typeof v === "number") return true; + if (typeof v !== "string") return false; + if (!v || v.trim() === "") return false; + if (v.includes("{") || v.includes("}") || v.includes('"') || v.includes("\\")) return false; + return true; + }); + dataToSave[key] = validValues.join(","); + } else { + dataToSave[key] = value; + } + }); - // UPDATE 액션 실행 - const response = await dynamicFormApi.updateFormDataPartial( + console.log("[EditModal] UPDATE(PUT) 실행:", { recordId, - originalData, - changedData, - screenData.screenInfo.tableName, - ); + fieldCount: Object.keys(dataToSave).length, + tableName: screenData.screenInfo.tableName, + }); + + const response = await dynamicFormApi.updateFormData(recordId, { + tableName: screenData.screenInfo.tableName, + data: dataToSave, + }); if (response.success) { toast.success("데이터가 수정되었습니다."); diff --git a/frontend/components/screen/StyleEditor.tsx b/frontend/components/screen/StyleEditor.tsx index f265115b..3add842c 100644 --- a/frontend/components/screen/StyleEditor.tsx +++ b/frontend/components/screen/StyleEditor.tsx @@ -33,6 +33,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd onStyleChange(newStyle); }; + // 숫자만 입력했을 때 자동으로 px 붙여주는 핸들러 + const autoPxProperties: (keyof ComponentStyle)[] = ["fontSize", "borderWidth", "borderRadius"]; + const handlePxBlur = (property: keyof ComponentStyle) => { + const val = localStyle[property]; + if (val && /^\d+(\.\d+)?$/.test(String(val))) { + handleStyleChange(property, `${val}px`); + } + }; + const toggleSection = (section: string) => { setOpenSections((prev) => ({ ...prev, @@ -66,6 +75,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="1px" value={localStyle.borderWidth || ""} onChange={(e) => handleStyleChange("borderWidth", e.target.value)} + onBlur={() => handlePxBlur("borderWidth")} className="h-6 w-full px-2 py-0 text-xs" /> @@ -121,6 +131,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="5px" value={localStyle.borderRadius || ""} onChange={(e) => handleStyleChange("borderRadius", e.target.value)} + onBlur={() => handlePxBlur("borderRadius")} className="h-6 w-full px-2 py-0 text-xs" /> @@ -209,6 +220,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd placeholder="14px" value={localStyle.fontSize || ""} onChange={(e) => handleStyleChange("fontSize", e.target.value)} + onBlur={() => handlePxBlur("fontSize")} className="h-6 w-full px-2 py-0 text-xs" /> diff --git a/frontend/components/v2/V2Input.tsx b/frontend/components/v2/V2Input.tsx index 17183050..78732ab1 100644 --- a/frontend/components/v2/V2Input.tsx +++ b/frontend/components/v2/V2Input.tsx @@ -82,8 +82,9 @@ const TextInput = forwardRef< disabled?: boolean; className?: string; columnName?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName }, ref) => { +>(({ value, onChange, format = "none", placeholder, readonly, disabled, className, columnName, inputStyle }, ref) => { // 검증 상태 const [hasBlurred, setHasBlurred] = useState(false); const [validationError, setValidationError] = useState(""); @@ -210,6 +211,7 @@ const TextInput = forwardRef< hasError && "border-destructive focus-visible:ring-destructive", className, )} + style={inputStyle} /> {hasError && (

{validationError}

@@ -234,8 +236,9 @@ const NumberInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, min, max, step = 1, placeholder, readonly, disabled, className, inputStyle }, ref) => { const handleChange = useCallback( (e: React.ChangeEvent) => { const val = e.target.value; @@ -268,6 +271,7 @@ const NumberInput = forwardRef< readOnly={readonly} disabled={disabled} className={cn("h-full w-full", className)} + style={inputStyle} /> ); }); @@ -285,8 +289,9 @@ const PasswordInput = forwardRef< readonly?: boolean; disabled?: boolean; className?: string; + inputStyle?: React.CSSProperties; } ->(({ value, onChange, placeholder, readonly, disabled, className }, ref) => { +>(({ value, onChange, placeholder, readonly, disabled, className, inputStyle }, ref) => { const [showPassword, setShowPassword] = useState(false); return ( @@ -300,6 +305,7 @@ const PasswordInput = forwardRef< readOnly={readonly} disabled={disabled} className={cn("h-full w-full pr-10", className)} + style={inputStyle} />