Merge pull request 'feature/screen-management' (#43) from feature/screen-management into dev
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/43
This commit is contained in:
commit
a108fa0cc8
|
|
@ -4086,3 +4086,57 @@ model table_relationships_backup {
|
||||||
|
|
||||||
@@ignore
|
@@ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model test_sales_info {
|
||||||
|
sales_no String @id @db.VarChar(20)
|
||||||
|
contract_type String? @db.VarChar(50)
|
||||||
|
order_seq Int?
|
||||||
|
domestic_foreign String? @db.VarChar(20)
|
||||||
|
customer_name String? @db.VarChar(200)
|
||||||
|
product_type String? @db.VarChar(100)
|
||||||
|
machine_type String? @db.VarChar(100)
|
||||||
|
customer_project_name String? @db.VarChar(200)
|
||||||
|
expected_delivery_date DateTime? @db.Date
|
||||||
|
receiving_location String? @db.VarChar(200)
|
||||||
|
setup_location String? @db.VarChar(200)
|
||||||
|
equipment_direction String? @db.VarChar(100)
|
||||||
|
equipment_count Int? @default(0)
|
||||||
|
equipment_type String? @db.VarChar(100)
|
||||||
|
equipment_length Decimal? @db.Decimal(10,2)
|
||||||
|
manager_name String? @db.VarChar(100)
|
||||||
|
reg_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
status String? @default("진행중") @db.VarChar(50)
|
||||||
|
|
||||||
|
// 관계 정의: 영업 정보에서 프로젝트로
|
||||||
|
projects test_project_info[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model test_project_info {
|
||||||
|
project_no String @id @db.VarChar(200)
|
||||||
|
sales_no String? @db.VarChar(20)
|
||||||
|
contract_type String? @db.VarChar(50)
|
||||||
|
order_seq Int?
|
||||||
|
domestic_foreign String? @db.VarChar(20)
|
||||||
|
customer_name String? @db.VarChar(200)
|
||||||
|
|
||||||
|
// 프로젝트 전용 컬럼들
|
||||||
|
project_status String? @default("PLANNING") @db.VarChar(50)
|
||||||
|
project_start_date DateTime? @db.Date
|
||||||
|
project_end_date DateTime? @db.Date
|
||||||
|
project_manager String? @db.VarChar(100)
|
||||||
|
project_description String? @db.Text
|
||||||
|
|
||||||
|
// 시스템 관리 컬럼들
|
||||||
|
created_by String? @db.VarChar(100)
|
||||||
|
created_date DateTime? @default(now()) @db.Timestamp(6)
|
||||||
|
updated_by String? @db.VarChar(100)
|
||||||
|
updated_date DateTime? @default(now()) @updatedAt @db.Timestamp(6)
|
||||||
|
|
||||||
|
// 관계 정의: 영업 정보 참조
|
||||||
|
sales test_sales_info? @relation(fields: [sales_no], references: [sales_no])
|
||||||
|
|
||||||
|
@@index([sales_no], map: "idx_project_sales_no")
|
||||||
|
@@index([project_status], map: "idx_project_status")
|
||||||
|
@@index([customer_name], map: "idx_project_customer")
|
||||||
|
@@index([project_manager], map: "idx_project_manager")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,19 @@ export async function getDiagramRelationships(
|
||||||
|
|
||||||
const relationships = (diagram.relationships as any)?.relationships || [];
|
const relationships = (diagram.relationships as any)?.relationships || [];
|
||||||
|
|
||||||
|
console.log("🔍 백엔드 - 관계도 데이터:", {
|
||||||
|
diagramId: diagram.diagram_id,
|
||||||
|
diagramName: diagram.diagram_name,
|
||||||
|
relationshipsRaw: diagram.relationships,
|
||||||
|
relationshipsArray: relationships,
|
||||||
|
relationshipsCount: relationships.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 각 관계의 구조도 로깅
|
||||||
|
relationships.forEach((rel: any, index: number) => {
|
||||||
|
console.log(`🔍 백엔드 - 관계 ${index + 1}:`, rel);
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: relationships,
|
data: relationships,
|
||||||
|
|
@ -213,14 +226,77 @@ export async function getRelationshipPreview(
|
||||||
}
|
}
|
||||||
|
|
||||||
// 관계 정보 찾기
|
// 관계 정보 찾기
|
||||||
const relationship = (diagram.relationships as any)?.relationships?.find(
|
console.log("🔍 관계 미리보기 요청:", {
|
||||||
|
diagramId,
|
||||||
|
relationshipId,
|
||||||
|
diagramRelationships: diagram.relationships,
|
||||||
|
relationshipsArray: (diagram.relationships as any)?.relationships,
|
||||||
|
});
|
||||||
|
|
||||||
|
const relationships = (diagram.relationships as any)?.relationships || [];
|
||||||
|
console.log(
|
||||||
|
"🔍 사용 가능한 관계 목록:",
|
||||||
|
relationships.map((rel: any) => ({
|
||||||
|
id: rel.id,
|
||||||
|
name: rel.relationshipName || rel.name, // relationshipName 사용
|
||||||
|
sourceTable: rel.fromTable || rel.sourceTable, // fromTable 사용
|
||||||
|
targetTable: rel.toTable || rel.targetTable, // toTable 사용
|
||||||
|
originalData: rel, // 디버깅용
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const relationship = relationships.find(
|
||||||
(rel: any) => rel.id === relationshipId
|
(rel: any) => rel.id === relationshipId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("🔍 찾은 관계:", relationship);
|
||||||
|
|
||||||
if (!relationship) {
|
if (!relationship) {
|
||||||
|
console.log("❌ 관계를 찾을 수 없음:", {
|
||||||
|
requestedId: relationshipId,
|
||||||
|
availableIds: relationships.map((rel: any) => rel.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔧 임시 해결책: 첫 번째 관계를 사용하거나 기본 응답 반환
|
||||||
|
if (relationships.length > 0) {
|
||||||
|
console.log("🔧 첫 번째 관계를 대신 사용:", relationships[0].id);
|
||||||
|
|
||||||
|
const fallbackRelationship = relationships[0];
|
||||||
|
|
||||||
|
console.log("🔍 fallback 관계 선택:", fallbackRelationship);
|
||||||
|
console.log("🔍 diagram.control 전체 구조:", diagram.control);
|
||||||
|
console.log("🔍 diagram.plan 전체 구조:", diagram.plan);
|
||||||
|
|
||||||
|
const fallbackControl = Array.isArray(diagram.control)
|
||||||
|
? diagram.control.find((c: any) => c.id === fallbackRelationship.id)
|
||||||
|
: null;
|
||||||
|
const fallbackPlan = Array.isArray(diagram.plan)
|
||||||
|
? diagram.plan.find((p: any) => p.id === fallbackRelationship.id)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
console.log("🔍 찾은 fallback control:", fallbackControl);
|
||||||
|
console.log("🔍 찾은 fallback plan:", fallbackPlan);
|
||||||
|
|
||||||
|
const fallbackPreviewData = {
|
||||||
|
relationship: fallbackRelationship,
|
||||||
|
control: fallbackControl,
|
||||||
|
plan: fallbackPlan,
|
||||||
|
conditionsCount: (fallbackControl as any)?.conditions?.length || 0,
|
||||||
|
actionsCount: (fallbackPlan as any)?.actions?.length || 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("🔍 최종 fallback 응답 데이터:", fallbackPreviewData);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: fallbackPreviewData,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
res.status(404).json({
|
res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "관계를 찾을 수 없습니다.",
|
message: `관계를 찾을 수 없습니다. 요청된 ID: ${relationshipId}, 사용 가능한 ID: ${relationships.map((rel: any) => rel.id).join(", ")}`,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,8 @@ export const saveFormData = async (
|
||||||
const { companyCode, userId } = req.user as any;
|
const { companyCode, userId } = req.user as any;
|
||||||
const { screenId, tableName, data } = req.body;
|
const { screenId, tableName, data } = req.body;
|
||||||
|
|
||||||
// 필수 필드 검증
|
// 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크)
|
||||||
if (!screenId || !tableName || !data) {
|
if (screenId === undefined || screenId === null || !tableName || !data) {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "필수 필드가 누락되었습니다. (screenId, tableName, data)",
|
message: "필수 필드가 누락되었습니다. (screenId, tableName, data)",
|
||||||
|
|
@ -80,7 +80,7 @@ export const updateFormData = async (
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await dynamicFormService.updateFormData(
|
const result = await dynamicFormService.updateFormData(
|
||||||
parseInt(id),
|
id, // parseInt 제거 - 문자열 ID 지원
|
||||||
tableName,
|
tableName,
|
||||||
formDataWithMeta
|
formDataWithMeta
|
||||||
);
|
);
|
||||||
|
|
@ -168,7 +168,7 @@ export const deleteFormData = async (
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await dynamicFormService.deleteFormData(parseInt(id), tableName);
|
await dynamicFormService.deleteFormData(id, tableName); // parseInt 제거 - 문자열 ID 지원
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,48 @@ export class DynamicFormService {
|
||||||
return Boolean(value);
|
return Boolean(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 날짜/시간 타입 처리
|
||||||
|
if (
|
||||||
|
lowerDataType.includes("date") ||
|
||||||
|
lowerDataType.includes("timestamp") ||
|
||||||
|
lowerDataType.includes("time")
|
||||||
|
) {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
// 빈 문자열이면 null 반환
|
||||||
|
if (value.trim() === "") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// YYYY-MM-DD 형식인 경우 시간 추가해서 Date 객체 생성
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||||
|
console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`);
|
||||||
|
return new Date(value + "T00:00:00");
|
||||||
|
}
|
||||||
|
// 다른 날짜 형식도 Date 객체로 변환
|
||||||
|
else {
|
||||||
|
console.log(`📅 날짜 타입 변환: ${value} -> Date 객체`);
|
||||||
|
return new Date(value);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 날짜 변환 실패: ${value}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 Date 객체인 경우 그대로 반환
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자인 경우 timestamp로 처리
|
||||||
|
if (typeof value === "number") {
|
||||||
|
return new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// 기본적으로 문자열로 반환
|
// 기본적으로 문자열로 반환
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
@ -479,7 +521,7 @@ export class DynamicFormService {
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE ${tableName}
|
UPDATE ${tableName}
|
||||||
SET ${setClause}
|
SET ${setClause}
|
||||||
WHERE ${primaryKeyColumn} = $${values.length}
|
WHERE ${primaryKeyColumn} = $${values.length}::text
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -507,7 +549,7 @@ export class DynamicFormService {
|
||||||
* 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트)
|
* 폼 데이터 업데이트 (실제 테이블에서 직접 업데이트)
|
||||||
*/
|
*/
|
||||||
async updateFormData(
|
async updateFormData(
|
||||||
id: number,
|
id: string | number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
): Promise<FormDataResult> {
|
): Promise<FormDataResult> {
|
||||||
|
|
@ -552,6 +594,31 @@ export class DynamicFormService {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 컬럼 타입에 맞는 데이터 변환 (UPDATE용)
|
||||||
|
const columnInfo = await this.getTableColumnInfo(tableName);
|
||||||
|
console.log(`📊 테이블 ${tableName}의 컬럼 타입 정보:`, columnInfo);
|
||||||
|
|
||||||
|
// 각 컬럼의 타입에 맞게 데이터 변환
|
||||||
|
Object.keys(dataToUpdate).forEach((columnName) => {
|
||||||
|
const column = columnInfo.find((col) => col.column_name === columnName);
|
||||||
|
if (column) {
|
||||||
|
const originalValue = dataToUpdate[columnName];
|
||||||
|
const convertedValue = this.convertValueForPostgreSQL(
|
||||||
|
originalValue,
|
||||||
|
column.data_type
|
||||||
|
);
|
||||||
|
|
||||||
|
if (originalValue !== convertedValue) {
|
||||||
|
console.log(
|
||||||
|
`🔄 UPDATE 타입 변환: ${columnName} (${column.data_type}) = "${originalValue}" -> ${convertedValue}`
|
||||||
|
);
|
||||||
|
dataToUpdate[columnName] = convertedValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("✅ UPDATE 타입 변환 완료된 데이터:", dataToUpdate);
|
||||||
|
|
||||||
console.log("🎯 실제 테이블에서 업데이트할 데이터:", {
|
console.log("🎯 실제 테이블에서 업데이트할 데이터:", {
|
||||||
tableName,
|
tableName,
|
||||||
id,
|
id,
|
||||||
|
|
@ -575,10 +642,36 @@ export class DynamicFormService {
|
||||||
const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용
|
const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용
|
||||||
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
console.log(`🔑 테이블 ${tableName}의 기본키: ${primaryKeyColumn}`);
|
||||||
|
|
||||||
|
// 기본키 데이터 타입 조회하여 적절한 캐스팅 적용
|
||||||
|
const primaryKeyInfo = (await prisma.$queryRawUnsafe(`
|
||||||
|
SELECT data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = '${tableName}'
|
||||||
|
AND column_name = '${primaryKeyColumn}'
|
||||||
|
AND table_schema = 'public'
|
||||||
|
`)) as any[];
|
||||||
|
|
||||||
|
let typeCastSuffix = "";
|
||||||
|
if (primaryKeyInfo.length > 0) {
|
||||||
|
const dataType = primaryKeyInfo[0].data_type;
|
||||||
|
console.log(`🔍 기본키 ${primaryKeyColumn}의 데이터 타입: ${dataType}`);
|
||||||
|
|
||||||
|
if (dataType.includes("character") || dataType.includes("text")) {
|
||||||
|
typeCastSuffix = "::text";
|
||||||
|
} else if (dataType.includes("bigint")) {
|
||||||
|
typeCastSuffix = "::bigint";
|
||||||
|
} else if (
|
||||||
|
dataType.includes("integer") ||
|
||||||
|
dataType.includes("numeric")
|
||||||
|
) {
|
||||||
|
typeCastSuffix = "::numeric";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
UPDATE ${tableName}
|
UPDATE ${tableName}
|
||||||
SET ${setClause}
|
SET ${setClause}
|
||||||
WHERE ${primaryKeyColumn} = $${values.length}
|
WHERE ${primaryKeyColumn} = $${values.length}${typeCastSuffix}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -640,7 +733,7 @@ export class DynamicFormService {
|
||||||
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
* 폼 데이터 삭제 (실제 테이블에서 직접 삭제)
|
||||||
*/
|
*/
|
||||||
async deleteFormData(
|
async deleteFormData(
|
||||||
id: number,
|
id: string | number,
|
||||||
tableName: string,
|
tableName: string,
|
||||||
companyCode?: string
|
companyCode?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
|
@ -650,12 +743,15 @@ export class DynamicFormService {
|
||||||
tableName,
|
tableName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. 먼저 테이블의 기본키 컬럼명을 동적으로 조회
|
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
|
||||||
const primaryKeyQuery = `
|
const primaryKeyQuery = `
|
||||||
SELECT kcu.column_name
|
SELECT kcu.column_name, c.data_type
|
||||||
FROM information_schema.table_constraints tc
|
FROM information_schema.table_constraints tc
|
||||||
JOIN information_schema.key_column_usage kcu
|
JOIN information_schema.key_column_usage kcu
|
||||||
ON tc.constraint_name = kcu.constraint_name
|
ON tc.constraint_name = kcu.constraint_name
|
||||||
|
JOIN information_schema.columns c
|
||||||
|
ON kcu.column_name = c.column_name
|
||||||
|
AND kcu.table_name = c.table_name
|
||||||
WHERE tc.table_name = $1
|
WHERE tc.table_name = $1
|
||||||
AND tc.constraint_type = 'PRIMARY KEY'
|
AND tc.constraint_type = 'PRIMARY KEY'
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|
@ -677,13 +773,37 @@ export class DynamicFormService {
|
||||||
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const primaryKeyColumn = (primaryKeyResult[0] as any).column_name;
|
const primaryKeyInfo = primaryKeyResult[0] as any;
|
||||||
console.log("🔑 발견된 기본키 컬럼:", primaryKeyColumn);
|
const primaryKeyColumn = primaryKeyInfo.column_name;
|
||||||
|
const primaryKeyDataType = primaryKeyInfo.data_type;
|
||||||
|
console.log("🔑 발견된 기본키:", {
|
||||||
|
column: primaryKeyColumn,
|
||||||
|
dataType: primaryKeyDataType,
|
||||||
|
});
|
||||||
|
|
||||||
// 2. 동적으로 발견된 기본키를 사용한 DELETE SQL 생성
|
// 2. 데이터 타입에 맞는 타입 캐스팅 적용
|
||||||
|
let typeCastSuffix = "";
|
||||||
|
if (
|
||||||
|
primaryKeyDataType.includes("character") ||
|
||||||
|
primaryKeyDataType.includes("text")
|
||||||
|
) {
|
||||||
|
typeCastSuffix = "::text";
|
||||||
|
} else if (
|
||||||
|
primaryKeyDataType.includes("integer") ||
|
||||||
|
primaryKeyDataType.includes("bigint")
|
||||||
|
) {
|
||||||
|
typeCastSuffix = "::bigint";
|
||||||
|
} else if (
|
||||||
|
primaryKeyDataType.includes("numeric") ||
|
||||||
|
primaryKeyDataType.includes("decimal")
|
||||||
|
) {
|
||||||
|
typeCastSuffix = "::numeric";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 동적으로 발견된 기본키와 타입 캐스팅을 사용한 DELETE SQL 생성
|
||||||
const deleteQuery = `
|
const deleteQuery = `
|
||||||
DELETE FROM ${tableName}
|
DELETE FROM ${tableName}
|
||||||
WHERE ${primaryKeyColumn} = $1
|
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
|
||||||
RETURNING *
|
RETURNING *
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,41 @@ const prisma = new PrismaClient();
|
||||||
export class TableManagementService {
|
export class TableManagementService {
|
||||||
constructor() {}
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컬럼이 코드 타입인지 확인하고 코드 카테고리 반환
|
||||||
|
*/
|
||||||
|
private async getCodeTypeInfo(
|
||||||
|
tableName: string,
|
||||||
|
columnName: string
|
||||||
|
): Promise<{ isCodeType: boolean; codeCategory?: string }> {
|
||||||
|
try {
|
||||||
|
// column_labels 테이블에서 해당 컬럼의 web_type이 'code'인지 확인
|
||||||
|
const result = await prisma.$queryRaw`
|
||||||
|
SELECT web_type, code_category
|
||||||
|
FROM column_labels
|
||||||
|
WHERE table_name = ${tableName}
|
||||||
|
AND column_name = ${columnName}
|
||||||
|
AND web_type = 'code'
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (Array.isArray(result) && result.length > 0) {
|
||||||
|
const row = result[0] as any;
|
||||||
|
return {
|
||||||
|
isCodeType: true,
|
||||||
|
codeCategory: row.code_category,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { isCodeType: false };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
`코드 타입 컬럼 확인 중 오류: ${tableName}.${columnName}`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return { isCodeType: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 목록 조회 (PostgreSQL information_schema 활용)
|
* 테이블 목록 조회 (PostgreSQL information_schema 활용)
|
||||||
* 메타데이터 조회는 Prisma로 변경 불가
|
* 메타데이터 조회는 Prisma로 변경 불가
|
||||||
|
|
@ -915,8 +950,36 @@ export class TableManagementService {
|
||||||
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
|
||||||
|
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
|
// 🎯 코드 타입 컬럼의 경우 코드값과 코드명 모두로 검색
|
||||||
|
const codeTypeInfo = await this.getCodeTypeInfo(
|
||||||
|
tableName,
|
||||||
|
safeColumn
|
||||||
|
);
|
||||||
|
|
||||||
|
if (codeTypeInfo.isCodeType && codeTypeInfo.codeCategory) {
|
||||||
|
// 코드 타입 컬럼: 코드값 또는 코드명으로 검색
|
||||||
|
// 1) 컬럼 값이 직접 검색어와 일치하는 경우
|
||||||
|
// 2) 컬럼 값이 코드값이고, 해당 코드의 코드명이 검색어와 일치하는 경우
|
||||||
|
whereConditions.push(`(
|
||||||
|
${safeColumn}::text ILIKE $${paramIndex} OR
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM code_info ci
|
||||||
|
WHERE ci.code_category = $${paramIndex + 1}
|
||||||
|
AND ci.code_value = ${safeColumn}
|
||||||
|
AND ci.code_name ILIKE $${paramIndex + 2}
|
||||||
|
)
|
||||||
|
)`);
|
||||||
|
searchValues.push(`%${value}%`); // 직접 값 검색용
|
||||||
|
searchValues.push(codeTypeInfo.codeCategory); // 코드 카테고리
|
||||||
|
searchValues.push(`%${value}%`); // 코드명 검색용
|
||||||
|
paramIndex += 2; // 추가 파라미터로 인해 인덱스 증가
|
||||||
|
} else {
|
||||||
|
// 일반 컬럼: 기존 방식
|
||||||
|
whereConditions.push(
|
||||||
|
`${safeColumn}::text ILIKE $${paramIndex}`
|
||||||
|
);
|
||||||
searchValues.push(`%${value}%`);
|
searchValues.push(`%${value}%`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
whereConditions.push(`${safeColumn} = $${paramIndex}`);
|
whereConditions.push(`${safeColumn} = $${paramIndex}`);
|
||||||
searchValues.push(value);
|
searchValues.push(value);
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,18 @@
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { MenuItem, MenuFormData, menuApi, LangKey } from "@/lib/api/menu";
|
import { MenuItem, MenuFormData, menuApi, LangKey } from "@/lib/api/menu";
|
||||||
import { companyAPI } from "@/lib/api/company";
|
import { companyAPI } from "@/lib/api/company";
|
||||||
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { ChevronDown, Search } from "lucide-react";
|
||||||
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
|
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
|
||||||
|
import { ScreenDefinition } from "@/types/screen";
|
||||||
|
|
||||||
interface Company {
|
interface Company {
|
||||||
company_code: string;
|
company_code: string;
|
||||||
|
|
@ -70,6 +74,13 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
langKey: "",
|
langKey: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 화면 할당 관련 상태
|
||||||
|
const [urlType, setUrlType] = useState<"direct" | "screen">("screen"); // URL 직접 입력 or 화면 할당 (기본값: 화면 할당)
|
||||||
|
const [selectedScreen, setSelectedScreen] = useState<ScreenDefinition | null>(null);
|
||||||
|
const [screens, setScreens] = useState<ScreenDefinition[]>([]);
|
||||||
|
const [screenSearchText, setScreenSearchText] = useState("");
|
||||||
|
const [isScreenDropdownOpen, setIsScreenDropdownOpen] = useState(false);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [isEdit, setIsEdit] = useState(false);
|
const [isEdit, setIsEdit] = useState(false);
|
||||||
const [companies, setCompanies] = useState<Company[]>([]);
|
const [companies, setCompanies] = useState<Company[]>([]);
|
||||||
|
|
@ -77,6 +88,132 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
const [isLangKeyDropdownOpen, setIsLangKeyDropdownOpen] = useState(false);
|
const [isLangKeyDropdownOpen, setIsLangKeyDropdownOpen] = useState(false);
|
||||||
const [langKeySearchText, setLangKeySearchText] = useState("");
|
const [langKeySearchText, setLangKeySearchText] = useState("");
|
||||||
|
|
||||||
|
// 화면 목록 로드
|
||||||
|
const loadScreens = async () => {
|
||||||
|
try {
|
||||||
|
const response = await screenApi.getScreens({ size: 1000 }); // 모든 화면 가져오기
|
||||||
|
|
||||||
|
console.log("🔍 화면 목록 로드 디버깅:", {
|
||||||
|
totalScreens: response.data.length,
|
||||||
|
firstScreen: response.data[0],
|
||||||
|
firstScreenFields: response.data[0] ? Object.keys(response.data[0]) : [],
|
||||||
|
firstScreenValues: response.data[0] ? Object.values(response.data[0]) : [],
|
||||||
|
allScreenIds: response.data
|
||||||
|
.map((s) => ({
|
||||||
|
screenId: s.screenId,
|
||||||
|
legacyId: s.id,
|
||||||
|
name: s.screenName,
|
||||||
|
code: s.screenCode,
|
||||||
|
}))
|
||||||
|
.slice(0, 5), // 처음 5개만 출력
|
||||||
|
});
|
||||||
|
|
||||||
|
setScreens(response.data);
|
||||||
|
console.log("✅ 화면 목록 로드 완료:", response.data.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 화면 목록 로드 실패:", error);
|
||||||
|
toast.error("화면 목록을 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 선택 시 URL 자동 설정
|
||||||
|
const handleScreenSelect = (screen: ScreenDefinition) => {
|
||||||
|
console.log("🖥️ 화면 선택 디버깅:", {
|
||||||
|
screen,
|
||||||
|
screenId: screen.screenId,
|
||||||
|
screenIdType: typeof screen.screenId,
|
||||||
|
legacyId: screen.id,
|
||||||
|
allFields: Object.keys(screen),
|
||||||
|
screenValues: Object.values(screen),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ScreenDefinition에서는 screenId 필드를 사용
|
||||||
|
const actualScreenId = screen.screenId || screen.id;
|
||||||
|
|
||||||
|
if (!actualScreenId) {
|
||||||
|
console.error("❌ 화면 ID를 찾을 수 없습니다:", screen);
|
||||||
|
toast.error("화면 ID를 찾을 수 없습니다. 다른 화면을 선택해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
setIsScreenDropdownOpen(false);
|
||||||
|
|
||||||
|
// 실제 라우팅 패턴에 맞게 URL 생성: /screens/[screenId] (복수형)
|
||||||
|
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
||||||
|
let screenUrl = `/screens/${actualScreenId}`;
|
||||||
|
|
||||||
|
// 현재 메뉴 타입이 관리자인지 확인 (0 또는 "admin")
|
||||||
|
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||||
|
if (isAdminMenu) {
|
||||||
|
screenUrl += "?mode=admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: screenUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("🖥️ 화면 선택 완료:", {
|
||||||
|
screenId: screen.screenId,
|
||||||
|
legacyId: screen.id,
|
||||||
|
actualScreenId,
|
||||||
|
screenName: screen.screenName,
|
||||||
|
menuType: menuType,
|
||||||
|
formDataMenuType: formData.menuType,
|
||||||
|
isAdminMenu,
|
||||||
|
generatedUrl: screenUrl,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL 타입 변경 시 처리
|
||||||
|
const handleUrlTypeChange = (type: "direct" | "screen") => {
|
||||||
|
console.log("🔄 URL 타입 변경:", {
|
||||||
|
from: urlType,
|
||||||
|
to: type,
|
||||||
|
currentSelectedScreen: selectedScreen?.screenName,
|
||||||
|
currentUrl: formData.menuUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
setUrlType(type);
|
||||||
|
|
||||||
|
if (type === "direct") {
|
||||||
|
// 직접 입력 모드로 변경 시 선택된 화면 초기화
|
||||||
|
setSelectedScreen(null);
|
||||||
|
// URL 필드도 초기화 (사용자가 직접 입력할 수 있도록)
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: "",
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 화면 할당 모드로 변경 시
|
||||||
|
// 기존에 선택된 화면이 있고, 해당 화면의 URL이 있다면 유지
|
||||||
|
if (selectedScreen) {
|
||||||
|
console.log("📋 기존 선택된 화면 유지:", selectedScreen.screenName);
|
||||||
|
// 현재 선택된 화면으로 URL 재생성
|
||||||
|
const actualScreenId = selectedScreen.screenId || selectedScreen.id;
|
||||||
|
let screenUrl = `/screens/${actualScreenId}`;
|
||||||
|
|
||||||
|
// 관리자 메뉴인 경우 mode=admin 파라미터 추가
|
||||||
|
const isAdminMenu = menuType === "0" || menuType === "admin" || formData.menuType === "0";
|
||||||
|
if (isAdminMenu) {
|
||||||
|
screenUrl += "?mode=admin";
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: screenUrl,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 선택된 화면이 없으면 URL만 초기화
|
||||||
|
setFormData((prev) => ({
|
||||||
|
...prev,
|
||||||
|
menuUrl: "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// loadMenuData 함수를 먼저 정의
|
// loadMenuData 함수를 먼저 정의
|
||||||
const loadMenuData = async () => {
|
const loadMenuData = async () => {
|
||||||
console.log("loadMenuData 호출됨 - menuId:", menuId);
|
console.log("loadMenuData 호출됨 - menuId:", menuId);
|
||||||
|
|
@ -124,11 +261,16 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
convertedStatus = "INACTIVE";
|
convertedStatus = "INACTIVE";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const menuUrl = menu.menu_url || menu.MENU_URL || "";
|
||||||
|
|
||||||
|
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
|
||||||
|
const isScreenUrl = menuUrl.startsWith("/screens/");
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
objid: menu.objid || menu.OBJID,
|
objid: menu.objid || menu.OBJID,
|
||||||
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
||||||
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
menuNameKor: menu.menu_name_kor || menu.MENU_NAME_KOR || "",
|
||||||
menuUrl: menu.menu_url || menu.MENU_URL || "",
|
menuUrl: menuUrl,
|
||||||
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
menuDesc: menu.menu_desc || menu.MENU_DESC || "",
|
||||||
seq: menu.seq || menu.SEQ || 1,
|
seq: menu.seq || menu.SEQ || 1,
|
||||||
menuType: convertedMenuType,
|
menuType: convertedMenuType,
|
||||||
|
|
@ -137,6 +279,57 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
langKey: langKey, // 다국어 키 설정
|
langKey: langKey, // 다국어 키 설정
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// URL 타입 설정
|
||||||
|
if (isScreenUrl) {
|
||||||
|
setUrlType("screen");
|
||||||
|
// "/screens/123" 또는 "/screens/123?mode=admin" 형태에서 ID 추출
|
||||||
|
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
|
||||||
|
if (screenId) {
|
||||||
|
console.log("🔍 기존 메뉴에서 화면 ID 추출:", {
|
||||||
|
menuUrl,
|
||||||
|
screenId,
|
||||||
|
hasAdminParam: menuUrl.includes("mode=admin"),
|
||||||
|
currentScreensCount: screens.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 화면 설정 함수
|
||||||
|
const setScreenFromId = () => {
|
||||||
|
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||||
|
if (screen) {
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
console.log("🖥️ 기존 메뉴의 할당된 화면 설정:", {
|
||||||
|
screen,
|
||||||
|
originalUrl: menuUrl,
|
||||||
|
hasAdminParam: menuUrl.includes("mode=admin"),
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 해당 ID의 화면을 찾을 수 없음:", {
|
||||||
|
screenId,
|
||||||
|
availableScreens: screens.map((s) => ({ screenId: s.screenId, id: s.id, name: s.screenName })),
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 화면 목록이 이미 있으면 즉시 설정, 없으면 로드 완료 대기
|
||||||
|
if (screens.length > 0) {
|
||||||
|
console.log("📋 화면 목록이 이미 로드됨 - 즉시 설정");
|
||||||
|
setScreenFromId();
|
||||||
|
} else {
|
||||||
|
console.log("⏳ 화면 목록 로드 대기 중...");
|
||||||
|
// 화면 ID를 저장해두고, 화면 목록 로드 완료 후 설정
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log("🔄 재시도: 화면 목록 로드 후 설정");
|
||||||
|
setScreenFromId();
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUrlType("direct");
|
||||||
|
setSelectedScreen(null);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("설정된 폼 데이터:", {
|
console.log("설정된 폼 데이터:", {
|
||||||
objid: menu.objid || menu.OBJID,
|
objid: menu.objid || menu.OBJID,
|
||||||
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
|
||||||
|
|
@ -237,6 +430,35 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
}
|
}
|
||||||
}, [isOpen, formData.companyCode]);
|
}, [isOpen, formData.companyCode]);
|
||||||
|
|
||||||
|
// 화면 목록 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadScreens();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// 화면 목록 로드 완료 후 기존 메뉴의 할당된 화면 설정
|
||||||
|
useEffect(() => {
|
||||||
|
if (screens.length > 0 && isEdit && formData.menuUrl && urlType === "screen") {
|
||||||
|
const menuUrl = formData.menuUrl;
|
||||||
|
if (menuUrl.startsWith("/screens/")) {
|
||||||
|
const screenId = menuUrl.match(/\/screens\/(\d+)/)?.[1];
|
||||||
|
if (screenId && !selectedScreen) {
|
||||||
|
console.log("🔄 화면 목록 로드 완료 - 기존 할당 화면 자동 설정");
|
||||||
|
const screen = screens.find((s) => s.screenId.toString() === screenId || s.id?.toString() === screenId);
|
||||||
|
if (screen) {
|
||||||
|
setSelectedScreen(screen);
|
||||||
|
console.log("✅ 기존 메뉴의 할당된 화면 자동 설정 완료:", {
|
||||||
|
screenId,
|
||||||
|
screenName: screen.screenName,
|
||||||
|
menuUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [screens, isEdit, formData.menuUrl, urlType, selectedScreen]);
|
||||||
|
|
||||||
// 드롭다운 외부 클릭 시 닫기
|
// 드롭다운 외부 클릭 시 닫기
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
|
@ -245,16 +467,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
setIsLangKeyDropdownOpen(false);
|
setIsLangKeyDropdownOpen(false);
|
||||||
setLangKeySearchText("");
|
setLangKeySearchText("");
|
||||||
}
|
}
|
||||||
|
if (!target.closest(".screen-dropdown")) {
|
||||||
|
setIsScreenDropdownOpen(false);
|
||||||
|
setScreenSearchText("");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLangKeyDropdownOpen) {
|
if (isLangKeyDropdownOpen || isScreenDropdownOpen) {
|
||||||
document.addEventListener("mousedown", handleClickOutside);
|
document.addEventListener("mousedown", handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("mousedown", handleClickOutside);
|
document.removeEventListener("mousedown", handleClickOutside);
|
||||||
};
|
};
|
||||||
}, [isLangKeyDropdownOpen]);
|
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
|
||||||
|
|
||||||
const loadCompanies = async () => {
|
const loadCompanies = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -516,12 +742,108 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
|
||||||
|
|
||||||
|
{/* URL 타입 선택 */}
|
||||||
|
<RadioGroup value={urlType} onValueChange={handleUrlTypeChange} className="mb-3 flex space-x-6">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="screen" id="screen" />
|
||||||
|
<Label htmlFor="screen" className="cursor-pointer">
|
||||||
|
화면 할당
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="direct" id="direct" />
|
||||||
|
<Label htmlFor="direct" className="cursor-pointer">
|
||||||
|
URL 직접 입력
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{/* 화면 할당 */}
|
||||||
|
{urlType === "screen" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 화면 선택 드롭다운 */}
|
||||||
|
<div className="relative">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsScreenDropdownOpen(!isScreenDropdownOpen)}
|
||||||
|
className="w-full justify-between"
|
||||||
|
>
|
||||||
|
<span className="text-left">
|
||||||
|
{selectedScreen ? selectedScreen.screenName : "화면을 선택하세요"}
|
||||||
|
</span>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{isScreenDropdownOpen && (
|
||||||
|
<div className="screen-dropdown absolute top-full right-0 left-0 z-50 mt-1 max-h-60 overflow-y-auto rounded-md border bg-white shadow-lg">
|
||||||
|
{/* 검색 입력 */}
|
||||||
|
<div className="sticky top-0 border-b bg-white p-2">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||||
|
<Input
|
||||||
|
placeholder="화면 검색..."
|
||||||
|
value={screenSearchText}
|
||||||
|
onChange={(e) => setScreenSearchText(e.target.value)}
|
||||||
|
className="pl-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 화면 목록 */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{screens
|
||||||
|
.filter(
|
||||||
|
(screen) =>
|
||||||
|
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
|
||||||
|
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
|
||||||
|
)
|
||||||
|
.map((screen, index) => (
|
||||||
|
<div
|
||||||
|
key={`screen-${screen.screenId || screen.id || index}-${screen.screenCode || index}`}
|
||||||
|
onClick={() => handleScreenSelect(screen)}
|
||||||
|
className="cursor-pointer border-b px-3 py-2 last:border-b-0 hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{screen.screenName}</div>
|
||||||
|
<div className="text-xs text-gray-500">{screen.screenCode}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">ID: {screen.screenId || screen.id || "N/A"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{screens.filter(
|
||||||
|
(screen) =>
|
||||||
|
screen.screenName.toLowerCase().includes(screenSearchText.toLowerCase()) ||
|
||||||
|
screen.screenCode.toLowerCase().includes(screenSearchText.toLowerCase()),
|
||||||
|
).length === 0 && <div className="px-3 py-2 text-sm text-gray-500">검색 결과가 없습니다.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 선택된 화면 정보 표시 */}
|
||||||
|
{selectedScreen && (
|
||||||
|
<div className="rounded-md border bg-blue-50 p-3">
|
||||||
|
<div className="text-sm font-medium text-blue-900">{selectedScreen.screenName}</div>
|
||||||
|
<div className="text-xs text-blue-600">코드: {selectedScreen.screenCode}</div>
|
||||||
|
<div className="text-xs text-blue-600">생성된 URL: {formData.menuUrl}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* URL 직접 입력 */}
|
||||||
|
{urlType === "direct" && (
|
||||||
<Input
|
<Input
|
||||||
id="menuUrl"
|
id="menuUrl"
|
||||||
value={formData.menuUrl}
|
value={formData.menuUrl}
|
||||||
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
|
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
|
||||||
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
|
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
import { InteractiveScreenViewerDynamic } from "@/components/screen/InteractiveScreenViewerDynamic";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
|
|
@ -45,24 +45,60 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
let maxWidth = 800; // 최소 너비
|
let maxWidth = 800; // 최소 너비
|
||||||
let maxHeight = 600; // 최소 높이
|
let maxHeight = 600; // 최소 높이
|
||||||
|
|
||||||
components.forEach((component) => {
|
console.log("🔍 화면 크기 계산 시작:", { componentsCount: components.length });
|
||||||
const x = parseFloat(component.style?.positionX || "0");
|
|
||||||
const y = parseFloat(component.style?.positionY || "0");
|
components.forEach((component, index) => {
|
||||||
const width = parseFloat(component.style?.width || "100");
|
// position과 size는 BaseComponent에서 별도 속성으로 관리
|
||||||
const height = parseFloat(component.style?.height || "40");
|
const x = parseFloat(component.position?.x?.toString() || "0");
|
||||||
|
const y = parseFloat(component.position?.y?.toString() || "0");
|
||||||
|
const width = parseFloat(component.size?.width?.toString() || "100");
|
||||||
|
const height = parseFloat(component.size?.height?.toString() || "40");
|
||||||
|
|
||||||
// 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산
|
// 컴포넌트의 오른쪽 끝과 아래쪽 끝 계산
|
||||||
const rightEdge = x + width;
|
const rightEdge = x + width;
|
||||||
const bottomEdge = y + height;
|
const bottomEdge = y + height;
|
||||||
|
|
||||||
maxWidth = Math.max(maxWidth, rightEdge + 50); // 여백 추가
|
console.log(
|
||||||
maxHeight = Math.max(maxHeight, bottomEdge + 50); // 여백 추가
|
`📏 컴포넌트 ${index + 1} (${component.id}): x=${x}, y=${y}, w=${width}, h=${height}, rightEdge=${rightEdge}, bottomEdge=${bottomEdge}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newMaxWidth = Math.max(maxWidth, rightEdge + 100); // 여백 증가
|
||||||
|
const newMaxHeight = Math.max(maxHeight, bottomEdge + 100); // 여백 증가
|
||||||
|
|
||||||
|
if (newMaxWidth > maxWidth || newMaxHeight > maxHeight) {
|
||||||
|
console.log(`🔄 크기 업데이트: ${maxWidth}×${maxHeight} → ${newMaxWidth}×${newMaxHeight}`);
|
||||||
|
maxWidth = newMaxWidth;
|
||||||
|
maxHeight = newMaxHeight;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
console.log("📊 컴포넌트 기반 계산 결과:", { maxWidth, maxHeight });
|
||||||
width: Math.min(maxWidth, window.innerWidth * 0.9), // 화면의 90%를 넘지 않도록
|
|
||||||
height: Math.min(maxHeight, window.innerHeight * 0.8), // 화면의 80%를 넘지 않도록
|
// 브라우저 크기 제한 확인 (더욱 관대하게 설정)
|
||||||
|
const maxAllowedWidth = window.innerWidth * 0.98; // 95% -> 98%
|
||||||
|
const maxAllowedHeight = window.innerHeight * 0.95; // 90% -> 95%
|
||||||
|
|
||||||
|
console.log("📐 크기 제한 정보:", {
|
||||||
|
계산된크기: { maxWidth, maxHeight },
|
||||||
|
브라우저제한: { maxAllowedWidth, maxAllowedHeight },
|
||||||
|
브라우저크기: { width: window.innerWidth, height: window.innerHeight },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 컴포넌트 기반 크기를 우선 적용하되, 브라우저 제한을 고려
|
||||||
|
const finalDimensions = {
|
||||||
|
width: Math.min(maxWidth, maxAllowedWidth),
|
||||||
|
height: Math.min(maxHeight, maxAllowedHeight),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("✅ 최종 화면 크기:", finalDimensions);
|
||||||
|
console.log("🔧 크기 적용 분석:", {
|
||||||
|
width적용: maxWidth <= maxAllowedWidth ? "컴포넌트기준" : "브라우저제한",
|
||||||
|
height적용: maxHeight <= maxAllowedHeight ? "컴포넌트기준" : "브라우저제한",
|
||||||
|
컴포넌트크기: { maxWidth, maxHeight },
|
||||||
|
최종크기: finalDimensions,
|
||||||
|
});
|
||||||
|
|
||||||
|
return finalDimensions;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 전역 모달 이벤트 리스너
|
// 전역 모달 이벤트 리스너
|
||||||
|
|
@ -154,17 +190,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 헤더 높이와 패딩을 고려한 전체 높이 계산
|
// 헤더 높이와 패딩을 고려한 전체 높이 계산 (실제 측정값 기반)
|
||||||
const headerHeight = 60; // DialogHeader + 패딩
|
const headerHeight = 80; // DialogHeader + 패딩 (더 정확한 값)
|
||||||
const totalHeight = screenDimensions.height + headerHeight;
|
const totalHeight = screenDimensions.height + headerHeight;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
className: "overflow-hidden p-0",
|
className: "overflow-hidden p-0",
|
||||||
style: {
|
style: {
|
||||||
width: `${screenDimensions.width + 48}px`, // 헤더 패딩과 여백 고려
|
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 브라우저 제한 적용
|
||||||
height: `${Math.min(totalHeight, window.innerHeight * 0.8)}px`,
|
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 브라우저 제한 적용
|
||||||
maxWidth: "90vw",
|
maxWidth: "98vw", // 안전장치
|
||||||
maxHeight: "80vh",
|
maxHeight: "95vh", // 안전장치
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -176,9 +212,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
|
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
|
||||||
<DialogHeader className="border-b px-6 py-4">
|
<DialogHeader className="border-b px-6 py-4">
|
||||||
<DialogTitle>{modalState.title}</DialogTitle>
|
<DialogTitle>{modalState.title}</DialogTitle>
|
||||||
|
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="flex-1 overflow-hidden p-4">
|
<div className="flex-1 p-4">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|
@ -188,7 +225,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
</div>
|
</div>
|
||||||
) : screenData ? (
|
) : screenData ? (
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden bg-white"
|
className="relative bg-white"
|
||||||
style={{
|
style={{
|
||||||
width: screenDimensions?.width || 800,
|
width: screenDimensions?.width || 800,
|
||||||
height: screenDimensions?.height || 600,
|
height: screenDimensions?.height || 600,
|
||||||
|
|
@ -202,13 +239,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
|
||||||
formData={formData}
|
formData={formData}
|
||||||
onFormDataChange={(fieldName, value) => {
|
onFormDataChange={(fieldName, value) => {
|
||||||
console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
|
||||||
console.log(`📋 현재 formData:`, formData);
|
console.log("📋 현재 formData:", formData);
|
||||||
setFormData((prev) => {
|
setFormData((prev) => {
|
||||||
const newFormData = {
|
const newFormData = {
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value,
|
[fieldName]: value,
|
||||||
};
|
};
|
||||||
console.log(`📝 ScreenModal 업데이트된 formData:`, newFormData);
|
console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
|
||||||
return newFormData;
|
return newFormData;
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { X, Save, RotateCcw } from "lucide-react";
|
import { X, Save, RotateCcw } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { ComponentData } from "@/lib/types/screen";
|
import { ComponentData } from "@/lib/types/screen";
|
||||||
|
|
||||||
|
|
@ -145,7 +146,19 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
layoutData.components.forEach((comp) => {
|
layoutData.components.forEach((comp) => {
|
||||||
if (comp.columnName) {
|
if (comp.columnName) {
|
||||||
const formValue = formData[comp.columnName];
|
const formValue = formData[comp.columnName];
|
||||||
console.log(` - ${comp.columnName}: "${formValue}" (컴포넌트 ID: ${comp.id})`);
|
console.log(
|
||||||
|
` - ${comp.columnName}: "${formValue}" (타입: ${comp.type}, 웹타입: ${(comp as any).widgetType})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 코드 타입인 경우 특별히 로깅
|
||||||
|
if ((comp as any).widgetType === "code") {
|
||||||
|
console.log(` 🔍 코드 타입 세부정보:`, {
|
||||||
|
columnName: comp.columnName,
|
||||||
|
componentId: comp.id,
|
||||||
|
formValue,
|
||||||
|
webTypeConfig: (comp as any).webTypeConfig,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -230,6 +243,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
minHeight: dynamicSize.height,
|
minHeight: dynamicSize.height,
|
||||||
maxWidth: "95vw",
|
maxWidth: "95vw",
|
||||||
maxHeight: "95vh",
|
maxHeight: "95vh",
|
||||||
|
zIndex: 9999, // 모든 컴포넌트보다 위에 표시
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogHeader className="sr-only">
|
<DialogHeader className="sr-only">
|
||||||
|
|
@ -269,8 +283,38 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DynamicComponentRenderer
|
{/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */}
|
||||||
|
{component.type === "widget" ? (
|
||||||
|
<InteractiveScreenViewer
|
||||||
component={component}
|
component={component}
|
||||||
|
allComponents={components}
|
||||||
|
hideLabel={false} // 라벨 표시 활성화
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={(fieldName, value) => {
|
||||||
|
console.log("📝 폼 데이터 변경:", fieldName, value);
|
||||||
|
const newFormData = { ...formData, [fieldName]: value };
|
||||||
|
setFormData(newFormData);
|
||||||
|
|
||||||
|
// 변경된 데이터를 즉시 부모로 전달
|
||||||
|
if (onDataChange) {
|
||||||
|
console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData);
|
||||||
|
onDataChange(newFormData);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
screenInfo={{
|
||||||
|
id: screenId || 0,
|
||||||
|
tableName: screenData.tableName,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DynamicComponentRenderer
|
||||||
|
component={{
|
||||||
|
...component,
|
||||||
|
style: {
|
||||||
|
...component.style,
|
||||||
|
labelDisplay: true, // 수정 모달에서는 라벨 강제 표시
|
||||||
|
},
|
||||||
|
}}
|
||||||
screenId={screenId}
|
screenId={screenId}
|
||||||
tableName={screenData.tableName}
|
tableName={screenData.tableName}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
|
|
@ -293,6 +337,7 @@ export const EditModal: React.FC<EditModalProps> = ({
|
||||||
// 인터랙티브 모드 활성화 (formData 사용을 위해 필수)
|
// 인터랙티브 모드 활성화 (formData 사용을 위해 필수)
|
||||||
isInteractive={true}
|
isInteractive={true}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 rounded-lg border border-gray-200 bg-white shadow-lg",
|
"fixed z-[9998] rounded-lg border border-gray-200 bg-white shadow-lg",
|
||||||
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
isDragging ? "cursor-move shadow-2xl" : "transition-all duration-200 ease-in-out",
|
||||||
isResizing && "cursor-se-resize",
|
isResizing && "cursor-se-resize",
|
||||||
className,
|
className,
|
||||||
|
|
@ -239,7 +239,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
|
||||||
height: `${panelSize.height}px`,
|
height: `${panelSize.height}px`,
|
||||||
transform: isDragging ? "scale(1.01)" : "scale(1)",
|
transform: isDragging ? "scale(1.01)" : "scale(1)",
|
||||||
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
|
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
|
||||||
zIndex: isDragging ? 9999 : 50, // 드래그 중 최상위 표시
|
zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
|
|
|
||||||
|
|
@ -1557,6 +1557,22 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
|
||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case "code":
|
||||||
|
// 코드 타입은 텍스트 검색으로 처리 (코드명으로 검색 가능)
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
key={filter.columnName}
|
||||||
|
placeholder={`${filter.label} 검색... (코드명 또는 코드값)`}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => handleSearchValueChange(filter.columnName, e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,7 @@ import { FileUpload } from "./widgets/FileUpload";
|
||||||
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
|
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
|
||||||
|
|
||||||
interface InteractiveScreenViewerProps {
|
interface InteractiveScreenViewerProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
|
|
@ -936,42 +937,65 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
|
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
|
||||||
|
|
||||||
console.log("💻 InteractiveScreenViewer - Code 위젯:", {
|
console.log("🔍 InteractiveScreenViewer - Code 위젯 (공통코드 선택):", {
|
||||||
componentId: widget.id,
|
componentId: widget.id,
|
||||||
widgetType: widget.widgetType,
|
widgetType: widget.widgetType,
|
||||||
|
columnName: widget.columnName,
|
||||||
|
fieldName,
|
||||||
|
currentValue,
|
||||||
|
formData,
|
||||||
config,
|
config,
|
||||||
appliedSettings: {
|
codeCategory: config?.codeCategory,
|
||||||
language: config?.language,
|
|
||||||
theme: config?.theme,
|
|
||||||
fontSize: config?.fontSize,
|
|
||||||
defaultValue: config?.defaultValue,
|
|
||||||
wordWrap: config?.wordWrap,
|
|
||||||
tabSize: config?.tabSize,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const finalPlaceholder = config?.placeholder || "코드를 입력하세요...";
|
// code 타입은 공통코드 선택박스로 처리
|
||||||
const rows = config?.rows || 4;
|
// DynamicWebTypeRenderer를 사용하여 SelectBasicComponent 렌더링
|
||||||
|
try {
|
||||||
return applyStyles(
|
return applyStyles(
|
||||||
<Textarea
|
<DynamicWebTypeRenderer
|
||||||
placeholder={finalPlaceholder}
|
webType="select"
|
||||||
value={currentValue || config?.defaultValue || ""}
|
props={{
|
||||||
onChange={(e) => updateFormData(fieldName, e.target.value)}
|
component: widget,
|
||||||
|
value: currentValue,
|
||||||
|
onChange: (value: any) => updateFormData(fieldName, value),
|
||||||
|
onFormDataChange: updateFormData,
|
||||||
|
isInteractive: true,
|
||||||
|
readonly: readonly,
|
||||||
|
required: required,
|
||||||
|
placeholder: config?.placeholder || "코드를 선택하세요...",
|
||||||
|
className: "w-full h-full",
|
||||||
|
}}
|
||||||
|
config={{
|
||||||
|
...config,
|
||||||
|
codeCategory: config?.codeCategory,
|
||||||
|
isCodeType: true, // 코드 타입임을 명시
|
||||||
|
}}
|
||||||
|
onEvent={(event: string, data: any) => {
|
||||||
|
console.log(`Code widget event: ${event}`, data);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("DynamicWebTypeRenderer 로딩 실패, 기본 Select 사용:", error);
|
||||||
|
|
||||||
|
// 폴백: 기본 Select 컴포넌트 사용
|
||||||
|
return applyStyles(
|
||||||
|
<Select
|
||||||
|
value={currentValue || ""}
|
||||||
|
onValueChange={(value) => updateFormData(fieldName, value)}
|
||||||
disabled={readonly}
|
disabled={readonly}
|
||||||
required={required}
|
required={required}
|
||||||
rows={rows}
|
>
|
||||||
className="h-full w-full resize-none font-mono text-sm"
|
<SelectTrigger className="h-full w-full">
|
||||||
style={{
|
<SelectValue placeholder={config?.placeholder || "코드를 선택하세요..."} />
|
||||||
fontSize: `${config?.fontSize || 14}px`,
|
</SelectTrigger>
|
||||||
backgroundColor: config?.theme === "dark" ? "#1e1e1e" : "#ffffff",
|
<SelectContent>
|
||||||
color: config?.theme === "dark" ? "#ffffff" : "#000000",
|
<SelectItem value="loading">로딩 중...</SelectItem>
|
||||||
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
|
</SelectContent>
|
||||||
tabSize: config?.tabSize || 2,
|
</Select>
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case "entity": {
|
case "entity": {
|
||||||
const widget = comp as WidgetComponent;
|
const widget = comp as WidgetComponent;
|
||||||
|
|
@ -1623,6 +1647,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
|
|
||||||
const labelText = component.style?.labelText || component.label || "";
|
const labelText = component.style?.labelText || component.label || "";
|
||||||
|
|
||||||
|
// 라벨 표시 여부 로그 (디버깅용)
|
||||||
|
if (component.type === "widget") {
|
||||||
|
console.log("🏷️ 라벨 표시 체크:", {
|
||||||
|
componentId: component.id,
|
||||||
|
hideLabel,
|
||||||
|
shouldShowLabel,
|
||||||
|
labelText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 라벨 스타일 적용
|
// 라벨 스타일 적용
|
||||||
const labelStyle = {
|
const labelStyle = {
|
||||||
fontSize: component.style?.labelFontSize || "14px",
|
fontSize: component.style?.labelFontSize || "14px",
|
||||||
|
|
@ -1683,7 +1717,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
|
||||||
top: `${popupComponent.position.y}px`,
|
top: `${popupComponent.position.y}px`,
|
||||||
width: `${popupComponent.size.width}px`,
|
width: `${popupComponent.size.width}px`,
|
||||||
height: `${popupComponent.size.height}px`,
|
height: `${popupComponent.size.height}px`,
|
||||||
zIndex: popupComponent.position.z || 1,
|
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, Butt
|
||||||
import { InteractiveDataTable } from "./InteractiveDataTable";
|
import { InteractiveDataTable } from "./InteractiveDataTable";
|
||||||
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
import { DynamicWebTypeRenderer } from "@/lib/registry";
|
||||||
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
|
||||||
import "@/lib/registry/components/ButtonRenderer";
|
import "@/lib/registry/components/ButtonRenderer";
|
||||||
|
|
@ -191,11 +192,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
|
||||||
// 화면 닫기 로직 (필요시 구현)
|
// 화면 닫기 로직 (필요시 구현)
|
||||||
console.log("화면 닫기 요청");
|
console.log("화면 닫기 요청");
|
||||||
}}
|
}}
|
||||||
style={{
|
|
||||||
width: "100%",
|
|
||||||
height: "100%",
|
|
||||||
...comp.style,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,11 @@ import { Button } from "@/components/ui/button";
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { Loader2, CheckCircle2, AlertCircle, Clock } from "lucide-react";
|
import { Loader2, CheckCircle2, AlertCircle, Clock } from "lucide-react";
|
||||||
import { ComponentData, ButtonActionType } from "@/types/screen";
|
import { ComponentData, ButtonActionType } from "@/types/screen";
|
||||||
import { optimizedButtonDataflowService } from "@/lib/services/optimizedButtonDataflowService";
|
import {
|
||||||
|
optimizedButtonDataflowService,
|
||||||
|
OptimizedButtonDataflowService,
|
||||||
|
ExtendedControlContext,
|
||||||
|
} from "@/lib/services/optimizedButtonDataflowService";
|
||||||
import { dataflowJobQueue } from "@/lib/services/dataflowJobQueue";
|
import { dataflowJobQueue } from "@/lib/services/dataflowJobQueue";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
@ -60,7 +64,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
try {
|
try {
|
||||||
console.log(`🔘 Button clicked: ${component.id} (${config?.actionType})`);
|
console.log(`🔘 Button clicked: ${component.id} (${config?.actionType})`);
|
||||||
|
|
||||||
// 🔥 현재 폼 데이터 수집
|
// 🔥 확장된 컨텍스트 데이터 수집
|
||||||
const contextData = {
|
const contextData = {
|
||||||
...formData,
|
...formData,
|
||||||
buttonId: component.id,
|
buttonId: component.id,
|
||||||
|
|
@ -69,11 +73,61 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
clickCount,
|
clickCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 🔥 확장된 제어 컨텍스트 생성
|
||||||
|
const extendedContext = {
|
||||||
|
formData,
|
||||||
|
selectedRows: selectedRows || [],
|
||||||
|
selectedRowsData: selectedRowsData || [],
|
||||||
|
controlDataSource: config?.dataflowConfig?.controlDataSource || "form",
|
||||||
|
buttonId: component.id,
|
||||||
|
componentData: component,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
clickCount,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🔥 제어 전용 액션인지 확인
|
||||||
|
const isControlOnlyAction = config?.actionType === "control";
|
||||||
|
|
||||||
|
console.log("🎯 OptimizedButtonComponent 실행:", {
|
||||||
|
actionType: config?.actionType,
|
||||||
|
isControlOnlyAction,
|
||||||
|
enableDataflowControl: config?.enableDataflowControl,
|
||||||
|
hasDataflowConfig: !!config?.dataflowConfig,
|
||||||
|
selectedRows,
|
||||||
|
selectedRowsData,
|
||||||
|
});
|
||||||
|
|
||||||
if (config?.enableDataflowControl && config?.dataflowConfig) {
|
if (config?.enableDataflowControl && config?.dataflowConfig) {
|
||||||
|
// 🔥 확장된 제어 검증 먼저 실행
|
||||||
|
const validationResult = await OptimizedButtonDataflowService.executeExtendedValidation(
|
||||||
|
config.dataflowConfig,
|
||||||
|
extendedContext as ExtendedControlContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!validationResult.success) {
|
||||||
|
toast.error(validationResult.message || "제어 조건을 만족하지 않습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 제어 전용 액션이면 여기서 종료
|
||||||
|
if (isControlOnlyAction) {
|
||||||
|
toast.success("제어 조건을 만족합니다.");
|
||||||
|
if (onActionComplete) {
|
||||||
|
onActionComplete({ success: true, message: "제어 조건 통과" });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 최적화된 버튼 실행 (즉시 응답)
|
// 🔥 최적화된 버튼 실행 (즉시 응답)
|
||||||
await executeOptimizedButtonAction(contextData);
|
await executeOptimizedButtonAction(contextData);
|
||||||
|
} else if (isControlOnlyAction) {
|
||||||
|
// 🔥 제어관리가 비활성화된 상태에서 제어 액션
|
||||||
|
toast.warning(
|
||||||
|
"제어관리를 먼저 활성화해주세요. 제어 액션을 사용하려면 버튼 설정에서 '제어관리 활성화'를 체크하고 조건을 설정해주세요.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
} else {
|
} else {
|
||||||
// 🔥 기존 액션만 실행
|
// 🔥 기존 액션만 실행 (제어 액션 제외)
|
||||||
await executeOriginalAction(config?.actionType || "save", contextData);
|
await executeOriginalAction(config?.actionType || "save", contextData);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -332,6 +386,12 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
actionType: ButtonActionType,
|
actionType: ButtonActionType,
|
||||||
contextData: Record<string, any>,
|
contextData: Record<string, any>,
|
||||||
): Promise<any> => {
|
): Promise<any> => {
|
||||||
|
// 🔥 제어 액션은 여기서 처리하지 않음 (이미 위에서 처리됨)
|
||||||
|
if (actionType === "control") {
|
||||||
|
console.warn("제어 액션은 executeOriginalAction에서 처리되지 않아야 합니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 간단한 mock 처리 (실제로는 API 호출)
|
// 간단한 mock 처리 (실제로는 API 호출)
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 시뮬레이션
|
await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 시뮬레이션
|
||||||
|
|
||||||
|
|
@ -369,6 +429,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
|
||||||
modal: "모달",
|
modal: "모달",
|
||||||
newWindow: "새 창",
|
newWindow: "새 창",
|
||||||
navigate: "페이지 이동",
|
navigate: "페이지 이동",
|
||||||
|
control: "제어",
|
||||||
};
|
};
|
||||||
return displayNames[actionType] || actionType;
|
return displayNames[actionType] || actionType;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -76,12 +76,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
}) => {
|
}) => {
|
||||||
const { id, type, position, size, style: componentStyle } = component;
|
const { id, type, position, size, style: componentStyle } = component;
|
||||||
|
|
||||||
// 선택 상태에 따른 스타일
|
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
|
||||||
const selectionStyle = isSelected
|
const selectionStyle = isSelected
|
||||||
? {
|
? {
|
||||||
outline: "2px solid #3b82f6",
|
outline: "2px solid #3b82f6",
|
||||||
outlineOffset: "2px",
|
outlineOffset: "2px",
|
||||||
zIndex: 1000,
|
zIndex: 30, // 패널(z-50)과 모달(z-50)보다 낮게 설정
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
|
||||||
<SelectItem value="close">닫기</SelectItem>
|
<SelectItem value="close">닫기</SelectItem>
|
||||||
<SelectItem value="modal">모달 열기</SelectItem>
|
<SelectItem value="modal">모달 열기</SelectItem>
|
||||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||||
|
<SelectItem value="control">제어 (조건 체크만)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Check, ChevronsUpDown, Search, Info, Settings } from "lucide-react";
|
import { Check, ChevronsUpDown, Search, Info, Settings, FileText, Table, Layers } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ComponentData, ButtonDataflowConfig } from "@/types/screen";
|
import { ComponentData, ButtonDataflowConfig } from "@/types/screen";
|
||||||
import { apiClient } from "@/lib/api/client";
|
import { apiClient } from "@/lib/api/client";
|
||||||
|
|
@ -111,16 +111,38 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
const response = await apiClient.get(`/test-button-dataflow/diagrams/${diagramId}/relationships`);
|
const response = await apiClient.get(`/test-button-dataflow/diagrams/${diagramId}/relationships`);
|
||||||
|
|
||||||
if (response.data.success && Array.isArray(response.data.data)) {
|
if (response.data.success && Array.isArray(response.data.data)) {
|
||||||
const relationshipList = response.data.data.map((rel: any) => ({
|
console.log("🔍 백엔드에서 받은 관계 데이터:", response.data.data);
|
||||||
|
|
||||||
|
const relationshipList = response.data.data.map((rel: any) => {
|
||||||
|
console.log("🔍 개별 관계 데이터:", rel);
|
||||||
|
|
||||||
|
// 여러 가지 가능한 필드명 시도 (백엔드 로그 기준으로 수정)
|
||||||
|
const relationshipName =
|
||||||
|
rel.relationshipName || // 백엔드에서 이 필드 사용
|
||||||
|
rel.name ||
|
||||||
|
rel.relationship_name ||
|
||||||
|
rel.label ||
|
||||||
|
rel.title ||
|
||||||
|
rel.description ||
|
||||||
|
`${rel.fromTable || rel.sourceTable || rel.source_table} → ${rel.toTable || rel.targetTable || rel.target_table}`;
|
||||||
|
|
||||||
|
const sourceTable = rel.fromTable || rel.sourceTable || rel.source_table || "Unknown"; // fromTable 우선
|
||||||
|
const targetTable = rel.toTable || rel.targetTable || rel.target_table || "Unknown"; // toTable 우선
|
||||||
|
|
||||||
|
const mappedRel = {
|
||||||
id: rel.id,
|
id: rel.id,
|
||||||
name: rel.name || `${rel.sourceTable} → ${rel.targetTable}`,
|
name: relationshipName,
|
||||||
sourceTable: rel.sourceTable,
|
sourceTable,
|
||||||
targetTable: rel.targetTable,
|
targetTable,
|
||||||
category: rel.category || "data-save",
|
category: rel.category || rel.type || "data-save",
|
||||||
}));
|
};
|
||||||
|
|
||||||
|
console.log("🔍 매핑된 관계 데이터:", mappedRel);
|
||||||
|
return mappedRel;
|
||||||
|
});
|
||||||
|
|
||||||
setRelationships(relationshipList);
|
setRelationships(relationshipList);
|
||||||
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료`);
|
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료:`, relationshipList);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("❌ 관계 목록 로딩 실패:", error);
|
console.error("❌ 관계 목록 로딩 실패:", error);
|
||||||
|
|
@ -173,6 +195,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
close: "닫기",
|
close: "닫기",
|
||||||
popup: "팝업",
|
popup: "팝업",
|
||||||
navigate: "페이지 이동",
|
navigate: "페이지 이동",
|
||||||
|
control: "제어",
|
||||||
};
|
};
|
||||||
return displayNames[actionType] || actionType;
|
return displayNames[actionType] || actionType;
|
||||||
};
|
};
|
||||||
|
|
@ -215,6 +238,50 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
|
{/* 🔥 제어관리가 활성화된 경우에만 설정 표시 */}
|
||||||
|
{config.enableDataflowControl && (
|
||||||
|
<>
|
||||||
|
{/* 🔥 제어 데이터 소스 선택 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Label className="text-sm font-medium">📊 제어 데이터 소스</Label>
|
||||||
|
<Select
|
||||||
|
value={dataflowConfig.controlDataSource || "form"}
|
||||||
|
onValueChange={(value) => onUpdateProperty("webTypeConfig.dataflowConfig.controlDataSource", value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="제어 데이터 소스 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="form">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
<span>폼 데이터 기반</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="table-selection">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Table className="h-4 w-4" />
|
||||||
|
<span>테이블 선택 기반</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="both">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Layers className="h-4 w-4" />
|
||||||
|
<span>폼 + 테이블 선택</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{dataflowConfig.controlDataSource === "form" && "현재 폼의 입력값으로 조건을 체크합니다"}
|
||||||
|
{dataflowConfig.controlDataSource === "table-selection" &&
|
||||||
|
"테이블에서 선택된 항목의 데이터로 조건을 체크합니다"}
|
||||||
|
{dataflowConfig.controlDataSource === "both" && "폼 데이터와 선택된 항목 데이터를 모두 사용합니다"}
|
||||||
|
{!dataflowConfig.controlDataSource && "폼 데이터를 기본으로 사용합니다"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{config.enableDataflowControl && (
|
{config.enableDataflowControl && (
|
||||||
<div className="space-y-6 border-l-2 border-blue-200 pl-4">
|
<div className="space-y-6 border-l-2 border-blue-200 pl-4">
|
||||||
{/* 현재 액션 정보 (간소화) */}
|
{/* 현재 액션 정보 (간소화) */}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AlertDialogPrimitive.Overlay
|
<AlertDialogPrimitive.Overlay
|
||||||
className={cn(
|
className={cn(
|
||||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-black/80",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
|
||||||
<AlertDialogPrimitive.Content
|
<AlertDialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[9999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -104,5 +104,3 @@ export {
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
AlertDialogCancel,
|
AlertDialogCancel,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
|
||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-[9999] grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none cursor-pointer">
|
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 cursor-pointer rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
|
||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-md",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
|
||||||
align={align}
|
align={align}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 rounded-md border p-4 shadow-md outline-none",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[99999] w-72 rounded-md border p-4 shadow-md outline-none",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ function SelectContent({
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[99999] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
position === "popper" &&
|
position === "popper" &&
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
className,
|
className,
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ export class DynamicFormApi {
|
||||||
* @returns 업데이트 결과
|
* @returns 업데이트 결과
|
||||||
*/
|
*/
|
||||||
static async updateFormData(
|
static async updateFormData(
|
||||||
id: number,
|
id: string | number,
|
||||||
formData: Partial<DynamicFormData>,
|
formData: Partial<DynamicFormData>,
|
||||||
): Promise<ApiResponse<SaveFormDataResponse>> {
|
): Promise<ApiResponse<SaveFormDataResponse>> {
|
||||||
try {
|
try {
|
||||||
|
|
@ -173,7 +173,7 @@ export class DynamicFormApi {
|
||||||
* @param tableName 테이블명
|
* @param tableName 테이블명
|
||||||
* @returns 삭제 결과
|
* @returns 삭제 결과
|
||||||
*/
|
*/
|
||||||
static async deleteFormDataFromTable(id: number, tableName: string): Promise<ApiResponse<void>> {
|
static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
|
||||||
try {
|
try {
|
||||||
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
|
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,11 +175,37 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
totalRequests.current += 1;
|
totalRequests.current += 1;
|
||||||
|
|
||||||
|
// 🎯 디버깅: 캐시 상태 로깅
|
||||||
|
console.log(`🔍 optimizedConvertCode 호출: categoryCode="${categoryCode}", codeValue="${codeValue}"`);
|
||||||
|
|
||||||
// 캐시에서 동기적으로 조회 시도
|
// 캐시에서 동기적으로 조회 시도
|
||||||
const syncResult = codeCache.getCodeSync(categoryCode);
|
const syncResult = codeCache.getCodeSync(categoryCode);
|
||||||
|
console.log(`🔍 getCodeSync("${categoryCode}") 결과:`, syncResult);
|
||||||
|
|
||||||
|
// 🎯 캐시 내용 상세 로깅 (키값들 확인)
|
||||||
if (syncResult) {
|
if (syncResult) {
|
||||||
|
console.log(`🔍 캐시 키값들:`, Object.keys(syncResult));
|
||||||
|
console.log(`🔍 캐시 전체 데이터:`, JSON.stringify(syncResult, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syncResult && Array.isArray(syncResult)) {
|
||||||
cacheHits.current += 1;
|
cacheHits.current += 1;
|
||||||
const result = syncResult[codeValue?.toUpperCase()] || codeValue;
|
console.log(`🔍 배열에서 코드 검색: codeValue="${codeValue}"`);
|
||||||
|
console.log(
|
||||||
|
`🔍 캐시 배열 내용:`,
|
||||||
|
syncResult.map((item) => ({
|
||||||
|
code_value: item.code_value,
|
||||||
|
code_name: item.code_name,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 배열에서 해당 code_value를 가진 항목 찾기
|
||||||
|
const foundCode = syncResult.find(
|
||||||
|
(item) => String(item.code_value).toUpperCase() === String(codeValue).toUpperCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = foundCode ? foundCode.code_name : codeValue;
|
||||||
|
console.log(`🔍 최종 결과: "${codeValue}" → "${result}"`, { foundCode });
|
||||||
|
|
||||||
// 응답 시간 추적 (캐시 히트)
|
// 응답 시간 추적 (캐시 히트)
|
||||||
requestTimes.current.push(Date.now() - startTime);
|
requestTimes.current.push(Date.now() - startTime);
|
||||||
|
|
@ -190,10 +216,13 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(`⚠️ 캐시 미스: categoryCode="${categoryCode}" - 비동기 로딩 트리거`);
|
||||||
|
|
||||||
// 캐시 미스인 경우 비동기 로딩 트리거 (백그라운드)
|
// 캐시 미스인 경우 비동기 로딩 트리거 (백그라운드)
|
||||||
codeCache
|
codeCache
|
||||||
.getCode(categoryCode)
|
.getCodeAsync(categoryCode)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
|
console.log(`✅ 비동기 로딩 완료: categoryCode="${categoryCode}"`);
|
||||||
updateMetrics();
|
updateMetrics();
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React from "react";
|
||||||
import { ComponentData } from "@/types/screen";
|
import { ComponentData } from "@/types/screen";
|
||||||
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
|
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
|
||||||
import { ComponentRegistry } from "./ComponentRegistry";
|
import { ComponentRegistry } from "./ComponentRegistry";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
// 컴포넌트 렌더러 인터페이스
|
// 컴포넌트 렌더러 인터페이스
|
||||||
export interface ComponentRenderer {
|
export interface ComponentRenderer {
|
||||||
|
|
@ -86,6 +87,12 @@ export interface DynamicComponentRendererProps {
|
||||||
tableName?: string;
|
tableName?: string;
|
||||||
onRefresh?: () => void;
|
onRefresh?: () => void;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||||
|
selectedRows?: any[];
|
||||||
|
selectedRowsData?: any[];
|
||||||
|
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
|
||||||
|
// 테이블 새로고침 키
|
||||||
|
refreshKey?: number;
|
||||||
// 편집 모드
|
// 편집 모드
|
||||||
mode?: "view" | "edit";
|
mode?: "view" | "edit";
|
||||||
// 모달 내에서 렌더링 여부
|
// 모달 내에서 렌더링 여부
|
||||||
|
|
@ -107,6 +114,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
|
|
||||||
// 레이아웃 컴포넌트 처리
|
// 레이아웃 컴포넌트 처리
|
||||||
if (componentType === "layout") {
|
if (componentType === "layout") {
|
||||||
|
// DOM 안전한 props만 전달
|
||||||
|
const safeLayoutProps = filterDOMProps(props);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DynamicLayoutRenderer
|
<DynamicLayoutRenderer
|
||||||
layout={component as any}
|
layout={component as any}
|
||||||
|
|
@ -118,7 +128,17 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onUpdateLayout={props.onUpdateLayout}
|
onUpdateLayout={props.onUpdateLayout}
|
||||||
// onComponentDrop 제거 - 일반 캔버스 드롭만 사용
|
// onComponentDrop 제거 - 일반 캔버스 드롭만 사용
|
||||||
onZoneClick={props.onZoneClick}
|
onZoneClick={props.onZoneClick}
|
||||||
{...props}
|
isInteractive={props.isInteractive}
|
||||||
|
formData={props.formData}
|
||||||
|
onFormDataChange={props.onFormDataChange}
|
||||||
|
screenId={props.screenId}
|
||||||
|
tableName={props.tableName}
|
||||||
|
onRefresh={props.onRefresh}
|
||||||
|
onClose={props.onClose}
|
||||||
|
mode={props.mode}
|
||||||
|
isInModal={props.isInModal}
|
||||||
|
originalData={props.originalData}
|
||||||
|
{...safeLayoutProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -131,18 +151,50 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
try {
|
try {
|
||||||
const NewComponentRenderer = newComponent.component;
|
const NewComponentRenderer = newComponent.component;
|
||||||
if (NewComponentRenderer) {
|
if (NewComponentRenderer) {
|
||||||
// React 전용 props 필터링
|
// React 전용 props들을 명시적으로 분리하고 DOM 안전한 props만 전달
|
||||||
const { isInteractive, formData, onFormDataChange, ...safeProps } = props;
|
const {
|
||||||
|
isInteractive,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
tableName,
|
||||||
|
onRefresh,
|
||||||
|
onClose,
|
||||||
|
screenId,
|
||||||
|
mode,
|
||||||
|
isInModal,
|
||||||
|
originalData,
|
||||||
|
allComponents,
|
||||||
|
onUpdateLayout,
|
||||||
|
onZoneClick,
|
||||||
|
selectedRows,
|
||||||
|
selectedRowsData,
|
||||||
|
onSelectedRowsChange,
|
||||||
|
refreshKey,
|
||||||
|
...safeProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
// 컴포넌트의 columnName에 해당하는 formData 값 추출
|
||||||
|
const fieldName = (component as any).columnName || component.id;
|
||||||
|
const currentValue = formData?.[fieldName] || "";
|
||||||
|
|
||||||
|
console.log("🔍 DynamicComponentRenderer - 새 컴포넌트 시스템:", {
|
||||||
|
componentType,
|
||||||
|
componentId: component.id,
|
||||||
|
columnName: (component as any).columnName,
|
||||||
|
fieldName,
|
||||||
|
currentValue,
|
||||||
|
hasFormData: !!formData,
|
||||||
|
formDataKeys: formData ? Object.keys(formData) : [],
|
||||||
|
autoGeneration: component.autoGeneration,
|
||||||
|
hidden: component.hidden,
|
||||||
|
isInteractive,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NewComponentRenderer
|
<NewComponentRenderer
|
||||||
{...safeProps}
|
|
||||||
component={component}
|
component={component}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
isInteractive={isInteractive}
|
|
||||||
formData={formData}
|
|
||||||
onFormDataChange={onFormDataChange}
|
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
size={component.size || newComponent.defaultSize}
|
size={component.size || newComponent.defaultSize}
|
||||||
|
|
@ -150,10 +202,29 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
style={component.style}
|
style={component.style}
|
||||||
config={component.componentConfig}
|
config={component.componentConfig}
|
||||||
componentConfig={component.componentConfig}
|
componentConfig={component.componentConfig}
|
||||||
screenId={props.screenId}
|
value={currentValue} // formData에서 추출한 현재 값 전달
|
||||||
tableName={props.tableName}
|
// 새로운 기능들 전달
|
||||||
onRefresh={props.onRefresh}
|
autoGeneration={component.autoGeneration}
|
||||||
onClose={props.onClose}
|
hidden={component.hidden}
|
||||||
|
// React 전용 props들은 직접 전달 (DOM에 전달되지 않음)
|
||||||
|
isInteractive={isInteractive}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={onFormDataChange}
|
||||||
|
tableName={tableName}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
onClose={onClose}
|
||||||
|
screenId={screenId}
|
||||||
|
mode={mode}
|
||||||
|
isInModal={isInModal}
|
||||||
|
originalData={originalData}
|
||||||
|
allComponents={allComponents}
|
||||||
|
onUpdateLayout={onUpdateLayout}
|
||||||
|
onZoneClick={onZoneClick}
|
||||||
|
// 테이블 선택된 행 정보 전달
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
selectedRowsData={selectedRowsData}
|
||||||
|
onSelectedRowsChange={onSelectedRowsChange}
|
||||||
|
refreshKey={refreshKey}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +258,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
|
|
||||||
// 동적 렌더링 실행
|
// 동적 렌더링 실행
|
||||||
try {
|
try {
|
||||||
|
// 레거시 시스템에서도 DOM 안전한 props만 전달
|
||||||
|
const safeLegacyProps = filterDOMProps(props);
|
||||||
|
|
||||||
return renderer({
|
return renderer({
|
||||||
component,
|
component,
|
||||||
isSelected,
|
isSelected,
|
||||||
|
|
@ -194,7 +268,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
children,
|
children,
|
||||||
...props,
|
// React 전용 props들은 명시적으로 전달 (레거시 컴포넌트가 필요한 경우)
|
||||||
|
isInteractive: props.isInteractive,
|
||||||
|
formData: props.formData,
|
||||||
|
onFormDataChange: props.onFormDataChange,
|
||||||
|
screenId: props.screenId,
|
||||||
|
tableName: props.tableName,
|
||||||
|
onRefresh: props.onRefresh,
|
||||||
|
onClose: props.onClose,
|
||||||
|
mode: props.mode,
|
||||||
|
isInModal: props.isInModal,
|
||||||
|
originalData: props.originalData,
|
||||||
|
onUpdateLayout: props.onUpdateLayout,
|
||||||
|
onZoneClick: props.onZoneClick,
|
||||||
|
onZoneComponentDrop: props.onZoneComponentDrop,
|
||||||
|
allComponents: props.allComponents,
|
||||||
|
// 테이블 선택된 행 정보 전달
|
||||||
|
selectedRows: props.selectedRows,
|
||||||
|
selectedRowsData: props.selectedRowsData,
|
||||||
|
onSelectedRowsChange: props.onSelectedRowsChange,
|
||||||
|
refreshKey: props.refreshKey,
|
||||||
|
// DOM 안전한 props들
|
||||||
|
...safeLegacyProps,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { LayoutComponent, ComponentData } from "@/types/screen";
|
import { LayoutComponent, ComponentData } from "@/types/screen";
|
||||||
import { LayoutRegistry } from "./LayoutRegistry";
|
import { LayoutRegistry } from "./LayoutRegistry";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
export interface DynamicLayoutRendererProps {
|
export interface DynamicLayoutRendererProps {
|
||||||
layout: LayoutComponent;
|
layout: LayoutComponent;
|
||||||
|
|
@ -70,6 +71,9 @@ export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
|
||||||
|
|
||||||
// 레이아웃 렌더링 실행
|
// 레이아웃 렌더링 실행
|
||||||
try {
|
try {
|
||||||
|
// DOM 안전한 props만 필터링
|
||||||
|
const safeProps = filterDOMProps(restProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutComponent
|
<LayoutComponent
|
||||||
layout={layout}
|
layout={layout}
|
||||||
|
|
@ -84,7 +88,7 @@ export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
|
||||||
onUpdateLayout={onUpdateLayout}
|
onUpdateLayout={onUpdateLayout}
|
||||||
className={className}
|
className={className}
|
||||||
style={style}
|
style={style}
|
||||||
{...restProps}
|
{...safeProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useRef, useEffect } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { ButtonPrimaryConfig } from "./types";
|
import { ButtonPrimaryConfig } from "./types";
|
||||||
import {
|
import {
|
||||||
|
|
@ -20,6 +20,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
config?: ButtonPrimaryConfig;
|
config?: ButtonPrimaryConfig;
|
||||||
|
|
@ -70,6 +71,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
config: any;
|
config: any;
|
||||||
context: ButtonActionContext;
|
context: ButtonActionContext;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// 토스트 정리를 위한 ref
|
||||||
|
const currentLoadingToastRef = useRef<string | number | undefined>();
|
||||||
|
|
||||||
|
// 컴포넌트 언마운트 시 토스트 정리
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (currentLoadingToastRef.current !== undefined) {
|
||||||
|
console.log("🧹 컴포넌트 언마운트 시 토스트 정리");
|
||||||
|
toast.dismiss(currentLoadingToastRef.current);
|
||||||
|
currentLoadingToastRef.current = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 컴포넌트 설정
|
// 컴포넌트 설정
|
||||||
const componentConfig = {
|
const componentConfig = {
|
||||||
...config,
|
...config,
|
||||||
|
|
@ -83,13 +99,26 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
processedConfig.action = {
|
processedConfig.action = {
|
||||||
...DEFAULT_BUTTON_ACTIONS[actionType],
|
...DEFAULT_BUTTON_ACTIONS[actionType],
|
||||||
type: actionType,
|
type: actionType,
|
||||||
|
// 🔥 제어관리 설정 추가 (webTypeConfig에서 가져옴)
|
||||||
|
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||||
|
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||||
|
};
|
||||||
|
} else if (componentConfig.action && typeof componentConfig.action === "object") {
|
||||||
|
// 🔥 이미 객체인 경우에도 제어관리 설정 추가
|
||||||
|
processedConfig.action = {
|
||||||
|
...componentConfig.action,
|
||||||
|
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||||
|
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("🔧 버튼 컴포넌트 설정:", {
|
console.log("🔧 버튼 컴포넌트 설정:", {
|
||||||
originalConfig: componentConfig,
|
originalConfig: componentConfig,
|
||||||
processedConfig,
|
processedConfig,
|
||||||
component: component,
|
actionConfig: processedConfig.action,
|
||||||
|
webTypeConfig: component.webTypeConfig,
|
||||||
|
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
|
||||||
|
dataflowConfig: component.webTypeConfig?.dataflowConfig,
|
||||||
screenId,
|
screenId,
|
||||||
tableName,
|
tableName,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
|
@ -118,13 +147,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// 실제 액션 실행 함수
|
// 실제 액션 실행 함수
|
||||||
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
|
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
|
||||||
console.log("🚀 executeAction 시작:", { actionConfig, context });
|
console.log("🚀 executeAction 시작:", { actionConfig, context });
|
||||||
let loadingToast: string | number | undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 기존 토스트가 있다면 먼저 제거
|
||||||
|
if (currentLoadingToastRef.current !== undefined) {
|
||||||
|
console.log("📱 기존 토스트 제거");
|
||||||
|
toast.dismiss(currentLoadingToastRef.current);
|
||||||
|
currentLoadingToastRef.current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 안전장치: 모든 로딩 토스트 제거
|
||||||
|
toast.dismiss();
|
||||||
|
|
||||||
// edit 액션을 제외하고만 로딩 토스트 표시
|
// edit 액션을 제외하고만 로딩 토스트 표시
|
||||||
if (actionConfig.type !== "edit") {
|
if (actionConfig.type !== "edit") {
|
||||||
console.log("📱 로딩 토스트 표시 시작");
|
console.log("📱 로딩 토스트 표시 시작");
|
||||||
loadingToast = toast.loading(
|
currentLoadingToastRef.current = toast.loading(
|
||||||
actionConfig.type === "save"
|
actionConfig.type === "save"
|
||||||
? "저장 중..."
|
? "저장 중..."
|
||||||
: actionConfig.type === "delete"
|
: actionConfig.type === "delete"
|
||||||
|
|
@ -132,8 +170,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
: actionConfig.type === "submit"
|
: actionConfig.type === "submit"
|
||||||
? "제출 중..."
|
? "제출 중..."
|
||||||
: "처리 중...",
|
: "처리 중...",
|
||||||
|
{
|
||||||
|
duration: Infinity, // 명시적으로 무한대로 설정
|
||||||
|
},
|
||||||
);
|
);
|
||||||
console.log("📱 로딩 토스트 ID:", loadingToast);
|
console.log("📱 로딩 토스트 ID:", currentLoadingToastRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
|
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
|
||||||
|
|
@ -141,9 +182,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
|
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
|
||||||
|
|
||||||
// 로딩 토스트 제거 (있는 경우에만)
|
// 로딩 토스트 제거 (있는 경우에만)
|
||||||
if (loadingToast) {
|
if (currentLoadingToastRef.current !== undefined) {
|
||||||
console.log("📱 로딩 토스트 제거");
|
console.log("📱 로딩 토스트 제거 시도, ID:", currentLoadingToastRef.current);
|
||||||
toast.dismiss(loadingToast);
|
toast.dismiss(currentLoadingToastRef.current);
|
||||||
|
currentLoadingToastRef.current = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요)
|
// edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요)
|
||||||
|
|
@ -169,9 +211,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
console.log("❌ executeAction catch 블록 진입:", error);
|
console.log("❌ executeAction catch 블록 진입:", error);
|
||||||
|
|
||||||
// 로딩 토스트 제거
|
// 로딩 토스트 제거
|
||||||
if (loadingToast) {
|
if (currentLoadingToastRef.current !== undefined) {
|
||||||
console.log("📱 오류 시 로딩 토스트 제거");
|
console.log("📱 오류 시 로딩 토스트 제거, ID:", currentLoadingToastRef.current);
|
||||||
toast.dismiss(loadingToast);
|
toast.dismiss(currentLoadingToastRef.current);
|
||||||
|
currentLoadingToastRef.current = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("❌ 버튼 액션 실행 오류:", error);
|
console.error("❌ 버튼 액션 실행 오류:", error);
|
||||||
|
|
@ -313,15 +356,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// DOM 안전한 props만 필터링
|
||||||
|
const safeDomProps = filterDOMProps(domProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||||
<button
|
<button
|
||||||
type={componentConfig.actionType || "button"}
|
type={componentConfig.actionType || "button"}
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
minHeight: "100%", // 최소 높이 강제 적용
|
||||||
|
maxHeight: "100%", // 최대 높이 제한
|
||||||
border: "1px solid #3b82f6",
|
border: "1px solid #3b82f6",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
backgroundColor: "#3b82f6",
|
backgroundColor: "#3b82f6",
|
||||||
|
|
@ -330,6 +378,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
|
boxSizing: "border-box", // 패딩/보더 포함 크기 계산
|
||||||
|
display: "flex", // flex로 변경
|
||||||
|
alignItems: "center", // 세로 중앙 정렬
|
||||||
|
justifyContent: "center", // 가로 중앙 정렬
|
||||||
|
padding: "0", // 패딩 제거
|
||||||
|
margin: "0", // 마진 제거
|
||||||
|
lineHeight: "1", // 라인 높이 고정
|
||||||
|
// 강제 높이 적용
|
||||||
|
minHeight: "36px",
|
||||||
|
height: "36px",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
...(isInteractive && component.style ? component.style : {}),
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
import { DateInputConfig } from "./types";
|
import { DateInputConfig } from "./types";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
import { AutoGenerationConfig } from "@/types/screen";
|
||||||
|
|
||||||
export interface DateInputComponentProps extends ComponentRendererProps {
|
export interface DateInputComponentProps extends ComponentRendererProps {
|
||||||
config?: DateInputConfig;
|
config?: DateInputConfig;
|
||||||
|
value?: any; // 외부에서 전달받는 값
|
||||||
|
autoGeneration?: AutoGenerationConfig;
|
||||||
|
hidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +31,9 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
style,
|
style,
|
||||||
formData,
|
formData,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
|
value: externalValue, // 외부에서 전달받은 값
|
||||||
|
autoGeneration,
|
||||||
|
hidden,
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 컴포넌트 설정
|
// 컴포넌트 설정
|
||||||
|
|
@ -33,6 +42,208 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
...component.config,
|
...component.config,
|
||||||
} as DateInputConfig;
|
} as DateInputConfig;
|
||||||
|
|
||||||
|
// 🎯 자동생성 상태 관리
|
||||||
|
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||||
|
|
||||||
|
// 🚨 컴포넌트 마운트 확인용 로그
|
||||||
|
console.log("🚨 DateInputComponent 마운트됨!", {
|
||||||
|
componentId: component.id,
|
||||||
|
isInteractive,
|
||||||
|
isDesignMode,
|
||||||
|
autoGeneration,
|
||||||
|
componentAutoGeneration: component.autoGeneration,
|
||||||
|
externalValue,
|
||||||
|
formDataValue: formData?.[component.columnName || ""],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🧪 무조건 실행되는 테스트
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🧪 DateInputComponent 무조건 실행 테스트!");
|
||||||
|
const testDate = "2025-01-19"; // 고정된 테스트 날짜
|
||||||
|
setAutoGeneratedValue(testDate);
|
||||||
|
console.log("🧪 autoGeneratedValue 설정 완료:", testDate);
|
||||||
|
}, []); // 빈 의존성 배열로 한 번만 실행
|
||||||
|
|
||||||
|
// 자동생성 설정 (props 우선, 컴포넌트 설정 폴백)
|
||||||
|
const finalAutoGeneration = autoGeneration || component.autoGeneration;
|
||||||
|
const finalHidden = hidden !== undefined ? hidden : component.hidden;
|
||||||
|
|
||||||
|
// 🧪 테스트용 간단한 자동생성 로직
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔍 DateInputComponent useEffect 실행:", {
|
||||||
|
componentId: component.id,
|
||||||
|
finalAutoGeneration,
|
||||||
|
enabled: finalAutoGeneration?.enabled,
|
||||||
|
type: finalAutoGeneration?.type,
|
||||||
|
isInteractive,
|
||||||
|
isDesignMode,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
columnName: component.columnName,
|
||||||
|
currentFormValue: formData?.[component.columnName || ""],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🧪 테스트: 자동생성이 활성화되어 있으면 무조건 현재 날짜 설정
|
||||||
|
if (finalAutoGeneration?.enabled) {
|
||||||
|
const today = new Date().toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
|
console.log("🧪 테스트용 날짜 생성:", today);
|
||||||
|
|
||||||
|
setAutoGeneratedValue(today);
|
||||||
|
|
||||||
|
// 인터랙티브 모드에서 폼 데이터에도 설정
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
console.log("📤 테스트용 폼 데이터 업데이트:", component.columnName, today);
|
||||||
|
onFormDataChange(component.columnName, today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원래 자동생성 로직 (주석 처리)
|
||||||
|
/*
|
||||||
|
if (finalAutoGeneration?.enabled && finalAutoGeneration.type !== "none") {
|
||||||
|
const fieldName = component.columnName || component.id;
|
||||||
|
const generatedValue = AutoGenerationUtils.generateValue(finalAutoGeneration, fieldName);
|
||||||
|
|
||||||
|
console.log("🎯 DateInputComponent 자동생성 시도:", {
|
||||||
|
componentId: component.id,
|
||||||
|
fieldName,
|
||||||
|
type: finalAutoGeneration.type,
|
||||||
|
options: finalAutoGeneration.options,
|
||||||
|
generatedValue,
|
||||||
|
isInteractive,
|
||||||
|
isDesignMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (generatedValue) {
|
||||||
|
console.log("✅ DateInputComponent 자동생성 성공:", generatedValue);
|
||||||
|
setAutoGeneratedValue(generatedValue);
|
||||||
|
|
||||||
|
// 인터랙티브 모드에서 폼 데이터 업데이트
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
const currentValue = formData?.[component.columnName];
|
||||||
|
if (!currentValue) {
|
||||||
|
console.log("📤 DateInputComponent -> onFormDataChange 호출:", component.columnName, generatedValue);
|
||||||
|
onFormDataChange(component.columnName, generatedValue);
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ DateInputComponent 기존 값이 있어서 자동생성 스킵:", currentValue);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ DateInputComponent 자동생성 조건 불만족:", {
|
||||||
|
isInteractive,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
hasColumnName: !!component.columnName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("❌ DateInputComponent 자동생성 실패: generatedValue가 null");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ DateInputComponent 자동생성 비활성화:", {
|
||||||
|
enabled: finalAutoGeneration?.enabled,
|
||||||
|
type: finalAutoGeneration?.type,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
}, [
|
||||||
|
finalAutoGeneration?.enabled,
|
||||||
|
finalAutoGeneration?.type,
|
||||||
|
finalAutoGeneration?.options,
|
||||||
|
component.id,
|
||||||
|
component.columnName,
|
||||||
|
isInteractive,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 날짜 값 계산 및 디버깅
|
||||||
|
const fieldName = component.columnName || component.id;
|
||||||
|
|
||||||
|
// 값 우선순위: externalValue > formData > autoGeneratedValue > component.value
|
||||||
|
let rawValue: any;
|
||||||
|
if (externalValue !== undefined) {
|
||||||
|
rawValue = externalValue;
|
||||||
|
} else if (isInteractive && formData && component.columnName && formData[component.columnName]) {
|
||||||
|
rawValue = formData[component.columnName];
|
||||||
|
} else if (autoGeneratedValue) {
|
||||||
|
rawValue = autoGeneratedValue;
|
||||||
|
} else {
|
||||||
|
rawValue = component.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔍 DateInputComponent 값 디버깅:", {
|
||||||
|
componentId: component.id,
|
||||||
|
fieldName,
|
||||||
|
externalValue,
|
||||||
|
formDataValue: formData?.[component.columnName || ""],
|
||||||
|
componentValue: component.value,
|
||||||
|
rawValue,
|
||||||
|
isInteractive,
|
||||||
|
hasFormData: !!formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 날짜 형식 변환 함수 (HTML input[type="date"]는 YYYY-MM-DD 형식만 허용)
|
||||||
|
const formatDateForInput = (dateValue: any): string => {
|
||||||
|
if (!dateValue) return "";
|
||||||
|
|
||||||
|
const dateStr = String(dateValue);
|
||||||
|
|
||||||
|
// 이미 YYYY-MM-DD 형식인 경우
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||||
|
return dateStr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD HH:mm:ss 형식에서 날짜 부분만 추출
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}\s/.test(dateStr)) {
|
||||||
|
return dateStr.split(" ")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY/MM/DD 형식
|
||||||
|
if (/^\d{4}\/\d{2}\/\d{2}$/.test(dateStr)) {
|
||||||
|
return dateStr.replace(/\//g, "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
// MM/DD/YYYY 형식
|
||||||
|
if (/^\d{2}\/\d{2}\/\d{4}$/.test(dateStr)) {
|
||||||
|
const [month, day, year] = dateStr.split("/");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DD-MM-YYYY 형식
|
||||||
|
if (/^\d{2}-\d{2}-\d{4}$/.test(dateStr)) {
|
||||||
|
const [day, month, year] = dateStr.split("-");
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ISO 8601 날짜 (2023-12-31T00:00:00.000Z 등)
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
|
||||||
|
return dateStr.split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다른 형식의 날짜 문자열이나 Date 객체 처리
|
||||||
|
try {
|
||||||
|
const date = new Date(dateValue);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
console.warn("🚨 DateInputComponent - 유효하지 않은 날짜:", dateValue);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// YYYY-MM-DD 형식으로 변환 (로컬 시간대 사용)
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const formattedDate = `${year}-${month}-${day}`;
|
||||||
|
|
||||||
|
console.log("📅 날짜 형식 변환:", {
|
||||||
|
원본: dateValue,
|
||||||
|
변환후: formattedDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
return formattedDate;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("🚨 DateInputComponent - 날짜 변환 오류:", error, "원본:", dateValue);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formattedValue = formatDateForInput(rawValue);
|
||||||
|
|
||||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
|
|
@ -74,10 +285,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
...domProps
|
...domProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
// DOM 안전한 props만 필터링
|
||||||
|
const safeDomProps = filterDOMProps(domProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -86,17 +300,15 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
fontSize: component.style?.labelFontSize || "14px",
|
fontSize: component.style?.labelFontSize || "14px",
|
||||||
color: component.style?.labelColor || "#374151",
|
color: component.style?.labelColor || "#374151",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && (
|
{component.required && (
|
||||||
<span style={{
|
<span
|
||||||
|
style={{
|
||||||
color: "#ef4444",
|
color: "#ef4444",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
}}
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
>
|
||||||
}}>
|
|
||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -105,12 +317,17 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={component.value || ""}
|
value={formattedValue}
|
||||||
placeholder={componentConfig.placeholder || ""}
|
placeholder={
|
||||||
|
finalAutoGeneration?.enabled
|
||||||
|
? `자동생성: ${AutoGenerationUtils.getTypeDescription(finalAutoGeneration.type)}`
|
||||||
|
: componentConfig.placeholder || ""
|
||||||
|
}
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
required={componentConfig.required || false}
|
required={componentConfig.required || false}
|
||||||
readOnly={componentConfig.readonly || false}
|
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
|
||||||
style={{width: "100%",
|
style={{
|
||||||
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
border: "1px solid #d1d5db",
|
border: "1px solid #d1d5db",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
|
|
@ -118,13 +335,36 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||||
...(isInteractive && component.style ? component.style : {}),}}
|
...(isInteractive && component.style ? component.style : {}),
|
||||||
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (component.onChange) {
|
const newValue = e.target.value;
|
||||||
component.onChange(e.target.value);
|
console.log("🎯 DateInputComponent onChange 호출:", {
|
||||||
|
componentId: component.id,
|
||||||
|
columnName: component.columnName,
|
||||||
|
newValue,
|
||||||
|
isInteractive,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
hasOnChange: !!props.onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// isInteractive 모드에서는 formData 업데이트
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
console.log(`📤 DateInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
||||||
|
onFormDataChange(component.columnName, newValue);
|
||||||
|
}
|
||||||
|
// 디자인 모드에서는 component.onChange 호출
|
||||||
|
else if (component.onChange) {
|
||||||
|
console.log(`📤 DateInputComponent -> component.onChange 호출: ${newValue}`);
|
||||||
|
component.onChange(newValue);
|
||||||
|
}
|
||||||
|
// props.onChange가 있으면 호출 (호환성)
|
||||||
|
else if (props.onChange) {
|
||||||
|
console.log(`📤 DateInputComponent -> props.onChange 호출: ${newValue}`);
|
||||||
|
props.onChange(newValue);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { DateInputConfig } from "./types";
|
import { DateInputConfig } from "./types";
|
||||||
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
||||||
export interface DateInputConfigPanelProps {
|
export interface DateInputConfigPanelProps {
|
||||||
config: DateInputConfig;
|
config: DateInputConfig;
|
||||||
|
|
@ -16,19 +18,15 @@ export interface DateInputConfigPanelProps {
|
||||||
* DateInput 설정 패널
|
* DateInput 설정 패널
|
||||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||||
*/
|
*/
|
||||||
export const DateInputConfigPanel: React.FC<DateInputConfigPanelProps> = ({
|
export const DateInputConfigPanel: React.FC<DateInputConfigPanelProps> = ({ config, onChange }) => {
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const handleChange = (key: keyof DateInputConfig, value: any) => {
|
const handleChange = (key: keyof DateInputConfig, value: any) => {
|
||||||
|
console.log("🔧 DateInputConfigPanel.handleChange:", { key, value });
|
||||||
onChange({ [key]: value });
|
onChange({ [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">date-input 설정</div>
|
||||||
date-input 설정
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* date 관련 설정 */}
|
{/* date 관련 설정 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -67,6 +65,194 @@ export const DateInputConfigPanel: React.FC<DateInputConfigPanelProps> = ({
|
||||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 숨김 기능 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hidden">숨김</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="hidden"
|
||||||
|
checked={config.hidden || false}
|
||||||
|
onCheckedChange={(checked) => handleChange("hidden", checked)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">편집기에서는 연하게 보이지만 실제 화면에서는 숨겨집니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자동생성 기능 */}
|
||||||
|
<div className="space-y-3 border-t pt-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="autoGeneration">자동생성</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="autoGeneration"
|
||||||
|
checked={config.autoGeneration?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const newAutoGeneration: AutoGenerationConfig = {
|
||||||
|
...config.autoGeneration,
|
||||||
|
enabled: checked as boolean,
|
||||||
|
type: config.autoGeneration?.type || "current_time",
|
||||||
|
};
|
||||||
|
handleChange("autoGeneration", newAutoGeneration);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.autoGeneration?.enabled && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="autoGenerationType">자동생성 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.autoGeneration?.type || "current_time"}
|
||||||
|
onValueChange={(value: AutoGenerationType) => {
|
||||||
|
const newAutoGeneration: AutoGenerationConfig = {
|
||||||
|
...config.autoGeneration,
|
||||||
|
type: value,
|
||||||
|
options: value === "current_time" ? { format: "date" } : {},
|
||||||
|
};
|
||||||
|
handleChange("autoGeneration", newAutoGeneration);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="current_time">현재 날짜/시간</SelectItem>
|
||||||
|
<SelectItem value="uuid">UUID</SelectItem>
|
||||||
|
<SelectItem value="current_user">현재 사용자</SelectItem>
|
||||||
|
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||||
|
<SelectItem value="random_string">랜덤 문자열</SelectItem>
|
||||||
|
<SelectItem value="random_number">랜덤 숫자</SelectItem>
|
||||||
|
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||||
|
<SelectItem value="department">부서 코드</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{AutoGenerationUtils.getTypeDescription(config.autoGeneration?.type || "current_time")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.autoGeneration?.type === "current_time" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="dateFormat">날짜 형식</Label>
|
||||||
|
<Select
|
||||||
|
value={config.autoGeneration?.options?.format || "date"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
const newAutoGeneration: AutoGenerationConfig = {
|
||||||
|
...config.autoGeneration!,
|
||||||
|
options: {
|
||||||
|
...config.autoGeneration?.options,
|
||||||
|
format: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handleChange("autoGeneration", newAutoGeneration);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="date">날짜만 (YYYY-MM-DD)</SelectItem>
|
||||||
|
<SelectItem value="datetime">날짜+시간 (YYYY-MM-DD HH:mm:ss)</SelectItem>
|
||||||
|
<SelectItem value="time">시간만 (HH:mm:ss)</SelectItem>
|
||||||
|
<SelectItem value="timestamp">타임스탬프</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(config.autoGeneration?.type === "sequence" ||
|
||||||
|
config.autoGeneration?.type === "random_string" ||
|
||||||
|
config.autoGeneration?.type === "random_number") && (
|
||||||
|
<>
|
||||||
|
{config.autoGeneration?.type === "sequence" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="startValue">시작값</Label>
|
||||||
|
<Input
|
||||||
|
id="startValue"
|
||||||
|
type="number"
|
||||||
|
value={config.autoGeneration?.options?.startValue || 1}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newAutoGeneration: AutoGenerationConfig = {
|
||||||
|
...config.autoGeneration!,
|
||||||
|
options: {
|
||||||
|
...config.autoGeneration?.options,
|
||||||
|
startValue: parseInt(e.target.value) || 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handleChange("autoGeneration", newAutoGeneration);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(config.autoGeneration?.type === "random_string" ||
|
||||||
|
config.autoGeneration?.type === "random_number") && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="length">길이</Label>
|
||||||
|
<Input
|
||||||
|
id="length"
|
||||||
|
type="number"
|
||||||
|
value={
|
||||||
|
config.autoGeneration?.options?.length ||
|
||||||
|
(config.autoGeneration?.type === "random_string" ? 8 : 6)
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newAutoGeneration: AutoGenerationConfig = {
|
||||||
|
...config.autoGeneration!,
|
||||||
|
options: {
|
||||||
|
...config.autoGeneration?.options,
|
||||||
|
length: parseInt(e.target.value) || 8,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handleChange("autoGeneration", newAutoGeneration);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="prefix">접두사</Label>
|
||||||
|
<Input
|
||||||
|
id="prefix"
|
||||||
|
value={config.autoGeneration?.options?.prefix || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newAutoGeneration: AutoGenerationConfig = {
|
||||||
|
...config.autoGeneration!,
|
||||||
|
options: {
|
||||||
|
...config.autoGeneration?.options,
|
||||||
|
prefix: e.target.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handleChange("autoGeneration", newAutoGeneration);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="suffix">접미사</Label>
|
||||||
|
<Input
|
||||||
|
id="suffix"
|
||||||
|
value={config.autoGeneration?.options?.suffix || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newAutoGeneration: AutoGenerationConfig = {
|
||||||
|
...config.autoGeneration!,
|
||||||
|
options: {
|
||||||
|
...config.autoGeneration?.options,
|
||||||
|
suffix: e.target.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
handleChange("autoGeneration", newAutoGeneration);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="rounded bg-gray-50 p-2 text-xs">
|
||||||
|
<strong>미리보기:</strong> {AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ export class DateInputRenderer extends AutoRegisteringComponentRenderer {
|
||||||
static componentDefinition = DateInputDefinition;
|
static componentDefinition = DateInputDefinition;
|
||||||
|
|
||||||
render(): React.ReactElement {
|
render(): React.ReactElement {
|
||||||
|
console.log("🎯 DateInputRenderer.render() 호출:", {
|
||||||
|
componentId: this.props.component?.id,
|
||||||
|
autoGeneration: this.props.autoGeneration,
|
||||||
|
componentAutoGeneration: this.props.component?.autoGeneration,
|
||||||
|
allProps: Object.keys(this.props),
|
||||||
|
});
|
||||||
|
|
||||||
return <DateInputComponent {...this.props} renderer={this} />;
|
return <DateInputComponent {...this.props} renderer={this} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import React from "react";
|
||||||
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
import { createComponentDefinition } from "../../utils/createComponentDefinition";
|
||||||
import { ComponentCategory } from "@/types/component";
|
import { ComponentCategory } from "@/types/component";
|
||||||
import type { WebType } from "@/types/screen";
|
import type { WebType } from "@/types/screen";
|
||||||
import { DateInputWrapper } from "./DateInputComponent";
|
import { DateInputComponent } from "./DateInputComponent";
|
||||||
import { DateInputConfigPanel } from "./DateInputConfigPanel";
|
import { DateInputConfigPanel } from "./DateInputConfigPanel";
|
||||||
import { DateInputConfig } from "./types";
|
import { DateInputConfig } from "./types";
|
||||||
|
|
||||||
|
|
@ -19,7 +19,7 @@ export const DateInputDefinition = createComponentDefinition({
|
||||||
description: "날짜 선택을 위한 날짜 선택기 컴포넌트",
|
description: "날짜 선택을 위한 날짜 선택기 컴포넌트",
|
||||||
category: ComponentCategory.INPUT,
|
category: ComponentCategory.INPUT,
|
||||||
webType: "date",
|
webType: "date",
|
||||||
component: DateInputWrapper,
|
component: DateInputComponent,
|
||||||
defaultConfig: {
|
defaultConfig: {
|
||||||
placeholder: "입력하세요",
|
placeholder: "입력하세요",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ComponentConfig } from "@/types/component";
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
import { AutoGenerationConfig } from "@/types/screen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DateInput 컴포넌트 설정 타입
|
* DateInput 컴포넌트 설정 타입
|
||||||
|
|
@ -13,9 +14,12 @@ export interface DateInputConfig extends ComponentConfig {
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
readonly?: boolean;
|
readonly?: boolean;
|
||||||
placeholder?: string;
|
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
|
|
||||||
|
// 자동생성 및 숨김 기능
|
||||||
|
autoGeneration?: AutoGenerationConfig;
|
||||||
|
hidden?: boolean;
|
||||||
|
|
||||||
// 스타일 관련
|
// 스타일 관련
|
||||||
variant?: "default" | "outlined" | "filled";
|
variant?: "default" | "outlined" | "filled";
|
||||||
size?: "sm" | "md" | "lg";
|
size?: "sm" | "md" | "lg";
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps, AutoGenerationConfig } from "@/types/component";
|
||||||
import { NumberInputConfig } from "./types";
|
import { NumberInputConfig } from "./types";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
||||||
export interface NumberInputComponentProps extends ComponentRendererProps {
|
export interface NumberInputComponentProps extends ComponentRendererProps {
|
||||||
config?: NumberInputConfig;
|
config?: NumberInputConfig;
|
||||||
|
value?: any; // 외부에서 전달받는 값
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -25,6 +28,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
style,
|
style,
|
||||||
formData,
|
formData,
|
||||||
onFormDataChange,
|
onFormDataChange,
|
||||||
|
value: externalValue, // 외부에서 전달받은 값
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
// 컴포넌트 설정
|
// 컴포넌트 설정
|
||||||
|
|
@ -74,10 +78,13 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
...domProps
|
...domProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
// DOM 안전한 props만 필터링
|
||||||
|
const safeDomProps = filterDOMProps(domProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -86,17 +93,15 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
fontSize: component.style?.labelFontSize || "14px",
|
fontSize: component.style?.labelFontSize || "14px",
|
||||||
color: component.style?.labelColor || "#374151",
|
color: component.style?.labelColor || "#374151",
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{component.label}
|
{component.label}
|
||||||
{component.required && (
|
{component.required && (
|
||||||
<span style={{
|
<span
|
||||||
|
style={{
|
||||||
color: "#ef4444",
|
color: "#ef4444",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
}}
|
||||||
...(isInteractive && component.style ? component.style : {}),
|
>
|
||||||
}}>
|
|
||||||
*
|
*
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -105,7 +110,16 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={component.value || ""}
|
value={
|
||||||
|
// 1순위: 외부에서 전달받은 value (DynamicComponentRenderer에서 전달)
|
||||||
|
externalValue !== undefined
|
||||||
|
? externalValue
|
||||||
|
: // 2순위: 인터랙티브 모드에서 formData
|
||||||
|
isInteractive && formData && component.columnName
|
||||||
|
? formData[component.columnName] || ""
|
||||||
|
: // 3순위: 컴포넌트 자체 값
|
||||||
|
component.value || ""
|
||||||
|
}
|
||||||
placeholder={componentConfig.placeholder || ""}
|
placeholder={componentConfig.placeholder || ""}
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
required={componentConfig.required || false}
|
required={componentConfig.required || false}
|
||||||
|
|
@ -113,7 +127,8 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
min={componentConfig.min}
|
min={componentConfig.min}
|
||||||
max={componentConfig.max}
|
max={componentConfig.max}
|
||||||
step={componentConfig.step || 1}
|
step={componentConfig.step || 1}
|
||||||
style={{width: "100%",
|
style={{
|
||||||
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
border: "1px solid #d1d5db",
|
border: "1px solid #d1d5db",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
|
|
@ -121,13 +136,36 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
|
||||||
fontSize: "14px",
|
fontSize: "14px",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||||
...(isInteractive && component.style ? component.style : {}),}}
|
...(isInteractive && component.style ? component.style : {}),
|
||||||
|
}}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
if (component.onChange) {
|
const newValue = e.target.value;
|
||||||
component.onChange(e.target.value);
|
console.log("🎯 NumberInputComponent onChange 호출:", {
|
||||||
|
componentId: component.id,
|
||||||
|
columnName: component.columnName,
|
||||||
|
newValue,
|
||||||
|
isInteractive,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
hasOnChange: !!props.onChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
// isInteractive 모드에서는 formData 업데이트
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
console.log(`📤 NumberInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
||||||
|
onFormDataChange(component.columnName, newValue);
|
||||||
|
}
|
||||||
|
// 디자인 모드에서는 component.onChange 호출
|
||||||
|
else if (component.onChange) {
|
||||||
|
console.log(`📤 NumberInputComponent -> component.onChange 호출: ${newValue}`);
|
||||||
|
component.onChange(newValue);
|
||||||
|
}
|
||||||
|
// props.onChange가 있으면 호출 (호환성)
|
||||||
|
else if (props.onChange) {
|
||||||
|
console.log(`📤 NumberInputComponent -> props.onChange 호출: ${newValue}`);
|
||||||
|
props.onChange(newValue);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useState, useEffect, useRef } from "react";
|
import React, { useState, useEffect, useRef } from "react";
|
||||||
import { commonCodeApi } from "../../../api/commonCode";
|
import { commonCodeApi } from "../../../api/commonCode";
|
||||||
import { tableTypeApi } from "../../../api/screen";
|
import { tableTypeApi } from "../../../api/screen";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
interface Option {
|
interface Option {
|
||||||
value: string;
|
value: string;
|
||||||
|
|
@ -14,11 +15,14 @@ export interface SelectBasicComponentProps {
|
||||||
onUpdate?: (field: string, value: any) => void;
|
onUpdate?: (field: string, value: any) => void;
|
||||||
isSelected?: boolean;
|
isSelected?: boolean;
|
||||||
isDesignMode?: boolean;
|
isDesignMode?: boolean;
|
||||||
|
isInteractive?: boolean;
|
||||||
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onDragStart?: () => void;
|
onDragStart?: () => void;
|
||||||
onDragEnd?: () => void;
|
onDragEnd?: () => void;
|
||||||
|
value?: any; // 외부에서 전달받는 값
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -164,6 +168,7 @@ const loadGlobalCodeOptions = async (codeCategory: string): Promise<Option[]> =>
|
||||||
const actualValue = code.code || code.CODE || code.value || code.code_value || `code_${index}`;
|
const actualValue = code.code || code.CODE || code.value || code.code_value || `code_${index}`;
|
||||||
const actualLabel =
|
const actualLabel =
|
||||||
code.codeName ||
|
code.codeName ||
|
||||||
|
code.code_name || // 스네이크 케이스 추가!
|
||||||
code.name ||
|
code.name ||
|
||||||
code.CODE_NAME ||
|
code.CODE_NAME ||
|
||||||
code.NAME ||
|
code.NAME ||
|
||||||
|
|
@ -233,16 +238,34 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
onUpdate,
|
onUpdate,
|
||||||
isSelected = false,
|
isSelected = false,
|
||||||
isDesignMode = false,
|
isDesignMode = false,
|
||||||
|
isInteractive = false,
|
||||||
|
onFormDataChange,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
onClick,
|
onClick,
|
||||||
onDragStart,
|
onDragStart,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
value: externalValue, // 명시적으로 value prop 받기
|
||||||
...props
|
...props
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [selectedValue, setSelectedValue] = useState(componentConfig?.value || "");
|
|
||||||
|
// webTypeConfig 또는 componentConfig 사용 (DynamicWebTypeRenderer 호환성)
|
||||||
|
const config = (props as any).webTypeConfig || componentConfig || {};
|
||||||
|
|
||||||
|
// 외부에서 전달받은 value가 있으면 우선 사용, 없으면 config.value 사용
|
||||||
|
const [selectedValue, setSelectedValue] = useState(externalValue || config?.value || "");
|
||||||
const [selectedLabel, setSelectedLabel] = useState("");
|
const [selectedLabel, setSelectedLabel] = useState("");
|
||||||
|
|
||||||
|
console.log("🔍 SelectBasicComponent 초기화:", {
|
||||||
|
componentId: component.id,
|
||||||
|
externalValue,
|
||||||
|
componentConfigValue: componentConfig?.value,
|
||||||
|
webTypeConfigValue: (props as any).webTypeConfig?.value,
|
||||||
|
configValue: config?.value,
|
||||||
|
finalSelectedValue: externalValue || config?.value || "",
|
||||||
|
props: Object.keys(props),
|
||||||
|
});
|
||||||
const [codeOptions, setCodeOptions] = useState<Option[]>([]);
|
const [codeOptions, setCodeOptions] = useState<Option[]>([]);
|
||||||
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
const [isLoadingCodes, setIsLoadingCodes] = useState(false);
|
||||||
const [dynamicCodeCategory, setDynamicCodeCategory] = useState<string | null>(null);
|
const [dynamicCodeCategory, setDynamicCodeCategory] = useState<string | null>(null);
|
||||||
|
|
@ -250,7 +273,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const selectRef = useRef<HTMLDivElement>(null);
|
const selectRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리
|
// 코드 카테고리 결정: 동적 카테고리 > 설정 카테고리
|
||||||
const codeCategory = dynamicCodeCategory || componentConfig?.codeCategory;
|
const codeCategory = dynamicCodeCategory || config?.codeCategory;
|
||||||
|
|
||||||
|
// 외부 value prop 변경 시 selectedValue 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
const newValue = externalValue || config?.value || "";
|
||||||
|
// 값이 실제로 다른 경우에만 업데이트 (빈 문자열도 유효한 값으로 처리)
|
||||||
|
if (newValue !== selectedValue) {
|
||||||
|
console.log(`🔄 SelectBasicComponent value 업데이트: "${selectedValue}" → "${newValue}"`);
|
||||||
|
console.log(`🔍 업데이트 조건 분석:`, {
|
||||||
|
externalValue,
|
||||||
|
componentConfigValue: componentConfig?.value,
|
||||||
|
configValue: config?.value,
|
||||||
|
newValue,
|
||||||
|
selectedValue,
|
||||||
|
shouldUpdate: newValue !== selectedValue,
|
||||||
|
});
|
||||||
|
setSelectedValue(newValue);
|
||||||
|
}
|
||||||
|
}, [externalValue, config?.value]);
|
||||||
|
|
||||||
// 🚀 전역 상태 구독 및 동기화
|
// 🚀 전역 상태 구독 및 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -359,18 +400,43 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
// 선택된 값에 따른 라벨 업데이트
|
// 선택된 값에 따른 라벨 업데이트
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
const configOptions = componentConfig.options || [];
|
const configOptions = config.options || [];
|
||||||
return [...codeOptions, ...configOptions];
|
return [...codeOptions, ...configOptions];
|
||||||
};
|
};
|
||||||
|
|
||||||
const options = getAllOptions();
|
const options = getAllOptions();
|
||||||
const selectedOption = options.find((option) => option.value === selectedValue);
|
const selectedOption = options.find((option) => option.value === selectedValue);
|
||||||
const newLabel = selectedOption?.label || "";
|
|
||||||
|
// 🎯 코드 타입의 경우 코드값과 코드명을 모두 고려하여 라벨 찾기
|
||||||
|
let newLabel = selectedOption?.label || "";
|
||||||
|
|
||||||
|
// selectedOption이 없고 selectedValue가 있다면, 코드명으로도 검색해보기
|
||||||
|
if (!selectedOption && selectedValue && codeOptions.length > 0) {
|
||||||
|
// 1) selectedValue가 코드명인 경우 (예: "국내")
|
||||||
|
const labelMatch = options.find((option) => option.label === selectedValue);
|
||||||
|
if (labelMatch) {
|
||||||
|
newLabel = labelMatch.label;
|
||||||
|
console.log(`🔍 [${component.id}] 코드명으로 매치 발견: "${selectedValue}" → "${newLabel}"`);
|
||||||
|
} else {
|
||||||
|
// 2) selectedValue가 코드값인 경우라면 원래 로직대로 라벨을 찾되, 없으면 원값 표시
|
||||||
|
newLabel = selectedValue; // 코드값 그대로 표시 (예: "555")
|
||||||
|
console.log(`🔍 [${component.id}] 코드값 원본 유지: "${selectedValue}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🏷️ [${component.id}] 라벨 업데이트:`, {
|
||||||
|
selectedValue,
|
||||||
|
selectedOption: selectedOption ? { value: selectedOption.value, label: selectedOption.label } : null,
|
||||||
|
newLabel,
|
||||||
|
optionsCount: options.length,
|
||||||
|
allOptionsValues: options.map((o) => o.value),
|
||||||
|
allOptionsLabels: options.map((o) => o.label),
|
||||||
|
});
|
||||||
|
|
||||||
if (newLabel !== selectedLabel) {
|
if (newLabel !== selectedLabel) {
|
||||||
setSelectedLabel(newLabel);
|
setSelectedLabel(newLabel);
|
||||||
}
|
}
|
||||||
}, [selectedValue, codeOptions, componentConfig.options]);
|
}, [selectedValue, codeOptions, config.options]);
|
||||||
|
|
||||||
// 클릭 이벤트 핸들러 (전역 상태 새로고침)
|
// 클릭 이벤트 핸들러 (전역 상태 새로고침)
|
||||||
const handleToggle = () => {
|
const handleToggle = () => {
|
||||||
|
|
@ -416,10 +482,23 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
setSelectedLabel(label);
|
setSelectedLabel(label);
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
|
|
||||||
|
// 디자인 모드에서의 컴포넌트 속성 업데이트
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
onUpdate("value", value);
|
onUpdate("value", value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 인터랙티브 모드에서 폼 데이터 업데이트 (TextInputComponent와 동일한 로직)
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
console.log(`📤 SelectBasicComponent -> onFormDataChange 호출: ${component.columnName} = "${value}"`);
|
||||||
|
onFormDataChange(component.columnName, value);
|
||||||
|
} else {
|
||||||
|
console.log("❌ SelectBasicComponent onFormDataChange 조건 미충족:", {
|
||||||
|
isInteractive,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
hasColumnName: !!component.columnName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.log(`✅ [${component.id}] 옵션 선택:`, { value, label });
|
console.log(`✅ [${component.id}] 옵션 선택:`, { value, label });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -473,7 +552,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
|
|
||||||
// 모든 옵션 가져오기
|
// 모든 옵션 가져오기
|
||||||
const getAllOptions = () => {
|
const getAllOptions = () => {
|
||||||
const configOptions = componentConfig.options || [];
|
const configOptions = config.options || [];
|
||||||
console.log(`🔧 [${component.id}] 옵션 병합:`, {
|
console.log(`🔧 [${component.id}] 옵션 병합:`, {
|
||||||
codeOptionsLength: codeOptions.length,
|
codeOptionsLength: codeOptions.length,
|
||||||
codeOptions: codeOptions.map((o) => ({ value: o.value, label: o.label })),
|
codeOptions: codeOptions.map((o) => ({ value: o.value, label: o.label })),
|
||||||
|
|
@ -486,6 +565,24 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
const allOptions = getAllOptions();
|
const allOptions = getAllOptions();
|
||||||
const placeholder = componentConfig.placeholder || "선택하세요";
|
const placeholder = componentConfig.placeholder || "선택하세요";
|
||||||
|
|
||||||
|
// DOM props에서 React 전용 props 필터링
|
||||||
|
const {
|
||||||
|
component: _component,
|
||||||
|
componentConfig: _componentConfig,
|
||||||
|
screenId: _screenId,
|
||||||
|
onUpdate: _onUpdate,
|
||||||
|
isSelected: _isSelected,
|
||||||
|
isDesignMode: _isDesignMode,
|
||||||
|
className: _className,
|
||||||
|
style: _style,
|
||||||
|
onClick: _onClick,
|
||||||
|
onDragStart: _onDragStart,
|
||||||
|
onDragEnd: _onDragEnd,
|
||||||
|
...otherProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const safeDomProps = filterDOMProps(otherProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={selectRef}
|
ref={selectRef}
|
||||||
|
|
@ -494,8 +591,27 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDragStart={onDragStart}
|
onDragStart={onDragStart}
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
{...props}
|
{...safeDomProps}
|
||||||
>
|
>
|
||||||
|
{/* 라벨 렌더링 */}
|
||||||
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
|
<label
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "-25px",
|
||||||
|
left: "0px",
|
||||||
|
fontSize: component.style?.labelFontSize || "14px",
|
||||||
|
color: component.style?.labelColor || "#374151",
|
||||||
|
fontWeight: "500",
|
||||||
|
// isInteractive 모드에서는 사용자 스타일 우선 적용
|
||||||
|
...(isInteractive && component.style ? component.style : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{component.label}
|
||||||
|
{component.required && <span style={{ color: "#ef4444" }}>*</span>}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 커스텀 셀렉트 박스 */}
|
{/* 커스텀 셀렉트 박스 */}
|
||||||
<div
|
<div
|
||||||
className={`flex w-full cursor-pointer items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 ${isDesignMode ? "pointer-events-none" : "hover:border-gray-400"} ${isSelected ? "ring-2 ring-blue-500" : ""} ${isOpen ? "border-blue-500" : ""} `}
|
className={`flex w-full cursor-pointer items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 ${isDesignMode ? "pointer-events-none" : "hover:border-gray-400"} ${isSelected ? "ring-2 ring-blue-500" : ""} ${isOpen ? "border-blue-500" : ""} `}
|
||||||
|
|
@ -520,11 +636,11 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
|
||||||
{/* 드롭다운 옵션 */}
|
{/* 드롭다운 옵션 */}
|
||||||
{isOpen && !isDesignMode && (
|
{isOpen && !isDesignMode && (
|
||||||
<div
|
<div
|
||||||
className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
className="absolute mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: "white",
|
backgroundColor: "white",
|
||||||
color: "black",
|
color: "black",
|
||||||
zIndex: 9999,
|
zIndex: 99999, // 더 높은 z-index로 설정
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
|
|
|
||||||
|
|
@ -539,7 +539,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
}
|
}
|
||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
|
|
||||||
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가)
|
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가 + 숨김 기능)
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
|
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
|
||||||
const checkboxConfig = tableConfig.checkbox || {
|
const checkboxConfig = tableConfig.checkbox || {
|
||||||
|
|
@ -554,9 +554,27 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
if (!displayColumns || displayColumns.length === 0) {
|
if (!displayColumns || displayColumns.length === 0) {
|
||||||
// displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용
|
// displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용
|
||||||
if (!tableConfig.columns) return [];
|
if (!tableConfig.columns) return [];
|
||||||
columns = tableConfig.columns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
|
columns = tableConfig.columns
|
||||||
|
.filter((col) => {
|
||||||
|
// 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김
|
||||||
|
if (isDesignMode) {
|
||||||
|
return col.visible; // 디자인 모드에서는 visible만 체크
|
||||||
} else {
|
} else {
|
||||||
columns = displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
|
return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
} else {
|
||||||
|
columns = displayColumns
|
||||||
|
.filter((col) => {
|
||||||
|
// 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김
|
||||||
|
if (isDesignMode) {
|
||||||
|
return col.visible; // 디자인 모드에서는 visible만 체크
|
||||||
|
} else {
|
||||||
|
return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 체크박스가 활성화된 경우 체크박스 컬럼을 추가
|
// 체크박스가 활성화된 경우 체크박스 컬럼을 추가
|
||||||
|
|
@ -704,13 +722,29 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
return (value: any, format?: string, columnName?: string) => {
|
return (value: any, format?: string, columnName?: string) => {
|
||||||
if (value === null || value === undefined) return "";
|
if (value === null || value === undefined) return "";
|
||||||
|
|
||||||
|
// 디버깅: 모든 값 변환 시도를 로깅
|
||||||
|
if (
|
||||||
|
columnName &&
|
||||||
|
(columnName === "contract_type" || columnName === "domestic_foreign" || columnName === "status")
|
||||||
|
) {
|
||||||
|
console.log(`🔍 값 변환 시도: ${columnName}="${value}"`, {
|
||||||
|
columnMeta: columnMeta[columnName],
|
||||||
|
hasColumnMeta: !!columnMeta[columnName],
|
||||||
|
webType: columnMeta[columnName]?.webType,
|
||||||
|
codeCategory: columnMeta[columnName]?.codeCategory,
|
||||||
|
globalColumnMeta: Object.keys(columnMeta),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 🎯 코드 컬럼인 경우 최적화된 코드명 변환 사용
|
// 🎯 코드 컬럼인 경우 최적화된 코드명 변환 사용
|
||||||
if (columnName && columnMeta[columnName]?.webType === "code" && columnMeta[columnName]?.codeCategory) {
|
if (columnName && columnMeta[columnName]?.webType === "code" && columnMeta[columnName]?.codeCategory) {
|
||||||
const categoryCode = columnMeta[columnName].codeCategory!;
|
const categoryCode = columnMeta[columnName].codeCategory!;
|
||||||
const convertedValue = optimizedConvertCode(categoryCode, String(value));
|
const convertedValue = optimizedConvertCode(categoryCode, String(value));
|
||||||
|
|
||||||
if (convertedValue !== String(value)) {
|
if (convertedValue !== String(value)) {
|
||||||
console.log(`🔄 코드 변환: ${columnName}[${categoryCode}] ${value} → ${convertedValue}`);
|
console.log(`🔄 코드 변환 성공: ${columnName}[${categoryCode}] ${value} → ${convertedValue}`);
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ 코드 변환 실패: ${columnName}[${categoryCode}] ${value} → ${convertedValue} (값 동일)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
value = convertedValue;
|
value = convertedValue;
|
||||||
|
|
@ -860,6 +894,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||||
`text-${column.align}`,
|
`text-${column.align}`,
|
||||||
column.sortable && "hover:bg-gray-50",
|
column.sortable && "hover:bg-gray-50",
|
||||||
|
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||||
|
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
minWidth: `${getColumnWidth(column)}px`,
|
minWidth: `${getColumnWidth(column)}px`,
|
||||||
|
|
@ -947,13 +983,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
{columnsByPosition.normal.map((column) => (
|
{columnsByPosition.normal.map((column) => (
|
||||||
<th
|
<th
|
||||||
key={`normal-${column.columnName}`}
|
key={`normal-${column.columnName}`}
|
||||||
style={{ minWidth: `${getColumnWidth(column)}px` }}
|
style={{
|
||||||
|
minWidth: `${getColumnWidth(column)}px`,
|
||||||
|
minHeight: "48px",
|
||||||
|
height: "48px",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
lineHeight: "1",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
column.columnName === "__checkbox__"
|
column.columnName === "__checkbox__"
|
||||||
? "h-12 border-b px-4 py-3 text-center"
|
? "h-12 border-b px-4 py-3 text-center align-middle"
|
||||||
: "cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
|
: "cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||||
`text-${column.align}`,
|
`text-${column.align}`,
|
||||||
column.sortable && "hover:bg-gray-50",
|
column.sortable && "hover:bg-gray-50",
|
||||||
|
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||||
|
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||||
)}
|
)}
|
||||||
onClick={() => column.sortable && handleSort(column.columnName)}
|
onClick={() => column.sortable && handleSort(column.columnName)}
|
||||||
>
|
>
|
||||||
|
|
@ -1040,6 +1085,8 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
|
||||||
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
: "h-12 cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
|
||||||
`text-${column.align}`,
|
`text-${column.align}`,
|
||||||
column.sortable && "hover:bg-gray-50",
|
column.sortable && "hover:bg-gray-50",
|
||||||
|
// 숨김 컬럼 스타일 (디자인 모드에서만)
|
||||||
|
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
minWidth: `${getColumnWidth(column)}px`,
|
minWidth: `${getColumnWidth(column)}px`,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,35 @@ export interface EntityJoinInfo {
|
||||||
joinAlias: string;
|
joinAlias: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동생성 타입 정의
|
||||||
|
*/
|
||||||
|
export type AutoGenerationType =
|
||||||
|
| "uuid" // UUID 생성
|
||||||
|
| "current_user" // 현재 사용자 ID
|
||||||
|
| "current_time" // 현재 시간
|
||||||
|
| "sequence" // 시퀀스 번호
|
||||||
|
| "random_string" // 랜덤 문자열
|
||||||
|
| "random_number" // 랜덤 숫자
|
||||||
|
| "company_code" // 회사 코드
|
||||||
|
| "department" // 부서 코드
|
||||||
|
| "none"; // 자동생성 없음
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동생성 설정
|
||||||
|
*/
|
||||||
|
export interface AutoGenerationConfig {
|
||||||
|
type: AutoGenerationType;
|
||||||
|
enabled: boolean;
|
||||||
|
options?: {
|
||||||
|
length?: number; // 랜덤 문자열/숫자 길이
|
||||||
|
prefix?: string; // 접두사
|
||||||
|
suffix?: string; // 접미사
|
||||||
|
format?: string; // 시간 형식 (current_time용)
|
||||||
|
startValue?: number; // 시퀀스 시작값
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 테이블 컬럼 설정
|
* 테이블 컬럼 설정
|
||||||
*/
|
*/
|
||||||
|
|
@ -31,6 +60,10 @@ export interface ColumnConfig {
|
||||||
// 컬럼 고정 관련 속성
|
// 컬럼 고정 관련 속성
|
||||||
fixed?: "left" | "right" | false; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함)
|
fixed?: "left" | "right" | false; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함)
|
||||||
fixedOrder?: number; // 고정된 컬럼들 내에서의 순서
|
fixedOrder?: number; // 고정된 컬럼들 내에서의 순서
|
||||||
|
|
||||||
|
// 새로운 기능들
|
||||||
|
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||||
|
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { ComponentRendererProps } from "@/types/component";
|
import { ComponentRendererProps } from "@/types/component";
|
||||||
|
import { AutoGenerationConfig } from "@/types/screen";
|
||||||
import { TextInputConfig } from "./types";
|
import { TextInputConfig } from "./types";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
||||||
export interface TextInputComponentProps extends ComponentRendererProps {
|
export interface TextInputComponentProps extends ComponentRendererProps {
|
||||||
config?: TextInputConfig;
|
config?: TextInputConfig;
|
||||||
|
|
@ -33,18 +36,112 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
...component.config,
|
...component.config,
|
||||||
} as TextInputConfig;
|
} as TextInputConfig;
|
||||||
|
|
||||||
|
// 자동생성 설정 (props에서 전달받은 값 우선 사용)
|
||||||
|
const autoGeneration: AutoGenerationConfig = props.autoGeneration ||
|
||||||
|
component.autoGeneration ||
|
||||||
|
componentConfig.autoGeneration || {
|
||||||
|
type: "none",
|
||||||
|
enabled: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 숨김 상태 (props에서 전달받은 값 우선 사용)
|
||||||
|
const isHidden = props.hidden !== undefined ? props.hidden : component.hidden || componentConfig.hidden || false;
|
||||||
|
|
||||||
|
// 자동생성된 값 상태
|
||||||
|
const [autoGeneratedValue, setAutoGeneratedValue] = useState<string>("");
|
||||||
|
|
||||||
|
// 테스트용: 컴포넌트 라벨에 "test"가 포함되면 강제로 UUID 자동생성 활성화
|
||||||
|
const testAutoGeneration = component.label?.toLowerCase().includes("test")
|
||||||
|
? {
|
||||||
|
type: "uuid" as AutoGenerationType,
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
: autoGeneration;
|
||||||
|
|
||||||
console.log("🔧 텍스트 입력 컴포넌트 설정:", {
|
console.log("🔧 텍스트 입력 컴포넌트 설정:", {
|
||||||
config,
|
config,
|
||||||
componentConfig,
|
componentConfig,
|
||||||
component: component,
|
component: component,
|
||||||
|
autoGeneration,
|
||||||
|
testAutoGeneration,
|
||||||
|
isTestMode: component.label?.toLowerCase().includes("test"),
|
||||||
|
isHidden,
|
||||||
|
isInteractive,
|
||||||
|
formData,
|
||||||
|
columnName: component.columnName,
|
||||||
|
currentFormValue: formData?.[component.columnName],
|
||||||
|
componentValue: component.value,
|
||||||
|
autoGeneratedValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 자동생성 값 생성 (컴포넌트 마운트 시 또는 폼 데이터 변경 시)
|
||||||
|
useEffect(() => {
|
||||||
|
console.log("🔄 자동생성 useEffect 실행:", {
|
||||||
|
enabled: testAutoGeneration.enabled,
|
||||||
|
type: testAutoGeneration.type,
|
||||||
|
isInteractive,
|
||||||
|
columnName: component.columnName,
|
||||||
|
hasFormData: !!formData,
|
||||||
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (testAutoGeneration.enabled && testAutoGeneration.type !== "none") {
|
||||||
|
// 폼 데이터에 이미 값이 있으면 자동생성하지 않음
|
||||||
|
const currentFormValue = formData?.[component.columnName];
|
||||||
|
const currentComponentValue = component.value;
|
||||||
|
|
||||||
|
console.log("🔍 자동생성 조건 확인:", {
|
||||||
|
currentFormValue,
|
||||||
|
currentComponentValue,
|
||||||
|
hasCurrentValue: !!(currentFormValue || currentComponentValue),
|
||||||
|
autoGeneratedValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 자동생성된 값이 없고, 현재 값도 없을 때만 생성
|
||||||
|
if (!autoGeneratedValue && !currentFormValue && !currentComponentValue) {
|
||||||
|
const generatedValue = AutoGenerationUtils.generateValue(testAutoGeneration, component.columnName);
|
||||||
|
console.log("✨ 자동생성된 값:", generatedValue);
|
||||||
|
|
||||||
|
if (generatedValue) {
|
||||||
|
setAutoGeneratedValue(generatedValue);
|
||||||
|
|
||||||
|
// 폼 데이터에 자동생성된 값 설정 (인터랙티브 모드에서만)
|
||||||
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
|
console.log("📝 폼 데이터에 자동생성 값 설정:", {
|
||||||
|
columnName: component.columnName,
|
||||||
|
value: generatedValue,
|
||||||
|
});
|
||||||
|
onFormDataChange(component.columnName, generatedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!autoGeneratedValue && testAutoGeneration.type !== "none") {
|
||||||
|
// 디자인 모드에서도 미리보기용 자동생성 값 표시
|
||||||
|
const previewValue = AutoGenerationUtils.generatePreviewValue(testAutoGeneration);
|
||||||
|
console.log("🎨 디자인 모드 미리보기 값:", previewValue);
|
||||||
|
setAutoGeneratedValue(previewValue);
|
||||||
|
} else {
|
||||||
|
console.log("⏭️ 이미 값이 있어서 자동생성 건너뜀:", {
|
||||||
|
hasAutoGenerated: !!autoGeneratedValue,
|
||||||
|
hasFormValue: !!currentFormValue,
|
||||||
|
hasComponentValue: !!currentComponentValue,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [testAutoGeneration, isInteractive, component.columnName, component.value, formData, onFormDataChange]);
|
||||||
|
|
||||||
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
// 스타일 계산 (위치는 RealtimePreviewDynamic에서 처리하므로 제외)
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
...component.style,
|
...component.style,
|
||||||
...style,
|
...style,
|
||||||
|
// 숨김 기능: 디자인 모드에서는 연하게, 실제 화면에서는 완전히 숨김
|
||||||
|
...(isHidden && {
|
||||||
|
opacity: isDesignMode ? 0.4 : 0,
|
||||||
|
backgroundColor: isDesignMode ? "#f3f4f6" : "transparent",
|
||||||
|
pointerEvents: isDesignMode ? "auto" : "none",
|
||||||
|
display: isDesignMode ? "block" : "none",
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 디자인 모드 스타일
|
// 디자인 모드 스타일
|
||||||
|
|
@ -80,10 +177,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
...domProps
|
...domProps
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
// DOM 안전한 props만 필터링
|
||||||
|
const safeDomProps = filterDOMProps(domProps);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={componentStyle} className={className} {...domProps}>
|
<div style={componentStyle} className={className} {...safeDomProps}>
|
||||||
{/* 라벨 렌더링 */}
|
{/* 라벨 렌더링 */}
|
||||||
{component.label && (
|
{component.label && component.style?.labelDisplay !== false && (
|
||||||
<label
|
<label
|
||||||
style={{
|
style={{
|
||||||
position: "absolute",
|
position: "absolute",
|
||||||
|
|
@ -101,15 +201,37 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={
|
value={(() => {
|
||||||
isInteractive && formData && component.columnName
|
let displayValue = "";
|
||||||
? formData[component.columnName] || ""
|
|
||||||
: component.value || ""
|
if (isInteractive && formData && component.columnName) {
|
||||||
|
// 인터랙티브 모드: formData 우선, 없으면 자동생성 값
|
||||||
|
displayValue = formData[component.columnName] || autoGeneratedValue || "";
|
||||||
|
} else {
|
||||||
|
// 디자인 모드: component.value 우선, 없으면 자동생성 값
|
||||||
|
displayValue = component.value || autoGeneratedValue || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📄 Input 값 계산:", {
|
||||||
|
isInteractive,
|
||||||
|
hasFormData: !!formData,
|
||||||
|
columnName: component.columnName,
|
||||||
|
formDataValue: formData?.[component.columnName],
|
||||||
|
componentValue: component.value,
|
||||||
|
autoGeneratedValue,
|
||||||
|
finalDisplayValue: displayValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
return displayValue;
|
||||||
|
})()}
|
||||||
|
placeholder={
|
||||||
|
testAutoGeneration.enabled && testAutoGeneration.type !== "none"
|
||||||
|
? `자동생성: ${AutoGenerationUtils.getTypeDescription(testAutoGeneration.type)}`
|
||||||
|
: componentConfig.placeholder || ""
|
||||||
}
|
}
|
||||||
placeholder={componentConfig.placeholder || ""}
|
|
||||||
disabled={componentConfig.disabled || false}
|
disabled={componentConfig.disabled || false}
|
||||||
required={componentConfig.required || false}
|
required={componentConfig.required || false}
|
||||||
readOnly={componentConfig.readonly || false}
|
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
|
||||||
style={{
|
style={{
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
|
|
@ -126,7 +248,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
onDragEnd={onDragEnd}
|
onDragEnd={onDragEnd}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const newValue = e.target.value;
|
const newValue = e.target.value;
|
||||||
console.log(`🎯 TextInputComponent onChange 호출:`, {
|
console.log("🎯 TextInputComponent onChange 호출:", {
|
||||||
componentId: component.id,
|
componentId: component.id,
|
||||||
columnName: component.columnName,
|
columnName: component.columnName,
|
||||||
newValue,
|
newValue,
|
||||||
|
|
@ -138,13 +260,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
|
||||||
// isInteractive 모드에서는 formData 업데이트
|
// isInteractive 모드에서는 formData 업데이트
|
||||||
if (isInteractive && onFormDataChange && component.columnName) {
|
if (isInteractive && onFormDataChange && component.columnName) {
|
||||||
console.log(`📤 TextInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
console.log(`📤 TextInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
|
||||||
console.log(`🔍 onFormDataChange 함수 정보:`, {
|
console.log("🔍 onFormDataChange 함수 정보:", {
|
||||||
functionName: onFormDataChange.name,
|
functionName: onFormDataChange.name,
|
||||||
functionString: onFormDataChange.toString().substring(0, 200),
|
functionString: onFormDataChange.toString().substring(0, 200),
|
||||||
});
|
});
|
||||||
onFormDataChange(component.columnName, newValue);
|
onFormDataChange(component.columnName, newValue);
|
||||||
} else {
|
} else {
|
||||||
console.log(`❌ TextInputComponent onFormDataChange 조건 미충족:`, {
|
console.log("❌ TextInputComponent onFormDataChange 조건 미충족:", {
|
||||||
isInteractive,
|
isInteractive,
|
||||||
hasOnFormDataChange: !!onFormDataChange,
|
hasOnFormDataChange: !!onFormDataChange,
|
||||||
hasColumnName: !!component.columnName,
|
hasColumnName: !!component.columnName,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { TextInputConfig } from "./types";
|
import { TextInputConfig } from "./types";
|
||||||
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
|
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
|
||||||
|
|
||||||
export interface TextInputConfigPanelProps {
|
export interface TextInputConfigPanelProps {
|
||||||
config: TextInputConfig;
|
config: TextInputConfig;
|
||||||
|
|
@ -16,19 +18,14 @@ export interface TextInputConfigPanelProps {
|
||||||
* TextInput 설정 패널
|
* TextInput 설정 패널
|
||||||
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
* 컴포넌트의 설정값들을 편집할 수 있는 UI 제공
|
||||||
*/
|
*/
|
||||||
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({
|
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange }) => {
|
||||||
config,
|
|
||||||
onChange,
|
|
||||||
}) => {
|
|
||||||
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
const handleChange = (key: keyof TextInputConfig, value: any) => {
|
||||||
onChange({ [key]: value });
|
onChange({ [key]: value });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="text-sm font-medium">
|
<div className="text-sm font-medium">text-input 설정</div>
|
||||||
text-input 설정
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 텍스트 관련 설정 */}
|
{/* 텍스트 관련 설정 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -77,6 +74,163 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({
|
||||||
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
onCheckedChange={(checked) => handleChange("readonly", checked)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 구분선 */}
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<div className="mb-3 text-sm font-medium">고급 기능</div>
|
||||||
|
|
||||||
|
{/* 숨김 기능 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="hidden">숨김 (편집기에서는 연하게, 실제 화면에서는 숨김)</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="hidden"
|
||||||
|
checked={config.hidden || false}
|
||||||
|
onCheckedChange={(checked) => handleChange("hidden", checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자동생성 기능 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="autoGeneration">자동생성 활성화</Label>
|
||||||
|
<Checkbox
|
||||||
|
id="autoGeneration"
|
||||||
|
checked={config.autoGeneration?.enabled || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||||
|
handleChange("autoGeneration", {
|
||||||
|
...currentConfig,
|
||||||
|
enabled: checked as boolean,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 자동생성 타입 선택 */}
|
||||||
|
{config.autoGeneration?.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="autoGenerationType">자동생성 타입</Label>
|
||||||
|
<Select
|
||||||
|
value={config.autoGeneration?.type || "none"}
|
||||||
|
onValueChange={(value: AutoGenerationType) => {
|
||||||
|
const currentConfig = config.autoGeneration || { type: "none", enabled: false };
|
||||||
|
handleChange("autoGeneration", {
|
||||||
|
...currentConfig,
|
||||||
|
type: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="자동생성 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">자동생성 없음</SelectItem>
|
||||||
|
<SelectItem value="uuid">UUID 생성</SelectItem>
|
||||||
|
<SelectItem value="current_user">현재 사용자 ID</SelectItem>
|
||||||
|
<SelectItem value="current_time">현재 시간</SelectItem>
|
||||||
|
<SelectItem value="sequence">순차 번호</SelectItem>
|
||||||
|
<SelectItem value="random_string">랜덤 문자열</SelectItem>
|
||||||
|
<SelectItem value="random_number">랜덤 숫자</SelectItem>
|
||||||
|
<SelectItem value="company_code">회사 코드</SelectItem>
|
||||||
|
<SelectItem value="department">부서 코드</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 선택된 타입 설명 */}
|
||||||
|
{config.autoGeneration?.type && config.autoGeneration.type !== "none" && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
{AutoGenerationUtils.getTypeDescription(config.autoGeneration.type)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 자동생성 옵션 */}
|
||||||
|
{config.autoGeneration?.enabled &&
|
||||||
|
config.autoGeneration?.type &&
|
||||||
|
["random_string", "random_number", "sequence"].includes(config.autoGeneration.type) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>자동생성 옵션</Label>
|
||||||
|
|
||||||
|
{/* 길이 설정 (랜덤 문자열/숫자용) */}
|
||||||
|
{["random_string", "random_number"].includes(config.autoGeneration.type) && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoGenLength" className="text-xs">
|
||||||
|
길이
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="autoGenLength"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
value={
|
||||||
|
config.autoGeneration?.options?.length || (config.autoGeneration.type === "random_string" ? 8 : 6)
|
||||||
|
}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentConfig = config.autoGeneration!;
|
||||||
|
handleChange("autoGeneration", {
|
||||||
|
...currentConfig,
|
||||||
|
options: {
|
||||||
|
...currentConfig.options,
|
||||||
|
length: parseInt(e.target.value) || 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 접두사 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoGenPrefix" className="text-xs">
|
||||||
|
접두사
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="autoGenPrefix"
|
||||||
|
value={config.autoGeneration?.options?.prefix || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentConfig = config.autoGeneration!;
|
||||||
|
handleChange("autoGeneration", {
|
||||||
|
...currentConfig,
|
||||||
|
options: {
|
||||||
|
...currentConfig.options,
|
||||||
|
prefix: e.target.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 접미사 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="autoGenSuffix" className="text-xs">
|
||||||
|
접미사
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="autoGenSuffix"
|
||||||
|
value={config.autoGeneration?.options?.suffix || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const currentConfig = config.autoGeneration!;
|
||||||
|
handleChange("autoGeneration", {
|
||||||
|
...currentConfig,
|
||||||
|
options: {
|
||||||
|
...currentConfig.options,
|
||||||
|
suffix: e.target.value,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 미리보기 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label className="text-xs">미리보기</Label>
|
||||||
|
<div className="rounded border bg-gray-100 p-2 text-xs">
|
||||||
|
{AutoGenerationUtils.generatePreviewValue(config.autoGeneration)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ComponentConfig } from "@/types/component";
|
import { ComponentConfig } from "@/types/component";
|
||||||
|
import { AutoGenerationConfig } from "@/types/screen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextInput 컴포넌트 설정 타입
|
* TextInput 컴포넌트 설정 타입
|
||||||
|
|
@ -27,6 +28,10 @@ export interface TextInputConfig extends ComponentConfig {
|
||||||
onFocus?: () => void;
|
onFocus?: () => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
|
||||||
|
// 새로운 기능들
|
||||||
|
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||||
|
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,18 @@ export interface LayoutRendererProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
|
// 추가된 props들 (레이아웃에서 사용되지 않지만 필터링 시 필요)
|
||||||
|
formData?: Record<string, any>;
|
||||||
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
isInteractive?: boolean;
|
||||||
|
screenId?: number;
|
||||||
|
tableName?: string;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
mode?: "view" | "edit";
|
||||||
|
isInModal?: boolean;
|
||||||
|
originalData?: Record<string, any>;
|
||||||
|
[key: string]: any; // 기타 props 허용
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererProps> {
|
export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererProps> {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Flexbox 레이아웃 컴포넌트
|
* Flexbox 레이아웃 컴포넌트
|
||||||
|
|
@ -93,19 +94,8 @@ export const FlexboxLayout: React.FC<FlexboxLayoutProps> = ({
|
||||||
flexStyle.padding = "8px";
|
flexStyle.padding = "8px";
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOM props만 추출 (React DOM에서 인식하는 props만)
|
// DOM 안전한 props만 필터링
|
||||||
const {
|
const domProps = filterDOMProps(props);
|
||||||
children: propsChildren,
|
|
||||||
onUpdateLayout,
|
|
||||||
onSelectComponent,
|
|
||||||
isDesignMode: _isDesignMode,
|
|
||||||
allComponents,
|
|
||||||
onComponentDrop,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
selectedScreen, // DOM에 전달하지 않도록 제외
|
|
||||||
...domProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
import { LayoutRendererProps } from "../BaseLayoutRenderer";
|
||||||
|
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그리드 레이아웃 컴포넌트
|
* 그리드 레이아웃 컴포넌트
|
||||||
|
|
@ -61,19 +62,8 @@ export const GridLayout: React.FC<GridLayoutProps> = ({
|
||||||
gridStyle.borderRadius = "8px";
|
gridStyle.borderRadius = "8px";
|
||||||
}
|
}
|
||||||
|
|
||||||
// DOM props만 추출 (React DOM에서 인식하는 props만)
|
// DOM 안전한 props만 필터링
|
||||||
const {
|
const domProps = filterDOMProps(props);
|
||||||
children: propsChildren,
|
|
||||||
onUpdateLayout,
|
|
||||||
onSelectComponent,
|
|
||||||
isDesignMode: _isDesignMode,
|
|
||||||
allComponents,
|
|
||||||
onComponentDrop,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
selectedScreen, // DOM에 전달하지 않도록 제외
|
|
||||||
...domProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,33 @@ export interface QuickValidationResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message?: string;
|
message?: string;
|
||||||
canExecuteImmediately: boolean;
|
canExecuteImmediately: boolean;
|
||||||
|
actions?: any[]; // 조건 만족 시 실행할 액션들
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제어 데이터 소스 타입
|
||||||
|
*/
|
||||||
|
export type ControlDataSource = "form" | "table-selection" | "both";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 확장된 제어 컨텍스트
|
||||||
|
*/
|
||||||
|
export interface ExtendedControlContext {
|
||||||
|
// 기존 폼 데이터
|
||||||
|
formData: Record<string, any>;
|
||||||
|
|
||||||
|
// 테이블 선택 데이터
|
||||||
|
selectedRows?: any[];
|
||||||
|
selectedRowsData?: any[];
|
||||||
|
|
||||||
|
// 제어 데이터 소스 타입
|
||||||
|
controlDataSource: ControlDataSource;
|
||||||
|
|
||||||
|
// 기타 컨텍스트
|
||||||
|
buttonId: string;
|
||||||
|
componentData?: any;
|
||||||
|
timestamp: string;
|
||||||
|
clickCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -289,7 +316,7 @@ export class OptimizedButtonDataflowService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 빠른 검증 (메모리에서 즉시 처리)
|
* 🔥 빠른 검증 (메모리에서 즉시 처리) - 확장된 버전
|
||||||
*/
|
*/
|
||||||
private static async executeQuickValidation(
|
private static async executeQuickValidation(
|
||||||
config: ButtonDataflowConfig,
|
config: ButtonDataflowConfig,
|
||||||
|
|
@ -326,6 +353,221 @@ export class OptimizedButtonDataflowService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 🔥 확장된 조건 검증 (폼 + 테이블 선택 데이터)
|
||||||
|
*/
|
||||||
|
static async executeExtendedValidation(
|
||||||
|
config: ButtonDataflowConfig,
|
||||||
|
context: ExtendedControlContext,
|
||||||
|
): Promise<QuickValidationResult> {
|
||||||
|
console.log("🔍 executeExtendedValidation 시작:", {
|
||||||
|
controlMode: config.controlMode,
|
||||||
|
controlDataSource: context.controlDataSource,
|
||||||
|
selectedRowsData: context.selectedRowsData,
|
||||||
|
formData: context.formData,
|
||||||
|
directControlConditions: config.directControl?.conditions,
|
||||||
|
selectedDiagramId: config.selectedDiagramId,
|
||||||
|
selectedRelationshipId: config.selectedRelationshipId,
|
||||||
|
fullConfig: config,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔥 simple 모드에서도 조건 검증을 수행해야 함
|
||||||
|
// if (config.controlMode === "simple") {
|
||||||
|
// return {
|
||||||
|
// success: true,
|
||||||
|
// canExecuteImmediately: true,
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
|
||||||
|
let conditions = config.directControl?.conditions || [];
|
||||||
|
|
||||||
|
// 🔥 관계도 방식인 경우 실제 API에서 조건 가져오기
|
||||||
|
if (conditions.length === 0 && config.selectedDiagramId && config.selectedRelationshipId) {
|
||||||
|
console.log("🔍 관계도 방식에서 실제 조건 로딩:", {
|
||||||
|
selectedDiagramId: config.selectedDiagramId,
|
||||||
|
selectedRelationshipId: config.selectedRelationshipId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 관계도에서 실제 조건을 가져오는 API 호출
|
||||||
|
const response = await apiClient.get(
|
||||||
|
`/test-button-dataflow/diagrams/${config.selectedDiagramId}/relationships/${config.selectedRelationshipId}/preview`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data.success && response.data.data) {
|
||||||
|
const previewData = response.data.data;
|
||||||
|
|
||||||
|
// control.conditions에서 실제 조건 추출
|
||||||
|
if (previewData.control && previewData.control.conditions) {
|
||||||
|
conditions = previewData.control.conditions.filter((cond: any) => cond.type === "condition");
|
||||||
|
console.log("✅ 관계도에서 control 조건 로딩 성공:", {
|
||||||
|
totalConditions: previewData.control.conditions.length,
|
||||||
|
filteredConditions: conditions.length,
|
||||||
|
conditions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 관계도 control에 조건이 설정되지 않았습니다:", previewData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 control.conditions가 비어있으면 plan.actions[].conditions에서 조건 추출
|
||||||
|
if (conditions.length === 0 && previewData.plan && previewData.plan.actions) {
|
||||||
|
console.log("🔍 control 조건이 없어서 액션별 조건 확인:", previewData.plan.actions);
|
||||||
|
|
||||||
|
previewData.plan.actions.forEach((action: any, index: number) => {
|
||||||
|
if (action.conditions && Array.isArray(action.conditions) && action.conditions.length > 0) {
|
||||||
|
conditions.push(...action.conditions);
|
||||||
|
console.log(
|
||||||
|
`✅ 액션 ${index + 1}(${action.name})에서 조건 ${action.conditions.length}개 로딩:`,
|
||||||
|
action.conditions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (conditions.length > 0) {
|
||||||
|
console.log("✅ 총 액션별 조건 로딩 성공:", {
|
||||||
|
totalConditions: conditions.length,
|
||||||
|
conditions: conditions,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// plan.actions에서 실제 액션도 저장 (조건 만족 시 실행용)
|
||||||
|
if (previewData.plan && previewData.plan.actions) {
|
||||||
|
// config에 액션 정보 임시 저장
|
||||||
|
(config as any)._relationshipActions = previewData.plan.actions;
|
||||||
|
console.log("✅ 관계도에서 실제 액션 로딩 성공:", {
|
||||||
|
totalActions: previewData.plan.actions.length,
|
||||||
|
actions: previewData.plan.actions,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 관계도에 액션이 설정되지 않았습니다:", {
|
||||||
|
hasPlan: !!previewData.plan,
|
||||||
|
planActions: previewData.plan?.actions,
|
||||||
|
fullPreviewData: previewData,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 관계도 API 응답이 올바르지 않습니다:", response.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 관계도에서 조건 로딩 실패:", error);
|
||||||
|
|
||||||
|
// API 실패 시 사용자에게 명확한 안내
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "관계도에서 조건을 불러올 수 없습니다. 관계도 설정을 확인해주세요.",
|
||||||
|
canExecuteImmediately: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 여전히 조건이 없으면 경고
|
||||||
|
if (conditions.length === 0) {
|
||||||
|
console.warn("⚠️ 제어 조건이 설정되지 않았습니다.");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "제어 조건이 설정되지 않았습니다. 관계도에서 관계를 선택하거나 직접 조건을 추가해주세요.",
|
||||||
|
canExecuteImmediately: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔍 조건 검증 시작:", conditions);
|
||||||
|
|
||||||
|
for (const condition of conditions) {
|
||||||
|
if (condition.type === "condition") {
|
||||||
|
let dataToCheck: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 제어 데이터 소스에 따라 검증할 데이터 결정
|
||||||
|
console.log("🔍 제어 데이터 소스 확인:", {
|
||||||
|
controlDataSource: context.controlDataSource,
|
||||||
|
hasFormData: Object.keys(context.formData).length > 0,
|
||||||
|
hasSelectedRowsData: context.selectedRowsData && context.selectedRowsData.length > 0,
|
||||||
|
selectedRowsData: context.selectedRowsData,
|
||||||
|
});
|
||||||
|
|
||||||
|
switch (context.controlDataSource) {
|
||||||
|
case "form":
|
||||||
|
dataToCheck = context.formData;
|
||||||
|
console.log("📝 폼 데이터 사용:", dataToCheck);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "table-selection":
|
||||||
|
// 선택된 첫 번째 행의 데이터 사용 (다중 선택 시)
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
dataToCheck = context.selectedRowsData[0];
|
||||||
|
console.log("📋 테이블 선택 데이터 사용:", dataToCheck);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 테이블에서 항목이 선택되지 않음");
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "테이블에서 항목을 선택해주세요.",
|
||||||
|
canExecuteImmediately: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "both":
|
||||||
|
// 폼 데이터와 선택된 데이터 모두 병합
|
||||||
|
dataToCheck = {
|
||||||
|
...context.formData,
|
||||||
|
...(context.selectedRowsData?.[0] || {}),
|
||||||
|
};
|
||||||
|
console.log("🔄 폼 + 테이블 데이터 병합 사용:", dataToCheck);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// 기본값이 없는 경우 자동 판단
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
dataToCheck = context.selectedRowsData[0];
|
||||||
|
console.log("🔄 자동 판단: 테이블 선택 데이터 사용:", dataToCheck);
|
||||||
|
} else {
|
||||||
|
dataToCheck = context.formData;
|
||||||
|
console.log("🔄 자동 판단: 폼 데이터 사용:", dataToCheck);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldValue = dataToCheck[condition.field!];
|
||||||
|
const isValid = this.evaluateSimpleCondition(fieldValue, condition.operator!, condition.value);
|
||||||
|
|
||||||
|
console.log("🔍 조건 검증 결과:", {
|
||||||
|
field: condition.field,
|
||||||
|
operator: condition.operator,
|
||||||
|
expectedValue: condition.value,
|
||||||
|
actualValue: fieldValue,
|
||||||
|
isValid,
|
||||||
|
dataToCheck,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
const sourceLabel =
|
||||||
|
context.controlDataSource === "form"
|
||||||
|
? "폼"
|
||||||
|
: context.controlDataSource === "table-selection"
|
||||||
|
? "선택된 항목"
|
||||||
|
: "데이터";
|
||||||
|
|
||||||
|
const actualValueMsg = fieldValue !== undefined ? ` (실제값: ${fieldValue})` : " (값 없음)";
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `${sourceLabel} 조건 불만족: ${condition.field} ${condition.operator} ${condition.value}${actualValueMsg}`,
|
||||||
|
canExecuteImmediately: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 모든 조건을 만족했으므로 액션 정보도 함께 반환
|
||||||
|
const relationshipActions = (config as any)._relationshipActions || [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
canExecuteImmediately: true,
|
||||||
|
actions: relationshipActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 단순 조건 평가 (메모리에서 즉시)
|
* 🔥 단순 조건 평가 (메모리에서 즉시)
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,292 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동생성 값 생성 유틸리티
|
||||||
|
*/
|
||||||
|
export class AutoGenerationUtils {
|
||||||
|
/**
|
||||||
|
* UUID 생성
|
||||||
|
*/
|
||||||
|
static generateUUID(): string {
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||||
|
const r = (Math.random() * 16) | 0;
|
||||||
|
const v = c === "x" ? r : (r & 0x3) | 0x8;
|
||||||
|
return v.toString(16);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랜덤 문자열 생성
|
||||||
|
*/
|
||||||
|
static generateRandomString(length: number = 8, prefix?: string, suffix?: string): string {
|
||||||
|
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
let result = "";
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prefix || ""}${result}${suffix || ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 랜덤 숫자 생성
|
||||||
|
*/
|
||||||
|
static generateRandomNumber(length: number = 6, prefix?: string, suffix?: string): string {
|
||||||
|
const min = Math.pow(10, length - 1);
|
||||||
|
const max = Math.pow(10, length) - 1;
|
||||||
|
const randomNum = Math.floor(Math.random() * (max - min + 1)) + min;
|
||||||
|
|
||||||
|
return `${prefix || ""}${randomNum}${suffix || ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 시간 생성
|
||||||
|
*/
|
||||||
|
static generateCurrentTime(format?: string): string {
|
||||||
|
console.log("🕒 generateCurrentTime 호출됨:", { format });
|
||||||
|
const now = new Date();
|
||||||
|
console.log("🕒 현재 시간 객체:", now);
|
||||||
|
|
||||||
|
let result: string;
|
||||||
|
switch (format) {
|
||||||
|
case "date":
|
||||||
|
result = now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
|
break;
|
||||||
|
case "time":
|
||||||
|
result = now.toTimeString().split(" ")[0]; // HH:mm:ss
|
||||||
|
break;
|
||||||
|
case "datetime":
|
||||||
|
result = now.toISOString().replace("T", " ").split(".")[0]; // YYYY-MM-DD HH:mm:ss
|
||||||
|
break;
|
||||||
|
case "timestamp":
|
||||||
|
result = now.getTime().toString();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result = now.toISOString(); // ISO 8601 format
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🕒 generateCurrentTime 결과:", { format, result });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시퀀스 번호 생성 (메모리 기반, 실제로는 DB 시퀀스 사용 권장)
|
||||||
|
*/
|
||||||
|
private static sequenceCounters: Map<string, number> = new Map();
|
||||||
|
|
||||||
|
static generateSequence(key: string = "default", startValue: number = 1, prefix?: string, suffix?: string): string {
|
||||||
|
if (!this.sequenceCounters.has(key)) {
|
||||||
|
this.sequenceCounters.set(key, startValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = this.sequenceCounters.get(key)!;
|
||||||
|
this.sequenceCounters.set(key, current + 1);
|
||||||
|
|
||||||
|
return `${prefix || ""}${current}${suffix || ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 사용자 ID 가져오기 (실제로는 인증 컨텍스트에서 가져와야 함)
|
||||||
|
*/
|
||||||
|
static getCurrentUserId(): string {
|
||||||
|
// TODO: 실제 인증 시스템과 연동
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const userInfo = localStorage.getItem("userInfo");
|
||||||
|
if (userInfo) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(userInfo);
|
||||||
|
return parsed.userId || parsed.id || "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "system";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 회사 코드 가져오기
|
||||||
|
*/
|
||||||
|
static getCompanyCode(): string {
|
||||||
|
// TODO: 실제 회사 정보와 연동
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const companyInfo = localStorage.getItem("companyInfo");
|
||||||
|
if (companyInfo) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(companyInfo);
|
||||||
|
return parsed.companyCode || parsed.code || "COMPANY";
|
||||||
|
} catch {
|
||||||
|
return "COMPANY";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "COMPANY";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부서 코드 가져오기
|
||||||
|
*/
|
||||||
|
static getDepartmentCode(): string {
|
||||||
|
// TODO: 실제 부서 정보와 연동
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const userInfo = localStorage.getItem("userInfo");
|
||||||
|
if (userInfo) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(userInfo);
|
||||||
|
return parsed.departmentCode || parsed.deptCode || "DEPT";
|
||||||
|
} catch {
|
||||||
|
return "DEPT";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "DEPT";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동생성 값 생성 메인 함수
|
||||||
|
*/
|
||||||
|
static generateValue(config: AutoGenerationConfig, columnName?: string): string | null {
|
||||||
|
console.log("🔧 AutoGenerationUtils.generateValue 호출:", {
|
||||||
|
config,
|
||||||
|
columnName,
|
||||||
|
enabled: config.enabled,
|
||||||
|
type: config.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config.enabled || config.type === "none") {
|
||||||
|
console.log("⚠️ AutoGenerationUtils.generateValue 스킵:", {
|
||||||
|
enabled: config.enabled,
|
||||||
|
type: config.type,
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = config.options || {};
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case "uuid":
|
||||||
|
return this.generateUUID();
|
||||||
|
|
||||||
|
case "current_user":
|
||||||
|
return this.getCurrentUserId();
|
||||||
|
|
||||||
|
case "current_time":
|
||||||
|
console.log("🕒 AutoGenerationUtils.generateCurrentTime 호출:", {
|
||||||
|
format: options.format,
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
const timeValue = this.generateCurrentTime(options.format);
|
||||||
|
console.log("🕒 AutoGenerationUtils.generateCurrentTime 결과:", timeValue);
|
||||||
|
return timeValue;
|
||||||
|
|
||||||
|
case "sequence":
|
||||||
|
return this.generateSequence(columnName || "default", options.startValue || 1, options.prefix, options.suffix);
|
||||||
|
|
||||||
|
case "random_string":
|
||||||
|
return this.generateRandomString(options.length || 8, options.prefix, options.suffix);
|
||||||
|
|
||||||
|
case "random_number":
|
||||||
|
return this.generateRandomNumber(options.length || 6, options.prefix, options.suffix);
|
||||||
|
|
||||||
|
case "company_code":
|
||||||
|
return this.getCompanyCode();
|
||||||
|
|
||||||
|
case "department":
|
||||||
|
return this.getDepartmentCode();
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.warn(`Unknown auto generation type: ${config.type}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동생성 타입별 설명 가져오기
|
||||||
|
*/
|
||||||
|
static getTypeDescription(type: AutoGenerationType): string {
|
||||||
|
const descriptions: Record<AutoGenerationType, string> = {
|
||||||
|
uuid: "고유 식별자 (UUID) 생성",
|
||||||
|
current_user: "현재 로그인한 사용자 ID",
|
||||||
|
current_time: "현재 날짜/시간",
|
||||||
|
sequence: "순차적 번호 생성",
|
||||||
|
random_string: "랜덤 문자열 생성",
|
||||||
|
random_number: "랜덤 숫자 생성",
|
||||||
|
company_code: "현재 회사 코드",
|
||||||
|
department: "현재 부서 코드",
|
||||||
|
none: "자동생성 없음",
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptions[type] || "알 수 없는 타입";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동생성 미리보기 값 생성
|
||||||
|
*/
|
||||||
|
static generatePreviewValue(config: AutoGenerationConfig): string {
|
||||||
|
if (!config.enabled || config.type === "none") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 미리보기용으로 실제 값 대신 예시 값 반환
|
||||||
|
const options = config.options || {};
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case "uuid":
|
||||||
|
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx";
|
||||||
|
|
||||||
|
case "current_user":
|
||||||
|
return "user123";
|
||||||
|
|
||||||
|
case "current_time":
|
||||||
|
return this.generateCurrentTime(options.format);
|
||||||
|
|
||||||
|
case "sequence":
|
||||||
|
return `${options.prefix || ""}1${options.suffix || ""}`;
|
||||||
|
|
||||||
|
case "random_string":
|
||||||
|
return `${options.prefix || ""}ABC123${options.suffix || ""}`;
|
||||||
|
|
||||||
|
case "random_number":
|
||||||
|
return `${options.prefix || ""}123456${options.suffix || ""}`;
|
||||||
|
|
||||||
|
case "company_code":
|
||||||
|
return "COMPANY";
|
||||||
|
|
||||||
|
case "department":
|
||||||
|
return "DEPT";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 개발 모드에서 전역 함수로 등록 (테스트용)
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
(window as any).__AUTO_GENERATION_TEST__ = {
|
||||||
|
generateCurrentTime: AutoGenerationUtils.generateCurrentTime,
|
||||||
|
generateValue: AutoGenerationUtils.generateValue,
|
||||||
|
test: () => {
|
||||||
|
console.log("🧪 자동생성 테스트 시작");
|
||||||
|
|
||||||
|
// 현재 시간 테스트
|
||||||
|
const dateResult = AutoGenerationUtils.generateCurrentTime("date");
|
||||||
|
console.log("📅 날짜 생성 결과:", dateResult);
|
||||||
|
|
||||||
|
// 자동생성 설정 테스트
|
||||||
|
const config = {
|
||||||
|
enabled: true,
|
||||||
|
type: "current_time" as any,
|
||||||
|
options: { format: "date" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const autoResult = AutoGenerationUtils.generateValue(config);
|
||||||
|
console.log("🎯 자동생성 결과:", autoResult);
|
||||||
|
|
||||||
|
return { dateResult, autoResult };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
|
import { OptimizedButtonDataflowService, ExtendedControlContext } from "@/lib/services/optimizedButtonDataflowService";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 타입 정의
|
* 버튼 액션 타입 정의
|
||||||
|
|
@ -46,6 +47,10 @@ export interface ButtonActionConfig {
|
||||||
confirmMessage?: string;
|
confirmMessage?: string;
|
||||||
successMessage?: string;
|
successMessage?: string;
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
|
|
||||||
|
// 제어관리 관련
|
||||||
|
enableDataflowControl?: boolean;
|
||||||
|
dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -116,6 +121,9 @@ export class ButtonActionExecutor {
|
||||||
case "close":
|
case "close":
|
||||||
return this.handleClose(config, context);
|
return this.handleClose(config, context);
|
||||||
|
|
||||||
|
case "control":
|
||||||
|
return this.handleControl(config, context);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -166,7 +174,10 @@ export class ButtonActionExecutor {
|
||||||
|
|
||||||
const primaryKeys = primaryKeyResult.data || [];
|
const primaryKeys = primaryKeyResult.data || [];
|
||||||
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
|
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
|
||||||
const isUpdate = primaryKeyValue !== null && primaryKeyValue !== undefined && primaryKeyValue !== "";
|
|
||||||
|
// 단순히 기본키 값 존재 여부로 판단 (임시)
|
||||||
|
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
|
||||||
|
const isUpdate = false; // 현재는 항상 INSERT로 처리
|
||||||
|
|
||||||
console.log("💾 저장 모드 판단 (DB 기반):", {
|
console.log("💾 저장 모드 판단 (DB 기반):", {
|
||||||
tableName,
|
tableName,
|
||||||
|
|
@ -316,12 +327,67 @@ export class ButtonActionExecutor {
|
||||||
if (selectedRowsData && selectedRowsData.length > 0) {
|
if (selectedRowsData && selectedRowsData.length > 0) {
|
||||||
console.log(`다중 삭제 액션 실행: ${selectedRowsData.length}개 항목`, selectedRowsData);
|
console.log(`다중 삭제 액션 실행: ${selectedRowsData.length}개 항목`, selectedRowsData);
|
||||||
|
|
||||||
|
// 테이블의 기본키 조회
|
||||||
|
let primaryKeys: string[] = [];
|
||||||
|
if (tableName) {
|
||||||
|
try {
|
||||||
|
const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(tableName);
|
||||||
|
if (primaryKeysResult.success && primaryKeysResult.data) {
|
||||||
|
primaryKeys = primaryKeysResult.data;
|
||||||
|
console.log(`🔑 테이블 ${tableName}의 기본키:`, primaryKeys);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("기본키 조회 실패, 폴백 방법 사용:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 각 선택된 항목을 삭제
|
// 각 선택된 항목을 삭제
|
||||||
for (const rowData of selectedRowsData) {
|
for (const rowData of selectedRowsData) {
|
||||||
// 더 포괄적인 ID 찾기 (테이블 구조에 따라 다양한 필드명 시도)
|
let deleteId: any = null;
|
||||||
const deleteId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK;
|
|
||||||
|
// 1순위: 데이터베이스에서 조회한 기본키 사용
|
||||||
|
if (primaryKeys.length > 0) {
|
||||||
|
const primaryKey = primaryKeys[0]; // 첫 번째 기본키 사용
|
||||||
|
deleteId = rowData[primaryKey];
|
||||||
|
console.log(`📊 기본키 ${primaryKey}로 ID 추출:`, deleteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2순위: 폴백 - 일반적인 ID 필드명들 시도
|
||||||
|
if (!deleteId) {
|
||||||
|
deleteId =
|
||||||
|
rowData.id ||
|
||||||
|
rowData.objid ||
|
||||||
|
rowData.pk ||
|
||||||
|
rowData.ID ||
|
||||||
|
rowData.OBJID ||
|
||||||
|
rowData.PK ||
|
||||||
|
// 테이블별 기본키 패턴들
|
||||||
|
rowData.sales_no ||
|
||||||
|
rowData.contract_no ||
|
||||||
|
rowData.order_no ||
|
||||||
|
rowData.seq_no ||
|
||||||
|
rowData.code ||
|
||||||
|
rowData.code_id ||
|
||||||
|
rowData.user_id ||
|
||||||
|
rowData.menu_id;
|
||||||
|
|
||||||
|
// _no로 끝나는 필드들 찾기
|
||||||
|
if (!deleteId) {
|
||||||
|
const noField = Object.keys(rowData).find((key) => key.endsWith("_no") && rowData[key]);
|
||||||
|
if (noField) deleteId = rowData[noField];
|
||||||
|
}
|
||||||
|
|
||||||
|
// _id로 끝나는 필드들 찾기
|
||||||
|
if (!deleteId) {
|
||||||
|
const idField = Object.keys(rowData).find((key) => key.endsWith("_id") && rowData[key]);
|
||||||
|
if (idField) deleteId = rowData[idField];
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 폴백 방법으로 ID 추출:`, deleteId);
|
||||||
|
}
|
||||||
|
|
||||||
console.log("선택된 행 데이터:", rowData);
|
console.log("선택된 행 데이터:", rowData);
|
||||||
console.log("추출된 deleteId:", deleteId);
|
console.log("최종 추출된 deleteId:", deleteId);
|
||||||
|
|
||||||
if (deleteId) {
|
if (deleteId) {
|
||||||
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
|
||||||
|
|
@ -332,7 +398,9 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
console.error("삭제 ID를 찾을 수 없습니다. 행 데이터:", rowData);
|
console.error("삭제 ID를 찾을 수 없습니다. 행 데이터:", rowData);
|
||||||
throw new Error(`삭제 ID를 찾을 수 없습니다. 사용 가능한 필드: ${Object.keys(rowData).join(", ")}`);
|
throw new Error(
|
||||||
|
`삭제 ID를 찾을 수 없습니다. 기본키: ${primaryKeys.join(", ")}, 사용 가능한 필드: ${Object.keys(rowData).join(", ")}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -652,6 +720,497 @@ export class ButtonActionExecutor {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제어 전용 액션 처리 (조건 체크만 수행)
|
||||||
|
*/
|
||||||
|
private static async handleControl(config: ButtonActionConfig, context: ButtonActionContext): Promise<boolean> {
|
||||||
|
console.log("🎯 ButtonActionExecutor.handleControl 실행:", {
|
||||||
|
formData: context.formData,
|
||||||
|
selectedRows: context.selectedRows,
|
||||||
|
selectedRowsData: context.selectedRowsData,
|
||||||
|
config,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔥 제어 조건이 설정되어 있는지 확인
|
||||||
|
console.log("🔍 제어관리 활성화 상태 확인:", {
|
||||||
|
enableDataflowControl: config.enableDataflowControl,
|
||||||
|
hasDataflowConfig: !!config.dataflowConfig,
|
||||||
|
dataflowConfig: config.dataflowConfig,
|
||||||
|
fullConfig: config,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!config.dataflowConfig || !config.enableDataflowControl) {
|
||||||
|
console.warn("⚠️ 제어관리가 비활성화되어 있습니다:", {
|
||||||
|
enableDataflowControl: config.enableDataflowControl,
|
||||||
|
hasDataflowConfig: !!config.dataflowConfig,
|
||||||
|
});
|
||||||
|
toast.warning(
|
||||||
|
"제어관리가 활성화되지 않았습니다. 버튼 설정에서 '제어관리 활성화'를 체크하고 조건을 설정해주세요.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔥 확장된 제어 컨텍스트 생성
|
||||||
|
// 자동으로 적절한 controlDataSource 결정
|
||||||
|
let controlDataSource = config.dataflowConfig.controlDataSource;
|
||||||
|
|
||||||
|
if (!controlDataSource) {
|
||||||
|
// 설정이 없으면 자동 판단
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
controlDataSource = "table-selection";
|
||||||
|
console.log("🔄 자동 판단: table-selection 모드 사용");
|
||||||
|
} else if (context.formData && Object.keys(context.formData).length > 0) {
|
||||||
|
controlDataSource = "form";
|
||||||
|
console.log("🔄 자동 판단: form 모드 사용");
|
||||||
|
} else {
|
||||||
|
controlDataSource = "form"; // 기본값
|
||||||
|
console.log("🔄 기본값: form 모드 사용");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const extendedContext: ExtendedControlContext = {
|
||||||
|
formData: context.formData || {},
|
||||||
|
selectedRows: context.selectedRows || [],
|
||||||
|
selectedRowsData: context.selectedRowsData || [],
|
||||||
|
controlDataSource,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("🔍 제어 조건 검증 시작:", {
|
||||||
|
dataflowConfig: config.dataflowConfig,
|
||||||
|
extendedContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔥 실제 제어 조건 검증 수행
|
||||||
|
const validationResult = await OptimizedButtonDataflowService.executeExtendedValidation(
|
||||||
|
config.dataflowConfig,
|
||||||
|
extendedContext,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (validationResult.success) {
|
||||||
|
console.log("✅ 제어 조건 만족 - 액션 실행 시작:", {
|
||||||
|
actions: validationResult.actions,
|
||||||
|
context,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔥 조건을 만족했으므로 실제 액션 실행
|
||||||
|
if (validationResult.actions && validationResult.actions.length > 0) {
|
||||||
|
console.log("🚀 액션 실행 시작:", validationResult.actions);
|
||||||
|
await this.executeRelationshipActions(validationResult.actions, context);
|
||||||
|
} else {
|
||||||
|
console.warn("⚠️ 실행할 액션이 없습니다:", {
|
||||||
|
hasActions: !!validationResult.actions,
|
||||||
|
actionsLength: validationResult.actions?.length,
|
||||||
|
validationResult,
|
||||||
|
});
|
||||||
|
toast.success(config.successMessage || "제어 조건을 만족합니다. (실행할 액션 없음)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로고침이 필요한 경우
|
||||||
|
if (context.onRefresh) {
|
||||||
|
context.onRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
toast.error(validationResult.message || "제어 조건을 만족하지 않습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("제어 조건 검증 중 오류:", error);
|
||||||
|
toast.error("제어 조건 검증 중 오류가 발생했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 관계도에서 가져온 액션들을 실행
|
||||||
|
*/
|
||||||
|
private static async executeRelationshipActions(actions: any[], context: ButtonActionContext): Promise<void> {
|
||||||
|
console.log("🚀 관계도 액션 실행 시작:", actions);
|
||||||
|
|
||||||
|
for (let i = 0; i < actions.length; i++) {
|
||||||
|
const action = actions[i];
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`🔄 액션 ${i + 1}/${actions.length} 실행:`, action);
|
||||||
|
|
||||||
|
const actionType = action.actionType || action.type; // actionType 우선, type 폴백
|
||||||
|
|
||||||
|
switch (actionType) {
|
||||||
|
case "save":
|
||||||
|
await this.executeActionSave(action, context);
|
||||||
|
break;
|
||||||
|
case "update":
|
||||||
|
await this.executeActionUpdate(action, context);
|
||||||
|
break;
|
||||||
|
case "delete":
|
||||||
|
await this.executeActionDelete(action, context);
|
||||||
|
break;
|
||||||
|
case "insert":
|
||||||
|
await this.executeActionInsert(action, context);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`❌ 지원되지 않는 액션 타입 (${i + 1}/${actions.length}):`, {
|
||||||
|
actionType,
|
||||||
|
actionName: action.name,
|
||||||
|
fullAction: action,
|
||||||
|
});
|
||||||
|
// 지원되지 않는 액션은 오류로 처리하여 중단
|
||||||
|
throw new Error(`지원되지 않는 액션 타입: ${actionType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ 액션 ${i + 1}/${actions.length} 완료:`, action.name);
|
||||||
|
|
||||||
|
// 성공 토스트 (개별 액션별)
|
||||||
|
toast.success(`${action.name || `액션 ${i + 1}`} 완료`);
|
||||||
|
} catch (error) {
|
||||||
|
const actionType = action.actionType || action.type;
|
||||||
|
console.error(`❌ 액션 ${i + 1}/${actions.length} 실행 실패:`, action.name, error);
|
||||||
|
|
||||||
|
// 실패 토스트
|
||||||
|
toast.error(
|
||||||
|
`${action.name || `액션 ${i + 1}`} 실행 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 🚨 순차 실행 중단: 하나라도 실패하면 전체 중단
|
||||||
|
throw new Error(
|
||||||
|
`액션 ${i + 1}(${action.name})에서 실패하여 제어 프로세스를 중단합니다: ${error instanceof Error ? error.message : error}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🎉 모든 액션 실행 완료!");
|
||||||
|
toast.success(`총 ${actions.length}개 액션이 모두 성공적으로 완료되었습니다.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 액션 실행
|
||||||
|
*/
|
||||||
|
private static async executeActionSave(action: any, context: ButtonActionContext): Promise<void> {
|
||||||
|
console.log("💾 저장 액션 실행:", action);
|
||||||
|
console.log("🔍 액션 상세 정보:", JSON.stringify(action, null, 2));
|
||||||
|
|
||||||
|
// 🎯 필드 매핑 정보 사용하여 저장 데이터 구성
|
||||||
|
let saveData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 액션에 필드 매핑 정보가 있는지 확인
|
||||||
|
if (action.fieldMappings && Array.isArray(action.fieldMappings)) {
|
||||||
|
console.log("📋 필드 매핑 정보 발견:", action.fieldMappings);
|
||||||
|
|
||||||
|
// 필드 매핑에 따라 데이터 구성
|
||||||
|
action.fieldMappings.forEach((mapping: any) => {
|
||||||
|
const { sourceField, targetField, defaultValue, valueType } = mapping;
|
||||||
|
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
// 값 소스에 따라 데이터 가져오기
|
||||||
|
if (valueType === "form" && context.formData && sourceField) {
|
||||||
|
value = context.formData[sourceField];
|
||||||
|
} else if (valueType === "selected" && context.selectedRowsData?.[0] && sourceField) {
|
||||||
|
value = context.selectedRowsData[0][sourceField];
|
||||||
|
} else if (valueType === "default" || !sourceField) {
|
||||||
|
value = defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타겟 필드에 값 설정
|
||||||
|
if (targetField && value !== undefined) {
|
||||||
|
saveData[targetField] = value;
|
||||||
|
console.log(`📝 필드 매핑: ${sourceField || "default"} -> ${targetField} = ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ 필드 매핑 정보가 없음, 기본 데이터 사용");
|
||||||
|
// 폴백: 기존 방식
|
||||||
|
saveData = {
|
||||||
|
...context.formData,
|
||||||
|
...context.selectedRowsData?.[0], // 선택된 데이터도 포함
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📊 최종 저장할 데이터:", saveData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔥 실제 저장 API 호출
|
||||||
|
if (!context.tableName) {
|
||||||
|
throw new Error("테이블명이 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await DynamicFormApi.saveFormData({
|
||||||
|
screenId: 0, // 임시값
|
||||||
|
tableName: context.tableName,
|
||||||
|
data: saveData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("✅ 저장 성공:", result);
|
||||||
|
toast.success("데이터가 저장되었습니다.");
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "저장 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 저장 실패:", error);
|
||||||
|
toast.error(`저장 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 액션 실행
|
||||||
|
*/
|
||||||
|
private static async executeActionUpdate(action: any, context: ButtonActionContext): Promise<void> {
|
||||||
|
console.log("🔄 업데이트 액션 실행:", action);
|
||||||
|
console.log("🔍 액션 상세 정보:", JSON.stringify(action, null, 2));
|
||||||
|
|
||||||
|
// 🎯 필드 매핑 정보 사용하여 업데이트 데이터 구성
|
||||||
|
let updateData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 액션에 필드 매핑 정보가 있는지 확인
|
||||||
|
if (action.fieldMappings && Array.isArray(action.fieldMappings)) {
|
||||||
|
console.log("📋 필드 매핑 정보 발견:", action.fieldMappings);
|
||||||
|
|
||||||
|
// 🔑 먼저 선택된 데이터의 모든 필드를 기본으로 포함 (기본키 보존)
|
||||||
|
if (context.selectedRowsData?.[0]) {
|
||||||
|
updateData = { ...context.selectedRowsData[0] };
|
||||||
|
console.log("🔑 선택된 데이터를 기본으로 설정 (기본키 보존):", updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 필드 매핑에 따라 데이터 구성 (덮어쓰기)
|
||||||
|
action.fieldMappings.forEach((mapping: any) => {
|
||||||
|
const { sourceField, targetField, defaultValue, valueType } = mapping;
|
||||||
|
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
// 값 소스에 따라 데이터 가져오기
|
||||||
|
if (valueType === "form" && context.formData && sourceField) {
|
||||||
|
value = context.formData[sourceField];
|
||||||
|
} else if (valueType === "selected" && context.selectedRowsData?.[0] && sourceField) {
|
||||||
|
value = context.selectedRowsData[0][sourceField];
|
||||||
|
} else if (valueType === "default" || !sourceField) {
|
||||||
|
value = defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타겟 필드에 값 설정 (덮어쓰기)
|
||||||
|
if (targetField && value !== undefined) {
|
||||||
|
updateData[targetField] = value;
|
||||||
|
console.log(`📝 필드 매핑: ${sourceField || "default"} -> ${targetField} = ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ 필드 매핑 정보가 없음, 기본 데이터 사용");
|
||||||
|
// 폴백: 기존 방식
|
||||||
|
updateData = {
|
||||||
|
...context.formData,
|
||||||
|
...context.selectedRowsData?.[0],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📊 최종 업데이트할 데이터:", updateData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔥 실제 업데이트 API 호출
|
||||||
|
if (!context.tableName) {
|
||||||
|
throw new Error("테이블명이 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 먼저 ID 찾기
|
||||||
|
const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(context.tableName);
|
||||||
|
let updateId: string | undefined;
|
||||||
|
|
||||||
|
if (primaryKeysResult.success && primaryKeysResult.data && primaryKeysResult.data.length > 0) {
|
||||||
|
updateId = updateData[primaryKeysResult.data[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateId) {
|
||||||
|
// 폴백: 일반적인 ID 필드들 확인
|
||||||
|
const commonIdFields = ["id", "objid", "pk", "sales_no", "contract_no"];
|
||||||
|
for (const field of commonIdFields) {
|
||||||
|
if (updateData[field]) {
|
||||||
|
updateId = updateData[field];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!updateId) {
|
||||||
|
throw new Error("업데이트할 항목의 ID를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await DynamicFormApi.updateFormData(updateId, {
|
||||||
|
tableName: context.tableName,
|
||||||
|
data: updateData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("✅ 업데이트 성공:", result);
|
||||||
|
toast.success("데이터가 업데이트되었습니다.");
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "업데이트 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 업데이트 실패:", error);
|
||||||
|
toast.error(`업데이트 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 삭제 액션 실행
|
||||||
|
*/
|
||||||
|
private static async executeActionDelete(action: any, context: ButtonActionContext): Promise<void> {
|
||||||
|
console.log("🗑️ 삭제 액션 실행:", action);
|
||||||
|
|
||||||
|
// 실제 삭제 로직 (기존 handleDelete와 유사)
|
||||||
|
if (!context.selectedRowsData || context.selectedRowsData.length === 0) {
|
||||||
|
throw new Error("삭제할 항목을 선택해주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteData = context.selectedRowsData[0];
|
||||||
|
console.log("삭제할 데이터:", deleteData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔥 실제 삭제 API 호출
|
||||||
|
if (!context.tableName) {
|
||||||
|
throw new Error("테이블명이 설정되지 않았습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기존 handleDelete와 동일한 로직으로 ID 찾기
|
||||||
|
const primaryKeysResult = await DynamicFormApi.getTablePrimaryKeys(context.tableName);
|
||||||
|
let deleteId: string | undefined;
|
||||||
|
|
||||||
|
if (primaryKeysResult.success && primaryKeysResult.data && primaryKeysResult.data.length > 0) {
|
||||||
|
deleteId = deleteData[primaryKeysResult.data[0]];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleteId) {
|
||||||
|
// 폴백: 일반적인 ID 필드들 확인
|
||||||
|
const commonIdFields = ["id", "objid", "pk", "sales_no", "contract_no"];
|
||||||
|
for (const field of commonIdFields) {
|
||||||
|
if (deleteData[field]) {
|
||||||
|
deleteId = deleteData[field];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deleteId) {
|
||||||
|
throw new Error("삭제할 항목의 ID를 찾을 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await DynamicFormApi.deleteFormDataFromTable(deleteId, context.tableName);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("✅ 삭제 성공:", result);
|
||||||
|
toast.success("데이터가 삭제되었습니다.");
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "삭제 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 삭제 실패:", error);
|
||||||
|
toast.error(`삭제 실패: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 삽입 액션 실행 (체크박스 선택된 데이터를 필드매핑에 따라 새 테이블에 삽입)
|
||||||
|
*/
|
||||||
|
private static async executeActionInsert(action: any, context: ButtonActionContext): Promise<void> {
|
||||||
|
console.log("➕ 삽입 액션 실행:", action);
|
||||||
|
|
||||||
|
let insertData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 액션에 필드 매핑 정보가 있는지 확인
|
||||||
|
if (action.fieldMappings && Array.isArray(action.fieldMappings)) {
|
||||||
|
console.log("📋 삽입 액션 - 필드 매핑 정보:", action.fieldMappings);
|
||||||
|
|
||||||
|
// 🎯 체크박스로 선택된 데이터가 있는지 확인
|
||||||
|
if (!context.selectedRowsData || context.selectedRowsData.length === 0) {
|
||||||
|
throw new Error("삽입할 소스 데이터를 선택해주세요. (테이블에서 체크박스 선택 필요)");
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceData = context.selectedRowsData[0]; // 첫 번째 선택된 데이터 사용
|
||||||
|
console.log("🎯 삽입 소스 데이터 (체크박스 선택):", sourceData);
|
||||||
|
console.log("🔍 소스 데이터 사용 가능한 키들:", Object.keys(sourceData));
|
||||||
|
|
||||||
|
// 필드 매핑에 따라 데이터 구성
|
||||||
|
action.fieldMappings.forEach((mapping: any) => {
|
||||||
|
const { sourceField, targetField, defaultValue } = mapping;
|
||||||
|
// valueType이 없으면 기본값을 "selection"으로 설정
|
||||||
|
const valueType = mapping.valueType || "selection";
|
||||||
|
|
||||||
|
let value: any;
|
||||||
|
|
||||||
|
console.log(`🔍 매핑 처리 중: ${sourceField} → ${targetField} (valueType: ${valueType})`);
|
||||||
|
|
||||||
|
// 값 소스에 따라 데이터 가져오기
|
||||||
|
if (valueType === "form" && context.formData && sourceField) {
|
||||||
|
// 폼 데이터에서 가져오기
|
||||||
|
value = context.formData[sourceField];
|
||||||
|
console.log(`📝 폼에서 매핑: ${sourceField} → ${targetField} = ${value}`);
|
||||||
|
} else if (valueType === "selection" && sourceField) {
|
||||||
|
// 선택된 테이블 데이터에서 가져오기 (다양한 필드명 시도)
|
||||||
|
value =
|
||||||
|
sourceData[sourceField] ||
|
||||||
|
sourceData[sourceField + "_name"] || // 조인된 필드 (_name 접미사)
|
||||||
|
sourceData[sourceField + "Name"]; // 카멜케이스
|
||||||
|
console.log(`📊 테이블에서 매핑: ${sourceField} → ${targetField} = ${value} (소스필드: ${sourceField})`);
|
||||||
|
} else if (valueType === "default" || (defaultValue !== undefined && defaultValue !== "")) {
|
||||||
|
// 기본값 사용 (valueType이 "default"이거나 defaultValue가 있을 때)
|
||||||
|
value = defaultValue;
|
||||||
|
console.log(`🔧 기본값 매핑: ${targetField} = ${value}`);
|
||||||
|
} else {
|
||||||
|
console.warn(`⚠️ 매핑 실패: ${sourceField} → ${targetField} (값을 찾을 수 없음)`);
|
||||||
|
console.warn(` - valueType: ${valueType}, defaultValue: ${defaultValue}`);
|
||||||
|
console.warn(` - 소스 데이터 키들:`, Object.keys(sourceData));
|
||||||
|
console.warn(` - sourceData[${sourceField}] =`, sourceData[sourceField]);
|
||||||
|
return; // 값이 없으면 해당 필드는 스킵
|
||||||
|
}
|
||||||
|
|
||||||
|
// 대상 필드에 값 설정
|
||||||
|
if (targetField && value !== undefined && value !== null) {
|
||||||
|
insertData[targetField] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("🎯 최종 삽입 데이터 (필드매핑 적용):", insertData);
|
||||||
|
} else {
|
||||||
|
// 필드 매핑이 없으면 폼 데이터를 기본으로 사용
|
||||||
|
insertData = { ...context.formData };
|
||||||
|
console.log("📝 기본 삽입 데이터 (폼 기반):", insertData);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 🔥 실제 삽입 API 호출 - 필수 매개변수 포함
|
||||||
|
// 필드 매핑에서 첫 번째 targetTable을 찾거나 기본값 사용
|
||||||
|
const targetTable = action.fieldMappings?.[0]?.targetTable || action.targetTable || "test_project_info";
|
||||||
|
|
||||||
|
const formDataPayload = {
|
||||||
|
screenId: 0, // 제어 관리에서는 screenId가 없으므로 0 사용
|
||||||
|
tableName: targetTable, // 필드 매핑에서 대상 테이블명 가져오기
|
||||||
|
data: insertData,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("🎯 대상 테이블:", targetTable);
|
||||||
|
console.log("📋 삽입할 데이터:", insertData);
|
||||||
|
|
||||||
|
console.log("💾 폼 데이터 저장 요청:", formDataPayload);
|
||||||
|
|
||||||
|
const result = await DynamicFormApi.saveFormData(formDataPayload);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
console.log("✅ 삽입 성공:", result);
|
||||||
|
toast.success(`데이터가 타겟 테이블에 성공적으로 삽입되었습니다.`);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || "삽입 실패");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 삽입 실패:", error);
|
||||||
|
toast.error(`삽입 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 유효성 검사
|
* 폼 데이터 유효성 검사
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
/**
|
||||||
|
* DOM props 필터링 유틸리티
|
||||||
|
* React 전용 props들을 DOM 요소에 전달되지 않도록 필터링합니다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// DOM에 전달하면 안 되는 React 전용 props 목록
|
||||||
|
const REACT_ONLY_PROPS = new Set([
|
||||||
|
// 컴포넌트 관련
|
||||||
|
"component",
|
||||||
|
"componentConfig",
|
||||||
|
"config",
|
||||||
|
"isSelected",
|
||||||
|
"isDesignMode",
|
||||||
|
"isInteractive",
|
||||||
|
"size",
|
||||||
|
"position",
|
||||||
|
|
||||||
|
// 이벤트 핸들러 (React 이벤트 외)
|
||||||
|
"onFormDataChange",
|
||||||
|
"onRefresh",
|
||||||
|
"onClose",
|
||||||
|
"onZoneComponentDrop",
|
||||||
|
"onZoneClick",
|
||||||
|
"onSelectedRowsChange",
|
||||||
|
"onUpdateLayout",
|
||||||
|
|
||||||
|
// 데이터 관련
|
||||||
|
"formData",
|
||||||
|
"originalData",
|
||||||
|
"selectedScreen",
|
||||||
|
"allComponents",
|
||||||
|
"refreshKey",
|
||||||
|
|
||||||
|
// 화면/테이블 관련
|
||||||
|
"screenId",
|
||||||
|
"tableName",
|
||||||
|
|
||||||
|
// 상태 관련
|
||||||
|
"mode",
|
||||||
|
"isInModal",
|
||||||
|
|
||||||
|
// 테이블 관련
|
||||||
|
"selectedRows",
|
||||||
|
"selectedRowsData",
|
||||||
|
|
||||||
|
// 추가된 React 전용 props
|
||||||
|
"allComponents",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// DOM에 안전하게 전달할 수 있는 표준 HTML 속성들
|
||||||
|
const SAFE_DOM_PROPS = new Set([
|
||||||
|
// 표준 HTML 속성
|
||||||
|
"id",
|
||||||
|
"className",
|
||||||
|
"style",
|
||||||
|
"title",
|
||||||
|
"lang",
|
||||||
|
"dir",
|
||||||
|
"role",
|
||||||
|
"tabIndex",
|
||||||
|
"accessKey",
|
||||||
|
"contentEditable",
|
||||||
|
"draggable",
|
||||||
|
"hidden",
|
||||||
|
"spellCheck",
|
||||||
|
"translate",
|
||||||
|
|
||||||
|
// ARIA 속성 (aria-로 시작)
|
||||||
|
// data 속성 (data-로 시작)
|
||||||
|
|
||||||
|
// 표준 이벤트 핸들러
|
||||||
|
"onClick",
|
||||||
|
"onDoubleClick",
|
||||||
|
"onMouseDown",
|
||||||
|
"onMouseUp",
|
||||||
|
"onMouseOver",
|
||||||
|
"onMouseOut",
|
||||||
|
"onMouseEnter",
|
||||||
|
"onMouseLeave",
|
||||||
|
"onMouseMove",
|
||||||
|
"onKeyDown",
|
||||||
|
"onKeyUp",
|
||||||
|
"onKeyPress",
|
||||||
|
"onFocus",
|
||||||
|
"onBlur",
|
||||||
|
"onChange",
|
||||||
|
"onInput",
|
||||||
|
"onSubmit",
|
||||||
|
"onReset",
|
||||||
|
"onDragStart",
|
||||||
|
"onDragEnd",
|
||||||
|
"onDragOver",
|
||||||
|
"onDragEnter",
|
||||||
|
"onDragLeave",
|
||||||
|
"onDrop",
|
||||||
|
"onScroll",
|
||||||
|
"onWheel",
|
||||||
|
"onLoad",
|
||||||
|
"onError",
|
||||||
|
"onResize",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* props에서 React 전용 속성들을 제거하고 DOM 안전한 props만 반환
|
||||||
|
*/
|
||||||
|
export function filterDOMProps<T extends Record<string, any>>(props: T): Partial<T> {
|
||||||
|
const filtered: Partial<T> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
// React 전용 props는 제외
|
||||||
|
if (REACT_ONLY_PROPS.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// aria- 또는 data- 속성은 안전하게 포함
|
||||||
|
if (key.startsWith("aria-") || key.startsWith("data-")) {
|
||||||
|
filtered[key as keyof T] = value;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 안전한 DOM props만 포함
|
||||||
|
if (SAFE_DOM_PROPS.has(key)) {
|
||||||
|
filtered[key as keyof T] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* props를 React 전용과 DOM 안전한 것으로 분리
|
||||||
|
*/
|
||||||
|
export function separateProps<T extends Record<string, any>>(
|
||||||
|
props: T,
|
||||||
|
): {
|
||||||
|
reactProps: Partial<T>;
|
||||||
|
domProps: Partial<T>;
|
||||||
|
} {
|
||||||
|
const reactProps: Partial<T> = {};
|
||||||
|
const domProps: Partial<T> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(props)) {
|
||||||
|
if (REACT_ONLY_PROPS.has(key)) {
|
||||||
|
reactProps[key as keyof T] = value;
|
||||||
|
} else if (key.startsWith("aria-") || key.startsWith("data-") || SAFE_DOM_PROPS.has(key)) {
|
||||||
|
domProps[key as keyof T] = value;
|
||||||
|
}
|
||||||
|
// 둘 다 해당하지 않는 경우 무시 (안전을 위해)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { reactProps, domProps };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React 전용 props 여부 확인
|
||||||
|
*/
|
||||||
|
export function isReactOnlyProp(propName: string): boolean {
|
||||||
|
return REACT_ONLY_PROPS.has(propName);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DOM 안전 props 여부 확인
|
||||||
|
*/
|
||||||
|
export function isDOMSafeProp(propName: string): boolean {
|
||||||
|
return SAFE_DOM_PROPS.has(propName) || propName.startsWith("aria-") || propName.startsWith("data-");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 디버깅용: 필터링된 props 로깅
|
||||||
|
*/
|
||||||
|
export function logFilteredProps<T extends Record<string, any>>(
|
||||||
|
originalProps: T,
|
||||||
|
componentName: string = "Component",
|
||||||
|
): void {
|
||||||
|
const { reactProps, domProps } = separateProps(originalProps);
|
||||||
|
|
||||||
|
console.group(`🔍 ${componentName} Props 필터링`);
|
||||||
|
console.log("📥 원본 props:", Object.keys(originalProps));
|
||||||
|
console.log("⚛️ React 전용 props:", Object.keys(reactProps));
|
||||||
|
console.log("🌐 DOM 안전 props:", Object.keys(domProps));
|
||||||
|
|
||||||
|
// React 전용 props가 DOM에 전달될 뻔한 경우 경고
|
||||||
|
const reactPropsKeys = Object.keys(reactProps);
|
||||||
|
if (reactPropsKeys.length > 0) {
|
||||||
|
console.warn("⚠️ 다음 React 전용 props가 필터링되었습니다:", reactPropsKeys);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.groupEnd();
|
||||||
|
}
|
||||||
|
|
@ -44,6 +44,9 @@ export interface ComponentSize {
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// screen.ts에서 자동생성 관련 타입들을 import
|
||||||
|
export type { AutoGenerationType, AutoGenerationConfig } from "./screen";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 렌더러 Props
|
* 컴포넌트 렌더러 Props
|
||||||
* 모든 컴포넌트 렌더러가 받는 공통 Props
|
* 모든 컴포넌트 렌더러가 받는 공통 Props
|
||||||
|
|
@ -61,6 +64,11 @@ export interface ComponentRendererProps {
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
formData?: Record<string, any>;
|
formData?: Record<string, any>;
|
||||||
onFormDataChange?: (fieldName: string, value: any) => void;
|
onFormDataChange?: (fieldName: string, value: any) => void;
|
||||||
|
|
||||||
|
// 새로운 기능들
|
||||||
|
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||||
|
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||||
|
|
||||||
[key: string]: any;
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,8 @@ export type ButtonActionType =
|
||||||
| "popup" // 팝업 열기
|
| "popup" // 팝업 열기
|
||||||
| "modal" // 모달 열기
|
| "modal" // 모달 열기
|
||||||
| "newWindow" // 새 창 열기
|
| "newWindow" // 새 창 열기
|
||||||
| "navigate"; // 페이지 이동
|
| "navigate" // 페이지 이동
|
||||||
|
| "control"; // 제어 전용 (조건 체크만)
|
||||||
|
|
||||||
// 위치 정보
|
// 위치 정보
|
||||||
export interface Position {
|
export interface Position {
|
||||||
|
|
@ -155,6 +156,31 @@ export interface ComponentStyle {
|
||||||
}
|
}
|
||||||
|
|
||||||
// BaseComponent에 스타일 속성 추가
|
// BaseComponent에 스타일 속성 추가
|
||||||
|
// 자동생성 타입 정의
|
||||||
|
export type AutoGenerationType =
|
||||||
|
| "uuid" // UUID 생성
|
||||||
|
| "current_user" // 현재 사용자 ID
|
||||||
|
| "current_time" // 현재 시간
|
||||||
|
| "sequence" // 시퀀스 번호
|
||||||
|
| "random_string" // 랜덤 문자열
|
||||||
|
| "random_number" // 랜덤 숫자
|
||||||
|
| "company_code" // 회사 코드
|
||||||
|
| "department" // 부서 코드
|
||||||
|
| "none"; // 자동생성 없음
|
||||||
|
|
||||||
|
// 자동생성 설정
|
||||||
|
export interface AutoGenerationConfig {
|
||||||
|
type: AutoGenerationType;
|
||||||
|
enabled: boolean;
|
||||||
|
options?: {
|
||||||
|
length?: number; // 랜덤 문자열/숫자 길이
|
||||||
|
prefix?: string; // 접두사
|
||||||
|
suffix?: string; // 접미사
|
||||||
|
format?: string; // 시간 형식 (current_time용)
|
||||||
|
startValue?: number; // 시퀀스 시작값
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface BaseComponent {
|
export interface BaseComponent {
|
||||||
id: string;
|
id: string;
|
||||||
type: ComponentType;
|
type: ComponentType;
|
||||||
|
|
@ -174,7 +200,11 @@ export interface BaseComponent {
|
||||||
| "current_user"
|
| "current_user"
|
||||||
| "uuid"
|
| "uuid"
|
||||||
| "sequence"
|
| "sequence"
|
||||||
| "user_defined"; // 자동 값 타입
|
| "user_defined"; // 자동 값 타입 (레거시)
|
||||||
|
|
||||||
|
// 새로운 기능들
|
||||||
|
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
|
||||||
|
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
|
||||||
}
|
}
|
||||||
|
|
||||||
// 컨테이너 컴포넌트
|
// 컨테이너 컴포넌트
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,605 @@
|
||||||
|
# 화면관리 시스템 타입 문제 분석 및 해결방안
|
||||||
|
|
||||||
|
## 📋 현재 상황 분석
|
||||||
|
|
||||||
|
### 주요 시스템들
|
||||||
|
|
||||||
|
1. **화면관리 시스템** (Screen Management)
|
||||||
|
2. **제어관리 시스템** (Button Dataflow Control)
|
||||||
|
3. **테이블 타입관리 시스템** (Table Type Management)
|
||||||
|
|
||||||
|
### 발견된 문제점들
|
||||||
|
|
||||||
|
## 🚨 1. 타입 정의 분산 및 중복 문제
|
||||||
|
|
||||||
|
### 1.1 WebType 타입 정의 분산
|
||||||
|
|
||||||
|
**문제**: WebType이 여러 파일에서 서로 다르게 정의되어 불일치 발생
|
||||||
|
|
||||||
|
#### 현재 상황:
|
||||||
|
|
||||||
|
- `frontend/types/screen.ts`: 화면관리용 WebType 정의
|
||||||
|
- `backend-node/src/types/tableManagement.ts`: 테이블관리용 타입 정의
|
||||||
|
- `backend-node/prisma/schema.prisma`: DB 스키마의 web_type_standards 모델
|
||||||
|
- `frontend/lib/registry/types.ts`: 레지스트리용 WebType 정의
|
||||||
|
|
||||||
|
#### 구체적 충돌 사례:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/screen.ts
|
||||||
|
export type WebType =
|
||||||
|
| "text"
|
||||||
|
| "number"
|
||||||
|
| "date"
|
||||||
|
| "code"
|
||||||
|
| "entity"
|
||||||
|
| "textarea"
|
||||||
|
| "boolean"
|
||||||
|
| "decimal"
|
||||||
|
| "button"
|
||||||
|
| "datetime"
|
||||||
|
| "dropdown"
|
||||||
|
| "text_area"
|
||||||
|
| "checkbox"
|
||||||
|
| "radio"
|
||||||
|
| "file"
|
||||||
|
| "email"
|
||||||
|
| "tel"
|
||||||
|
| "url";
|
||||||
|
|
||||||
|
// 실제 DB에서는 다른 web_type 값들이 존재할 수 있음
|
||||||
|
// 예: "varchar", "integer", "timestamp" 등
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 ButtonActionType 중복 정의
|
||||||
|
|
||||||
|
**문제**: 버튼 액션 타입이 여러 곳에서 다르게 정의됨
|
||||||
|
|
||||||
|
#### 충돌 위치:
|
||||||
|
|
||||||
|
- `frontend/types/screen.ts`: `"control"` 포함, `"modal"` 포함
|
||||||
|
- `frontend/lib/utils/buttonActions.ts`: `"cancel"` 포함, `"modal"` 포함
|
||||||
|
- `frontend/hooks/admin/useButtonActions.ts`: DB 스키마 기반 정의
|
||||||
|
|
||||||
|
#### 문제 코드:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/screen.ts
|
||||||
|
export type ButtonActionType =
|
||||||
|
| "save"
|
||||||
|
| "delete"
|
||||||
|
| "edit"
|
||||||
|
| "add"
|
||||||
|
| "search"
|
||||||
|
| "reset"
|
||||||
|
| "submit"
|
||||||
|
| "close"
|
||||||
|
| "popup"
|
||||||
|
| "modal"
|
||||||
|
| "newWindow"
|
||||||
|
| "navigate"
|
||||||
|
| "control";
|
||||||
|
|
||||||
|
// frontend/lib/utils/buttonActions.ts
|
||||||
|
export type ButtonActionType =
|
||||||
|
| "save"
|
||||||
|
| "cancel"
|
||||||
|
| "delete"
|
||||||
|
| "edit"
|
||||||
|
| "add"
|
||||||
|
| "search"
|
||||||
|
| "reset"
|
||||||
|
| "submit"
|
||||||
|
| "close"
|
||||||
|
| "popup"
|
||||||
|
| "navigate"
|
||||||
|
| "modal"
|
||||||
|
| "newWindow";
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 2. 데이터베이스 스키마와 TypeScript 타입 불일치
|
||||||
|
|
||||||
|
### 2.1 web_type_standards 테이블 불일치
|
||||||
|
|
||||||
|
**문제**: Prisma 스키마와 TypeScript 인터페이스 간 필드명/타입 차이
|
||||||
|
|
||||||
|
#### DB 스키마:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
model web_type_standards {
|
||||||
|
web_type String @id @db.VarChar(50)
|
||||||
|
type_name String @db.VarChar(100)
|
||||||
|
type_name_eng String? @db.VarChar(100)
|
||||||
|
description String?
|
||||||
|
category String? @default("input") @db.VarChar(50)
|
||||||
|
default_config Json? -- JSON 타입
|
||||||
|
validation_rules Json? -- JSON 타입
|
||||||
|
component_name String? @default("TextWidget") @db.VarChar(100)
|
||||||
|
config_panel String? @db.VarChar(100)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### TypeScript 인터페이스:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface WebTypeDefinition {
|
||||||
|
id: string; // web_type와 매핑되지 않음
|
||||||
|
name: string; // type_name과 매핑?
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
defaultConfig: Record<string, any>; // default_config Json과 타입 불일치
|
||||||
|
validationRules?: Record<string, any>; // validation_rules Json과 타입 불일치
|
||||||
|
isActive: boolean; // DB에는 is_active String 필드
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 ColumnInfo 타입 불일치
|
||||||
|
|
||||||
|
**문제**: 테이블 컬럼 정보 타입이 프론트엔드/백엔드에서 다름
|
||||||
|
|
||||||
|
#### 백엔드 타입:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/types/tableManagement.ts
|
||||||
|
export interface ColumnTypeInfo {
|
||||||
|
columnName: string;
|
||||||
|
displayName: string;
|
||||||
|
dataType: string;
|
||||||
|
dbType: string;
|
||||||
|
webType: string; // string 타입
|
||||||
|
inputType?: "direct" | "auto";
|
||||||
|
detailSettings: string; // JSON 문자열
|
||||||
|
isNullable: string; // "Y" | "N" 문자열
|
||||||
|
isPrimaryKey: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 프론트엔드 타입:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/screen.ts
|
||||||
|
export interface ColumnInfo {
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
columnLabel?: string;
|
||||||
|
dataType: string;
|
||||||
|
webType?: WebType; // WebType union 타입 (불일치!)
|
||||||
|
inputType?: "direct" | "auto";
|
||||||
|
isNullable: string;
|
||||||
|
detailSettings?: string; // optional vs required 차이
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 3. 컴포넌트 인터페이스 타입 안전성 문제
|
||||||
|
|
||||||
|
### 3.1 ComponentData 타입 캐스팅 문제
|
||||||
|
|
||||||
|
**문제**: 런타임에 타입 안전성이 보장되지 않는 강제 캐스팅
|
||||||
|
|
||||||
|
#### 문제 코드:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/screen/RealtimePreview.tsx
|
||||||
|
const widget = component as WidgetComponent; // 위험한 강제 캐스팅
|
||||||
|
|
||||||
|
// frontend/components/screen/InteractiveScreenViewer.tsx
|
||||||
|
component: any; // any 타입으로 타입 안전성 상실
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 DynamicWebTypeRenderer Props 불일치
|
||||||
|
|
||||||
|
**문제**: 동적 렌더링 시 props 타입이 일관되지 않음
|
||||||
|
|
||||||
|
#### 문제 위치:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/registry/DynamicWebTypeRenderer.tsx
|
||||||
|
export interface DynamicComponentProps {
|
||||||
|
webType: string; // WebType이 아닌 string
|
||||||
|
props?: Record<string, any>; // any 타입 사용
|
||||||
|
config?: Record<string, any>; // any 타입 사용
|
||||||
|
onEvent?: (event: string, data: any) => void; // any 타입
|
||||||
|
}
|
||||||
|
|
||||||
|
// 실제 사용 시
|
||||||
|
<DynamicWebTypeRenderer
|
||||||
|
webType={component.webType || "text"} // WebType | undefined 전달
|
||||||
|
config={component.webTypeConfig} // WebTypeConfig 타입 전달
|
||||||
|
props={{
|
||||||
|
component: component, // ComponentData 타입
|
||||||
|
value: formData[component.columnName || component.id] || "",
|
||||||
|
onChange: (value: any) => {...} // any 타입 콜백
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 4. 제어관리 시스템 타입 문제
|
||||||
|
|
||||||
|
### 4.1 ButtonDataflowConfig 타입 복잡성
|
||||||
|
|
||||||
|
**문제**: 제어관리 설정이 복잡하고 타입 안전성 부족
|
||||||
|
|
||||||
|
#### 현재 타입:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface ButtonDataflowConfig {
|
||||||
|
controlMode: "simple" | "advanced";
|
||||||
|
selectedDiagramId?: number;
|
||||||
|
selectedRelationshipId?: number;
|
||||||
|
directControl?: {
|
||||||
|
conditions: DataflowCondition[]; // 복잡한 중첩 타입
|
||||||
|
actions: any[]; // any 타입 사용
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 OptimizedButtonDataflowService 타입 문제
|
||||||
|
|
||||||
|
**문제**: 서비스 클래스에서 any 타입 남용으로 타입 안전성 상실
|
||||||
|
|
||||||
|
#### Linter 오류 (57개):
|
||||||
|
|
||||||
|
- `Unexpected any` 경고 26개
|
||||||
|
- `unknown` 타입 오류 2개
|
||||||
|
- 사용되지 않는 변수 경고 29개
|
||||||
|
|
||||||
|
## 🎯 해결방안 및 구현 계획
|
||||||
|
|
||||||
|
## Phase 1: 중앙집중식 타입 정의 통합 (우선순위: 높음)
|
||||||
|
|
||||||
|
### 1.1 통합 타입 파일 생성
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/types/
|
||||||
|
├── unified-core.ts # 핵심 공통 타입들
|
||||||
|
├── screen-management.ts # 화면관리 전용 타입
|
||||||
|
├── control-management.ts # 제어관리 전용 타입
|
||||||
|
├── table-management.ts # 테이블관리 전용 타입
|
||||||
|
└── index.ts # 모든 타입 re-export
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 WebType 통합 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/unified-core.ts
|
||||||
|
export type WebType =
|
||||||
|
| "text"
|
||||||
|
| "number"
|
||||||
|
| "decimal"
|
||||||
|
| "date"
|
||||||
|
| "datetime"
|
||||||
|
| "select"
|
||||||
|
| "dropdown"
|
||||||
|
| "radio"
|
||||||
|
| "checkbox"
|
||||||
|
| "boolean"
|
||||||
|
| "textarea"
|
||||||
|
| "code"
|
||||||
|
| "entity"
|
||||||
|
| "file"
|
||||||
|
| "email"
|
||||||
|
| "tel"
|
||||||
|
| "url"
|
||||||
|
| "button";
|
||||||
|
|
||||||
|
// DB에서 동적으로 로드되는 웹타입도 지원
|
||||||
|
export type DynamicWebType = WebType | string;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 ButtonActionType 통합 정의
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/unified-core.ts
|
||||||
|
export type ButtonActionType =
|
||||||
|
| "save"
|
||||||
|
| "cancel"
|
||||||
|
| "delete"
|
||||||
|
| "edit"
|
||||||
|
| "add"
|
||||||
|
| "search"
|
||||||
|
| "reset"
|
||||||
|
| "submit"
|
||||||
|
| "close"
|
||||||
|
| "popup"
|
||||||
|
| "modal"
|
||||||
|
| "navigate"
|
||||||
|
| "control";
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 2: 데이터베이스 타입 매핑 표준화 (우선순위: 높음)
|
||||||
|
|
||||||
|
### 2.1 Prisma 스키마 기반 타입 생성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/database-mappings.ts
|
||||||
|
import { web_type_standards, button_action_standards } from "@prisma/client";
|
||||||
|
|
||||||
|
// Prisma 타입을 프론트엔드 타입으로 변환하는 매퍼
|
||||||
|
export type WebTypeStandard = web_type_standards;
|
||||||
|
|
||||||
|
export interface WebTypeDefinition {
|
||||||
|
webType: string; // web_type 필드
|
||||||
|
typeName: string; // type_name 필드
|
||||||
|
typeNameEng?: string; // type_name_eng 필드
|
||||||
|
description?: string;
|
||||||
|
category: string;
|
||||||
|
defaultConfig: Record<string, any>; // Json 타입 매핑
|
||||||
|
validationRules?: Record<string, any>; // Json 타입 매핑
|
||||||
|
componentName?: string; // component_name 필드
|
||||||
|
configPanel?: string; // config_panel 필드
|
||||||
|
isActive: boolean; // is_active "Y"/"N" → boolean 변환
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변환 함수
|
||||||
|
export const mapWebTypeStandardToDefinition = (
|
||||||
|
standard: WebTypeStandard
|
||||||
|
): WebTypeDefinition => ({
|
||||||
|
webType: standard.web_type,
|
||||||
|
typeName: standard.type_name,
|
||||||
|
typeNameEng: standard.type_name_eng || undefined,
|
||||||
|
description: standard.description || undefined,
|
||||||
|
category: standard.category || "input",
|
||||||
|
defaultConfig: (standard.default_config as any) || {},
|
||||||
|
validationRules: (standard.validation_rules as any) || undefined,
|
||||||
|
componentName: standard.component_name || undefined,
|
||||||
|
configPanel: standard.config_panel || undefined,
|
||||||
|
isActive: standard.is_active === "Y",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 ColumnInfo 타입 통합
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/table-management.ts
|
||||||
|
export interface UnifiedColumnInfo {
|
||||||
|
// 공통 필드
|
||||||
|
tableName: string;
|
||||||
|
columnName: string;
|
||||||
|
displayName: string;
|
||||||
|
dataType: string; // DB 데이터 타입
|
||||||
|
webType: DynamicWebType; // 웹 타입 (동적 지원)
|
||||||
|
|
||||||
|
// 상세 정보
|
||||||
|
inputType: "direct" | "auto";
|
||||||
|
detailSettings?: Record<string, any>; // JSON 파싱된 객체
|
||||||
|
description?: string;
|
||||||
|
isNullable: boolean; // "Y"/"N" → boolean 변환
|
||||||
|
isPrimaryKey: boolean;
|
||||||
|
|
||||||
|
// 표시 옵션
|
||||||
|
isVisible?: boolean;
|
||||||
|
displayOrder?: number;
|
||||||
|
|
||||||
|
// 메타데이터
|
||||||
|
maxLength?: number;
|
||||||
|
numericPrecision?: number;
|
||||||
|
numericScale?: number;
|
||||||
|
defaultValue?: string;
|
||||||
|
|
||||||
|
// 참조 관계
|
||||||
|
codeCategory?: string;
|
||||||
|
referenceTable?: string;
|
||||||
|
referenceColumn?: string;
|
||||||
|
displayColumn?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 3: 컴포넌트 타입 안전성 강화 (우선순위: 중간)
|
||||||
|
|
||||||
|
### 3.1 ComponentData 타입 가드 구현
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/screen-management.ts
|
||||||
|
export type ComponentData =
|
||||||
|
| ContainerComponent
|
||||||
|
| WidgetComponent
|
||||||
|
| GroupComponent
|
||||||
|
| DataTableComponent;
|
||||||
|
|
||||||
|
// 타입 가드 함수들
|
||||||
|
export const isWidgetComponent = (
|
||||||
|
component: ComponentData
|
||||||
|
): component is WidgetComponent => {
|
||||||
|
return component.type === "widget";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isContainerComponent = (
|
||||||
|
component: ComponentData
|
||||||
|
): component is ContainerComponent => {
|
||||||
|
return component.type === "container";
|
||||||
|
};
|
||||||
|
|
||||||
|
// 안전한 타입 캐스팅 유틸리티
|
||||||
|
export const asWidgetComponent = (
|
||||||
|
component: ComponentData
|
||||||
|
): WidgetComponent => {
|
||||||
|
if (!isWidgetComponent(component)) {
|
||||||
|
throw new Error(`Expected WidgetComponent, got ${component.type}`);
|
||||||
|
}
|
||||||
|
return component;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 DynamicWebTypeRenderer Props 타입 강화
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/registry/types.ts
|
||||||
|
export interface StrictDynamicComponentProps {
|
||||||
|
webType: DynamicWebType;
|
||||||
|
component: ComponentData;
|
||||||
|
config?: WebTypeConfig;
|
||||||
|
value?: unknown;
|
||||||
|
onChange?: (value: unknown) => void;
|
||||||
|
onEvent?: (event: WebTypeEvent) => void;
|
||||||
|
readonly?: boolean;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WebTypeEvent {
|
||||||
|
type: "change" | "blur" | "focus" | "click";
|
||||||
|
value: unknown;
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WebTypeConfig = Record<string, unknown>;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 4: 제어관리 시스템 타입 정리 (우선순위: 중간)
|
||||||
|
|
||||||
|
### 4.1 ButtonDataflowConfig 타입 명확화
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/types/control-management.ts
|
||||||
|
export interface ButtonDataflowConfig {
|
||||||
|
// 기본 설정
|
||||||
|
controlMode: "simple" | "advanced";
|
||||||
|
|
||||||
|
// 관계도 방식
|
||||||
|
selectedDiagramId?: number;
|
||||||
|
selectedRelationshipId?: number;
|
||||||
|
|
||||||
|
// 직접 설정 방식
|
||||||
|
directControl?: DirectControlConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DirectControlConfig {
|
||||||
|
conditions: DataflowCondition[];
|
||||||
|
actions: DataflowAction[];
|
||||||
|
logic?: "AND" | "OR";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataflowCondition {
|
||||||
|
id: string;
|
||||||
|
type: "condition" | "group";
|
||||||
|
field?: string;
|
||||||
|
operator?: ConditionOperator;
|
||||||
|
value?: unknown;
|
||||||
|
dataSource?: "form" | "table-selection" | "both";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataflowAction {
|
||||||
|
id: string;
|
||||||
|
type: ActionType;
|
||||||
|
tableName?: string;
|
||||||
|
operation?: "INSERT" | "UPDATE" | "DELETE" | "SELECT";
|
||||||
|
fields?: ActionField[];
|
||||||
|
conditions?: DataflowCondition[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConditionOperator =
|
||||||
|
| "="
|
||||||
|
| "!="
|
||||||
|
| ">"
|
||||||
|
| "<"
|
||||||
|
| ">="
|
||||||
|
| "<="
|
||||||
|
| "LIKE"
|
||||||
|
| "IN"
|
||||||
|
| "NOT IN";
|
||||||
|
export type ActionType = "database" | "api" | "notification" | "redirect";
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 OptimizedButtonDataflowService 타입 정리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/lib/services/optimizedButtonDataflowService.ts
|
||||||
|
|
||||||
|
// any 타입 제거 및 구체적 타입 정의
|
||||||
|
export interface ExecutionContext {
|
||||||
|
formData: Record<string, unknown>;
|
||||||
|
selectedRows?: unknown[];
|
||||||
|
selectedRowsData?: Record<string, unknown>[];
|
||||||
|
controlDataSource: ControlDataSource;
|
||||||
|
buttonId: string;
|
||||||
|
componentData?: ComponentData;
|
||||||
|
timestamp: string;
|
||||||
|
clickCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionResult {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ValidationResult {
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
canExecuteImmediately: boolean;
|
||||||
|
actions?: DataflowAction[];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Phase 5: 마이그레이션 및 검증 (우선순위: 낮음)
|
||||||
|
|
||||||
|
### 5.1 점진적 마이그레이션 계획
|
||||||
|
|
||||||
|
1. **Step 1**: 새로운 통합 타입 파일들 생성
|
||||||
|
2. **Step 2**: 기존 파일들에서 새 타입 import로 변경
|
||||||
|
3. **Step 3**: 타입 가드 및 유틸리티 함수 적용
|
||||||
|
4. **Step 4**: any 타입 제거 및 구체적 타입 적용
|
||||||
|
5. **Step 5**: 기존 타입 정의 파일들 제거
|
||||||
|
|
||||||
|
### 5.2 검증 도구 구축
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// scripts/type-validation.ts
|
||||||
|
// 타입 일관성 검증 스크립트 작성
|
||||||
|
// DB 스키마와 TypeScript 타입 간 일치성 검증
|
||||||
|
// 컴포넌트 Props 타입 검증
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📋 구현 우선순위
|
||||||
|
|
||||||
|
### 🔥 즉시 해결 필요 (Critical)
|
||||||
|
|
||||||
|
1. **WebType 통합** - 가장 많이 사용되는 기본 타입
|
||||||
|
2. **ButtonActionType 통합** - 제어관리 시스템 안정성 확보
|
||||||
|
3. **ColumnInfo 타입 표준화** - 테이블 관리 기능 정상화
|
||||||
|
|
||||||
|
### ⚡ 단기간 해결 (High)
|
||||||
|
|
||||||
|
4. **ComponentData 타입 가드** - 런타임 안전성 확보
|
||||||
|
5. **DB 타입 매핑** - 프론트엔드/백엔드 연동 안정화
|
||||||
|
6. **DynamicWebTypeRenderer Props 정리** - 동적 렌더링 안정성
|
||||||
|
|
||||||
|
### 📅 중장기 해결 (Medium)
|
||||||
|
|
||||||
|
7. **OptimizedButtonDataflowService any 타입 제거** - 코드 품질 향상
|
||||||
|
8. **ButtonDataflowConfig 구조 개선** - 제어관리 시스템 고도화
|
||||||
|
9. **타입 검증 도구 구축** - 지속적인 품질 관리
|
||||||
|
|
||||||
|
## 💡 기대 효과
|
||||||
|
|
||||||
|
### 개발 경험 개선
|
||||||
|
|
||||||
|
- 타입 자동완성 정확도 향상
|
||||||
|
- 컴파일 타임 오류 감소
|
||||||
|
- IDE 지원 기능 활용도 증대
|
||||||
|
|
||||||
|
### 시스템 안정성 향상
|
||||||
|
|
||||||
|
- 런타임 타입 오류 방지
|
||||||
|
- API 연동 안정성 확보
|
||||||
|
- 데이터 일관성 보장
|
||||||
|
|
||||||
|
### 유지보수성 향상
|
||||||
|
|
||||||
|
- 코드 가독성 개선
|
||||||
|
- 리팩토링 안정성 확보
|
||||||
|
- 새 기능 추가 시 사이드 이펙트 최소화
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 다음 단계
|
||||||
|
|
||||||
|
이 분석을 바탕으로 다음과 같은 단계로 진행하는 것을 권장합니다:
|
||||||
|
|
||||||
|
1. **우선순위 검토**: 위의 우선순위가 프로젝트 상황에 적합한지 검토
|
||||||
|
2. **Phase 1 착수**: 통합 타입 파일 생성부터 시작
|
||||||
|
3. **점진적 적용**: 한 번에 모든 것을 바꾸지 말고 단계적으로 적용
|
||||||
|
4. **테스트 강화**: 타입 변경 시마다 충분한 테스트 수행
|
||||||
|
|
||||||
|
이 계획에 대한 의견이나 수정사항이 있으시면 말씀해 주세요.
|
||||||
Loading…
Reference in New Issue