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
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

View File

@@ -113,6 +113,10 @@ export const EditModal: React.FC<EditModalProps> = ({ className }) => {
// 폼 데이터 상태 (편집 데이터로 초기화됨)
const [formData, setFormData] = 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의 모든 품목)
const [groupData, setGroupData] = useState<Record<string, any>[]>([]);
@@ -271,13 +275,19 @@ export const EditModal: React.FC<EditModalProps> = ({ 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<EditModalProps> = ({ className }) => {
setZones([]);
setConditionalLayers([]);
setOriginalData({});
setIsCreateModeFlag(true); // 기본값은 INSERT (안전 방향)
setGroupData([]); // 🆕
setOriginalGroupData([]); // 🆕
};
@@ -942,8 +953,31 @@ export const EditModal: React.FC<EditModalProps> = ({ 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<EditModalProps> = ({ className }) => {
throw new Error(response.message || "생성에 실패했습니다.");
}
} else {
// UPDATE 모드 - 기존 로직
const changedData: Record<string, any> = {};
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<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 액션 실행
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("데이터가 수정되었습니다.");

View File

@@ -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"
/>
</div>
@@ -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"
/>
</div>
@@ -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"
/>
</div>

View File

@@ -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<string>("");
@@ -210,6 +211,7 @@ const TextInput = forwardRef<
hasError && "border-destructive focus-visible:ring-destructive",
className,
)}
style={inputStyle}
/>
{hasError && (
<p className="text-destructive mt-1 text-[11px]">{validationError}</p>
@@ -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<HTMLInputElement>) => {
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}
/>
<button
type="button"
@@ -393,8 +399,9 @@ const TextareaInput = forwardRef<
readonly?: boolean;
disabled?: boolean;
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 (
<textarea
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",
className,
)}
style={inputStyle}
/>
);
});
@@ -767,6 +775,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
readonly={readonly || (autoGeneration.enabled && hasGeneratedRef.current)}
disabled={disabled}
columnName={columnName}
inputStyle={inputTextStyle}
/>
);
@@ -784,6 +793,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
@@ -798,6 +808,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
@@ -840,6 +851,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
rows={config.rows}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
@@ -859,6 +871,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
readonly={true}
disabled={disabled || isGeneratingNumbering}
inputStyle={inputTextStyle}
/>
);
}
@@ -905,6 +918,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder="입력"
className="h-full min-w-[60px] flex-1 bg-transparent px-2 text-sm focus-visible:outline-none"
disabled={disabled || isGeneratingNumbering}
style={inputTextStyle}
/>
{/* 고정 접미어 */}
{templateSuffix && (
@@ -929,6 +943,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
readonly={readonly}
disabled={disabled}
columnName={columnName}
inputStyle={inputTextStyle}
/>
);
}
@@ -954,13 +969,15 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
const hasCustomBackground = !!style?.backgroundColor;
const hasCustomRadius = !!style?.borderRadius;
// 텍스트 스타일 오버라이드 (CSS 상속으로 내부 input에 전달)
// 텍스트 스타일 오버라이드 (내부 input/textarea에 직접 전달)
const customTextStyle: React.CSSProperties = {};
if (style?.color) customTextStyle.color = style.color;
if (style?.fontSize) customTextStyle.fontSize = style.fontSize;
if (style?.fontWeight) customTextStyle.fontWeight = style.fontWeight;
if (style?.textAlign) customTextStyle.textAlign = style.textAlign as React.CSSProperties["textAlign"];
const hasCustomText = Object.keys(customTextStyle).length > 0;
// 내부 input에 직접 적용할 텍스트 스타일 (fontSize, color, fontWeight, textAlign)
const inputTextStyle: React.CSSProperties | undefined = hasCustomText ? customTextStyle : undefined;
return (
<div

View File

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

View File

@@ -109,6 +109,9 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
return value.replace(/,/g, "");
};
// 입력 필드에 직접 적용할 폰트 크기
const inputFontSize = component.style?.fontSize;
// Currency 타입 전용 UI
if (webType === "currency") {
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`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
</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`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
/>
{/* 퍼센트 기호 */}
@@ -218,6 +223,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
max={componentConfig.max}
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`}
style={inputFontSize ? { fontSize: inputFontSize } : undefined}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}

View File

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

View File

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

View File

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

View File

@@ -3404,11 +3404,11 @@ export const SplitPanelLayoutComponent: React.FC<SplitPanelLayoutComponentProps>
{/* 우측 패널 */}
<div
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
className="flex-shrink-0 border-b"
className="flex-shrink-0 border-b bg-muted/30"
style={{
height: componentConfig.rightPanel?.panelHeaderHeight || 48,
minHeight: componentConfig.rightPanel?.panelHeaderHeight || 48,

View File

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