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.
This commit is contained in:
DDD1542 2026-02-12 16:20:26 +09:00
parent 4294e6206b
commit df04afa5de
11 changed files with 180 additions and 97 deletions

View File

@ -1488,13 +1488,13 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT SELECT
sd.screen_id, sd.screen_id,
sd.screen_name, sd.screen_name,
sd.table_name as main_table, sd.table_name::text as main_table,
jsonb_array_elements_text( jsonb_array_elements(
COALESCE( COALESCE(
sl.properties->'componentConfig'->'columns', sl.properties->'componentConfig'->'columns',
'[]'::jsonb '[]'::jsonb
) )
)::jsonb->>'columnName' as column_name )->>'columnName' as column_name
FROM screen_definitions sd FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
WHERE sd.screen_id = ANY($1) WHERE sd.screen_id = ANY($1)
@ -1507,7 +1507,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT SELECT
sd.screen_id, sd.screen_id,
sd.screen_name, sd.screen_name,
sd.table_name as main_table, sd.table_name::text as main_table,
COALESCE( COALESCE(
sl.properties->'componentConfig'->>'bindField', sl.properties->'componentConfig'->>'bindField',
sl.properties->>'bindField', sl.properties->>'bindField',
@ -1530,7 +1530,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT SELECT
sd.screen_id, sd.screen_id,
sd.screen_name, sd.screen_name,
sd.table_name as main_table, sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'valueField' as column_name sl.properties->'componentConfig'->>'valueField' as column_name
FROM screen_definitions sd FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
@ -1543,7 +1543,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT SELECT
sd.screen_id, sd.screen_id,
sd.screen_name, sd.screen_name,
sd.table_name as main_table, sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'parentFieldId' as column_name sl.properties->'componentConfig'->>'parentFieldId' as column_name
FROM screen_definitions sd FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
@ -1556,7 +1556,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT SELECT
sd.screen_id, sd.screen_id,
sd.screen_name, sd.screen_name,
sd.table_name as main_table, sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'cascadingParentField' as column_name sl.properties->'componentConfig'->>'cascadingParentField' as column_name
FROM screen_definitions sd FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id JOIN screen_layouts sl ON sd.screen_id = sl.screen_id
@ -1569,7 +1569,7 @@ export const getScreenSubTables = async (req: AuthenticatedRequest, res: Respons
SELECT SELECT
sd.screen_id, sd.screen_id,
sd.screen_name, sd.screen_name,
sd.table_name as main_table, sd.table_name::text as main_table,
sl.properties->'componentConfig'->>'controlField' as column_name sl.properties->'componentConfig'->>'controlField' as column_name
FROM screen_definitions sd FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id 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, sd.table_name as main_table,
sl.properties->>'componentType' as component_type, sl.properties->>'componentType' as component_type,
sl.properties->'componentConfig'->'rightPanel'->'relation' as right_panel_relation, 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 sl.properties->'componentConfig'->'rightPanel'->'columns' as right_panel_columns
FROM screen_definitions sd FROM screen_definitions sd
JOIN screen_layouts sl ON sd.screen_id = sl.screen_id JOIN screen_layouts sl ON sd.screen_id = sl.screen_id

View File

@ -113,6 +113,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 폼 데이터 상태 (편집 데이터로 초기화됨) // 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = useState<Record<string, any>>({}); const [formData, setFormData] = useState<Record<string, any>>({});
const [originalData, setOriginalData] = useState<Record<string, any>>({}); const [originalData, setOriginalData] = useState<Record<string, any>>({});
// INSERT/UPDATE 판단용 플래그 (이벤트에서 명시적으로 전달받음)
// true = INSERT (등록/복사), false = UPDATE (수정)
// originalData 상태에 의존하지 않고 이벤트의 isCreateMode 값을 직접 사용
const [isCreateModeFlag, setIsCreateModeFlag] = useState<boolean>(true);
// 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목) // 🆕 그룹 데이터 상태 (같은 order_no의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]); const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
@ -271,13 +275,19 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 편집 데이터로 폼 데이터 초기화 // 편집 데이터로 폼 데이터 초기화
setFormData(editData || {}); setFormData(editData || {});
// 🆕 isCreateMode가 true이면 originalData를 빈 객체로 설정 (INSERT 모드) // originalData: changedData 계산(PATCH)에만 사용
// originalData가 비어있으면 INSERT, 있으면 UPDATE로 처리됨 // INSERT/UPDATE 판단에는 사용하지 않음
setOriginalData(isCreateMode ? {} : editData || {}); setOriginalData(isCreateMode ? {} : editData || {});
// INSERT/UPDATE 판단: 이벤트의 isCreateMode 플래그를 직접 저장
// isCreateMode=true(복사/등록) → INSERT, false/undefined(수정) → UPDATE
setIsCreateModeFlag(!!isCreateMode);
if (isCreateMode) { console.log("[EditModal] 모달 열림:", {
console.log("[EditModal] 생성 모드로 열림, 초기값:", editData); mode: isCreateMode ? "INSERT (생성/복사)" : "UPDATE (수정)",
} hasEditData: !!editData,
editDataId: editData?.id,
isCreateMode,
});
}; };
const handleCloseEditModal = () => { const handleCloseEditModal = () => {
@ -579,6 +589,7 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
setZones([]); setZones([]);
setConditionalLayers([]); setConditionalLayers([]);
setOriginalData({}); setOriginalData({});
setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향)
setGroupData([]); // 🆕 setGroupData([]); // 🆕
setOriginalGroupData([]); // 🆕 setOriginalGroupData([]); // 🆕
}; };
@ -942,8 +953,31 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
return; 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) { if (isCreateMode) {
// INSERT 모드 // INSERT 모드
@ -1134,70 +1168,57 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
throw new Error(response.message || "생성에 실패했습니다."); throw new Error(response.message || "생성에 실패했습니다.");
} }
} else { } else {
// UPDATE 모드 - 기존 로직 // UPDATE 모드 - PUT (전체 업데이트)
const changedData: Record<string, any> = {}; // originalData 비교 없이 formData 전체를 보냄
Object.keys(formData).forEach((key) => { const recordId = formData.id;
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;
}
});
if (Object.keys(changedData).length === 0) { if (!recordId) {
toast.info("변경된 내용이 없습니다."); console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
handleClose(); formDataKeys: Object.keys(formData),
});
toast.error("수정할 레코드의 ID를 찾을 수 없습니다.");
return; return;
} }
// 기본키 확인 (id 또는 첫 번째 키) // 배열 값 → 쉼표 구분 문자열 변환 (리피터 데이터 제외)
const recordId = originalData.id || Object.values(originalData)[0]; const dataToSave: Record<string, any> = {};
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 액션 실행 console.log("[EditModal] UPDATE(PUT) 실행:", {
const response = await dynamicFormApi.updateFormDataPartial(
recordId, recordId,
originalData, fieldCount: Object.keys(dataToSave).length,
changedData, tableName: screenData.screenInfo.tableName,
screenData.screenInfo.tableName, });
);
const response = await dynamicFormApi.updateFormData(recordId, {
tableName: screenData.screenInfo.tableName,
data: dataToSave,
});
if (response.success) { if (response.success) {
toast.success("데이터가 수정되었습니다."); toast.success("데이터가 수정되었습니다.");

View File

@ -33,6 +33,15 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
onStyleChange(newStyle); 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) => { const toggleSection = (section: string) => {
setOpenSections((prev) => ({ setOpenSections((prev) => ({
...prev, ...prev,
@ -66,6 +75,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
placeholder="1px" placeholder="1px"
value={localStyle.borderWidth || ""} value={localStyle.borderWidth || ""}
onChange={(e) => handleStyleChange("borderWidth", e.target.value)} onChange={(e) => handleStyleChange("borderWidth", e.target.value)}
onBlur={() => handlePxBlur("borderWidth")}
className="h-6 w-full px-2 py-0 text-xs" className="h-6 w-full px-2 py-0 text-xs"
/> />
</div> </div>
@ -121,6 +131,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
placeholder="5px" placeholder="5px"
value={localStyle.borderRadius || ""} value={localStyle.borderRadius || ""}
onChange={(e) => handleStyleChange("borderRadius", e.target.value)} onChange={(e) => handleStyleChange("borderRadius", e.target.value)}
onBlur={() => handlePxBlur("borderRadius")}
className="h-6 w-full px-2 py-0 text-xs" className="h-6 w-full px-2 py-0 text-xs"
/> />
</div> </div>
@ -209,6 +220,7 @@ export default function StyleEditor({ style, onStyleChange, className }: StyleEd
placeholder="14px" placeholder="14px"
value={localStyle.fontSize || ""} value={localStyle.fontSize || ""}
onChange={(e) => handleStyleChange("fontSize", e.target.value)} onChange={(e) => handleStyleChange("fontSize", e.target.value)}
onBlur={() => handlePxBlur("fontSize")}
className="h-6 w-full px-2 py-0 text-xs" className="h-6 w-full px-2 py-0 text-xs"
/> />
</div> </div>

View File

@ -82,8 +82,9 @@ const TextInput = forwardRef<
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
columnName?: 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 [hasBlurred, setHasBlurred] = useState(false);
const [validationError, setValidationError] = useState<string>(""); const [validationError, setValidationError] = useState<string>("");
@ -210,6 +211,7 @@ const TextInput = forwardRef<
hasError && "border-destructive focus-visible:ring-destructive", hasError && "border-destructive focus-visible:ring-destructive",
className, className,
)} )}
style={inputStyle}
/> />
{hasError && ( {hasError && (
<p className="text-destructive mt-1 text-[11px]">{validationError}</p> <p className="text-destructive mt-1 text-[11px]">{validationError}</p>
@ -234,8 +236,9 @@ const NumberInput = forwardRef<
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
className?: string; 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( const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value; const val = e.target.value;
@ -268,6 +271,7 @@ const NumberInput = forwardRef<
readOnly={readonly} readOnly={readonly}
disabled={disabled} disabled={disabled}
className={cn("h-full w-full", className)} className={cn("h-full w-full", className)}
style={inputStyle}
/> />
); );
}); });
@ -285,8 +289,9 @@ const PasswordInput = forwardRef<
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
className?: string; 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); const [showPassword, setShowPassword] = useState(false);
return ( return (
@ -300,6 +305,7 @@ const PasswordInput = forwardRef<
readOnly={readonly} readOnly={readonly}
disabled={disabled} disabled={disabled}
className={cn("h-full w-full pr-10", className)} className={cn("h-full w-full pr-10", className)}
style={inputStyle}
/> />
<button <button
type="button" type="button"
@ -393,8 +399,9 @@ const TextareaInput = forwardRef<
readonly?: boolean; readonly?: boolean;
disabled?: boolean; disabled?: boolean;
className?: string; className?: string;
inputStyle?: React.CSSProperties;
} }
>(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className }, ref) => { >(({ value = "", onChange, placeholder, rows = 3, readonly, disabled, className, inputStyle }, ref) => {
return ( return (
<textarea <textarea
ref={ref} ref={ref}
@ -408,6 +415,7 @@ const TextareaInput = forwardRef<
"border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-full w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50", "border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-full w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-sm focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
style={inputStyle}
/> />
); );
}); });
@ -767,6 +775,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)} readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
disabled={disabled} disabled={disabled}
columnName={columnName} columnName={columnName}
inputStyle={inputTextStyle}
/> />
); );
@ -784,6 +793,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={config.placeholder} placeholder={config.placeholder}
readonly={readonly} readonly={readonly}
disabled={disabled} disabled={disabled}
inputStyle={inputTextStyle}
/> />
); );
@ -798,6 +808,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={config.placeholder} placeholder={config.placeholder}
readonly={readonly} readonly={readonly}
disabled={disabled} disabled={disabled}
inputStyle={inputTextStyle}
/> />
); );
@ -840,6 +851,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
rows={config.rows} rows={config.rows}
readonly={readonly} readonly={readonly}
disabled={disabled} disabled={disabled}
inputStyle={inputTextStyle}
/> />
); );
@ -859,6 +871,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"} placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
readonly={true} readonly={true}
disabled={disabled || isGeneratingNumbering} disabled={disabled || isGeneratingNumbering}
inputStyle={inputTextStyle}
/> />
); );
} }
@ -905,6 +918,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder="입력" placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none" className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
disabled={disabled || isGeneratingNumbering} disabled={disabled || isGeneratingNumbering}
style={inputTextStyle}
/> />
{/* 고정 접미어 */} {/* 고정 접미어 */}
{templateSuffix && ( {templateSuffix && (
@ -929,6 +943,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
readonly={readonly} readonly={readonly}
disabled={disabled} disabled={disabled}
columnName={columnName} columnName={columnName}
inputStyle={inputTextStyle}
/> />
); );
} }
@ -954,13 +969,15 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const hasCustomBackground = !!style?.backgroundColor; const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius; const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (CSS 상속으로 내부 input에 전달) // 텍스트 스타일 오버라이드 (내부 input/textarea 직접 전달)
const customTextStyle: React.CSSProperties = {}; const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color; if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize; if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight; if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"]; if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0; const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
return ( return (
<div <div

View File

@ -275,6 +275,9 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
return ["", ""]; return ["", ""];
}, [webType, rawValue]); }, [webType, rawValue]);
// 입력 필드에 직접 적용할 폰트 크기
const inputFontSize = component.style?.fontSize;
// daterange 타입 전용 UI // daterange 타입 전용 UI
if (webType === "daterange") { if (webType === "daterange") {
return ( return (
@ -312,6 +315,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
{/* 구분자 */} {/* 구분자 */}
@ -341,6 +345,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
</div> </div>
</div> </div>
@ -385,6 +390,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
</div> </div>
); );
@ -421,6 +427,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
onClick={handleClick} onClick={handleClick}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}

View File

@ -109,6 +109,9 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
return value.replace(/,/g, ""); return value.replace(/,/g, "");
}; };
// 입력 필드에 직접 적용할 폰트 크기
const inputFontSize = component.style?.fontSize;
// Currency 타입 전용 UI // Currency 타입 전용 UI
if (webType === "currency") { if (webType === "currency") {
return ( return (
@ -141,6 +144,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
} }
}} }}
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-green-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`} className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-green-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
</div> </div>
</div> </div>
@ -179,6 +183,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
} }
}} }}
className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-blue-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`} className={`h-full flex-1 rounded-md border px-3 py-2 text-right text-base font-semibold transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-blue-600"} focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
{/* 퍼센트 기호 */} {/* 퍼센트 기호 */}
@ -218,6 +223,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
max={componentConfig.max} max={componentConfig.max}
step={step} step={step}
className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`} className={`box-border h-full w-full rounded-md border px-3 py-2 text-sm shadow-sm transition-all duration-200 outline-none ${isSelected ? "border-blue-500 ring-2 ring-blue-100" : "border-gray-300"} ${componentConfig.disabled ? "bg-gray-100 text-gray-400" : "bg-white text-gray-900"} placeholder:text-gray-400 focus:border-orange-500 focus:ring-2 focus:ring-orange-100 disabled:cursor-not-allowed`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
onClick={handleClick} onClick={handleClick}
onDragStart={onDragStart} onDragStart={onDragStart}
onDragEnd={onDragEnd} onDragEnd={onDragEnd}

View File

@ -596,6 +596,11 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const additionalFields = componentConfig.additionalFields || []; const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!; const mainTable = componentConfig.targetTable!;
// 수정 모드 감지: URL에 mode=edit가 있으면 수정 모드
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
const isEditMode = urlParams?.get("mode") === "edit";
// fieldGroup별 sourceTable 분류 // fieldGroup별 sourceTable 분류
const groupsByTable = new Map<string, typeof groups>(); const groupsByTable = new Map<string, typeof groups>();
groups.forEach((group) => { groups.forEach((group) => {
@ -686,9 +691,10 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}); });
}); });
// 레코드에 id(기존 DB PK)가 있으면 EDIT 모드 → 고아 삭제 // 수정 모드이거나 레코드에 id(기존 DB PK)가 있으면 → 고아 삭제 (기존 레코드 교체)
// id 없으면 CREATE 모드 → 기존 레코드 건드리지 않음 // 신규 등록이고 id 없으면 → 기존 레코드 건드리지 않음
const mappingHasDbIds = mappingRecords.some((r) => !!r.id); const mappingHasDbIds = mappingRecords.some((r) => !!r.id);
const shouldDeleteOrphans = isEditMode || mappingHasDbIds;
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용) // 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
let savedMappingIds: string[] = []; let savedMappingIds: string[] = [];
try { try {
@ -696,7 +702,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
mainTable, mainTable,
itemParentKeys, itemParentKeys,
mappingRecords, mappingRecords,
{ deleteOrphans: mappingHasDbIds }, { deleteOrphans: shouldDeleteOrphans },
); );
// 백엔드에서 반환된 저장된 레코드 ID 목록 // 백엔드에서 반환된 저장된 레코드 ID 목록
if (mappingResult.success && mappingResult.savedIds) { if (mappingResult.success && mappingResult.savedIds) {
@ -775,12 +781,13 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
} }
const priceHasDbIds = priceRecords.some((r) => !!r.id); const priceHasDbIds = priceRecords.some((r) => !!r.id);
const shouldDeleteDetailOrphans = isEditMode || priceHasDbIds;
try { try {
const detailResult = await dataApi.upsertGroupedRecords( const detailResult = await dataApi.upsertGroupedRecords(
detailTable, detailTable,
itemParentKeys, itemParentKeys,
priceRecords, priceRecords,
{ deleteOrphans: priceHasDbIds }, { deleteOrphans: shouldDeleteDetailOrphans },
); );
if (!detailResult.success) { if (!detailResult.success) {
@ -805,8 +812,10 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
// 단일 테이블 저장 (기존 로직 - detailTable 없는 경우) // 단일 테이블 저장 (기존 로직 - detailTable 없는 경우)
// ============================================================ // ============================================================
const records = generateCartesianProduct(items); const records = generateCartesianProduct(items);
const singleHasDbIds = records.some((r) => !!r.id);
const shouldDeleteSingleOrphans = isEditMode || singleHasDbIds;
const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records); const result = await dataApi.upsertGroupedRecords(mainTable, parentKeys, records, { deleteOrphans: shouldDeleteSingleOrphans });
if (result.success) { if (result.success) {
window.dispatchEvent( window.dispatchEvent(

View File

@ -192,6 +192,9 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
} }
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외) // 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
// 입력 필드에 직접 적용할 폰트 크기
const inputFontSize = component.style?.fontSize;
const componentStyle: React.CSSProperties = { const componentStyle: React.CSSProperties = {
width: "100%", width: "100%",
height: "100%", height: "100%",
@ -412,6 +415,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
{/* @ 구분자 */} {/* @ 구분자 */}
@ -528,6 +532,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
<span className="text-muted-foreground text-base font-medium">-</span> <span className="text-muted-foreground text-base font-medium">-</span>
@ -558,6 +563,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
<span className="text-muted-foreground text-base font-medium">-</span> <span className="text-muted-foreground text-base font-medium">-</span>
@ -588,6 +594,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
</div> </div>
</div> </div>
@ -659,6 +666,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
</div> </div>
</div> </div>
@ -712,6 +720,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/> />
</div> </div>
); );
@ -791,6 +800,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
: "bg-background text-foreground", : "bg-background text-foreground",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
onClick={(e) => { onClick={(e) => {
handleClick(e); handleClick(e);
}} }}

View File

@ -102,7 +102,7 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
border: "1px solid #d1d5db", border: "1px solid #d1d5db",
borderRadius: "8px", borderRadius: "8px",
padding: "8px 12px", padding: "8px 12px",
fontSize: "14px", fontSize: component.style?.fontSize || "14px",
outline: "none", outline: "none",
resize: "none", resize: "none",
transition: "all 0.2s ease-in-out", transition: "all 0.2s ease-in-out",

View File

@ -3404,11 +3404,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 우측 패널 */} {/* 우측 패널 */}
<div <div
style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }} style={{ width: `${100 - leftWidth}%`, minWidth: isPreview ? "0" : `${minRightWidth}px`, height: "100%" }}
className="flex flex-shrink-0 flex-col" className="flex flex-shrink-0 flex-col border-l border-border/60 bg-muted/5"
> >
<Card className="flex flex-col border-0 shadow-none" style={{ height: "100%" }}> <Card className="flex flex-col border-0 bg-transparent shadow-none" style={{ height: "100%" }}>
<CardHeader <CardHeader
className="flex-shrink-0 border-b" className="flex-shrink-0 border-b bg-muted/30"
style={{ style={{
height: componentConfig.rightPanel?.panelHeaderHeight || 48, height: componentConfig.rightPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48, minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,

View File

@ -1528,7 +1528,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
{ {
id: "basic", id: "basic",
title: "기본 설정", title: "기본 설정",
desc: `${relationshipType === "detail" ? "상세" : "조건 필터"} | 비율 ${config.splitRatio || 30}%`, desc: `${relationshipType === "detail" ? "1건 상세보기" : "연관 목록"} | 비율 ${config.splitRatio || 30}%`,
icon: Settings2, icon: Settings2,
}, },
{ {
@ -1577,7 +1577,8 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-6"> <div className="space-y-6">
{/* 관계 타입 선택 */} {/* 관계 타입 선택 */}
<div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4"> <div className="space-y-3 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3> <h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> </h3>
<p className="text-muted-foreground text-xs"> </p>
<Select <Select
value={relationshipType} value={relationshipType}
onValueChange={(value: "join" | "detail") => { onValueChange={(value: "join" | "detail") => {
@ -1595,21 +1596,21 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
}} }}
> >
<SelectTrigger className="h-10 bg-white"> <SelectTrigger className="h-10 bg-white">
<SelectValue placeholder="관계 타입 선택"> <SelectValue placeholder="표시 방식 선택">
{relationshipType === "detail" ? "상세 (DETAIL)" : "조건 필터 (FILTERED)"} {relationshipType === "detail" ? "1건 상세보기" : "연관 목록"}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="detail"> <SelectItem value="detail">
<div className="flex flex-col py-1"> <div className="flex flex-col py-1">
<span className="text-sm font-medium"> (DETAIL)</span> <span className="text-sm font-medium">1 </span>
<span className="text-xs text-gray-500"> ( )</span> <span className="text-xs text-gray-500"> ( )</span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="join"> <SelectItem value="join">
<div className="flex flex-col py-1"> <div className="flex flex-col py-1">
<span className="text-sm font-medium"> (FILTERED)</span> <span className="text-sm font-medium"> </span>
<span className="text-xs text-gray-500"> </span> <span className="text-xs text-gray-500"> / </span>
</div> </div>
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
@ -2085,7 +2086,7 @@ export const SplitPanelLayoutConfigPanel: React.FC<SplitPanelLayoutConfigPanelPr
<div className="space-y-4"> <div className="space-y-4">
{/* 우측 패널 설정 */} {/* 우측 패널 설정 */}
<div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4"> <div className="space-y-4 rounded-lg border border-border/50 bg-muted/40 p-4">
<h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ({relationshipType === "detail" ? "상세" : "조건 필터"})</h3> <h3 className="border-l-2 border-l-primary/40 pl-2 text-sm font-semibold"> ({relationshipType === "detail" ? "1건 상세보기" : "연관 목록"})</h3>
<div className="space-y-2"> <div className="space-y-2">
<Label> </Label> <Label> </Label>