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:
@@ -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
|
||||
|
||||
@@ -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("데이터가 수정되었습니다.");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user