Merge branch 'dev' of http://39.117.244.52:3000/kjs/ERP-node into external-connections

This commit is contained in:
hyeonsu 2025-09-21 11:10:31 +09:00
commit 28bd0d55cd
48 changed files with 4234 additions and 289 deletions

View File

@ -4086,3 +4086,57 @@ model table_relationships_backup {
@@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")
}

View File

@ -167,6 +167,19 @@ export async function getDiagramRelationships(
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({
success: true,
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
);
console.log("🔍 찾은 관계:", 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({
success: false,
message: "관계를 찾을 수 없습니다.",
message: `관계를 찾을 수 없습니다. 요청된 ID: ${relationshipId}, 사용 가능한 ID: ${relationships.map((rel: any) => rel.id).join(", ")}`,
});
return;
}

View File

@ -11,8 +11,8 @@ export const saveFormData = async (
const { companyCode, userId } = req.user as any;
const { screenId, tableName, data } = req.body;
// 필수 필드 검증
if (!screenId || !tableName || !data) {
// 필수 필드 검증 (screenId는 0일 수 있으므로 undefined 체크)
if (screenId === undefined || screenId === null || !tableName || !data) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다. (screenId, tableName, data)",
@ -80,7 +80,7 @@ export const updateFormData = async (
};
const result = await dynamicFormService.updateFormData(
parseInt(id),
id, // parseInt 제거 - 문자열 ID 지원
tableName,
formDataWithMeta
);
@ -168,7 +168,7 @@ export const deleteFormData = async (
});
}
await dynamicFormService.deleteFormData(parseInt(id), tableName);
await dynamicFormService.deleteFormData(id, tableName); // parseInt 제거 - 문자열 ID 지원
res.json({
success: true,

View File

@ -87,6 +87,48 @@ export class DynamicFormService {
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;
}
@ -479,7 +521,7 @@ export class DynamicFormService {
const updateQuery = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${primaryKeyColumn} = $${values.length}
WHERE ${primaryKeyColumn} = $${values.length}::text
RETURNING *
`;
@ -507,7 +549,7 @@ export class DynamicFormService {
* ( )
*/
async updateFormData(
id: number,
id: string | number,
tableName: string,
data: Record<string, any>
): 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("🎯 실제 테이블에서 업데이트할 데이터:", {
tableName,
id,
@ -575,10 +642,36 @@ export class DynamicFormService {
const primaryKeyColumn = primaryKeys[0]; // 첫 번째 기본키 사용
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 = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${primaryKeyColumn} = $${values.length}
WHERE ${primaryKeyColumn} = $${values.length}${typeCastSuffix}
RETURNING *
`;
@ -640,7 +733,7 @@ export class DynamicFormService {
* ( )
*/
async deleteFormData(
id: number,
id: string | number,
tableName: string,
companyCode?: string
): Promise<void> {
@ -650,12 +743,15 @@ export class DynamicFormService {
tableName,
});
// 1. 먼저 테이블의 기본키 컬럼명을 동적으로 조회
// 1. 먼저 테이블의 기본키 컬럼명과 데이터 타입을 동적으로 조회
const primaryKeyQuery = `
SELECT kcu.column_name
SELECT kcu.column_name, c.data_type
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
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
AND tc.constraint_type = 'PRIMARY KEY'
LIMIT 1
@ -677,13 +773,37 @@ export class DynamicFormService {
throw new Error(`테이블 ${tableName}의 기본키를 찾을 수 없습니다.`);
}
const primaryKeyColumn = (primaryKeyResult[0] as any).column_name;
console.log("🔑 발견된 기본키 컬럼:", primaryKeyColumn);
const primaryKeyInfo = primaryKeyResult[0] as any;
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 = `
DELETE FROM ${tableName}
WHERE ${primaryKeyColumn} = $1
WHERE ${primaryKeyColumn} = $1${typeCastSuffix}
RETURNING *
`;

View File

@ -18,6 +18,41 @@ const prisma = new PrismaClient();
export class TableManagementService {
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 )
* Prisma로
@ -915,8 +950,36 @@ export class TableManagementService {
const safeColumn = column.replace(/[^a-zA-Z0-9_]/g, "");
if (typeof value === "string") {
whereConditions.push(`${safeColumn}::text ILIKE $${paramIndex}`);
searchValues.push(`%${value}%`);
// 🎯 코드 타입 컬럼의 경우 코드값과 코드명 모두로 검색
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}%`);
}
} else {
whereConditions.push(`${safeColumn} = $${paramIndex}`);
searchValues.push(value);

View File

@ -3,14 +3,18 @@
import React, { useState, useEffect } from "react";
import { MenuItem, MenuFormData, menuApi, LangKey } from "@/lib/api/menu";
import { companyAPI } from "@/lib/api/company";
import { screenApi } from "@/lib/api/screen";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { toast } from "sonner";
import { ChevronDown, Search } from "lucide-react";
import { MENU_MANAGEMENT_KEYS } from "@/lib/utils/multilang";
import { ScreenDefinition } from "@/types/screen";
interface Company {
company_code: string;
@ -70,6 +74,13 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
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 [isEdit, setIsEdit] = useState(false);
const [companies, setCompanies] = useState<Company[]>([]);
@ -77,6 +88,132 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
const [isLangKeyDropdownOpen, setIsLangKeyDropdownOpen] = useState(false);
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 함수를 먼저 정의
const loadMenuData = async () => {
console.log("loadMenuData 호출됨 - menuId:", menuId);
@ -124,11 +261,16 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
convertedStatus = "INACTIVE";
}
const menuUrl = menu.menu_url || menu.MENU_URL || "";
// URL이 "/screens/"로 시작하면 화면 할당으로 판단 (실제 라우팅 패턴에 맞게 수정)
const isScreenUrl = menuUrl.startsWith("/screens/");
setFormData({
objid: menu.objid || menu.OBJID,
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
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 || "",
seq: menu.seq || menu.SEQ || 1,
menuType: convertedMenuType,
@ -137,6 +279,57 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
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("설정된 폼 데이터:", {
objid: menu.objid || menu.OBJID,
parentObjId: menu.parent_obj_id || menu.PARENT_OBJ_ID || "0",
@ -237,6 +430,35 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
}
}, [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(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -245,16 +467,20 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
setIsLangKeyDropdownOpen(false);
setLangKeySearchText("");
}
if (!target.closest(".screen-dropdown")) {
setIsScreenDropdownOpen(false);
setScreenSearchText("");
}
};
if (isLangKeyDropdownOpen) {
if (isLangKeyDropdownOpen || isScreenDropdownOpen) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => {
document.removeEventListener("mousedown", handleClickOutside);
};
}, [isLangKeyDropdownOpen]);
}, [isLangKeyDropdownOpen, isScreenDropdownOpen]);
const loadCompanies = async () => {
try {
@ -516,12 +742,108 @@ export const MenuFormModal: React.FC<MenuFormModalProps> = ({
<div className="space-y-2">
<Label htmlFor="menuUrl">{getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL)}</Label>
<Input
id="menuUrl"
value={formData.menuUrl}
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
/>
{/* 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
id="menuUrl"
value={formData.menuUrl}
onChange={(e) => handleInputChange("menuUrl", e.target.value)}
placeholder={getText(MENU_MANAGEMENT_KEYS.FORM_MENU_URL_PLACEHOLDER)}
/>
)}
</div>
<div className="space-y-2">

View File

@ -1,7 +1,7 @@
"use client";
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 { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/types/screen";
@ -45,24 +45,60 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
let maxWidth = 800; // 최소 너비
let maxHeight = 600; // 최소 높이
components.forEach((component) => {
const x = parseFloat(component.style?.positionX || "0");
const y = parseFloat(component.style?.positionY || "0");
const width = parseFloat(component.style?.width || "100");
const height = parseFloat(component.style?.height || "40");
console.log("🔍 화면 크기 계산 시작:", { componentsCount: components.length });
components.forEach((component, index) => {
// position과 size는 BaseComponent에서 별도 속성으로 관리
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 bottomEdge = y + height;
maxWidth = Math.max(maxWidth, rightEdge + 50); // 여백 추가
maxHeight = Math.max(maxHeight, bottomEdge + 50); // 여백 추가
console.log(
`📏 컴포넌트 ${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 {
width: Math.min(maxWidth, window.innerWidth * 0.9), // 화면의 90%를 넘지 않도록
height: Math.min(maxHeight, window.innerHeight * 0.8), // 화면의 80%를 넘지 않도록
console.log("📊 컴포넌트 기반 계산 결과:", { maxWidth, maxHeight });
// 브라우저 크기 제한 확인 (더욱 관대하게 설정)
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;
return {
className: "overflow-hidden p-0",
style: {
width: `${screenDimensions.width + 48}px`, // 헤더 패딩과 여백 고려
height: `${Math.min(totalHeight, window.innerHeight * 0.8)}px`,
maxWidth: "90vw",
maxHeight: "80vh",
width: `${Math.min(screenDimensions.width + 48, window.innerWidth * 0.98)}px`, // 브라우저 제한 적용
height: `${Math.min(totalHeight, window.innerHeight * 0.95)}px`, // 브라우저 제한 적용
maxWidth: "98vw", // 안전장치
maxHeight: "95vh", // 안전장치
},
};
};
@ -176,9 +212,10 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
<DialogContent className={`${modalStyle.className} ${className || ""}`} style={modalStyle.style}>
<DialogHeader className="border-b px-6 py-4">
<DialogTitle>{modalState.title}</DialogTitle>
<DialogDescription>{loading ? "화면을 불러오는 중입니다..." : "화면 내용을 표시합니다."}</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden p-4">
<div className="flex-1 p-4">
{loading ? (
<div className="flex h-full items-center justify-center">
<div className="text-center">
@ -188,7 +225,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
</div>
) : screenData ? (
<div
className="relative overflow-hidden bg-white"
className="relative bg-white"
style={{
width: screenDimensions?.width || 800,
height: screenDimensions?.height || 600,
@ -202,13 +239,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
formData={formData}
onFormDataChange={(fieldName, value) => {
console.log(`🎯 ScreenModal onFormDataChange 호출: ${fieldName} = "${value}"`);
console.log(`📋 현재 formData:`, formData);
console.log("📋 현재 formData:", formData);
setFormData((prev) => {
const newFormData = {
...prev,
[fieldName]: value,
};
console.log(`📝 ScreenModal 업데이트된 formData:`, newFormData);
console.log("📝 ScreenModal 업데이트된 formData:", newFormData);
return newFormData;
});
}}

View File

@ -6,6 +6,7 @@ import { Button } from "@/components/ui/button";
import { X, Save, RotateCcw } from "lucide-react";
import { toast } from "sonner";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { InteractiveScreenViewer } from "./InteractiveScreenViewer";
import { screenApi } from "@/lib/api/screen";
import { ComponentData } from "@/lib/types/screen";
@ -145,7 +146,19 @@ export const EditModal: React.FC<EditModalProps> = ({
layoutData.components.forEach((comp) => {
if (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 {
@ -230,6 +243,7 @@ export const EditModal: React.FC<EditModalProps> = ({
minHeight: dynamicSize.height,
maxWidth: "95vw",
maxHeight: "95vh",
zIndex: 9999, // 모든 컴포넌트보다 위에 표시
}}
>
<DialogHeader className="sr-only">
@ -269,30 +283,61 @@ export const EditModal: React.FC<EditModalProps> = ({
zIndex: 1,
}}
>
<DynamicComponentRenderer
component={component}
screenId={screenId}
tableName={screenData.tableName}
formData={formData}
originalData={originalData} // 부분 업데이트용 원본 데이터 전달
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, value);
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
{/* 위젯 컴포넌트는 InteractiveScreenViewer 사용 (라벨 표시를 위해) */}
{component.type === "widget" ? (
<InteractiveScreenViewer
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);
}
}}
// 편집 모드로 설정
mode="edit"
// 모달 내에서 렌더링되고 있음을 표시
isInModal={true}
// 인터랙티브 모드 활성화 (formData 사용을 위해 필수)
isInteractive={true}
/>
// 변경된 데이터를 즉시 부모로 전달
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}
tableName={screenData.tableName}
formData={formData}
originalData={originalData} // 부분 업데이트용 원본 데이터 전달
onFormDataChange={(fieldName, value) => {
console.log("📝 폼 데이터 변경:", fieldName, value);
const newFormData = { ...formData, [fieldName]: value };
setFormData(newFormData);
// 변경된 데이터를 즉시 부모로 전달
if (onDataChange) {
console.log("📤 EditModal -> 부모로 데이터 전달:", newFormData);
onDataChange(newFormData);
}
}}
// 편집 모드로 설정
mode="edit"
// 모달 내에서 렌더링되고 있음을 표시
isInModal={true}
// 인터랙티브 모드 활성화 (formData 사용을 위해 필수)
isInteractive={true}
/>
)}
</div>
))}
</div>

