Merge branch 'feature/v2-renewal' of http://39.117.244.52:3000/kjs/ERP-node into jskim-node
This commit is contained in:
commit
97165ab007
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,45 @@ import { pool } from "../database/db"; // 🆕 Entity 조인을 위한 pool impo
|
||||||
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
import { buildDataFilterWhereClause } from "../utils/dataFilterUtil"; // 🆕 데이터 필터 유틸
|
||||||
import { v4 as uuidv4 } from "uuid"; // 🆕 UUID 생성
|
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 {
|
interface GetTableDataParams {
|
||||||
tableName: string;
|
tableName: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
|
@ -622,14 +661,14 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: normalizedGroupRows, // 🔧 배열로 반환!
|
data: await maskPasswordColumns(tableName, normalizedGroupRows), // 🔧 배열로 반환! + password 마스킹
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: normalizedRows[0], // 그룹핑 없으면 단일 레코드
|
data: await maskPasswordColumns(tableName, normalizedRows[0]), // 그룹핑 없으면 단일 레코드 + password 마스킹
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -648,7 +687,7 @@ class DataService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: result[0],
|
data: await maskPasswordColumns(tableName, result[0]), // password 마스킹
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
console.error(`레코드 상세 조회 오류 (${tableName}/${id}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { query, queryOne, transaction, getPool } from "../database/db";
|
||||||
import { EventTriggerService } from "./eventTriggerService";
|
import { EventTriggerService } from "./eventTriggerService";
|
||||||
import { DataflowControlService } from "./dataflowControlService";
|
import { DataflowControlService } from "./dataflowControlService";
|
||||||
import tableCategoryValueService from "./tableCategoryValueService";
|
import tableCategoryValueService from "./tableCategoryValueService";
|
||||||
|
import { PasswordUtils } from "../utils/passwordUtils";
|
||||||
|
|
||||||
export interface FormDataResult {
|
export interface FormDataResult {
|
||||||
id: number;
|
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) {
|
if (Object.keys(changedFields).length === 0) {
|
||||||
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
console.log("📋 변경된 필드가 없습니다. 업데이트를 건너뜁니다.");
|
||||||
|
|
|
||||||
|
|
@ -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("데이터가 수정되었습니다.");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -790,6 +799,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -804,6 +814,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -852,6 +863,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -871,6 +883,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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -917,6 +930,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 && (
|
||||||
|
|
@ -941,6 +955,7 @@ export const V2Input = forwardRef<HTMLDivElement, V2InputProps>((props, ref) =>
|
||||||
readonly={readonly}
|
readonly={readonly}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
columnName={columnName}
|
columnName={columnName}
|
||||||
|
inputStyle={inputTextStyle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -966,13 +981,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
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -596,6 +596,23 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
const additionalFields = componentConfig.additionalFields || [];
|
const additionalFields = componentConfig.additionalFields || [];
|
||||||
const mainTable = componentConfig.targetTable!;
|
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 분류
|
// fieldGroup별 sourceTable 분류
|
||||||
const groupsByTable = new Map<string, typeof groups>();
|
const groupsByTable = new Map<string, typeof groups>();
|
||||||
groups.forEach((group) => {
|
groups.forEach((group) => {
|
||||||
|
|
@ -686,9 +703,20 @@ 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;
|
||||||
|
|
||||||
|
console.log(`[SelectedItemsDetailInput] ${mainTable} 저장:`, {
|
||||||
|
isEditMode,
|
||||||
|
mappingHasDbIds,
|
||||||
|
shouldDeleteOrphans,
|
||||||
|
recordCount: mappingRecords.length,
|
||||||
|
recordIds: mappingRecords.map(r => r.id || "NEW"),
|
||||||
|
parentKeys: itemParentKeys,
|
||||||
|
});
|
||||||
|
|
||||||
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
|
// 저장된 매핑 ID를 추적 (디테일 테이블에 mapping_id 주입용)
|
||||||
let savedMappingIds: string[] = [];
|
let savedMappingIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
|
|
@ -696,7 +724,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 +803,23 @@ export const SelectedItemsDetailInputComponent: React.FC<SelectedItemsDetailInpu
|
||||||
}
|
}
|
||||||
|
|
||||||
const priceHasDbIds = priceRecords.some((r) => !!r.id);
|
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 {
|
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 +844,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(
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -3641,11 +3641,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,
|
||||||
|
|
|
||||||
|
|
@ -1584,7 +1584,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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1633,7 +1633,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") => {
|
||||||
|
|
@ -1651,21 +1652,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>
|
||||||
|
|
@ -2304,7 +2305,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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue