Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node

This commit is contained in:
kjs 2026-02-13 09:59:07 +09:00
commit 97165ab007
13 changed files with 282 additions and 100 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

@ -18,6 +18,45 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
/**
* (password)
* - table_type_columns에서 input_type = 'password'
* -
*/
async function maskPasswordColumns(tableName: string, data: any): Promise<any> {
try {
const passwordCols = await query<{ column_name: string }>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'password'`,
[tableName]
);
if (passwordCols.length === 0) return data;
const passwordColumnNames = new Set(passwordCols.map(c => c.column_name));
// 단일 객체 처리
const maskRow = (row: any) => {
if (!row || typeof row !== "object") return row;
const masked = { ...row };
for (const col of passwordColumnNames) {
if (col in masked) {
masked[col] = ""; // 해시값 대신 빈 문자열
}
}
return masked;
};
if (Array.isArray(data)) {
return data.map(maskRow);
}
return maskRow(data);
} catch (error) {
// 마스킹 실패해도 원본 데이터 반환 (서비스 중단 방지)
console.warn("⚠️ password 컬럼 마스킹 실패:", error);
return data;
}
}
interface GetTableDataParams {
tableName: string;
limit?: number;
@ -622,14 +661,14 @@ class DataService {
return {
success: true,
data: normalizedGroupRows, // 🔧 배열로 반환!
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
};
}
}
return {
success: true,
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
};
}
}
@ -648,7 +687,7 @@ class DataService {
return {
success: true,
data: result[0],
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
};
} catch (error) {
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);

View File

@ -2,6 +2,7 @@ import { query, queryOne, transaction, getPool } from "../database/db";
import { EventTriggerService } from "./eventTriggerService";
import { DataflowControlService } from "./dataflowControlService";
import tableCategoryValueService from "./tableCategoryValueService";
import { PasswordUtils } from "../utils/passwordUtils";
export interface FormDataResult {
id: number;
@ -859,6 +860,33 @@ export class DynamicFormService {
}
}
// 비밀번호(password) 타입 컬럼 처리
// - 빈 값이면 변경 목록에서 제거 (기존 비밀번호 유지)
// - 값이 있으면 암호화 후 저장
try {
const passwordCols = await query<{ column_name: string }>(
`SELECT DISTINCT column_name FROM table_type_columns
WHERE table_name = $1 AND input_type = 'password'`,
[tableName]
);
for (const { column_name } of passwordCols) {
if (column_name in changedFields) {
const pwValue = changedFields[column_name];
if (!pwValue || pwValue === "") {
// 빈 값 → 기존 비밀번호 유지 (변경 목록에서 제거)
delete changedFields[column_name];
console.log(`🔐 비밀번호 필드 ${column_name}: 빈 값이므로 업데이트 스킵 (기존 유지)`);
} else {
// 값 있음 → 암호화하여 저장
changedFields[column_name] = PasswordUtils.encrypt(pwValue);
console.log(`🔐 비밀번호 필드 ${column_name}: 새 비밀번호 암호화 완료`);
}
}
}
} catch (pwError) {
console.warn("⚠️ 비밀번호 컬럼 처리 중 오류:", pwError);
}
// 변경된 필드가 없으면 업데이트 건너뛰기
if (Object.keys(changedFields).length === 0) {
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");

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];
// UPDATE 모드 - PUT (전체 업데이트)
// originalData 비교 없이 formData 전체를 보냄
const recordId = formData.id;
// 🔧 배열이면 쉼표 구분 문자열로 변환 (리피터 데이터 제외)
if (!recordId) {
console.error("[EditModal] UPDATE 실패: formData에 id가 없습니다.", {
formDataKeys: Object.keys(formData),
});
toast.error("수정할 레코드의 ID를 찾을 수 없습니다.");
return;
}
// 배열 값 → 쉼표 구분 문자열 변환 (리피터 데이터 제외)
const dataToSave: Record<string, any> = {};
Object.entries(formData).forEach(([key, value]) => {
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) {
toast.info("변경된 내용이 없습니다.");
handleClose();
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;
}
});
// 기본키 확인 (id 또는 첫 번째 키)
const recordId = originalData.id || Object.values(originalData)[0];
// 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}
/>
);
@ -790,6 +799,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
@ -804,6 +814,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={config.placeholder}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
@ -852,6 +863,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
rows={config.rows}
readonly={readonly}
disabled={disabled}
inputStyle={inputTextStyle}
/>
);
@ -871,6 +883,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
placeholder={isGeneratingNumbering ? "생성 중..." : "자동 생성됩니다"}
readonly={true}
disabled={disabled || isGeneratingNumbering}
inputStyle={inputTextStyle}
/>
);
}
@ -917,6 +930,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 && (
@ -941,6 +955,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
readonly={readonly}
disabled={disabled}
columnName={columnName}
inputStyle={inputTextStyle}
/>
);
}
@ -966,13 +981,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,23 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
const additionalFields = componentConfig.additionalFields || [];
const mainTable = componentConfig.targetTable!;
// 수정 모드 감지 (2가지 방법으로 확인)
// 1. URL에 mode=edit 파라미터 확인
// 2. 로드된 데이터에 DB id(PK)가 존재하는지 확인
// 수정 모드에서는 항상 deleteOrphans=true (기존 레코드 교체, 복제 방지)
const urlParams = typeof window !== "undefined" ? new URLSearchParams(window.location.search) : null;
const urlEditMode = urlParams?.get("mode") === "edit";
const dataHasDbId = items.some(item => !!item.originalData?.id);
const isEditMode = urlEditMode || dataHasDbId;
console.log("[SelectedItemsDetailInput] 수정 모드 감지:", {
urlEditMode,
dataHasDbId,
isEditMode,
itemCount: items.length,
firstItemId: items[0]?.originalData?.id,
});
// fieldGroup별 sourceTable 분류
const groupsByTable = new Map<string, typeof groups>();
groups.forEach((group) => {
@ -686,9 +703,20 @@ 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;
console.log(`[SelectedItemsDetailInput] ${mainTable} 저장:`, {
isEditMode,
mappingHasDbIds,
shouldDeleteOrphans,
recordCount: mappingRecords.length,
recordIds: mappingRecords.map(r => r.id || "NEW"),
parentKeys: itemParentKeys,
});
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
let savedMappingIds: string[] = [];
try {
@ -696,7 +724,7 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
mainTable,
itemParentKeys,
mappingRecords,
{ deleteOrphans: mappingHasDbIds },
{ deleteOrphans: shouldDeleteOrphans },
);
// 백엔드에서 반환된 저장된 레코드 ID 목록
if (mappingResult.success && mappingResult.savedIds) {
@ -775,12 +803,23 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
}
const priceHasDbIds = priceRecords.some((r) => !!r.id);
const shouldDeleteDetailOrphans = isEditMode || priceHasDbIds;
console.log(`[SelectedItemsDetailInput] ${detailTable} 저장:`, {
isEditMode,
priceHasDbIds,
shouldDeleteDetailOrphans,
recordCount: priceRecords.length,
recordIds: priceRecords.map(r => r.id || "NEW"),
parentKeys: itemParentKeys,
});
try {
const detailResult = await dataApi.upsertGroupedRecords(
detailTable,
itemParentKeys,
priceRecords,
{ deleteOrphans: priceHasDbIds },
{ deleteOrphans: shouldDeleteDetailOrphans },
);
if (!detailResult.success) {
@ -805,8 +844,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

@ -3641,11 +3641,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

@ -1584,7 +1584,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,
},
{
@ -1633,7 +1633,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") => {
@ -1651,21 +1652,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>
@ -2304,7 +2305,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>