View File

@ -227,7 +227,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
<div
ref={panelRef}
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",
isResizing && "cursor-se-resize",
className,
@ -239,7 +239,7 @@ export const FloatingPanel: React.FC<FloatingPanelProps> = ({
height: `${panelSize.height}px`,
transform: isDragging ? "scale(1.01)" : "scale(1)",
transition: isDragging ? "none" : "transform 0.1s ease-out, box-shadow 0.1s ease-out",
zIndex: isDragging ? 9999 : 50, // 드래그 중 최상위 표시
zIndex: isDragging ? 9999 : 9998, // 항상 컴포넌트보다 위에 표시
}}
>
{/* 헤더 */}

View File

@ -1557,6 +1557,22 @@ export const InteractiveDataTable: React.FC<InteractiveDataTableProps> = ({
</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:
return (
<Input

View File

@ -37,6 +37,7 @@ import { FileUpload } from "./widgets/FileUpload";
import { dynamicFormApi, DynamicFormData } from "@/lib/api/dynamicForm";
import { useParams } from "next/navigation";
import { screenApi } from "@/lib/api/screen";
import { DynamicWebTypeRenderer } from "@/lib/registry/DynamicWebTypeRenderer";
interface InteractiveScreenViewerProps {
component: ComponentData;
@ -936,41 +937,64 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const widget = comp as WidgetComponent;
const config = widget.webTypeConfig as CodeTypeConfig | undefined;
console.log("💻 InteractiveScreenViewer - Code 위젯:", {
console.log("🔍 InteractiveScreenViewer - Code 위젯 (공통코드 선택):", {
componentId: widget.id,
widgetType: widget.widgetType,
columnName: widget.columnName,
fieldName,
currentValue,
formData,
config,
appliedSettings: {
language: config?.language,
theme: config?.theme,
fontSize: config?.fontSize,
defaultValue: config?.defaultValue,
wordWrap: config?.wordWrap,
tabSize: config?.tabSize,
},
codeCategory: config?.codeCategory,
});
const finalPlaceholder = config?.placeholder || "코드를 입력하세요...";
const rows = config?.rows || 4;
return applyStyles(
<Textarea
placeholder={finalPlaceholder}
value={currentValue || config?.defaultValue || ""}
onChange={(e) => updateFormData(fieldName, e.target.value)}
disabled={readonly}
required={required}
rows={rows}
className="h-full w-full resize-none font-mono text-sm"
style={{
fontSize: `${config?.fontSize || 14}px`,
backgroundColor: config?.theme === "dark" ? "#1e1e1e" : "#ffffff",
color: config?.theme === "dark" ? "#ffffff" : "#000000",
whiteSpace: config?.wordWrap === false ? "nowrap" : "normal",
tabSize: config?.tabSize || 2,
}}
/>,
);
// code 타입은 공통코드 선택박스로 처리
// DynamicWebTypeRenderer를 사용하여 SelectBasicComponent 렌더링
try {
return applyStyles(
<DynamicWebTypeRenderer
webType="select"
props={{
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}
required={required}
>
<SelectTrigger className="h-full w-full">
<SelectValue placeholder={config?.placeholder || "코드를 선택하세요..."} />
</SelectTrigger>
<SelectContent>
<SelectItem value="loading"> ...</SelectItem>
</SelectContent>
</Select>
);
}
}
case "entity": {
@ -1623,6 +1647,16 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
const labelText = component.style?.labelText || component.label || "";
// 라벨 표시 여부 로그 (디버깅용)
if (component.type === "widget") {
console.log("🏷️ 라벨 표시 체크:", {
componentId: component.id,
hideLabel,
shouldShowLabel,
labelText,
});
}
// 라벨 스타일 적용
const labelStyle = {
fontSize: component.style?.labelFontSize || "14px",
@ -1683,7 +1717,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
top: `${popupComponent.position.y}px`,
width: `${popupComponent.size.width}px`,
height: `${popupComponent.size.height}px`,
zIndex: popupComponent.position.z || 1,
zIndex: Math.min(popupComponent.position.z || 1, 20), // 최대 z-index 20으로 제한
}}
>
{/* 🎯 핵심 수정: 팝업 전용 formData 사용 */}

View File

@ -11,6 +11,7 @@ import { ComponentData, WidgetComponent, DataTableComponent, FileComponent, Butt
import { InteractiveDataTable } from "./InteractiveDataTable";
import { DynamicWebTypeRenderer } from "@/lib/registry";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
// 컴포넌트 렌더러들을 강제로 로드하여 레지스트리에 등록
import "@/lib/registry/components/ButtonRenderer";
@ -191,11 +192,6 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
// 화면 닫기 로직 (필요시 구현)
console.log("화면 닫기 요청");
}}
style={{
width: "100%",
height: "100%",
...comp.style,
}}
/>
);
}

View File

@ -5,7 +5,11 @@ import { Button } from "@/components/ui/button";
import { toast } from "react-hot-toast";
import { Loader2, CheckCircle2, AlertCircle, Clock } from "lucide-react";
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 { cn } from "@/lib/utils";
import { Badge } from "@/components/ui/badge";
@ -60,7 +64,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
try {
console.log(`🔘 Button clicked: ${component.id} (${config?.actionType})`);
// 🔥 현재 폼 데이터 수집
// 🔥 확장된 컨텍스트 데이터 수집
const contextData = {
...formData,
buttonId: component.id,
@ -69,11 +73,61 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
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) {
// 🔥 확장된 제어 검증 먼저 실행
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);
} else if (isControlOnlyAction) {
// 🔥 제어관리가 비활성화된 상태에서 제어 액션
toast.warning(
"제어관리를 먼저 활성화해주세요. 제어 액션을 사용하려면 버튼 설정에서 '제어관리 활성화'를 체크하고 조건을 설정해주세요.",
);
return;
} else {
// 🔥 기존 액션만 실행
// 🔥 기존 액션만 실행 (제어 액션 제외)
await executeOriginalAction(config?.actionType || "save", contextData);
}
} catch (error) {
@ -332,6 +386,12 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
actionType: ButtonActionType,
contextData: Record<string, any>,
): Promise<any> => {
// 🔥 제어 액션은 여기서 처리하지 않음 (이미 위에서 처리됨)
if (actionType === "control") {
console.warn("제어 액션은 executeOriginalAction에서 처리되지 않아야 합니다.");
return;
}
// 간단한 mock 처리 (실제로는 API 호출)
await new Promise((resolve) => setTimeout(resolve, 100)); // 100ms 시뮬레이션
@ -369,6 +429,7 @@ export const OptimizedButtonComponent: React.FC<OptimizedButtonProps> = ({
modal: "모달",
newWindow: "새 창",
navigate: "페이지 이동",
control: "제어",
};
return displayNames[actionType] || actionType;
};

View File

@ -76,12 +76,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
}) => {
const { id, type, position, size, style: componentStyle } = component;
// 선택 상태에 따른 스타일
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
const selectionStyle = isSelected
? {
outline: "2px solid #3b82f6",
outlineOffset: "2px",
zIndex: 1000,
zIndex: 30, // 패널(z-50)과 모달(z-50)보다 낮게 설정
}
: {};

View File

@ -151,6 +151,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<SelectItem value="close"></SelectItem>
<SelectItem value="modal"> </SelectItem>
<SelectItem value="navigate"> </SelectItem>
<SelectItem value="control"> ( )</SelectItem>
</SelectContent>
</Select>
</div>

View File

@ -6,7 +6,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
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 { ComponentData, ButtonDataflowConfig } from "@/types/screen";
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`);
if (response.data.success && Array.isArray(response.data.data)) {
const relationshipList = response.data.data.map((rel: any) => ({
id: rel.id,
name: rel.name || `${rel.sourceTable}${rel.targetTable}`,
sourceTable: rel.sourceTable,
targetTable: rel.targetTable,
category: rel.category || "data-save",
}));
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,
name: relationshipName,
sourceTable,
targetTable,
category: rel.category || rel.type || "data-save",
};
console.log("🔍 매핑된 관계 데이터:", mappedRel);
return mappedRel;
});
setRelationships(relationshipList);
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료`);
console.log(`✅ 관계 ${relationshipList.length}개 로딩 완료:`, relationshipList);
}
} catch (error) {
console.error("❌ 관계 목록 로딩 실패:", error);
@ -173,6 +195,7 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
close: "닫기",
popup: "팝업",
navigate: "페이지 이동",
control: "제어",
};
return displayNames[actionType] || actionType;
};
@ -215,6 +238,50 @@ export const ButtonDataflowConfigPanel: React.FC<ButtonDataflowConfigPanelProps>
</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 && (
<div className="space-y-6 border-l-2 border-blue-200 pl-4">
{/* 현재 액션 정보 (간소화) */}

View File

@ -18,7 +18,7 @@ const AlertDialogOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
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,
)}
{...props}
@ -36,7 +36,7 @@ const AlertDialogContent = React.forwardRef<
<AlertDialogPrimitive.Content
ref={ref}
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,
)}
{...props}
@ -104,5 +104,3 @@ export {
AlertDialogAction,
AlertDialogCancel,
};

View File

@ -38,13 +38,13 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
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,
)}
{...props}
>
{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" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>

View File

@ -46,7 +46,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent
ref={ref}
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,
)}
{...props}
@ -63,7 +63,7 @@ const DropdownMenuContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
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,
)}
{...props}

View File

@ -19,7 +19,7 @@ const PopoverContent = React.forwardRef<
align={align}
sideOffset={sideOffset}
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,
)}
{...props}

View File

@ -55,7 +55,7 @@ function SelectContent({
<SelectPrimitive.Content
data-slot="select-content"
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" &&
"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,

View File

@ -64,7 +64,7 @@ export class DynamicFormApi {
* @returns
*/
static async updateFormData(
id: number,
id: string | number,
formData: Partial<DynamicFormData>,
): Promise<ApiResponse<SaveFormDataResponse>> {
try {
@ -173,7 +173,7 @@ export class DynamicFormApi {
* @param tableName
* @returns
*/
static async deleteFormDataFromTable(id: number, tableName: string): Promise<ApiResponse<void>> {
static async deleteFormDataFromTable(id: string | number, tableName: string): Promise<ApiResponse<void>> {
try {
console.log("🗑️ 실제 테이블에서 폼 데이터 삭제 요청:", { id, tableName });

View File

@ -175,11 +175,37 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
const startTime = Date.now();
totalRequests.current += 1;
// 🎯 디버깅: 캐시 상태 로깅
console.log(`🔍 optimizedConvertCode 호출: categoryCode="${categoryCode}", codeValue="${codeValue}"`);
// 캐시에서 동기적으로 조회 시도
const syncResult = codeCache.getCodeSync(categoryCode);
console.log(`🔍 getCodeSync("${categoryCode}") 결과:`, syncResult);
// 🎯 캐시 내용 상세 로깅 (키값들 확인)
if (syncResult) {
console.log(`🔍 캐시 키값들:`, Object.keys(syncResult));
console.log(`🔍 캐시 전체 데이터:`, JSON.stringify(syncResult, null, 2));
}
if (syncResult && Array.isArray(syncResult)) {
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);
@ -190,10 +216,13 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
return result;
}
console.log(`⚠️ 캐시 미스: categoryCode="${categoryCode}" - 비동기 로딩 트리거`);
// 캐시 미스인 경우 비동기 로딩 트리거 (백그라운드)
codeCache
.getCode(categoryCode)
.getCodeAsync(categoryCode)
.then(() => {
console.log(`✅ 비동기 로딩 완료: categoryCode="${categoryCode}"`);
updateMetrics();
})
.catch((err) => {

View File

@ -4,6 +4,7 @@ import React from "react";
import { ComponentData } from "@/types/screen";
import { DynamicLayoutRenderer } from "./DynamicLayoutRenderer";
import { ComponentRegistry } from "./ComponentRegistry";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
// 컴포넌트 렌더러 인터페이스
export interface ComponentRenderer {
@ -86,6 +87,12 @@ export interface DynamicComponentRendererProps {
tableName?: string;
onRefresh?: () => void;
onClose?: () => void;
// 테이블 선택된 행 정보 (다중 선택 액션용)
selectedRows?: any[];
selectedRowsData?: any[];
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
// 테이블 새로고침 키
refreshKey?: number;
// 편집 모드
mode?: "view" | "edit";
// 모달 내에서 렌더링 여부
@ -107,6 +114,9 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 레이아웃 컴포넌트 처리
if (componentType === "layout") {
// DOM 안전한 props만 전달
const safeLayoutProps = filterDOMProps(props);
return (
<DynamicLayoutRenderer
layout={component as any}
@ -118,7 +128,17 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onUpdateLayout={props.onUpdateLayout}
// onComponentDrop 제거 - 일반 캔버스 드롭만 사용
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 {
const NewComponentRenderer = newComponent.component;
if (NewComponentRenderer) {
// React 전용 props 필터링
const { isInteractive, formData, onFormDataChange, ...safeProps } = props;
// React 전용 props들을 명시적으로 분리하고 DOM 안전한 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 (
<NewComponentRenderer
{...safeProps}
component={component}
isSelected={isSelected}
onClick={onClick}
isInteractive={isInteractive}
formData={formData}
onFormDataChange={onFormDataChange}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
size={component.size || newComponent.defaultSize}
@ -150,10 +202,29 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
style={component.style}
config={component.componentConfig}
componentConfig={component.componentConfig}
screenId={props.screenId}
tableName={props.tableName}
onRefresh={props.onRefresh}
onClose={props.onClose}
value={currentValue} // formData에서 추출한 현재 값 전달
// 새로운 기능들 전달
autoGeneration={component.autoGeneration}
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 {
// 레거시 시스템에서도 DOM 안전한 props만 전달
const safeLegacyProps = filterDOMProps(props);
return renderer({
component,
isSelected,
@ -194,7 +268,28 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
onDragStart,
onDragEnd,
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) {
console.error(`❌ 컴포넌트 렌더링 실패 (${componentType}):`, error);

View File

@ -3,6 +3,7 @@
import React from "react";
import { LayoutComponent, ComponentData } from "@/types/screen";
import { LayoutRegistry } from "./LayoutRegistry";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
export interface DynamicLayoutRendererProps {
layout: LayoutComponent;
@ -70,6 +71,9 @@ export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
// 레이아웃 렌더링 실행
try {
// DOM 안전한 props만 필터링
const safeProps = filterDOMProps(restProps);
return (
<LayoutComponent
layout={layout}
@ -84,7 +88,7 @@ export const DynamicLayoutRenderer: React.FC<DynamicLayoutRendererProps> = ({
onUpdateLayout={onUpdateLayout}
className={className}
style={style}
{...restProps}
{...safeProps}
/>
);
} catch (error) {

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState } from "react";
import React, { useState, useRef, useEffect } from "react";
import { ComponentRendererProps } from "@/types/component";
import { ButtonPrimaryConfig } from "./types";
import {
@ -20,6 +20,7 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { toast } from "sonner";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig;
@ -70,6 +71,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
config: any;
context: ButtonActionContext;
} | 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 = {
...config,
@ -83,13 +99,26 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
processedConfig.action = {
...DEFAULT_BUTTON_ACTIONS[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("🔧 버튼 컴포넌트 설정:", {
originalConfig: componentConfig,
processedConfig,
component: component,
actionConfig: processedConfig.action,
webTypeConfig: component.webTypeConfig,
enableDataflowControl: component.webTypeConfig?.enableDataflowControl,
dataflowConfig: component.webTypeConfig?.dataflowConfig,
screenId,
tableName,
onRefresh,
@ -118,13 +147,22 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 실제 액션 실행 함수
const executeAction = async (actionConfig: any, context: ButtonActionContext) => {
console.log("🚀 executeAction 시작:", { actionConfig, context });
let loadingToast: string | number | undefined;
try {
// 기존 토스트가 있다면 먼저 제거
if (currentLoadingToastRef.current !== undefined) {
console.log("📱 기존 토스트 제거");
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
}
// 추가 안전장치: 모든 로딩 토스트 제거
toast.dismiss();
// edit 액션을 제외하고만 로딩 토스트 표시
if (actionConfig.type !== "edit") {
console.log("📱 로딩 토스트 표시 시작");
loadingToast = toast.loading(
currentLoadingToastRef.current = toast.loading(
actionConfig.type === "save"
? "저장 중..."
: actionConfig.type === "delete"
@ -132,8 +170,11 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
: actionConfig.type === "submit"
? "제출 중..."
: "처리 중...",
{
duration: Infinity, // 명시적으로 무한대로 설정
},
);
console.log("📱 로딩 토스트 ID:", loadingToast);
console.log("📱 로딩 토스트 ID:", currentLoadingToastRef.current);
}
console.log("⚡ ButtonActionExecutor.executeAction 호출 시작");
@ -141,9 +182,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
console.log("⚡ ButtonActionExecutor.executeAction 완료, success:", success);
// 로딩 토스트 제거 (있는 경우에만)
if (loadingToast) {
console.log("📱 로딩 토스트 제거");
toast.dismiss(loadingToast);
if (currentLoadingToastRef.current !== undefined) {
console.log("📱 로딩 토스트 제거 시도, ID:", currentLoadingToastRef.current);
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
}
// edit 액션은 조용히 처리 (모달 열기만 하므로 토스트 불필요)
@ -169,9 +211,10 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
console.log("❌ executeAction catch 블록 진입:", error);
// 로딩 토스트 제거
if (loadingToast) {
console.log("📱 오류 시 로딩 토스트 제거");
toast.dismiss(loadingToast);
if (currentLoadingToastRef.current !== undefined) {
console.log("📱 오류 시 로딩 토스트 제거, ID:", currentLoadingToastRef.current);
toast.dismiss(currentLoadingToastRef.current);
currentLoadingToastRef.current = undefined;
}
console.error("❌ 버튼 액션 실행 오류:", error);
@ -313,15 +356,20 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}
};
// DOM 안전한 props만 필터링
const safeDomProps = filterDOMProps(domProps);
return (
<>
<div style={componentStyle} className={className} {...domProps}>
<div style={componentStyle} className={className} {...safeDomProps}>
<button
type={componentConfig.actionType || "button"}
disabled={componentConfig.disabled || false}
style={{
width: "100%",
height: "100%",
minHeight: "100%", // 최소 높이 강제 적용
maxHeight: "100%", // 최대 높이 제한
border: "1px solid #3b82f6",
borderRadius: "4px",
backgroundColor: "#3b82f6",
@ -330,6 +378,16 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
fontWeight: "500",
cursor: componentConfig.disabled ? "not-allowed" : "pointer",
outline: "none",
boxSizing: "border-box", // 패딩/보더 포함 크기 계산
display: "flex", // flex로 변경
alignItems: "center", // 세로 중앙 정렬
justifyContent: "center", // 가로 중앙 정렬
padding: "0", // 패딩 제거
margin: "0", // 마진 제거
lineHeight: "1", // 라인 높이 고정
// 강제 높이 적용
minHeight: "36px",
height: "36px",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}

View File

@ -1,11 +1,17 @@
"use client";
import React from "react";
import React, { useEffect, useState } from "react";
import { ComponentRendererProps } from "@/types/component";
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 {
config?: DateInputConfig;
value?: any; // 외부에서 전달받는 값
autoGeneration?: AutoGenerationConfig;
hidden?: boolean;
}
/**
@ -25,6 +31,9 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
style,
formData,
onFormDataChange,
value: externalValue, // 외부에서 전달받은 값
autoGeneration,
hidden,
...props
}) => {
// 컴포넌트 설정
@ -33,6 +42,208 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
...component.config,
} 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에서 처리하므로 제외)
const componentStyle: React.CSSProperties = {
width: "100%",
@ -74,10 +285,13 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
...domProps
} = props;
// DOM 안전한 props만 필터링
const safeDomProps = filterDOMProps(domProps);
return (
<div style={componentStyle} className={className} {...domProps}>
<div style={componentStyle} className={className} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -86,17 +300,15 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
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",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
<span
style={{
color: "#ef4444",
}}
>
*
</span>
)}
@ -105,12 +317,17 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
<input
type="date"
value={component.value || ""}
placeholder={componentConfig.placeholder || ""}
value={formattedValue}
placeholder={
finalAutoGeneration?.enabled
? `자동생성: ${AutoGenerationUtils.getTypeDescription(finalAutoGeneration.type)}`
: componentConfig.placeholder || ""
}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
readOnly={componentConfig.readonly || false}
style={{width: "100%",
readOnly={componentConfig.readonly || finalAutoGeneration?.enabled || false}
style={{
width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "4px",
@ -118,13 +335,36 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
fontSize: "14px",
outline: "none",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onChange={(e) => {
if (component.onChange) {
component.onChange(e.target.value);
const newValue = 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);
}
}}
/>

View File

@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { DateInputConfig } from "./types";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
export interface DateInputConfigPanelProps {
config: DateInputConfig;
@ -16,21 +18,17 @@ export interface DateInputConfigPanelProps {
* DateInput
* UI
*/
export const DateInputConfigPanel: React.FC<DateInputConfigPanelProps> = ({
config,
onChange,
}) => {
export const DateInputConfigPanel: React.FC<DateInputConfigPanelProps> = ({ config, onChange }) => {
const handleChange = (key: keyof DateInputConfig, value: any) => {
console.log("🔧 DateInputConfigPanel.handleChange:", { key, value });
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">
date-input
</div>
<div className="text-sm font-medium">date-input </div>
{/* date 관련 설정 */}
{/* date 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Input
@ -67,6 +65,194 @@ export const DateInputConfigPanel: React.FC<DateInputConfigPanelProps> = ({
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</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>
);
};

View File

@ -13,17 +13,24 @@ export class DateInputRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = DateInputDefinition;
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} />;
}
/**
*
*/
// date 타입 특화 속성 처리
protected getDateInputProps() {
const baseProps = this.getWebTypeProps();
// date 타입에 특화된 추가 속성들
return {
...baseProps,

View File

@ -4,7 +4,7 @@ import React from "react";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import type { WebType } from "@/types/screen";
import { DateInputWrapper } from "./DateInputComponent";
import { DateInputComponent } from "./DateInputComponent";
import { DateInputConfigPanel } from "./DateInputConfigPanel";
import { DateInputConfig } from "./types";
@ -19,7 +19,7 @@ export const DateInputDefinition = createComponentDefinition({
description: "날짜 선택을 위한 날짜 선택기 컴포넌트",
category: ComponentCategory.INPUT,
webType: "date",
component: DateInputWrapper,
component: DateInputComponent,
defaultConfig: {
placeholder: "입력하세요",
},

View File

@ -1,25 +1,29 @@
"use client";
import { ComponentConfig } from "@/types/component";
import { AutoGenerationConfig } from "@/types/screen";
/**
* DateInput
*/
export interface DateInputConfig extends ComponentConfig {
// date 관련 설정
// date 관련 설정
placeholder?: string;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 자동생성 및 숨김 기능
autoGeneration?: AutoGenerationConfig;
hidden?: boolean;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
@ -37,7 +41,7 @@ export interface DateInputProps {
config?: DateInputConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;

View File

@ -1,11 +1,14 @@
"use client";
import React from "react";
import { ComponentRendererProps } from "@/types/component";
import React, { useEffect, useState } from "react";
import { ComponentRendererProps, AutoGenerationConfig } from "@/types/component";
import { NumberInputConfig } from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
export interface NumberInputComponentProps extends ComponentRendererProps {
config?: NumberInputConfig;
value?: any; // 외부에서 전달받는 값
}
/**
@ -25,6 +28,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
style,
formData,
onFormDataChange,
value: externalValue, // 외부에서 전달받은 값
...props
}) => {
// 컴포넌트 설정
@ -74,10 +78,13 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
...domProps
} = props;
// DOM 안전한 props만 필터링
const safeDomProps = filterDOMProps(domProps);
return (
<div style={componentStyle} className={className} {...domProps}>
<div style={componentStyle} className={className} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -86,17 +93,15 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
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",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),
}}>
<span
style={{
color: "#ef4444",
}}
>
*
</span>
)}
@ -105,7 +110,16 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
<input
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 || ""}
disabled={componentConfig.disabled || false}
required={componentConfig.required || false}
@ -113,7 +127,8 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
min={componentConfig.min}
max={componentConfig.max}
step={componentConfig.step || 1}
style={{width: "100%",
style={{
width: "100%",
height: "100%",
border: "1px solid #d1d5db",
borderRadius: "4px",
@ -121,13 +136,36 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
fontSize: "14px",
outline: "none",
// isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}),}}
...(isInteractive && component.style ? component.style : {}),
}}
onClick={handleClick}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onChange={(e) => {
if (component.onChange) {
component.onChange(e.target.value);
const newValue = 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);
}
}}
/>

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from "react";
import { commonCodeApi } from "../../../api/commonCode";
import { tableTypeApi } from "../../../api/screen";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
interface Option {
value: string;
@ -14,11 +15,14 @@ export interface SelectBasicComponentProps {
onUpdate?: (field: string, value: any) => void;
isSelected?: boolean;
isDesignMode?: boolean;
isInteractive?: boolean;
onFormDataChange?: (fieldName: string, value: any) => void;
className?: string;
style?: React.CSSProperties;
onClick?: () => void;
onDragStart?: () => void;
onDragEnd?: () => void;
value?: 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 actualLabel =
code.codeName ||
code.code_name || // 스네이크 케이스 추가!
code.name ||
code.CODE_NAME ||
code.NAME ||
@ -233,16 +238,34 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
onUpdate,
isSelected = false,
isDesignMode = false,
isInteractive = false,
onFormDataChange,
className,
style,
onClick,
onDragStart,
onDragEnd,
value: externalValue, // 명시적으로 value prop 받기
...props
}) => {
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("");
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 [isLoadingCodes, setIsLoadingCodes] = useState(false);
const [dynamicCodeCategory, setDynamicCodeCategory] = useState<string | null>(null);
@ -250,7 +273,25 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
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(() => {
@ -359,18 +400,43 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 선택된 값에 따른 라벨 업데이트
useEffect(() => {
const getAllOptions = () => {
const configOptions = componentConfig.options || [];
const configOptions = config.options || [];
return [...codeOptions, ...configOptions];
};
const options = getAllOptions();
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) {
setSelectedLabel(newLabel);
}
}, [selectedValue, codeOptions, componentConfig.options]);
}, [selectedValue, codeOptions, config.options]);
// 클릭 이벤트 핸들러 (전역 상태 새로고침)
const handleToggle = () => {
@ -416,10 +482,23 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
setSelectedLabel(label);
setIsOpen(false);
// 디자인 모드에서의 컴포넌트 속성 업데이트
if (onUpdate) {
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 });
};
@ -473,7 +552,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// 모든 옵션 가져오기
const getAllOptions = () => {
const configOptions = componentConfig.options || [];
const configOptions = config.options || [];
console.log(`🔧 [${component.id}] 옵션 병합:`, {
codeOptionsLength: codeOptions.length,
codeOptions: codeOptions.map((o) => ({ value: o.value, label: o.label })),
@ -486,6 +565,24 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
const allOptions = getAllOptions();
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 (
<div
ref={selectRef}
@ -494,8 +591,27 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
onClick={onClick}
onDragStart={onDragStart}
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
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 && (
<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={{
backgroundColor: "white",
color: "black",
zIndex: 9999,
zIndex: 99999, // 더 높은 z-index로 설정
}}
>
{(() => {

View File

@ -539,7 +539,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}
}, [refreshKey]);
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가)
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가 + 숨김 기능)
const visibleColumns = useMemo(() => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용
const checkboxConfig = tableConfig.checkbox || {
@ -554,9 +554,27 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (!displayColumns || displayColumns.length === 0) {
// displayColumns가 아직 설정되지 않은 경우 기본 컬럼 사용
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 {
return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만
}
})
.sort((a, b) => a.order - b.order);
} else {
columns = displayColumns.filter((col) => col.visible).sort((a, b) => a.order - b.order);
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) => {
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) {
const categoryCode = columnMeta[columnName].codeCategory!;
const convertedValue = optimizedConvertCode(categoryCode, 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;
@ -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",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
style={{
minWidth: `${getColumnWidth(column)}px`,
@ -947,13 +983,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{columnsByPosition.normal.map((column) => (
<th
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(
column.columnName === "__checkbox__"
? "h-12 border-b px-4 py-3 text-center"
: "cursor-pointer border-b px-4 py-3 text-left font-medium whitespace-nowrap text-gray-900 select-none",
? "h-12 border-b px-4 py-3 text-center align-middle"
: "cursor-pointer border-b px-4 py-3 text-left align-middle font-medium whitespace-nowrap text-gray-900 select-none",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
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",
`text-${column.align}`,
column.sortable && "hover:bg-gray-50",
// 숨김 컬럼 스타일 (디자인 모드에서만)
isDesignMode && column.hidden && "bg-gray-100/50 opacity-40",
)}
style={{
minWidth: `${getColumnWidth(column)}px`,

View File

@ -11,6 +11,35 @@ export interface EntityJoinInfo {
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; // 컬럼 고정 위치 (왼쪽, 오른쪽, 고정 안함)
fixedOrder?: number; // 고정된 컬럼들 내에서의 순서
// 새로운 기능들
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
}
/**

View File

@ -1,8 +1,11 @@
"use client";
import React from "react";
import React, { useEffect, useState } from "react";
import { ComponentRendererProps } from "@/types/component";
import { AutoGenerationConfig } from "@/types/screen";
import { TextInputConfig } from "./types";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
export interface TextInputComponentProps extends ComponentRendererProps {
config?: TextInputConfig;
@ -33,18 +36,112 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
...component.config,
} 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("🔧 텍스트 입력 컴포넌트 설정:", {
config,
componentConfig,
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에서 처리하므로 제외)
const componentStyle: React.CSSProperties = {
width: "100%",
height: "100%",
...component.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
} = props;
// DOM 안전한 props만 필터링
const safeDomProps = filterDOMProps(domProps);
return (
<div style={componentStyle} className={className} {...domProps}>
<div style={componentStyle} className={className} {...safeDomProps}>
{/* 라벨 렌더링 */}
{component.label && (
{component.label && component.style?.labelDisplay !== false && (
<label
style={{
position: "absolute",
@ -101,15 +201,37 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
<input
type="text"
value={
isInteractive && formData && component.columnName
? formData[component.columnName] || ""
: component.value || ""
value={(() => {
let displayValue = "";
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}
required={componentConfig.required || false}
readOnly={componentConfig.readonly || false}
readOnly={componentConfig.readonly || (testAutoGeneration.enabled && testAutoGeneration.type !== "none")}
style={{
width: "100%",
height: "100%",
@ -126,7 +248,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
onDragEnd={onDragEnd}
onChange={(e) => {
const newValue = e.target.value;
console.log(`🎯 TextInputComponent onChange 호출:`, {
console.log("🎯 TextInputComponent onChange 호출:", {
componentId: component.id,
columnName: component.columnName,
newValue,
@ -138,13 +260,13 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
// isInteractive 모드에서는 formData 업데이트
if (isInteractive && onFormDataChange && component.columnName) {
console.log(`📤 TextInputComponent -> onFormDataChange 호출: ${component.columnName} = "${newValue}"`);
console.log(`🔍 onFormDataChange 함수 정보:`, {
console.log("🔍 onFormDataChange 함수 정보:", {
functionName: onFormDataChange.name,
functionString: onFormDataChange.toString().substring(0, 200),
});
onFormDataChange(component.columnName, newValue);
} else {
console.log(`❌ TextInputComponent onFormDataChange 조건 미충족:`, {
console.log("❌ TextInputComponent onFormDataChange 조건 미충족:", {
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
hasColumnName: !!component.columnName,

View File

@ -6,6 +6,8 @@ import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { TextInputConfig } from "./types";
import { AutoGenerationType, AutoGenerationConfig } from "@/types/screen";
import { AutoGenerationUtils } from "@/lib/utils/autoGeneration";
export interface TextInputConfigPanelProps {
config: TextInputConfig;
@ -16,21 +18,16 @@ export interface TextInputConfigPanelProps {
* TextInput
* UI
*/
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({
config,
onChange,
}) => {
export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({ config, onChange }) => {
const handleChange = (key: keyof TextInputConfig, value: any) => {
onChange({ [key]: value });
};
return (
<div className="space-y-4">
<div className="text-sm font-medium">
text-input
</div>
<div className="text-sm font-medium">text-input </div>
{/* 텍스트 관련 설정 */}
{/* 텍스트 관련 설정 */}
<div className="space-y-2">
<Label htmlFor="placeholder"></Label>
<Input
@ -77,6 +74,163 @@ export const TextInputConfigPanel: React.FC<TextInputConfigPanelProps> = ({
onCheckedChange={(checked) => handleChange("readonly", checked)}
/>
</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>
);
};

View File

@ -1,32 +1,37 @@
"use client";
import { ComponentConfig } from "@/types/component";
import { AutoGenerationConfig } from "@/types/screen";
/**
* TextInput
*/
export interface TextInputConfig extends ComponentConfig {
// 텍스트 관련 설정
// 텍스트 관련 설정
placeholder?: string;
maxLength?: number;
minLength?: number;
// 공통 설정
disabled?: boolean;
required?: boolean;
readonly?: boolean;
placeholder?: string;
helperText?: string;
// 스타일 관련
variant?: "default" | "outlined" | "filled";
size?: "sm" | "md" | "lg";
// 이벤트 관련
onChange?: (value: any) => void;
onFocus?: () => void;
onBlur?: () => void;
onClick?: () => void;
// 새로운 기능들
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
}
/**
@ -39,7 +44,7 @@ export interface TextInputProps {
config?: TextInputConfig;
className?: string;
style?: React.CSSProperties;
// 이벤트 핸들러
onChange?: (value: any) => void;
onFocus?: () => void;

View File

@ -19,6 +19,18 @@ export interface LayoutRendererProps {
children?: React.ReactNode;
className?: string;
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> {

View File

@ -2,6 +2,7 @@
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
/**
* Flexbox
@ -93,19 +94,8 @@ export const FlexboxLayout: React.FC<FlexboxLayoutProps> = ({
flexStyle.padding = "8px";
}
// DOM props만 추출 (React DOM에서 인식하는 props만)
const {
children: propsChildren,
onUpdateLayout,
onSelectComponent,
isDesignMode: _isDesignMode,
allComponents,
onComponentDrop,
onDragStart,
onDragEnd,
selectedScreen, // DOM에 전달하지 않도록 제외
...domProps
} = props;
// DOM 안전한 props만 필터링
const domProps = filterDOMProps(props);
return (
<>

View File

@ -2,6 +2,7 @@
import React from "react";
import { LayoutRendererProps } from "../BaseLayoutRenderer";
import { filterDOMProps } from "@/lib/utils/domPropsFilter";
/**
*
@ -61,19 +62,8 @@ export const GridLayout: React.FC<GridLayoutProps> = ({
gridStyle.borderRadius = "8px";
}
// DOM props만 추출 (React DOM에서 인식하는 props만)
const {
children: propsChildren,
onUpdateLayout,
onSelectComponent,
isDesignMode: _isDesignMode,
allComponents,
onComponentDrop,
onDragStart,
onDragEnd,
selectedScreen, // DOM에 전달하지 않도록 제외
...domProps
} = props;
// DOM 안전한 props만 필터링
const domProps = filterDOMProps(props);
return (
<>

View File

@ -27,6 +27,33 @@ export interface QuickValidationResult {
success: boolean;
message?: string;
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(
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,
};
}
/**
* 🔥 ( )
*/

View File

@ -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 };
},
};
}

View File

@ -3,6 +3,7 @@
import { toast } from "sonner";
import { screenApi } from "@/lib/api/screen";
import { DynamicFormApi } from "@/lib/api/dynamicForm";
import { OptimizedButtonDataflowService, ExtendedControlContext } from "@/lib/services/optimizedButtonDataflowService";
/**
*
@ -46,6 +47,10 @@ export interface ButtonActionConfig {
confirmMessage?: string;
successMessage?: string;
errorMessage?: string;
// 제어관리 관련
enableDataflowControl?: boolean;
dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용)
}
/**
@ -116,6 +121,9 @@ export class ButtonActionExecutor {
case "close":
return this.handleClose(config, context);
case "control":
return this.handleControl(config, context);
default:
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
return false;
@ -166,7 +174,10 @@ export class ButtonActionExecutor {
const primaryKeys = primaryKeyResult.data || [];
const primaryKeyValue = this.extractPrimaryKeyValueFromDB(formData, primaryKeys);
const isUpdate = primaryKeyValue !== null && primaryKeyValue !== undefined && primaryKeyValue !== "";
// 단순히 기본키 값 존재 여부로 판단 (임시)
// TODO: 실제 테이블에서 기본키로 레코드 존재 여부 확인하는 API 필요
const isUpdate = false; // 현재는 항상 INSERT로 처리
console.log("💾 저장 모드 판단 (DB 기반):", {
tableName,
@ -316,12 +327,67 @@ export class ButtonActionExecutor {
if (selectedRowsData && selectedRowsData.length > 0) {
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) {
// 더 포괄적인 ID 찾기 (테이블 구조에 따라 다양한 필드명 시도)
const deleteId = rowData.id || rowData.objid || rowData.pk || rowData.ID || rowData.OBJID || rowData.PK;
let deleteId: any = null;
// 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("추출된 deleteId:", deleteId);
console.log("최종 추출된 deleteId:", deleteId);
if (deleteId) {
console.log("다중 데이터 삭제:", { tableName, screenId, id: deleteId });
@ -332,7 +398,9 @@ export class ButtonActionExecutor {
}
} else {
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;
}
/**
* ( )
*/
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;
}
}
/**
*
*/

View File

@ -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();
}

View File

@ -44,6 +44,9 @@ export interface ComponentSize {
height: number;
}
// screen.ts에서 자동생성 관련 타입들을 import
export type { AutoGenerationType, AutoGenerationConfig } from "./screen";
/**
* Props
* Props
@ -61,6 +64,11 @@ export interface ComponentRendererProps {
style?: React.CSSProperties;
formData?: Record<string, any>;
onFormDataChange?: (fieldName: string, value: any) => void;
// 새로운 기능들
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
[key: string]: any;
}

View File

@ -46,7 +46,8 @@ export type ButtonActionType =
| "popup" // 팝업 열기
| "modal" // 모달 열기
| "newWindow" // 새 창 열기
| "navigate"; // 페이지 이동
| "navigate" // 페이지 이동
| "control"; // 제어 전용 (조건 체크만)
// 위치 정보
export interface Position {
@ -155,6 +156,31 @@ export interface ComponentStyle {
}
// 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 {
id: string;
type: ComponentType;
@ -174,7 +200,11 @@ export interface BaseComponent {
| "current_user"
| "uuid"
| "sequence"
| "user_defined"; // 자동 값 타입
| "user_defined"; // 자동 값 타입 (레거시)
// 새로운 기능들
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
autoGeneration?: AutoGenerationConfig; // 자동생성 설정
}
// 컨테이너 컴포넌트

View File

@ -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. **테스트 강화**: 타입 변경 시마다 충분한 테스트 수행
이 계획에 대한 의견이나 수정사항이 있으시면 말씀해 주세요.