데이터 저장까지 구현 #30
|
|
@ -15,7 +15,6 @@ const prisma = new PrismaClient({
|
|||
async function testConnection() {
|
||||
try {
|
||||
await prisma.$connect();
|
||||
console.log("✅ 데이터베이스 연결 성공");
|
||||
} catch (error) {
|
||||
console.error("❌ 데이터베이스 연결 실패:", error);
|
||||
process.exit(1);
|
||||
|
|
|
|||
|
|
@ -181,14 +181,6 @@ export class AuthController {
|
|||
return;
|
||||
}
|
||||
|
||||
// DB에서 조회한 원본 사용자 정보 로그
|
||||
console.log("🔍 DB에서 조회한 사용자 정보:", {
|
||||
userId: dbUserInfo.userId,
|
||||
companyCode: dbUserInfo.companyCode,
|
||||
deptCode: dbUserInfo.deptCode,
|
||||
dbUserInfoKeys: Object.keys(dbUserInfo),
|
||||
});
|
||||
|
||||
// 프론트엔드 호환성을 위해 더 많은 사용자 정보 반환
|
||||
const userInfoResponse: any = {
|
||||
userId: dbUserInfo.userId,
|
||||
|
|
@ -206,13 +198,6 @@ export class AuthController {
|
|||
dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin",
|
||||
};
|
||||
|
||||
console.log("📤 프론트엔드로 전송할 사용자 정보:", {
|
||||
companyCode: userInfoResponse.companyCode,
|
||||
company_code: userInfoResponse.company_code,
|
||||
deptCode: userInfoResponse.deptCode,
|
||||
responseKeys: Object.keys(userInfoResponse),
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
message: "사용자 정보 조회 성공",
|
||||
|
|
|
|||
|
|
@ -414,10 +414,6 @@ class ComponentStandardController {
|
|||
req.user?.companyCode
|
||||
);
|
||||
|
||||
console.log(
|
||||
`🔍 중복 체크 결과: component_code=${component_code}, company_code=${req.user?.companyCode}, isDuplicate=${isDuplicate}`
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
success: true,
|
||||
data: { isDuplicate, component_code },
|
||||
|
|
|
|||
|
|
@ -125,20 +125,6 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
|
|||
});
|
||||
}
|
||||
|
||||
// 🔍 백엔드에서 받은 실제 데이터 로깅
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 control 데이터:",
|
||||
JSON.stringify(control, null, 2)
|
||||
);
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 plan 데이터:",
|
||||
JSON.stringify(plan, null, 2)
|
||||
);
|
||||
console.log(
|
||||
"🔍 백엔드에서 받은 category 데이터:",
|
||||
JSON.stringify(category, null, 2)
|
||||
);
|
||||
|
||||
const newDiagram = await createDataflowDiagramService({
|
||||
diagram_name,
|
||||
relationships,
|
||||
|
|
|
|||
|
|
@ -11,14 +11,6 @@ export const saveFormData = async (
|
|||
const { companyCode, userId } = req.user as any;
|
||||
const { screenId, tableName, data } = req.body;
|
||||
|
||||
console.log("💾 폼 데이터 저장 요청:", {
|
||||
userId,
|
||||
companyCode,
|
||||
screenId,
|
||||
tableName,
|
||||
data,
|
||||
});
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!screenId || !tableName || !data) {
|
||||
return res.status(400).json({
|
||||
|
|
@ -49,8 +41,6 @@ export const saveFormData = async (
|
|||
formDataWithMeta
|
||||
);
|
||||
|
||||
console.log("✅ 폼 데이터 저장 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
|
@ -75,14 +65,6 @@ export const updateFormData = async (
|
|||
const { companyCode, userId } = req.user as any;
|
||||
const { tableName, data } = req.body;
|
||||
|
||||
console.log("🔄 폼 데이터 업데이트 요청:", {
|
||||
id,
|
||||
userId,
|
||||
companyCode,
|
||||
tableName,
|
||||
data,
|
||||
});
|
||||
|
||||
if (!tableName || !data) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
|
|
@ -103,8 +85,6 @@ export const updateFormData = async (
|
|||
formDataWithMeta
|
||||
);
|
||||
|
||||
console.log("✅ 폼 데이터 업데이트 성공:", result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
|
@ -129,8 +109,6 @@ export const deleteFormData = async (
|
|||
const { companyCode } = req.user as any;
|
||||
const { tableName } = req.body;
|
||||
|
||||
console.log("🗑️ 폼 데이터 삭제 요청:", { id, companyCode, tableName });
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
|
|
@ -140,8 +118,6 @@ export const deleteFormData = async (
|
|||
|
||||
await dynamicFormService.deleteFormData(parseInt(id), tableName);
|
||||
|
||||
console.log("✅ 폼 데이터 삭제 성공");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "데이터가 성공적으로 삭제되었습니다.",
|
||||
|
|
@ -164,8 +140,6 @@ export const getFormData = async (
|
|||
const { id } = req.params;
|
||||
const { companyCode } = req.user as any;
|
||||
|
||||
console.log("📄 폼 데이터 단건 조회 요청:", { id, companyCode });
|
||||
|
||||
const data = await dynamicFormService.getFormData(parseInt(id));
|
||||
|
||||
if (!data) {
|
||||
|
|
@ -175,8 +149,6 @@ export const getFormData = async (
|
|||
});
|
||||
}
|
||||
|
||||
console.log("✅ 폼 데이터 단건 조회 성공");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: data,
|
||||
|
|
@ -206,16 +178,6 @@ export const getFormDataList = async (
|
|||
sortOrder = "desc",
|
||||
} = req.query;
|
||||
|
||||
console.log("📋 폼 데이터 목록 조회 요청:", {
|
||||
screenId,
|
||||
companyCode,
|
||||
page,
|
||||
size,
|
||||
search,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
|
||||
const result = await dynamicFormService.getFormDataList(
|
||||
parseInt(screenId as string),
|
||||
{
|
||||
|
|
@ -227,8 +189,6 @@ export const getFormDataList = async (
|
|||
}
|
||||
);
|
||||
|
||||
console.log("✅ 폼 데이터 목록 조회 성공");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
|
|
@ -250,8 +210,6 @@ export const validateFormData = async (
|
|||
try {
|
||||
const { tableName, data } = req.body;
|
||||
|
||||
console.log("✅ 폼 데이터 검증 요청:", { tableName, data });
|
||||
|
||||
if (!tableName || !data) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
|
|
@ -264,8 +222,6 @@ export const validateFormData = async (
|
|||
data
|
||||
);
|
||||
|
||||
console.log("✅ 폼 데이터 검증 성공:", validationResult);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: validationResult,
|
||||
|
|
@ -287,12 +243,8 @@ export const getTableColumns = async (
|
|||
try {
|
||||
const { tableName } = req.params;
|
||||
|
||||
console.log("📊 테이블 컬럼 정보 조회 요청:", { tableName });
|
||||
|
||||
const columns = await dynamicFormService.getTableColumns(tableName);
|
||||
|
||||
console.log("✅ 테이블 컬럼 정보 조회 성공");
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,6 @@ const storage = multer.diskStorage({
|
|||
if (!fs.existsSync(tempDir)) {
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
}
|
||||
console.log(`📁 임시 업로드 디렉토리: ${tempDir}`);
|
||||
cb(null, tempDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
|
|
@ -51,7 +50,6 @@ const storage = multer.diskStorage({
|
|||
const timestamp = Date.now();
|
||||
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, "_");
|
||||
const savedFileName = `${timestamp}_${sanitizedName}`;
|
||||
console.log(`📄 저장 파일명: ${savedFileName}`);
|
||||
cb(null, savedFileName);
|
||||
},
|
||||
});
|
||||
|
|
@ -64,18 +62,12 @@ const upload = multer({
|
|||
fileFilter: (req, file, cb) => {
|
||||
// 프론트엔드에서 전송된 accept 정보 확인
|
||||
const acceptHeader = req.body?.accept;
|
||||
console.log("🔍 파일 타입 검증:", {
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
acceptFromFrontend: acceptHeader,
|
||||
});
|
||||
|
||||
// 프론트엔드에서 */* 또는 * 허용한 경우 모든 파일 허용
|
||||
if (
|
||||
acceptHeader &&
|
||||
(acceptHeader.includes("*/*") || acceptHeader.includes("*"))
|
||||
) {
|
||||
console.log("✅ 와일드카드 허용: 모든 파일 타입 허용");
|
||||
cb(null, true);
|
||||
return;
|
||||
}
|
||||
|
|
@ -97,10 +89,8 @@ const upload = multer({
|
|||
];
|
||||
|
||||
if (defaultAllowedTypes.includes(file.mimetype)) {
|
||||
console.log("✅ 기본 허용 파일 타입:", file.mimetype);
|
||||
cb(null, true);
|
||||
} else {
|
||||
console.log("❌ 허용되지 않는 파일 타입:", file.mimetype);
|
||||
cb(new Error("허용되지 않는 파일 타입입니다."));
|
||||
}
|
||||
},
|
||||
|
|
@ -114,23 +104,6 @@ export const uploadFiles = async (
|
|||
res: Response
|
||||
): Promise<void> => {
|
||||
try {
|
||||
console.log("📤 파일 업로드 요청 수신:", {
|
||||
body: req.body,
|
||||
companyCode: req.body.companyCode,
|
||||
writer: req.body.writer,
|
||||
docType: req.body.docType,
|
||||
user: req.user
|
||||
? {
|
||||
userId: req.user.userId,
|
||||
companyCode: req.user.companyCode,
|
||||
deptCode: req.user.deptCode,
|
||||
}
|
||||
: "no user",
|
||||
files: req.files
|
||||
? (req.files as Express.Multer.File[]).map((f) => f.originalname)
|
||||
: "none",
|
||||
});
|
||||
|
||||
if (!req.files || (req.files as Express.Multer.File[]).length === 0) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
|
|
@ -141,13 +114,6 @@ export const uploadFiles = async (
|
|||
|
||||
const files = req.files as Express.Multer.File[];
|
||||
|
||||
// 파라미터 확인 및 로깅
|
||||
console.log("📤 파일 업로드 요청 수신:", {
|
||||
filesCount: files?.length || 0,
|
||||
bodyKeys: Object.keys(req.body),
|
||||
fullBody: req.body, // 전체 body 내용 확인
|
||||
});
|
||||
|
||||
const {
|
||||
docType = "DOCUMENT",
|
||||
docTypeName = "일반 문서",
|
||||
|
|
@ -177,26 +143,8 @@ export const uploadFiles = async (
|
|||
} else {
|
||||
finalTargetObjid = `${linkedTable}:${recordId}`;
|
||||
}
|
||||
|
||||
console.log("🔗 자동 연결 활성화:", {
|
||||
linkedTable,
|
||||
linkedField,
|
||||
recordId,
|
||||
columnName,
|
||||
isVirtualFileColumn,
|
||||
generatedTargetObjid: finalTargetObjid,
|
||||
});
|
||||
}
|
||||
|
||||
console.log("🔍 사용자 정보 결정:", {
|
||||
bodyCompanyCode: req.body.companyCode,
|
||||
userCompanyCode: (req.user as any)?.companyCode,
|
||||
finalCompanyCode: companyCode,
|
||||
bodyWriter: req.body.writer,
|
||||
userWriter: (req.user as any)?.userId,
|
||||
finalWriter: writer,
|
||||
});
|
||||
|
||||
const savedFiles = [];
|
||||
|
||||
for (const file of files) {
|
||||
|
|
@ -218,23 +166,11 @@ export const uploadFiles = async (
|
|||
const relativePath = `/${actualCompanyCode}/${dateFolder}/${file.filename}`;
|
||||
const fullFilePath = `/uploads${relativePath}`;
|
||||
|
||||
console.log("📂 파일 경로 설정:", {
|
||||
companyCode,
|
||||
filename: file.filename,
|
||||
relativePath,
|
||||
fullFilePath,
|
||||
});
|
||||
|
||||
// 임시 파일을 최종 위치로 이동
|
||||
const tempFilePath = file.path; // Multer가 저장한 임시 파일 경로
|
||||
const finalUploadDir = getCompanyUploadDir(companyCode, dateFolder);
|
||||
const finalFilePath = path.join(finalUploadDir, file.filename);
|
||||
|
||||
console.log("📦 파일 이동:", {
|
||||
from: tempFilePath,
|
||||
to: finalFilePath,
|
||||
});
|
||||
|
||||
// 파일 이동
|
||||
fs.renameSync(tempFilePath, finalFilePath);
|
||||
|
||||
|
|
@ -261,13 +197,6 @@ export const uploadFiles = async (
|
|||
},
|
||||
});
|
||||
|
||||
console.log("💾 파일 정보 DB 저장 완료:", {
|
||||
objid: fileRecord.objid.toString(),
|
||||
saved_file_name: fileRecord.saved_file_name,
|
||||
real_file_name: fileRecord.real_file_name,
|
||||
file_size: fileRecord.file_size?.toString(),
|
||||
});
|
||||
|
||||
savedFiles.push({
|
||||
objid: fileRecord.objid.toString(),
|
||||
savedFileName: fileRecord.saved_file_name,
|
||||
|
|
@ -284,13 +213,6 @@ export const uploadFiles = async (
|
|||
regdate: fileRecord.regdate?.toISOString(),
|
||||
status: fileRecord.status,
|
||||
});
|
||||
|
||||
console.log("✅ 파일 저장 결과:", {
|
||||
objid: fileRecord.objid.toString(),
|
||||
company_code: companyCode,
|
||||
file_path: fileRecord.file_path,
|
||||
writer: fileRecord.writer,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
|
|
@ -319,8 +241,6 @@ export const deleteFile = async (
|
|||
const { objid } = req.params;
|
||||
const { writer = "system" } = req.body;
|
||||
|
||||
console.log("🗑️ 파일 삭제 요청:", { objid, writer });
|
||||
|
||||
// 파일 상태를 DELETED로 변경 (논리적 삭제)
|
||||
const deletedFile = await prisma.attach_file_info.update({
|
||||
where: {
|
||||
|
|
@ -331,11 +251,6 @@ export const deleteFile = async (
|
|||
},
|
||||
});
|
||||
|
||||
console.log("✅ 파일 삭제 완료 (논리적):", {
|
||||
objid: deletedFile.objid.toString(),
|
||||
status: deletedFile.status,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: "파일이 삭제되었습니다.",
|
||||
|
|
@ -360,21 +275,9 @@ export const getLinkedFiles = async (
|
|||
try {
|
||||
const { tableName, recordId } = req.params;
|
||||
|
||||
console.log("📎 연결된 파일 조회 요청:", {
|
||||
tableName,
|
||||
recordId,
|
||||
});
|
||||
|
||||
// target_objid 생성 (테이블명:레코드ID 형식)
|
||||
const baseTargetObjid = `${tableName}:${recordId}`;
|
||||
|
||||
console.log("🔍 파일 조회 쿼리:", {
|
||||
tableName,
|
||||
recordId,
|
||||
baseTargetObjid,
|
||||
queryPattern: `${baseTargetObjid}%`,
|
||||
});
|
||||
|
||||
// 기본 target_objid와 파일 컬럼 패턴 모두 조회 (tableName:recordId% 패턴)
|
||||
const files = await prisma.attach_file_info.findMany({
|
||||
where: {
|
||||
|
|
@ -388,11 +291,6 @@ export const getLinkedFiles = async (
|
|||
},
|
||||
});
|
||||
|
||||
console.log("📁 조회된 파일 목록:", {
|
||||
foundFiles: files.length,
|
||||
targetObjids: files.map((f) => f.target_objid),
|
||||
});
|
||||
|
||||
const fileList = files.map((file: any) => ({
|
||||
objid: file.objid.toString(),
|
||||
savedFileName: file.saved_file_name,
|
||||
|
|
@ -409,11 +307,6 @@ export const getLinkedFiles = async (
|
|||
status: file.status,
|
||||
}));
|
||||
|
||||
console.log("✅ 연결된 파일 조회 완료:", {
|
||||
baseTargetObjid,
|
||||
fileCount: fileList.length,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
files: fileList,
|
||||
|
|
@ -440,12 +333,6 @@ export const getFileList = async (
|
|||
try {
|
||||
const { targetObjid, docType, companyCode } = req.query;
|
||||
|
||||
console.log("📋 파일 목록 조회 요청:", {
|
||||
targetObjid,
|
||||
docType,
|
||||
companyCode,
|
||||
});
|
||||
|
||||
const where: any = {
|
||||
status: "ACTIVE",
|
||||
};
|
||||
|
|
@ -506,8 +393,6 @@ export const previewFile = async (
|
|||
const { objid } = req.params;
|
||||
const { serverFilename } = req.query;
|
||||
|
||||
console.log("👁️ 파일 미리보기 요청:", { objid, serverFilename });
|
||||
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: {
|
||||
objid: parseInt(objid),
|
||||
|
|
@ -539,13 +424,6 @@ export const previewFile = async (
|
|||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("👁️ 파일 미리보기 경로 확인:", {
|
||||
stored_file_path: fileRecord.file_path,
|
||||
company_code: companyCode,
|
||||
company_upload_dir: companyUploadDir,
|
||||
final_file_path: filePath,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
|
|
@ -599,12 +477,6 @@ export const previewFile = async (
|
|||
// 파일 스트림으로 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
console.log("✅ 파일 미리보기 완료:", {
|
||||
objid,
|
||||
fileName: fileRecord.real_file_name,
|
||||
mimeType,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("파일 미리보기 오류:", error);
|
||||
res.status(500).json({
|
||||
|
|
@ -624,8 +496,6 @@ export const downloadFile = async (
|
|||
try {
|
||||
const { objid } = req.params;
|
||||
|
||||
console.log("📥 파일 다운로드 요청:", { objid });
|
||||
|
||||
const fileRecord = await prisma.attach_file_info.findUnique({
|
||||
where: {
|
||||
objid: parseInt(objid),
|
||||
|
|
@ -658,13 +528,6 @@ export const downloadFile = async (
|
|||
);
|
||||
const filePath = path.join(companyUploadDir, fileName);
|
||||
|
||||
console.log("📥 파일 다운로드 경로 확인:", {
|
||||
stored_file_path: fileRecord.file_path,
|
||||
company_code: companyCode,
|
||||
company_upload_dir: companyUploadDir,
|
||||
final_file_path: filePath,
|
||||
});
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error("❌ 파일 없음:", filePath);
|
||||
res.status(404).json({
|
||||
|
|
@ -684,11 +547,6 @@ export const downloadFile = async (
|
|||
// 파일 스트림 전송
|
||||
const fileStream = fs.createReadStream(filePath);
|
||||
fileStream.pipe(res);
|
||||
|
||||
console.log("✅ 파일 다운로드 시작:", {
|
||||
objid: fileRecord.objid.toString(),
|
||||
real_file_name: fileRecord.real_file_name,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("파일 다운로드 오류:", error);
|
||||
res.status(500).json({
|
||||
|
|
|
|||
|
|
@ -344,7 +344,6 @@ export const getTableInfo = async (
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(`=== 테이블 정보 조회 API 호출: ${tableName} ===`);
|
||||
const tableInfo = await screenManagementService.getTableInfo(
|
||||
tableName,
|
||||
companyCode
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ interface ConditionNode {
|
|||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: any;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
|
|
@ -430,15 +430,15 @@ export class EventTriggerService {
|
|||
condition: ConditionNode,
|
||||
data: Record<string, any>
|
||||
): boolean {
|
||||
const { field, operator_type, value } = condition;
|
||||
const { field, operator, value } = condition;
|
||||
|
||||
if (!field || !operator_type) {
|
||||
if (!field || !operator) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const fieldValue = data[field];
|
||||
|
||||
switch (operator_type) {
|
||||
switch (operator) {
|
||||
case "=":
|
||||
return fieldValue == value;
|
||||
case "!=":
|
||||
|
|
@ -628,9 +628,9 @@ export class EventTriggerService {
|
|||
if (
|
||||
conditions.type === "condition" &&
|
||||
conditions.field &&
|
||||
conditions.operator_type
|
||||
conditions.operator
|
||||
) {
|
||||
return `${conditions.field} ${conditions.operator_type} '${conditions.value}'`;
|
||||
return `${conditions.field} ${conditions.operator} '${conditions.value}'`;
|
||||
}
|
||||
|
||||
return "1=1"; // 기본값
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { ArrowLeft } from "lucide-react";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { DataFlowDesigner } from "@/components/dataflow/DataFlowDesigner";
|
||||
import { DataFlowAPI } from "@/lib/api/dataflow";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function DataFlowEditPage() {
|
||||
const params = useParams();
|
||||
|
|
@ -80,6 +79,7 @@ export default function DataFlowEditPage() {
|
|||
{/* 데이터플로우 디자이너 */}
|
||||
<div className="rounded-lg border border-gray-200 bg-white">
|
||||
<DataFlowDesigner
|
||||
key={diagramId}
|
||||
selectedDiagram={diagramName}
|
||||
diagramId={diagramId}
|
||||
onBackToList={handleBackToList}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ export function RegistryProvider({ children }: RegistryProviderProps) {
|
|||
try {
|
||||
initializeRegistries();
|
||||
setIsInitialized(true);
|
||||
console.log("✅ 레지스트리 초기화 완료");
|
||||
} catch (error) {
|
||||
console.error("❌ 레지스트리 초기화 실패:", error);
|
||||
setIsInitialized(true); // 오류가 있어도 앱은 계속 실행
|
||||
|
|
@ -60,5 +59,3 @@ export function useRegistryInitialization() {
|
|||
|
||||
return { isInitialized, error };
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TableSelector } from "./TableSelector";
|
||||
import { TableDefinition } from "@/lib/api/dataflow";
|
||||
import { ExtendedJsonRelationship } from "@/types/dataflowTypes";
|
||||
|
||||
interface DataFlowSidebarProps {
|
||||
companyCode: string;
|
||||
nodes: Array<{ id: string; data: { table: { tableName: string } } }>;
|
||||
edges: Array<{ id: string }>;
|
||||
tempRelationships: ExtendedJsonRelationship[];
|
||||
hasUnsavedChanges: boolean;
|
||||
currentDiagramId: number | null;
|
||||
currentDiagramCategory: string;
|
||||
onTableAdd: (table: TableDefinition) => void;
|
||||
onRemoveOrphanedNodes: () => void;
|
||||
onClearAll: () => void;
|
||||
onOpenSaveModal: () => void;
|
||||
getSelectedTableNames: () => string[];
|
||||
}
|
||||
|
||||
export const DataFlowSidebar: React.FC<DataFlowSidebarProps> = ({
|
||||
companyCode,
|
||||
nodes,
|
||||
edges,
|
||||
tempRelationships,
|
||||
hasUnsavedChanges,
|
||||
currentDiagramId,
|
||||
currentDiagramCategory,
|
||||
onTableAdd,
|
||||
onRemoveOrphanedNodes,
|
||||
onClearAll,
|
||||
onOpenSaveModal,
|
||||
getSelectedTableNames,
|
||||
}) => {
|
||||
return (
|
||||
<div className="w-80 border-r border-gray-200 bg-white shadow-lg">
|
||||
<div className="p-6">
|
||||
<h2 className="mb-6 text-xl font-bold text-gray-800">테이블 간 데이터 관계 설정</h2>
|
||||
|
||||
{/* 테이블 선택기 */}
|
||||
<TableSelector companyCode={companyCode} onTableAdd={onTableAdd} selectedTables={getSelectedTableNames()} />
|
||||
|
||||
{/* 컨트롤 버튼들 */}
|
||||
<div className="space-y-3">
|
||||
<button
|
||||
onClick={onRemoveOrphanedNodes}
|
||||
className="w-full rounded-lg bg-orange-500 p-3 font-medium text-white transition-colors hover:bg-orange-600"
|
||||
disabled={nodes.length === 0}
|
||||
>
|
||||
🧹 고립된 노드 정리
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClearAll}
|
||||
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
|
||||
>
|
||||
🗑️ 전체 삭제
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onOpenSaveModal}
|
||||
className={`w-full rounded-lg bg-green-500 p-3 font-medium text-white transition-colors hover:bg-green-600 ${
|
||||
hasUnsavedChanges ? "animate-pulse" : ""
|
||||
}`}
|
||||
>
|
||||
💾 관계도 저장 {tempRelationships.length > 0 && `(${tempRelationships.length})`}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 통계 정보 */}
|
||||
<div className="mt-6 rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-700">통계</div>
|
||||
<div className="space-y-1 text-sm text-gray-600">
|
||||
<div className="flex justify-between">
|
||||
<span>테이블 노드:</span>
|
||||
<span className="font-medium">{nodes.length}개</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>연결:</span>
|
||||
<span className="font-medium">{edges.length}개</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>메모리 관계:</span>
|
||||
<span className="font-medium text-orange-600">{tempRelationships.length}개</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>관계도 ID:</span>
|
||||
<span className="font-medium">{currentDiagramId || "미설정"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>연결 종류:</span>
|
||||
<span className="font-medium">
|
||||
{currentDiagramCategory === "simple-key" && "단순 키값"}
|
||||
{currentDiagramCategory === "data-save" && "데이터 저장"}
|
||||
{currentDiagramCategory === "external-call" && "외부 호출"}
|
||||
</span>
|
||||
</div>
|
||||
{hasUnsavedChanges && (
|
||||
<div className="mt-2 text-xs font-medium text-orange-600">⚠️ 저장되지 않은 변경사항이 있습니다</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SelectedEdgeInfo } from "@/types/dataflowTypes";
|
||||
|
||||
interface EdgeInfoPanelProps {
|
||||
isOpen: boolean;
|
||||
edgeInfo: SelectedEdgeInfo | null;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
export const EdgeInfoPanel: React.FC<EdgeInfoPanelProps> = ({
|
||||
isOpen,
|
||||
edgeInfo,
|
||||
position,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) => {
|
||||
if (!isOpen || !edgeInfo) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-50 rounded-xl border border-gray-200 bg-white shadow-2xl"
|
||||
style={{
|
||||
left: position.x - 160,
|
||||
top: position.y - 100,
|
||||
minWidth: "320px",
|
||||
maxWidth: "380px",
|
||||
}}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between rounded-t-xl border-b border-gray-200 bg-blue-600 p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20 backdrop-blur-sm">
|
||||
<span className="text-sm text-white">🔗</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-bold text-white">{edgeInfo.relationshipName}</div>
|
||||
<div className="text-xs text-blue-100">데이터 관계 정보</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-white/80 transition-all hover:bg-white/20 hover:text-white"
|
||||
>
|
||||
<span className="text-sm">✕</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 관계 정보 요약 */}
|
||||
<div className="border-b border-gray-100 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-medium tracking-wide text-gray-500 uppercase">연결 유형</div>
|
||||
<div className="mt-1 inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-semibold text-indigo-800">
|
||||
{edgeInfo.connectionType}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 연결 정보 */}
|
||||
<div className="space-y-3 p-4">
|
||||
{/* From 테이블 */}
|
||||
<div className="rounded-lg border-l-4 border-emerald-400 bg-emerald-50 p-3">
|
||||
<div className="mb-2 text-xs font-bold tracking-wide text-emerald-700 uppercase">FROM</div>
|
||||
<div className="mb-2 text-base font-bold text-gray-800">{edgeInfo.fromTable}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{edgeInfo.fromColumns.map((column, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center rounded-md bg-emerald-100 px-2.5 py-0.5 text-xs font-medium text-emerald-800 ring-1 ring-emerald-200"
|
||||
>
|
||||
{column}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 관계 화살표 */}
|
||||
<div className="flex justify-center">
|
||||
<span className="text-l text-gray-600">→</span>
|
||||
</div>
|
||||
|
||||
{/* To 테이블 */}
|
||||
<div className="rounded-lg border-l-4 border-blue-400 bg-blue-50 p-3">
|
||||
<div className="mb-2 text-xs font-bold tracking-wide text-blue-700 uppercase">TO</div>
|
||||
<div className="mb-2 text-base font-bold text-gray-800">{edgeInfo.toTable}</div>
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{edgeInfo.toColumns.map((column, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center rounded-md bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-800 ring-1 ring-blue-200"
|
||||
>
|
||||
{column}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-2 border-t border-gray-200 bg-gray-50 p-3">
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-blue-600 px-3 py-2 text-xs font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md"
|
||||
>
|
||||
<span>수정</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-red-600 px-3 py-2 text-xs font-semibold text-white shadow-sm transition-all hover:bg-red-700 hover:shadow-md"
|
||||
>
|
||||
<span>삭제</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { ExtendedJsonRelationship, TableNodeData } from "@/types/dataflowTypes";
|
||||
import { DataFlowAPI } from "@/lib/api/dataflow";
|
||||
|
||||
interface RelationshipListModalProps {
|
||||
isOpen: boolean;
|
||||
relationships: ExtendedJsonRelationship[];
|
||||
nodes: Array<{ id: string; data: TableNodeData }>;
|
||||
diagramId?: number;
|
||||
companyCode: string;
|
||||
editingRelationshipId: string | null;
|
||||
onClose: () => void;
|
||||
onEdit: (relationship: ExtendedJsonRelationship) => void;
|
||||
onDelete: (relationshipId: string) => void;
|
||||
onSetEditingId: (id: string | null) => void;
|
||||
onSetSelectedColumns: (columns: { [tableName: string]: string[] }) => void;
|
||||
onSetPendingConnection: (connection: any) => void;
|
||||
}
|
||||
|
||||
export const RelationshipListModal: React.FC<RelationshipListModalProps> = ({
|
||||
isOpen,
|
||||
relationships,
|
||||
nodes,
|
||||
diagramId,
|
||||
companyCode,
|
||||
editingRelationshipId,
|
||||
onClose,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSetEditingId,
|
||||
onSetSelectedColumns,
|
||||
onSetPendingConnection,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleEdit = async (relationship: ExtendedJsonRelationship) => {
|
||||
// 관계 선택 시 수정 모드로 전환
|
||||
onSetEditingId(relationship.id);
|
||||
|
||||
// 관련 컬럼 하이라이트
|
||||
const newSelectedColumns: { [tableName: string]: string[] } = {};
|
||||
if (relationship.fromTable && relationship.fromColumns) {
|
||||
newSelectedColumns[relationship.fromTable] = [...relationship.fromColumns];
|
||||
}
|
||||
if (relationship.toTable && relationship.toColumns) {
|
||||
newSelectedColumns[relationship.toTable] = [...relationship.toColumns];
|
||||
}
|
||||
onSetSelectedColumns(newSelectedColumns);
|
||||
|
||||
// 🔥 수정: 데이터베이스에서 관계 설정 정보 로드
|
||||
let relationshipSettings = {};
|
||||
if (diagramId && diagramId > 0) {
|
||||
try {
|
||||
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode);
|
||||
if (jsonDiagram && relationship.connectionType === "data-save") {
|
||||
const control = jsonDiagram.control?.find((c) => c.id === relationship.id);
|
||||
const plan = jsonDiagram.plan?.find((p) => p.id === relationship.id);
|
||||
|
||||
relationshipSettings = {
|
||||
control: control
|
||||
? {
|
||||
triggerType: control.triggerType,
|
||||
conditionTree: control.conditions || [],
|
||||
}
|
||||
: undefined,
|
||||
actions: plan ? plan.actions || [] : [],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("관계 설정 정보 로드 실패:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// 연결 설정 모달 열기
|
||||
const fromTable = nodes.find((node) => node.data?.table?.tableName === relationship.fromTable);
|
||||
const toTable = nodes.find((node) => node.data?.table?.tableName === relationship.toTable);
|
||||
|
||||
if (fromTable && toTable) {
|
||||
onSetPendingConnection({
|
||||
fromNode: {
|
||||
id: fromTable.id,
|
||||
tableName: relationship.fromTable,
|
||||
displayName: fromTable.data?.table?.displayName || relationship.fromTable,
|
||||
},
|
||||
toNode: {
|
||||
id: toTable.id,
|
||||
tableName: relationship.toTable,
|
||||
displayName: toTable.data?.table?.displayName || relationship.toTable,
|
||||
},
|
||||
selectedColumnsData: {
|
||||
[relationship.fromTable]: {
|
||||
displayName: fromTable.data?.table?.displayName || relationship.fromTable,
|
||||
columns: relationship.fromColumns || [],
|
||||
},
|
||||
[relationship.toTable]: {
|
||||
displayName: toTable.data?.table?.displayName || relationship.toTable,
|
||||
columns: relationship.toColumns || [],
|
||||
},
|
||||
},
|
||||
existingRelationship: {
|
||||
relationshipName: relationship.relationshipName,
|
||||
connectionType: relationship.connectionType,
|
||||
settings: relationshipSettings,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleDelete = (relationship: ExtendedJsonRelationship) => {
|
||||
onDelete(relationship.id);
|
||||
|
||||
// 선택된 컬럼 초기화
|
||||
onSetSelectedColumns({});
|
||||
|
||||
// 편집 모드 해제
|
||||
if (editingRelationshipId === relationship.id) {
|
||||
onSetEditingId(null);
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto absolute top-4 right-4 z-40 w-80 rounded-xl border border-blue-200 bg-white shadow-lg">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between rounded-t-xl border-b border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="rounded-full bg-blue-100 p-1">
|
||||
<span className="text-sm text-blue-600">🔗</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-800">테이블 간 관계 목록</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-full text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 관계 목록 */}
|
||||
<div className="p-3">
|
||||
<div className="max-h-96 space-y-2 overflow-y-auto">
|
||||
{relationships.map((relationship) => (
|
||||
<div
|
||||
key={relationship.id}
|
||||
className="rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{relationship.relationshipName || `${relationship.fromTable} → ${relationship.toTable}`}
|
||||
</h4>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* 편집 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(relationship);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-blue-100 hover:text-blue-600"
|
||||
title="관계 편집"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(relationship);
|
||||
}}
|
||||
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-600"
|
||||
title="관계 삭제"
|
||||
>
|
||||
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 text-xs text-gray-600">
|
||||
<p>타입: {relationship.connectionType}</p>
|
||||
<p>From: {relationship.fromTable}</p>
|
||||
<p>To: {relationship.toTable}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -155,11 +155,11 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
|
|||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{relationship.relationshipType}
|
||||
{relationship.connectionType || "simple-key"}
|
||||
</Badge>
|
||||
<span className="font-medium">{relationship.fromTable}</span>
|
||||
<span className="text-gray-500">→</span>
|
||||
<span className="font-medium">{relationship.toTable}</span>
|
||||
<span className="font-medium">
|
||||
{relationship.relationshipName || `${relationship.fromTable} → ${relationship.toTable}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">
|
||||
{relationship.fromColumns.join(", ")} → {relationship.toColumns.join(", ")}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TableNodeData } from "@/types/dataflowTypes";
|
||||
|
||||
interface SelectedTablesPanelProps {
|
||||
selectedNodes: string[];
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
data: TableNodeData;
|
||||
}>;
|
||||
onClose: () => void;
|
||||
onOpenConnectionModal: () => void;
|
||||
onClear: () => void;
|
||||
canCreateConnection: boolean;
|
||||
}
|
||||
|
||||
export const SelectedTablesPanel: React.FC<SelectedTablesPanelProps> = ({
|
||||
selectedNodes,
|
||||
nodes,
|
||||
onClose,
|
||||
onOpenConnectionModal,
|
||||
onClear,
|
||||
canCreateConnection,
|
||||
}) => {
|
||||
return (
|
||||
<div className="pointer-events-auto absolute top-4 left-4 z-40 w-80 rounded-xl border border-blue-200 bg-white shadow-lg">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between rounded-t-xl border-b border-blue-100 bg-gradient-to-r from-blue-50 to-indigo-50 p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-blue-100">
|
||||
<span className="text-sm text-blue-600">📋</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-800">선택된 테이블</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{selectedNodes.length === 1
|
||||
? "FROM 테이블 선택됨"
|
||||
: selectedNodes.length === 2
|
||||
? "FROM → TO 연결 준비"
|
||||
: `${selectedNodes.length}개 테이블`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full text-gray-400 hover:bg-gray-100 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="max-h-80 overflow-y-auto p-3">
|
||||
<div className="space-y-3">
|
||||
{selectedNodes.map((nodeId, index) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const { tableName, displayName } = node.data.table;
|
||||
return (
|
||||
<div key={`selected-${nodeId}-${index}`}>
|
||||
{/* 테이블 정보 */}
|
||||
<div
|
||||
className={`rounded-lg p-2 ${
|
||||
index === 0
|
||||
? "border-l-4 border-emerald-400 bg-emerald-50"
|
||||
: index === 1
|
||||
? "border-l-4 border-blue-400 bg-blue-50"
|
||||
: "bg-gray-50"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div
|
||||
className={`text-xs font-medium ${
|
||||
index === 0 ? "text-emerald-700" : index === 1 ? "text-blue-700" : "text-gray-700"
|
||||
}`}
|
||||
>
|
||||
{displayName}
|
||||
</div>
|
||||
{selectedNodes.length === 2 && (
|
||||
<div
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-bold ${
|
||||
index === 0 ? "bg-emerald-200 text-emerald-800" : "bg-blue-200 text-blue-800"
|
||||
}`}
|
||||
>
|
||||
{index === 0 ? "FROM" : "TO"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">{tableName}</div>
|
||||
</div>
|
||||
|
||||
{/* 연결 화살표 (마지막이 아닌 경우) */}
|
||||
{index < selectedNodes.length - 1 && (
|
||||
<div className="flex justify-center py-1">
|
||||
<div className="text-gray-400">→</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-2 border-t border-blue-100 p-3">
|
||||
<button
|
||||
onClick={onOpenConnectionModal}
|
||||
disabled={!canCreateConnection}
|
||||
className={`flex flex-1 items-center justify-center gap-1 rounded-lg px-3 py-2 text-xs font-medium transition-colors ${
|
||||
canCreateConnection
|
||||
? "bg-blue-500 text-white hover:bg-blue-600"
|
||||
: "cursor-not-allowed bg-gray-300 text-gray-500"
|
||||
}`}
|
||||
>
|
||||
<span>🔗</span>
|
||||
<span>연결 설정</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onClear}
|
||||
className="flex flex-1 items-center justify-center gap-1 rounded-lg bg-gray-200 px-3 py-2 text-xs font-medium text-gray-600 hover:bg-gray-300"
|
||||
>
|
||||
<span>🗑️</span>
|
||||
<span>초기화</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -28,7 +28,7 @@ export const TableNode: React.FC<{ data: TableNodeData }> = ({ data }) => {
|
|||
const { table, onColumnClick, onScrollAreaEnter, onScrollAreaLeave, selectedColumns = [] } = data;
|
||||
|
||||
return (
|
||||
<div className="relative flex min-w-[280px] flex-col overflow-hidden rounded-lg border-2 border-gray-300 bg-white shadow-lg">
|
||||
<div className="relative flex min-w-[280px] cursor-pointer flex-col overflow-hidden rounded-lg border-2 border-gray-300 bg-white shadow-lg transition-all hover:shadow-xl">
|
||||
{/* React Flow Handles - 숨김 처리 */}
|
||||
<Handle type="target" position={Position.Left} id="left" className="!invisible !h-1 !w-1" />
|
||||
<Handle type="source" position={Position.Right} id="right" className="!invisible !h-1 !w-1" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,215 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { getInputTypeForDataType } from "@/utils/connectionUtils";
|
||||
|
||||
interface ConditionRendererProps {
|
||||
conditions: ConditionNode[];
|
||||
fromTableColumns: ColumnInfo[];
|
||||
onUpdateCondition: (index: number, field: keyof ConditionNode, value: string) => void;
|
||||
onRemoveCondition: (index: number) => void;
|
||||
getCurrentGroupLevel: (index: number) => number;
|
||||
}
|
||||
|
||||
export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
|
||||
conditions,
|
||||
fromTableColumns,
|
||||
onUpdateCondition,
|
||||
onRemoveCondition,
|
||||
getCurrentGroupLevel,
|
||||
}) => {
|
||||
const renderConditionValue = (condition: ConditionNode, index: number) => {
|
||||
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
|
||||
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||
const inputType = getInputTypeForDataType(dataType);
|
||||
|
||||
if (dataType.includes("bool")) {
|
||||
return (
|
||||
<Select
|
||||
value={String(condition.value || "")}
|
||||
onValueChange={(value) => onUpdateCondition(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">TRUE</SelectItem>
|
||||
<SelectItem value="false">FALSE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Input
|
||||
type={inputType}
|
||||
placeholder={inputType === "number" ? "숫자" : "값"}
|
||||
value={String(condition.value || "")}
|
||||
onChange={(e) => onUpdateCondition(index, "value", e.target.value)}
|
||||
className="h-8 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{conditions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-gray-500">
|
||||
조건을 추가하면 해당 조건을 만족할 때만 실행됩니다.
|
||||
<br />
|
||||
조건이 없으면 항상 실행됩니다.
|
||||
</div>
|
||||
) : (
|
||||
<React.Fragment key="conditions-list">
|
||||
{conditions.map((condition, index) => {
|
||||
// 그룹 시작 렌더링
|
||||
if (condition.type === "group-start") {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
{/* 그룹 시작 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */}
|
||||
{index > 0 && conditions[index - 1]?.type !== "group-end" && (
|
||||
<Select
|
||||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{/* 그룹 레벨에 따른 들여쓰기 */}
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600">(</span>
|
||||
<span className="text-xs text-blue-600">그룹 시작</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 그룹 끝 렌더링
|
||||
if (condition.type === "group-end") {
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 20}px` }}
|
||||
>
|
||||
<span className="font-mono text-sm text-blue-600">)</span>
|
||||
<span className="text-xs text-blue-600">그룹 끝</span>
|
||||
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-6 w-6 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 그룹 끝 다음에 다른 조건이나 그룹이 있으면 논리 연산자 표시 */}
|
||||
{index < conditions.length - 1 && (
|
||||
<Select
|
||||
value={conditions[index + 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index + 1, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 조건 렌더링
|
||||
return (
|
||||
<div key={condition.id} className="flex items-center gap-2">
|
||||
{/* 일반 조건 앞의 논리 연산자 - 이전 요소가 group-end가 아닌 경우에만 표시 */}
|
||||
{index > 0 &&
|
||||
conditions[index - 1]?.type !== "group-start" &&
|
||||
conditions[index - 1]?.type !== "group-end" && (
|
||||
<Select
|
||||
value={condition.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => onUpdateCondition(index, "logicalOperator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */}
|
||||
<div
|
||||
className="flex flex-1 items-center gap-2 rounded border bg-white p-2"
|
||||
style={{ marginLeft: `${getCurrentGroupLevel(index) * 20}px` }}
|
||||
>
|
||||
{/* 조건 필드 선택 */}
|
||||
<Select
|
||||
value={condition.field || ""}
|
||||
onValueChange={(value) => onUpdateCondition(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 flex-1 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 연산자 선택 */}
|
||||
<Select
|
||||
value={condition.operator || "="}
|
||||
onValueChange={(value) => onUpdateCondition(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=">=</SelectItem>
|
||||
<SelectItem value="!=">!=</SelectItem>
|
||||
<SelectItem value=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<="><=</SelectItem>
|
||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 값 입력 */}
|
||||
{renderConditionValue(condition, index)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button size="sm" variant="ghost" onClick={() => onRemoveCondition(index)} className="h-8 w-8 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Plus, Zap } from "lucide-react";
|
||||
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { ConditionRenderer } from "./ConditionRenderer";
|
||||
|
||||
interface ConditionalSettingsProps {
|
||||
conditions: ConditionNode[];
|
||||
fromTableColumns: ColumnInfo[];
|
||||
onAddCondition: () => void;
|
||||
onAddGroupStart: () => void;
|
||||
onAddGroupEnd: () => void;
|
||||
onUpdateCondition: (index: number, field: keyof ConditionNode, value: string) => void;
|
||||
onRemoveCondition: (index: number) => void;
|
||||
getCurrentGroupLevel: (index: number) => number;
|
||||
}
|
||||
|
||||
export const ConditionalSettings: React.FC<ConditionalSettingsProps> = ({
|
||||
conditions,
|
||||
fromTableColumns,
|
||||
onAddCondition,
|
||||
onAddGroupStart,
|
||||
onAddGroupEnd,
|
||||
onUpdateCondition,
|
||||
onRemoveCondition,
|
||||
getCurrentGroupLevel,
|
||||
}) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-l-4 border-l-purple-500 bg-purple-50/30 p-4">
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-sm font-medium">전체 실행 조건 (언제 이 연결이 동작할지)</span>
|
||||
</div>
|
||||
|
||||
{/* 실행 조건 설정 */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">실행 조건</Label>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={onAddCondition} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
조건 추가
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onAddGroupStart} className="h-7 text-xs">
|
||||
그룹 시작 (
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onAddGroupEnd} className="h-7 text-xs">
|
||||
그룹 끝 )
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건 목록 */}
|
||||
<ConditionRenderer
|
||||
conditions={conditions}
|
||||
fromTableColumns={fromTableColumns}
|
||||
onUpdateCondition={onUpdateCondition}
|
||||
onRemoveCondition={onRemoveCondition}
|
||||
getCurrentGroupLevel={getCurrentGroupLevel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||
import { getInputTypeForDataType } from "@/utils/connectionUtils";
|
||||
|
||||
interface ActionConditionRendererProps {
|
||||
condition: ConditionNode;
|
||||
condIndex: number;
|
||||
actionIndex: number;
|
||||
settings: DataSaveSettings;
|
||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
fromTableColumns: ColumnInfo[];
|
||||
getActionCurrentGroupLevel: (conditions: ConditionNode[], conditionIndex: number) => number;
|
||||
}
|
||||
|
||||
export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = ({
|
||||
condition,
|
||||
condIndex,
|
||||
actionIndex,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
fromTableColumns,
|
||||
getActionCurrentGroupLevel,
|
||||
}) => {
|
||||
const removeConditionGroup = (groupId: string) => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter((c) => c.groupId !== groupId);
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const removeCondition = () => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].conditions = newActions[actionIndex].conditions!.filter((_, i) => i !== condIndex);
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const updateCondition = (field: string, value: any) => {
|
||||
const newActions = [...settings.actions];
|
||||
(newActions[actionIndex].conditions![condIndex] as any)[field] = value;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const updateLogicalOperator = (targetIndex: number, value: "AND" | "OR") => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].conditions![targetIndex].logicalOperator = value;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const renderConditionValue = () => {
|
||||
const selectedColumn = fromTableColumns.find((col) => col.columnName === condition.field);
|
||||
const dataType = selectedColumn?.dataType?.toLowerCase() || "string";
|
||||
const inputType = getInputTypeForDataType(dataType);
|
||||
|
||||
if (dataType.includes("bool")) {
|
||||
return (
|
||||
<Select value={String(condition.value || "")} onValueChange={(value) => updateCondition("value", value)}>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">TRUE</SelectItem>
|
||||
<SelectItem value="false">FALSE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Input
|
||||
type={inputType}
|
||||
placeholder={inputType === "number" ? "숫자" : "값"}
|
||||
value={String(condition.value || "")}
|
||||
onChange={(e) => updateCondition("value", e.target.value)}
|
||||
className="h-6 flex-1 text-xs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 그룹 시작 렌더링
|
||||
if (condition.type === "group-start") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 그룹 시작 앞의 논리 연산자 */}
|
||||
{condIndex > 0 && (
|
||||
<Select
|
||||
value={settings.actions[actionIndex].conditions![condIndex - 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => updateLogicalOperator(condIndex - 1, value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-green-200 bg-green-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
|
||||
>
|
||||
<span className="font-mono text-xs text-green-600">(</span>
|
||||
<span className="text-xs text-green-600">그룹 시작</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeConditionGroup(condition.groupId!)}
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 그룹 끝 렌더링
|
||||
if (condition.type === "group-end") {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 rounded border-2 border-dashed border-green-300 bg-green-50/50 p-1"
|
||||
style={{ marginLeft: `${(condition.groupLevel || 0) * 15}px` }}
|
||||
>
|
||||
<span className="font-mono text-xs text-green-600">)</span>
|
||||
<span className="text-xs text-green-600">그룹 끝</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeConditionGroup(condition.groupId!)}
|
||||
className="h-4 w-4 p-0"
|
||||
>
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 일반 조건 렌더링
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */}
|
||||
{condIndex > 0 && settings.actions[actionIndex].conditions![condIndex - 1]?.type !== "group-start" && (
|
||||
<Select
|
||||
value={settings.actions[actionIndex].conditions![condIndex - 1]?.logicalOperator || "AND"}
|
||||
onValueChange={(value: "AND" | "OR") => updateLogicalOperator(condIndex - 1, value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-green-200 bg-green-50 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND</SelectItem>
|
||||
<SelectItem value="OR">OR</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<div
|
||||
className="flex flex-1 items-center gap-2 rounded border bg-white p-1"
|
||||
style={{
|
||||
marginLeft: `${getActionCurrentGroupLevel(settings.actions[actionIndex].conditions || [], condIndex) * 15}px`,
|
||||
}}
|
||||
>
|
||||
<Select value={condition.field || ""} onValueChange={(value) => updateCondition("field", value)}>
|
||||
<SelectTrigger className="h-6 flex-1 text-xs">
|
||||
<SelectValue placeholder="필드" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={condition.operator || "="}
|
||||
onValueChange={(value: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE") => updateCondition("operator", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-20 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="=">=</SelectItem>
|
||||
<SelectItem value="!=">!=</SelectItem>
|
||||
<SelectItem value=">">></SelectItem>
|
||||
<SelectItem value="<"><</SelectItem>
|
||||
<SelectItem value=">=">>=</SelectItem>
|
||||
<SelectItem value="<="><=</SelectItem>
|
||||
<SelectItem value="LIKE">LIKE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{renderConditionValue()}
|
||||
<Button size="sm" variant="ghost" onClick={removeCondition} className="h-6 w-6 p-0">
|
||||
<Trash2 className="h-2 w-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||
import { generateConditionId } from "@/utils/connectionUtils";
|
||||
import { useActionConditionHelpers } from "@/hooks/useConditionManager";
|
||||
import { ActionConditionRenderer } from "./ActionConditionRenderer";
|
||||
|
||||
interface ActionConditionsSectionProps {
|
||||
action: DataSaveSettings["actions"][0];
|
||||
actionIndex: number;
|
||||
settings: DataSaveSettings;
|
||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
fromTableColumns: ColumnInfo[];
|
||||
}
|
||||
|
||||
export const ActionConditionsSection: React.FC<ActionConditionsSectionProps> = ({
|
||||
action,
|
||||
actionIndex,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
fromTableColumns,
|
||||
}) => {
|
||||
const { addActionGroupStart, addActionGroupEnd, getActionCurrentGroupLevel } = useActionConditionHelpers();
|
||||
|
||||
const addActionCondition = () => {
|
||||
const newActions = [...settings.actions];
|
||||
if (!newActions[actionIndex].conditions) {
|
||||
newActions[actionIndex].conditions = [];
|
||||
}
|
||||
const currentConditions = newActions[actionIndex].conditions || [];
|
||||
const newCondition = {
|
||||
id: generateConditionId(),
|
||||
type: "condition" as const,
|
||||
field: "",
|
||||
operator: "=" as const,
|
||||
value: "",
|
||||
dataType: "string",
|
||||
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
|
||||
...(currentConditions.length > 0 &&
|
||||
currentConditions[currentConditions.length - 1]?.type !== "group-start" && {
|
||||
logicalOperator: "AND" as const,
|
||||
}),
|
||||
};
|
||||
newActions[actionIndex].conditions = [...currentConditions, newCondition];
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const clearAllConditions = () => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].conditions = [];
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
🔍 이 액션의 실행 조건 (선택사항)
|
||||
{action.conditions && action.conditions.length > 0 && (
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs text-blue-700">
|
||||
{action.conditions.length}개
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{action.conditions && action.conditions.length > 0 && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearAllConditions();
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
title="조건 모두 삭제"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="outline" onClick={addActionCondition} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-2 w-2" />
|
||||
조건 추가
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addActionGroupStart(actionIndex, settings, onSettingsChange)}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
그룹 시작 (
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => addActionGroupEnd(actionIndex, settings, onSettingsChange)}
|
||||
className="h-6 text-xs"
|
||||
>
|
||||
그룹 끝 )
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{action.conditions && action.conditions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{action.conditions.map((condition, condIndex) => (
|
||||
<div key={`action-${actionIndex}-condition-${condition.id}`}>
|
||||
<ActionConditionRenderer
|
||||
condition={condition}
|
||||
condIndex={condIndex}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
getActionCurrentGroupLevel={getActionCurrentGroupLevel}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||
|
||||
interface ActionFieldMappingsProps {
|
||||
action: DataSaveSettings["actions"][0];
|
||||
actionIndex: number;
|
||||
settings: DataSaveSettings;
|
||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
availableTables: TableInfo[];
|
||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||
}
|
||||
|
||||
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||
action,
|
||||
actionIndex,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
availableTables,
|
||||
tableColumnsCache,
|
||||
}) => {
|
||||
const addFieldMapping = () => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].fieldMappings.push({
|
||||
sourceTable: "",
|
||||
sourceField: "",
|
||||
targetTable: "",
|
||||
targetField: "",
|
||||
defaultValue: "",
|
||||
transformFunction: "",
|
||||
});
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const updateFieldMapping = (mappingIndex: number, field: string, value: string) => {
|
||||
const newActions = [...settings.actions];
|
||||
(newActions[actionIndex].fieldMappings[mappingIndex] as any)[field] = value;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const removeFieldMapping = (mappingIndex: number) => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].fieldMappings = newActions[actionIndex].fieldMappings.filter((_, i) => i !== mappingIndex);
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs font-medium">필드 매핑</Label>
|
||||
<Button size="sm" variant="outline" onClick={addFieldMapping} className="h-6 text-xs">
|
||||
<Plus className="mr-1 h-2 w-2" />
|
||||
매핑 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{action.fieldMappings.map((mapping, mappingIndex) => (
|
||||
<div
|
||||
key={`${action.id}-mapping-${mappingIndex}-${mapping.sourceField || "empty"}-${mapping.targetField || "empty"}`}
|
||||
className="rounded border bg-white p-2"
|
||||
>
|
||||
{/* 컴팩트한 매핑 표시 */}
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
{/* 소스 */}
|
||||
<div className="flex items-center gap-1 rounded bg-blue-50 px-2 py-1">
|
||||
<Select
|
||||
value={mapping.sourceTable || "__EMPTY__"}
|
||||
onValueChange={(value) => {
|
||||
const actualValue = value === "__EMPTY__" ? "" : value;
|
||||
updateFieldMapping(mappingIndex, "sourceTable", actualValue);
|
||||
updateFieldMapping(mappingIndex, "sourceField", "");
|
||||
if (actualValue) {
|
||||
updateFieldMapping(mappingIndex, "defaultValue", "");
|
||||
}
|
||||
}}
|
||||
disabled={!!(mapping.defaultValue && mapping.defaultValue.trim())}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||
<SelectValue placeholder="테이블" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__EMPTY__">비워두기 (기본값 사용)</SelectItem>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
<div className="truncate" title={table.tableName}>
|
||||
{table.tableName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{mapping.sourceTable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
updateFieldMapping(mappingIndex, "sourceTable", "");
|
||||
updateFieldMapping(mappingIndex, "sourceField", "");
|
||||
}}
|
||||
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-gray-600"
|
||||
title="소스 테이블 지우기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<span className="text-gray-400">.</span>
|
||||
<Select
|
||||
value={mapping.sourceField}
|
||||
onValueChange={(value) => {
|
||||
updateFieldMapping(mappingIndex, "sourceField", value);
|
||||
if (value) {
|
||||
updateFieldMapping(mappingIndex, "defaultValue", "");
|
||||
}
|
||||
}}
|
||||
disabled={!mapping.sourceTable || !!(mapping.defaultValue && mapping.defaultValue.trim())}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mapping.sourceTable &&
|
||||
tableColumnsCache[mapping.sourceTable]?.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
<div className="truncate" title={column.columnName}>
|
||||
{column.columnName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-gray-400">→</div>
|
||||
|
||||
{/* 타겟 */}
|
||||
<div className="flex items-center gap-1 rounded bg-green-50 px-2 py-1">
|
||||
<Select
|
||||
value={mapping.targetTable || ""}
|
||||
onValueChange={(value) => {
|
||||
updateFieldMapping(mappingIndex, "targetTable", value);
|
||||
updateFieldMapping(mappingIndex, "targetField", "");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||
<SelectValue placeholder="테이블" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTables.map((table) => (
|
||||
<SelectItem key={table.tableName} value={table.tableName}>
|
||||
<div className="truncate" title={table.tableName}>
|
||||
{table.tableName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-gray-400">.</span>
|
||||
<Select
|
||||
value={mapping.targetField}
|
||||
onValueChange={(value) => updateFieldMapping(mappingIndex, "targetField", value)}
|
||||
disabled={!mapping.targetTable}
|
||||
>
|
||||
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
|
||||
<SelectValue placeholder="컬럼" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{mapping.targetTable &&
|
||||
tableColumnsCache[mapping.targetTable]?.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
<div className="truncate" title={column.columnName}>
|
||||
{column.columnName}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 기본값 (인라인) */}
|
||||
<Input
|
||||
value={mapping.defaultValue || ""}
|
||||
onChange={(e) => {
|
||||
updateFieldMapping(mappingIndex, "defaultValue", e.target.value);
|
||||
if (e.target.value.trim()) {
|
||||
updateFieldMapping(mappingIndex, "sourceTable", "");
|
||||
updateFieldMapping(mappingIndex, "sourceField", "");
|
||||
}
|
||||
}}
|
||||
disabled={!!mapping.sourceTable}
|
||||
className="h-6 w-20 text-xs"
|
||||
placeholder="기본값"
|
||||
/>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => removeFieldMapping(mappingIndex)}
|
||||
className="h-6 w-6 p-0 text-red-500 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||
|
||||
interface ActionSplitConfigProps {
|
||||
action: DataSaveSettings["actions"][0];
|
||||
actionIndex: number;
|
||||
settings: DataSaveSettings;
|
||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
fromTableColumns: ColumnInfo[];
|
||||
toTableColumns: ColumnInfo[];
|
||||
}
|
||||
|
||||
export const ActionSplitConfig: React.FC<ActionSplitConfigProps> = ({
|
||||
action,
|
||||
actionIndex,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
fromTableColumns,
|
||||
toTableColumns,
|
||||
}) => {
|
||||
const updateSplitConfig = (field: string, value: string) => {
|
||||
const newActions = [...settings.actions];
|
||||
if (!newActions[actionIndex].splitConfig) {
|
||||
newActions[actionIndex].splitConfig = {
|
||||
sourceField: "",
|
||||
delimiter: ",",
|
||||
targetField: "",
|
||||
};
|
||||
}
|
||||
(newActions[actionIndex].splitConfig as any)[field] = value;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const clearSplitConfig = () => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].splitConfig = {
|
||||
sourceField: "",
|
||||
delimiter: ",",
|
||||
targetField: "",
|
||||
};
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3">
|
||||
<details className="group">
|
||||
<summary className="flex cursor-pointer items-center justify-between rounded border p-2 text-xs font-medium text-gray-700 hover:bg-gray-50 hover:text-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
✂️ 데이터 분할 설정 (선택사항)
|
||||
{action.splitConfig && action.splitConfig.sourceField && (
|
||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">설정됨</span>
|
||||
)}
|
||||
</div>
|
||||
{action.splitConfig && action.splitConfig.sourceField && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
clearSplitConfig();
|
||||
}}
|
||||
className="h-5 w-5 p-0 text-red-500 hover:text-red-700"
|
||||
title="분할 설정 초기화"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</summary>
|
||||
<div className="mt-2 space-y-2 border-l-2 border-gray-100 pl-4">
|
||||
<Label className="text-xs font-medium">데이터 분할 설정</Label>
|
||||
<div className="mt-1 grid grid-cols-3 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">분할할 필드</Label>
|
||||
<Select
|
||||
value={action.splitConfig?.sourceField || ""}
|
||||
onValueChange={(value) => updateSplitConfig("sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fromTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">구분자</Label>
|
||||
<Input
|
||||
value={action.splitConfig?.delimiter || ""}
|
||||
onChange={(e) => updateSplitConfig("delimiter", e.target.value)}
|
||||
className="h-6 text-xs"
|
||||
placeholder=","
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-500">저장할 필드</Label>
|
||||
<Select
|
||||
value={action.splitConfig?.targetField || ""}
|
||||
onValueChange={(value) => updateSplitConfig("targetField", value)}
|
||||
>
|
||||
<SelectTrigger className="h-6 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{toTableColumns.map((column) => (
|
||||
<SelectItem key={column.columnName} value={column.columnName}>
|
||||
{column.columnName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Key, Save, Globe } from "lucide-react";
|
||||
import { ConnectionConfig } from "@/types/connectionTypes";
|
||||
|
||||
interface ConnectionTypeSelectorProps {
|
||||
config: ConnectionConfig;
|
||||
onConfigChange: (config: ConnectionConfig) => void;
|
||||
}
|
||||
|
||||
export const ConnectionTypeSelector: React.FC<ConnectionTypeSelectorProps> = ({ config, onConfigChange }) => {
|
||||
return (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">연결 종류</Label>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2">
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
|
||||
config.connectionType === "simple-key"
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onConfigChange({ ...config, connectionType: "simple-key" })}
|
||||
>
|
||||
<Key className="mx-auto h-6 w-6 text-blue-500" />
|
||||
<div className="mt-1 text-xs font-medium">단순 키값 연결</div>
|
||||
<div className="text-xs text-gray-600">중계 테이블 생성</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
|
||||
config.connectionType === "data-save"
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onConfigChange({ ...config, connectionType: "data-save" })}
|
||||
>
|
||||
<Save className="mx-auto h-6 w-6 text-green-500" />
|
||||
<div className="mt-1 text-xs font-medium">데이터 저장</div>
|
||||
<div className="text-xs text-gray-600">필드 매핑 저장</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`cursor-pointer rounded-lg border-2 p-3 text-center transition-colors ${
|
||||
config.connectionType === "external-call"
|
||||
? "border-orange-500 bg-orange-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
}`}
|
||||
onClick={() => onConfigChange({ ...config, connectionType: "external-call" })}
|
||||
>
|
||||
<Globe className="mx-auto h-6 w-6 text-orange-500" />
|
||||
<div className="mt-1 text-xs font-medium">외부 호출</div>
|
||||
<div className="text-xs text-gray-600">API/이메일 호출</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Plus, Save, Trash2 } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings as DataSaveSettingsType } from "@/types/connectionTypes";
|
||||
import { ActionConditionsSection } from "./ActionConditionsSection";
|
||||
import { ActionFieldMappings } from "./ActionFieldMappings";
|
||||
import { ActionSplitConfig } from "./ActionSplitConfig";
|
||||
|
||||
interface DataSaveSettingsProps {
|
||||
settings: DataSaveSettingsType;
|
||||
onSettingsChange: (settings: DataSaveSettingsType) => void;
|
||||
availableTables: TableInfo[];
|
||||
fromTableColumns: ColumnInfo[];
|
||||
toTableColumns: ColumnInfo[];
|
||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||
}
|
||||
|
||||
export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
availableTables,
|
||||
fromTableColumns,
|
||||
toTableColumns,
|
||||
tableColumnsCache,
|
||||
}) => {
|
||||
const addAction = () => {
|
||||
const newAction = {
|
||||
id: `action_${settings.actions.length + 1}`,
|
||||
name: `액션 ${settings.actions.length + 1}`,
|
||||
actionType: "insert" as const,
|
||||
fieldMappings: [],
|
||||
conditions: [],
|
||||
splitConfig: {
|
||||
sourceField: "",
|
||||
delimiter: "",
|
||||
targetField: "",
|
||||
},
|
||||
};
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
actions: [...settings.actions, newAction],
|
||||
});
|
||||
};
|
||||
|
||||
const updateAction = (actionIndex: number, field: string, value: any) => {
|
||||
const newActions = [...settings.actions];
|
||||
(newActions[actionIndex] as any)[field] = value;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
const removeAction = (actionIndex: number) => {
|
||||
const newActions = settings.actions.filter((_, i) => i !== actionIndex);
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-l-4 border-l-green-500 bg-green-50/30 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Save className="h-4 w-4 text-green-500" />
|
||||
<span className="text-sm font-medium">데이터 저장 설정</span>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{/* 액션 목록 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-sm font-medium">저장 액션</Label>
|
||||
<Button size="sm" variant="outline" onClick={addAction} className="h-7 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
액션 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{settings.actions.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-3 text-center text-xs text-gray-500">
|
||||
저장 액션을 추가하여 데이터를 어떻게 저장할지 설정하세요.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{settings.actions.map((action, actionIndex) => (
|
||||
<div key={action.id} className="rounded border bg-white p-3">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<Input
|
||||
value={action.name}
|
||||
onChange={(e) => updateAction(actionIndex, "name", e.target.value)}
|
||||
className="h-7 flex-1 text-xs font-medium"
|
||||
placeholder="액션 이름"
|
||||
/>
|
||||
<Button size="sm" variant="ghost" onClick={() => removeAction(actionIndex)} className="h-7 w-7 p-0">
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{/* 액션 타입 */}
|
||||
<div>
|
||||
<Label className="text-xs">액션 타입</Label>
|
||||
<Select
|
||||
value={action.actionType}
|
||||
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
||||
updateAction(actionIndex, "actionType", value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="insert">INSERT</SelectItem>
|
||||
<SelectItem value="update">UPDATE</SelectItem>
|
||||
<SelectItem value="delete">DELETE</SelectItem>
|
||||
<SelectItem value="upsert">UPSERT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션별 개별 실행 조건 */}
|
||||
<ActionConditionsSection
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
/>
|
||||
|
||||
{/* 데이터 분할 설정 */}
|
||||
<ActionSplitConfig
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
/>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
<ActionFieldMappings
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
availableTables={availableTables}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Globe } from "lucide-react";
|
||||
import { ExternalCallSettings as ExternalCallSettingsType } from "@/types/connectionTypes";
|
||||
|
||||
interface ExternalCallSettingsProps {
|
||||
settings: ExternalCallSettingsType;
|
||||
onSettingsChange: (settings: ExternalCallSettingsType) => void;
|
||||
}
|
||||
|
||||
export const ExternalCallSettings: React.FC<ExternalCallSettingsProps> = ({ settings, onSettingsChange }) => {
|
||||
return (
|
||||
<div className="rounded-lg border border-l-4 border-l-orange-500 bg-orange-50/30 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm font-medium">외부 호출 설정</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="callType" className="text-sm">
|
||||
호출 유형
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.callType}
|
||||
onValueChange={(value: "rest-api" | "email" | "webhook" | "ftp" | "queue") =>
|
||||
onSettingsChange({ ...settings, callType: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="rest-api">REST API 호출</SelectItem>
|
||||
<SelectItem value="email">이메일 전송</SelectItem>
|
||||
<SelectItem value="webhook">웹훅</SelectItem>
|
||||
<SelectItem value="ftp">FTP 업로드</SelectItem>
|
||||
<SelectItem value="queue">메시지 큐</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{settings.callType === "rest-api" && (
|
||||
<>
|
||||
<div>
|
||||
<Label htmlFor="apiUrl" className="text-sm">
|
||||
API URL
|
||||
</Label>
|
||||
<Input
|
||||
id="apiUrl"
|
||||
value={settings.apiUrl}
|
||||
onChange={(e) => onSettingsChange({ ...settings, apiUrl: e.target.value })}
|
||||
placeholder="https://api.example.com/webhook"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label htmlFor="httpMethod" className="text-sm">
|
||||
HTTP Method
|
||||
</Label>
|
||||
<Select
|
||||
value={settings.httpMethod}
|
||||
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
|
||||
onSettingsChange({ ...settings, httpMethod: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="text-sm">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="headers" className="text-sm">
|
||||
Headers
|
||||
</Label>
|
||||
<Textarea
|
||||
id="headers"
|
||||
value={settings.headers}
|
||||
onChange={(e) => onSettingsChange({ ...settings, headers: e.target.value })}
|
||||
placeholder="{}"
|
||||
rows={1}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="bodyTemplate" className="text-sm">
|
||||
Body Template
|
||||
</Label>
|
||||
<Textarea
|
||||
id="bodyTemplate"
|
||||
value={settings.bodyTemplate}
|
||||
onChange={(e) => onSettingsChange({ ...settings, bodyTemplate: e.target.value })}
|
||||
placeholder="{}"
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Key } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { SimpleKeySettings as SimpleKeySettingsType } from "@/types/connectionTypes";
|
||||
|
||||
interface SimpleKeySettingsProps {
|
||||
settings: SimpleKeySettingsType;
|
||||
onSettingsChange: (settings: SimpleKeySettingsType) => void;
|
||||
availableTables: TableInfo[];
|
||||
selectedFromTable: string;
|
||||
selectedToTable: string;
|
||||
fromTableColumns: ColumnInfo[];
|
||||
toTableColumns: ColumnInfo[];
|
||||
selectedFromColumns: string[];
|
||||
selectedToColumns: string[];
|
||||
onFromColumnsChange: (columns: string[]) => void;
|
||||
onToColumnsChange: (columns: string[]) => void;
|
||||
}
|
||||
|
||||
export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
|
||||
settings,
|
||||
onSettingsChange,
|
||||
availableTables,
|
||||
selectedFromTable,
|
||||
selectedToTable,
|
||||
fromTableColumns,
|
||||
toTableColumns,
|
||||
selectedFromColumns,
|
||||
selectedToColumns,
|
||||
onFromColumnsChange,
|
||||
onToColumnsChange,
|
||||
}) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 테이블 및 컬럼 선택 */}
|
||||
<div className="rounded-lg border bg-gray-50 p-4">
|
||||
<div className="mb-4 text-sm font-medium">테이블 및 컬럼 선택</div>
|
||||
|
||||
{/* 현재 선택된 테이블 표시 */}
|
||||
<div className="mb-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">From 테이블</Label>
|
||||
<div className="mt-1">
|
||||
<span className="text-sm font-medium text-gray-800">
|
||||
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-gray-500">({selectedFromTable})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">To 테이블</Label>
|
||||
<div className="mt-1">
|
||||
<span className="text-sm font-medium text-gray-800">
|
||||
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
|
||||
</span>
|
||||
<span className="ml-2 text-xs text-gray-500">({selectedToTable})</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 선택 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">From 컬럼</Label>
|
||||
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
|
||||
{fromTableColumns.map((column) => (
|
||||
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedFromColumns.includes(column.columnName)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onFromColumnsChange([...selectedFromColumns, column.columnName]);
|
||||
} else {
|
||||
onFromColumnsChange(selectedFromColumns.filter((col) => col !== column.columnName));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{column.columnName}</span>
|
||||
<span className="text-xs text-gray-500">({column.dataType})</span>
|
||||
</label>
|
||||
))}
|
||||
{fromTableColumns.length === 0 && (
|
||||
<div className="py-2 text-xs text-gray-500">
|
||||
{selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">To 컬럼</Label>
|
||||
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
|
||||
{toTableColumns.map((column) => (
|
||||
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedToColumns.includes(column.columnName)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
onToColumnsChange([...selectedToColumns, column.columnName]);
|
||||
} else {
|
||||
onToColumnsChange(selectedToColumns.filter((col) => col !== column.columnName));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>{column.columnName}</span>
|
||||
<span className="text-xs text-gray-500">({column.dataType})</span>
|
||||
</label>
|
||||
))}
|
||||
{toTableColumns.length === 0 && (
|
||||
<div className="py-2 text-xs text-gray-500">
|
||||
{selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 선택된 컬럼 미리보기 */}
|
||||
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">선택된 From 컬럼</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{selectedFromColumns.length > 0 ? (
|
||||
selectedFromColumns.map((column) => (
|
||||
<Badge key={column} variant="outline" className="text-xs">
|
||||
{column}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">선택된 컬럼 없음</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs font-medium text-gray-600">선택된 To 컬럼</Label>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{selectedToColumns.length > 0 ? (
|
||||
selectedToColumns.map((column) => (
|
||||
<Badge key={column} variant="secondary" className="text-xs">
|
||||
{column}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">선택된 컬럼 없음</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 단순 키값 연결 설정 */}
|
||||
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-blue-50/30 p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm font-medium">단순 키값 연결 설정</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="notes" className="text-sm">
|
||||
연결 설명
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
value={settings.notes}
|
||||
onChange={(e) => onSettingsChange({ ...settings, notes: e.target.value })}
|
||||
placeholder="데이터 연결에 대한 설명을 입력하세요"
|
||||
rows={2}
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -47,7 +47,6 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
|||
const loadMenus = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("=== MenuContext API 호출 시작 ===");
|
||||
|
||||
// 사용자 로케일이 로드될 때까지 잠시 대기
|
||||
let retryCount = 0;
|
||||
|
|
@ -59,12 +58,10 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
|||
const hasStoredLang = !!localStorage.getItem("userLocale");
|
||||
|
||||
if (hasGlobalLang || hasStoredLang) {
|
||||
console.log("✅ 사용자 로케일 로드 완료, 메뉴 API 호출 진행");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`⏳ 사용자 로케일 로드 대기 중... (${retryCount + 1}/${maxRetries})`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
retryCount++;
|
||||
}
|
||||
|
|
@ -76,22 +73,15 @@ export function MenuProvider({ children }: { children: ReactNode }) {
|
|||
// 관리자 메뉴와 사용자 메뉴를 병렬로 로드
|
||||
const [adminResponse, userResponse] = await Promise.all([menuApi.getAdminMenus(), menuApi.getUserMenus()]);
|
||||
|
||||
console.log("관리자 메뉴 응답:", adminResponse);
|
||||
console.log("사용자 메뉴 응답:", userResponse);
|
||||
|
||||
if (adminResponse.success && adminResponse.data) {
|
||||
const convertedAdminMenus = convertMenuData(adminResponse.data);
|
||||
setAdminMenus(convertedAdminMenus);
|
||||
console.log("관리자 메뉴 변환 완료:", convertedAdminMenus.length, "개");
|
||||
}
|
||||
|
||||
if (userResponse.success && userResponse.data) {
|
||||
const convertedUserMenus = convertMenuData(userResponse.data);
|
||||
setUserMenus(convertedUserMenus);
|
||||
console.log("사용자 메뉴 변환 완료:", convertedUserMenus.length, "개");
|
||||
}
|
||||
|
||||
console.log("메뉴 로딩 완료");
|
||||
} catch (error) {
|
||||
console.error("메뉴 로드 오류:", error);
|
||||
toast.error("메뉴 목록을 불러오는데 실패했습니다.");
|
||||
|
|
|
|||
|
|
@ -107,19 +107,14 @@ export const useAuth = () => {
|
|||
*/
|
||||
const fetchCurrentUser = useCallback(async (): Promise<UserInfo | null> => {
|
||||
try {
|
||||
console.log("=== fetchCurrentUser 시작 ===");
|
||||
const response = await apiCall<UserInfo>("GET", "/auth/me");
|
||||
console.log("fetchCurrentUser 응답:", response);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("사용자 정보 조회 성공:", response.data);
|
||||
|
||||
// 사용자 로케일 정보도 함께 조회하여 전역 저장
|
||||
try {
|
||||
const localeResponse = await apiCall<string>("GET", "/admin/user-locale");
|
||||
if (localeResponse.success && localeResponse.data) {
|
||||
const userLocale = localeResponse.data;
|
||||
console.log("✅ 사용자 로케일 조회 성공:", userLocale);
|
||||
|
||||
// 전역 상태에 저장 (다른 컴포넌트에서 사용)
|
||||
(window as any).__GLOBAL_USER_LANG = userLocale;
|
||||
|
|
@ -128,8 +123,6 @@ export const useAuth = () => {
|
|||
// localStorage에도 저장 (새 창에서 공유)
|
||||
localStorage.setItem("userLocale", userLocale);
|
||||
localStorage.setItem("userLocaleLoaded", "true");
|
||||
|
||||
console.log("🌐 전역 사용자 로케일 저장됨:", userLocale);
|
||||
}
|
||||
} catch (localeError) {
|
||||
console.warn("⚠️ 사용자 로케일 조회 실패, 기본값 사용:", localeError);
|
||||
|
|
@ -144,7 +137,6 @@ export const useAuth = () => {
|
|||
return response.data;
|
||||
}
|
||||
|
||||
console.log("사용자 정보 조회 실패 - 응답이 없음");
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error("사용자 정보 조회 실패:", error);
|
||||
|
|
@ -157,26 +149,16 @@ export const useAuth = () => {
|
|||
*/
|
||||
const checkAuthStatus = useCallback(async (): Promise<AuthStatus> => {
|
||||
try {
|
||||
console.log("=== checkAuthStatus 시작 ===");
|
||||
const response = await apiCall<AuthStatus>("GET", "/auth/status");
|
||||
console.log("checkAuthStatus 응답:", response);
|
||||
console.log("checkAuthStatus 응답.data:", response.data);
|
||||
console.log("checkAuthStatus 응답.data.isLoggedIn:", response.data?.isLoggedIn);
|
||||
console.log("checkAuthStatus 응답.data.isAuthenticated:", (response.data as any)?.isAuthenticated);
|
||||
|
||||
if (response.success && response.data) {
|
||||
console.log("인증 상태 확인 성공:", response.data);
|
||||
|
||||
// 백엔드에서 isAuthenticated를 반환하므로 isLoggedIn으로 매핑
|
||||
const mappedData = {
|
||||
isLoggedIn: (response.data as any).isAuthenticated || response.data.isLoggedIn || false,
|
||||
isAdmin: response.data.isAdmin || false,
|
||||
};
|
||||
console.log("매핑된 인증 상태:", mappedData);
|
||||
return mappedData;
|
||||
}
|
||||
|
||||
console.log("인증 상태 확인 실패 - 응답이 없음");
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
|
|
@ -202,16 +184,12 @@ export const useAuth = () => {
|
|||
if (!token) {
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
console.log("토큰이 없음 - 3초 후 로그인 페이지로 리다이렉트");
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("=== refreshUserData 디버깅 ===");
|
||||
console.log("토큰 존재:", !!token);
|
||||
|
||||
// 토큰이 있으면 임시로 인증된 상태로 설정
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
|
|
@ -222,10 +200,6 @@ export const useAuth = () => {
|
|||
// 병렬로 사용자 정보와 인증 상태 조회
|
||||
const [userInfo, authStatusData] = await Promise.all([fetchCurrentUser(), checkAuthStatus()]);
|
||||
|
||||
console.log("userInfo:", userInfo);
|
||||
console.log("authStatusData:", authStatusData);
|
||||
console.log("authStatusData.isLoggedIn:", authStatusData?.isLoggedIn);
|
||||
|
||||
setUser(userInfo);
|
||||
|
||||
// 관리자 권한 확인 로직 개선
|
||||
|
|
@ -237,46 +211,27 @@ export const useAuth = () => {
|
|||
isLoggedIn: authStatusData.isLoggedIn,
|
||||
isAdmin: authStatusData.isAdmin || isAdminFromUser,
|
||||
};
|
||||
console.log("관리자 권한 확인:", {
|
||||
userId: userInfo.userId,
|
||||
userType: userInfo.userType,
|
||||
isAdminFromAuth: authStatusData.isAdmin,
|
||||
isAdminFromUser: isAdminFromUser,
|
||||
finalIsAdmin: finalAuthStatus.isAdmin,
|
||||
});
|
||||
}
|
||||
|
||||
setAuthStatus(finalAuthStatus);
|
||||
|
||||
// 디버깅용 로그
|
||||
if (userInfo) {
|
||||
console.log("사용자 정보 업데이트:", {
|
||||
userId: userInfo.userId,
|
||||
userName: userInfo.userName,
|
||||
hasPhoto: !!userInfo.photo,
|
||||
photoStart: userInfo.photo ? userInfo.photo.substring(0, 50) + "..." : "null",
|
||||
});
|
||||
}
|
||||
|
||||
// 로그인되지 않은 상태인 경우 토큰 제거 (리다이렉트는 useEffect에서 처리)
|
||||
if (!finalAuthStatus.isLoggedIn) {
|
||||
console.log("로그인되지 않은 상태 - 사용자 정보 제거");
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
} else {
|
||||
console.log("로그인된 상태 - 사용자 정보 유지");
|
||||
}
|
||||
} catch (apiError) {
|
||||
console.error("API 호출 실패:", apiError);
|
||||
|
||||
// API 호출 실패 시에도 토큰이 있으면 임시로 인증된 상태로 처리
|
||||
console.log("API 호출 실패했지만 토큰이 존재하므로 임시로 인증된 상태로 처리");
|
||||
|
||||
// 토큰에서 사용자 정보 추출 시도
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split(".")[1]));
|
||||
console.log("토큰 페이로드:", payload);
|
||||
|
||||
const tempUser = {
|
||||
userId: payload.userId || "unknown",
|
||||
|
|
@ -289,15 +244,12 @@ export const useAuth = () => {
|
|||
isLoggedIn: true,
|
||||
isAdmin: tempUser.isAdmin,
|
||||
});
|
||||
|
||||
console.log("임시 사용자 정보 설정:", tempUser);
|
||||
} catch (tokenError) {
|
||||
console.error("토큰 파싱 실패:", tokenError);
|
||||
// 토큰 파싱도 실패하면 로그인 페이지로 리다이렉트
|
||||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
console.log("토큰 파싱 실패 - 3초 후 로그인 페이지로 리다이렉트");
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
|
|
@ -311,7 +263,6 @@ export const useAuth = () => {
|
|||
TokenManager.removeToken();
|
||||
setUser(null);
|
||||
setAuthStatus({ isLoggedIn: false, isAdmin: false });
|
||||
console.log("사용자 데이터 새로고침 실패 - 3초 후 로그인 페이지로 리다이렉트");
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
|
|
@ -451,22 +402,15 @@ export const useAuth = () => {
|
|||
|
||||
initializedRef.current = true;
|
||||
|
||||
console.log("=== useAuth 초기 인증 상태 확인 ===");
|
||||
console.log("현재 경로:", window.location.pathname);
|
||||
|
||||
// 로그인 페이지에서는 인증 상태 확인하지 않음
|
||||
if (window.location.pathname === "/login") {
|
||||
console.log("로그인 페이지 - 인증 상태 확인 건너뜀");
|
||||
return;
|
||||
}
|
||||
|
||||
// 토큰이 있는 경우에만 인증 상태 확인
|
||||
const token = TokenManager.getToken();
|
||||
console.log("localStorage 토큰:", token ? "존재" : "없음");
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
console.log("유효한 토큰 존재 - 사용자 데이터 새로고침");
|
||||
|
||||
// 토큰이 있으면 임시로 인증된 상태로 설정 (API 호출 전에)
|
||||
setAuthStatus({
|
||||
isLoggedIn: true,
|
||||
|
|
@ -475,13 +419,11 @@ export const useAuth = () => {
|
|||
|
||||
refreshUserData();
|
||||
} else if (!token) {
|
||||
console.log("토큰이 없음 - 3초 후 로그인 페이지로 리다이렉트");
|
||||
// 토큰이 없으면 3초 후 로그인 페이지로 리다이렉트
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
}, 3000);
|
||||
} else {
|
||||
console.log("토큰 만료 - 3초 후 로그인 페이지로 리다이렉트");
|
||||
TokenManager.removeToken();
|
||||
setTimeout(() => {
|
||||
router.push("/login");
|
||||
|
|
|
|||
|
|
@ -0,0 +1,182 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback } from "react";
|
||||
import { ConditionNode } from "@/lib/api/dataflow";
|
||||
import {
|
||||
generateConditionId,
|
||||
generateGroupId,
|
||||
generateActionGroupId,
|
||||
findOpenGroups,
|
||||
getNextGroupLevel,
|
||||
getCurrentGroupLevel,
|
||||
} from "@/utils/connectionUtils";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
export const useConditionManager = () => {
|
||||
const [conditions, setConditions] = useState<ConditionNode[]>([]);
|
||||
|
||||
// 조건 추가
|
||||
const addCondition = useCallback(() => {
|
||||
const newCondition: ConditionNode = {
|
||||
id: generateConditionId(),
|
||||
type: "condition" as const,
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
dataType: "string",
|
||||
// 첫 번째 조건이 아니고, 바로 앞이 group-start가 아니면 logicalOperator 추가
|
||||
...(conditions.length > 0 &&
|
||||
conditions[conditions.length - 1]?.type !== "group-start" && { logicalOperator: "AND" as const }),
|
||||
};
|
||||
|
||||
setConditions([...conditions, newCondition]);
|
||||
}, [conditions]);
|
||||
|
||||
// 그룹 시작 추가
|
||||
const addGroupStart = useCallback(() => {
|
||||
const groupId = generateGroupId();
|
||||
const groupLevel = getNextGroupLevel(conditions);
|
||||
|
||||
const groupStart: ConditionNode = {
|
||||
id: generateConditionId(),
|
||||
type: "group-start" as const,
|
||||
groupId,
|
||||
groupLevel,
|
||||
// 첫 번째 그룹이 아니면 logicalOperator 추가
|
||||
...(conditions.length > 0 && { logicalOperator: "AND" as const }),
|
||||
};
|
||||
|
||||
setConditions([...conditions, groupStart]);
|
||||
}, [conditions]);
|
||||
|
||||
// 그룹 끝 추가
|
||||
const addGroupEnd = useCallback(() => {
|
||||
// 가장 최근에 열린 그룹 찾기
|
||||
const openGroups = findOpenGroups(conditions);
|
||||
if (openGroups.length === 0) {
|
||||
toast.error("닫을 그룹이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lastOpenGroup = openGroups[openGroups.length - 1];
|
||||
const groupEnd: ConditionNode = {
|
||||
id: generateConditionId(),
|
||||
type: "group-end" as const,
|
||||
groupId: lastOpenGroup.groupId,
|
||||
groupLevel: lastOpenGroup.groupLevel,
|
||||
};
|
||||
|
||||
setConditions([...conditions, groupEnd]);
|
||||
}, [conditions]);
|
||||
|
||||
// 조건 업데이트
|
||||
const updateCondition = useCallback(
|
||||
(index: number, field: keyof ConditionNode, value: string) => {
|
||||
const updatedConditions = [...conditions];
|
||||
updatedConditions[index] = { ...updatedConditions[index], [field]: value };
|
||||
setConditions(updatedConditions);
|
||||
},
|
||||
[conditions],
|
||||
);
|
||||
|
||||
// 조건 제거
|
||||
const removeCondition = useCallback(
|
||||
(index: number) => {
|
||||
const conditionToRemove = conditions[index];
|
||||
|
||||
// 그룹 시작/끝을 삭제하는 경우 해당 그룹 전체 삭제
|
||||
if (conditionToRemove.type === "group-start" || conditionToRemove.type === "group-end") {
|
||||
const updatedConditions = conditions.filter((c) => c.groupId !== conditionToRemove.groupId);
|
||||
setConditions(updatedConditions);
|
||||
} else {
|
||||
const updatedConditions = conditions.filter((_, i) => i !== index);
|
||||
setConditions(updatedConditions);
|
||||
}
|
||||
},
|
||||
[conditions],
|
||||
);
|
||||
|
||||
// 그룹 전체 삭제
|
||||
const removeGroup = useCallback(
|
||||
(groupId: string) => {
|
||||
const updatedConditions = conditions.filter((c) => c.groupId !== groupId);
|
||||
setConditions(updatedConditions);
|
||||
},
|
||||
[conditions],
|
||||
);
|
||||
|
||||
return {
|
||||
conditions,
|
||||
setConditions,
|
||||
addCondition,
|
||||
addGroupStart,
|
||||
addGroupEnd,
|
||||
updateCondition,
|
||||
removeCondition,
|
||||
removeGroup,
|
||||
getCurrentGroupLevel: (conditionIndex: number) => getCurrentGroupLevel(conditions, conditionIndex),
|
||||
};
|
||||
};
|
||||
|
||||
// 액션별 조건 관리를 위한 헬퍼 함수들
|
||||
export const useActionConditionHelpers = () => {
|
||||
// 액션별 그룹 시작 추가
|
||||
const addActionGroupStart = useCallback(
|
||||
(actionIndex: number, dataSaveSettings: any, setDataSaveSettings: (settings: any) => void) => {
|
||||
const groupId = generateActionGroupId();
|
||||
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
|
||||
const groupLevel = getNextGroupLevel(currentConditions);
|
||||
|
||||
const groupStart: ConditionNode = {
|
||||
id: generateConditionId(),
|
||||
type: "group-start" as const,
|
||||
groupId,
|
||||
groupLevel,
|
||||
// 첫 번째 그룹이 아니면 logicalOperator 추가
|
||||
...(currentConditions.length > 0 && { logicalOperator: "AND" as const }),
|
||||
};
|
||||
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = [...currentConditions, groupStart];
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 액션별 그룹 끝 추가
|
||||
const addActionGroupEnd = useCallback(
|
||||
(actionIndex: number, dataSaveSettings: any, setDataSaveSettings: (settings: any) => void) => {
|
||||
const currentConditions = dataSaveSettings.actions[actionIndex].conditions || [];
|
||||
const openGroups = findOpenGroups(currentConditions);
|
||||
|
||||
if (openGroups.length === 0) {
|
||||
toast.error("닫을 그룹이 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
const lastOpenGroup = openGroups[openGroups.length - 1];
|
||||
const groupEnd: ConditionNode = {
|
||||
id: generateConditionId(),
|
||||
type: "group-end" as const,
|
||||
groupId: lastOpenGroup.groupId,
|
||||
groupLevel: lastOpenGroup.groupLevel,
|
||||
};
|
||||
|
||||
const newActions = [...dataSaveSettings.actions];
|
||||
newActions[actionIndex].conditions = [...currentConditions, groupEnd];
|
||||
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// 액션별 현재 조건의 그룹 레벨 계산
|
||||
const getActionCurrentGroupLevel = useCallback((conditions: ConditionNode[], conditionIndex: number): number => {
|
||||
return getCurrentGroupLevel(conditions, conditionIndex);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
addActionGroupStart,
|
||||
addActionGroupEnd,
|
||||
getActionCurrentGroupLevel,
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { Node, Edge, useNodesState, useEdgesState } from "@xyflow/react";
|
||||
import { TableNodeData, ExtendedJsonRelationship, ConnectionInfo, SelectedEdgeInfo } from "@/types/dataflowTypes";
|
||||
|
||||
export const useDataFlowDesigner = () => {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
// 상태 관리
|
||||
const [selectedColumns, setSelectedColumns] = useState<{ [tableName: string]: string[] }>({});
|
||||
const [selectedNodes, setSelectedNodes] = useState<string[]>([]);
|
||||
const [pendingConnection, setPendingConnection] = useState<ConnectionInfo | null>(null);
|
||||
const [relationships, setRelationships] = useState<any[]>([]); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
const [currentDiagramId, setCurrentDiagramId] = useState<number | null>(null);
|
||||
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<SelectedEdgeInfo | null>(null);
|
||||
|
||||
// 메모리 기반 상태들
|
||||
const [tempRelationships, setTempRelationships] = useState<ExtendedJsonRelationship[]>([]);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showSaveModal, setShowSaveModal] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [currentDiagramName, setCurrentDiagramName] = useState<string>("");
|
||||
const [currentDiagramCategory, setCurrentDiagramCategory] = useState<string>("simple-key");
|
||||
const [selectedEdgeForEdit, setSelectedEdgeForEdit] = useState<Edge | null>(null);
|
||||
const [showEdgeActions, setShowEdgeActions] = useState(false);
|
||||
const [edgeActionPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
|
||||
const [editingRelationshipId, setEditingRelationshipId] = useState<string | null>(null);
|
||||
const [showRelationshipListModal, setShowRelationshipListModal] = useState(false);
|
||||
const [selectedTablePairRelationships, setSelectedTablePairRelationships] = useState<ExtendedJsonRelationship[]>([]);
|
||||
const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
|
||||
return {
|
||||
// Node & Edge states
|
||||
nodes,
|
||||
setNodes,
|
||||
onNodesChange,
|
||||
edges,
|
||||
setEdges,
|
||||
onEdgesChange,
|
||||
|
||||
// Selection states
|
||||
selectedColumns,
|
||||
setSelectedColumns,
|
||||
selectedNodes,
|
||||
setSelectedNodes,
|
||||
|
||||
// Connection states
|
||||
pendingConnection,
|
||||
setPendingConnection,
|
||||
relationships,
|
||||
setRelationships,
|
||||
|
||||
// Diagram states
|
||||
currentDiagramId,
|
||||
setCurrentDiagramId,
|
||||
currentDiagramName,
|
||||
setCurrentDiagramName,
|
||||
currentDiagramCategory,
|
||||
setCurrentDiagramCategory,
|
||||
|
||||
// Memory-based states
|
||||
tempRelationships,
|
||||
setTempRelationships,
|
||||
hasUnsavedChanges,
|
||||
setHasUnsavedChanges,
|
||||
|
||||
// Modal states
|
||||
showSaveModal,
|
||||
setShowSaveModal,
|
||||
isSaving,
|
||||
setIsSaving,
|
||||
showRelationshipListModal,
|
||||
setShowRelationshipListModal,
|
||||
selectedTablePairRelationships,
|
||||
setSelectedTablePairRelationships,
|
||||
|
||||
// Edge states
|
||||
selectedEdgeInfo,
|
||||
setSelectedEdgeInfo,
|
||||
selectedEdgeForEdit,
|
||||
setSelectedEdgeForEdit,
|
||||
showEdgeActions,
|
||||
setShowEdgeActions,
|
||||
edgeActionPosition,
|
||||
editingRelationshipId,
|
||||
setEditingRelationshipId,
|
||||
|
||||
// Refs
|
||||
toastShownRef,
|
||||
};
|
||||
};
|
||||
|
|
@ -132,7 +132,6 @@ export const useLogin = () => {
|
|||
localStorage.setItem("authToken", result.data.token);
|
||||
|
||||
// 로그인 성공
|
||||
console.log("로그인 성공:", result.message || "로그인에 성공했습니다.");
|
||||
router.push(AUTH_CONFIG.ROUTES.MAIN);
|
||||
} else {
|
||||
// 로그인 실패
|
||||
|
|
|
|||
|
|
@ -2,38 +2,28 @@ import axios, { AxiosResponse, AxiosError } from "axios";
|
|||
|
||||
// API URL 동적 설정 - 환경별 명확한 분리
|
||||
const getApiBaseUrl = (): string => {
|
||||
console.log("🔍 API URL 결정 시작!");
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
const fullUrl = window.location.href;
|
||||
|
||||
console.log("🌐 현재 접속 정보:", {
|
||||
hostname: currentHost,
|
||||
fullUrl: fullUrl,
|
||||
port: currentPort,
|
||||
});
|
||||
|
||||
// 로컬 개발환경: localhost:9771 또는 localhost:3000 → localhost:8080
|
||||
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && (currentPort === "9771" || currentPort === "3000")) {
|
||||
console.log("🏠 로컬 개발 환경 감지 → localhost:8080/api");
|
||||
if (
|
||||
(currentHost === "localhost" || currentHost === "127.0.0.1") &&
|
||||
(currentPort === "9771" || currentPort === "3000")
|
||||
) {
|
||||
return "http://localhost:8080/api";
|
||||
}
|
||||
|
||||
// 서버 환경에서 localhost:5555 → 39.117.244.52:8080
|
||||
if ((currentHost === "localhost" || currentHost === "127.0.0.1") && currentPort === "5555") {
|
||||
console.log("🌍 서버 환경 (localhost:5555) 감지 → 39.117.244.52:8080/api");
|
||||
return "http://39.117.244.52:8080/api";
|
||||
}
|
||||
|
||||
// 기타 서버 환경 (내부/외부 IP): → 39.117.244.52:8080
|
||||
console.log("🌍 서버 환경 감지 → 39.117.244.52:8080/api");
|
||||
return "http://39.117.244.52:8080/api";
|
||||
}
|
||||
|
||||
// 서버 사이드 렌더링 기본값
|
||||
console.log("🖥️ SSR 기본값 → 39.117.244.52:8080/api");
|
||||
return "http://39.117.244.52:8080/api";
|
||||
};
|
||||
|
||||
|
|
@ -73,18 +63,9 @@ apiClient.interceptors.request.use(
|
|||
(config) => {
|
||||
// JWT 토큰 추가
|
||||
const token = TokenManager.getToken();
|
||||
console.log("🔍 API 요청 토큰 확인:", {
|
||||
hasToken: !!token,
|
||||
tokenLength: token ? token.length : 0,
|
||||
tokenStart: token ? token.substring(0, 30) + "..." : "없음",
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
});
|
||||
|
||||
if (token && !TokenManager.isTokenExpired(token)) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
console.log("✅ JWT 토큰 추가됨:", token.substring(0, 50) + "...");
|
||||
console.log("🔑 Authorization 헤더:", `Bearer ${token.substring(0, 30)}...`);
|
||||
} else if (token && TokenManager.isTokenExpired(token)) {
|
||||
console.warn("❌ 토큰이 만료되었습니다.");
|
||||
// 토큰 제거
|
||||
|
|
@ -97,7 +78,6 @@ apiClient.interceptors.request.use(
|
|||
|
||||
// FormData 요청 시 Content-Type 자동 처리
|
||||
if (config.data instanceof FormData) {
|
||||
console.log("📎 FormData 감지 - Content-Type 헤더 제거");
|
||||
delete config.headers["Content-Type"];
|
||||
}
|
||||
|
||||
|
|
@ -120,13 +100,6 @@ apiClient.interceptors.request.use(
|
|||
}
|
||||
}
|
||||
|
||||
console.log("🌐 API 요청 시 언어 정보:", {
|
||||
currentLang,
|
||||
globalVar: (window as unknown as { __GLOBAL_USER_LANG?: string }).__GLOBAL_USER_LANG,
|
||||
localStorage: typeof window !== "undefined" ? localStorage.getItem("userLocale") : null,
|
||||
url: config.url,
|
||||
});
|
||||
|
||||
if (config.params) {
|
||||
config.params.userLang = currentLang;
|
||||
} else {
|
||||
|
|
@ -134,8 +107,6 @@ apiClient.interceptors.request.use(
|
|||
}
|
||||
}
|
||||
|
||||
console.log("📡 API 요청:", config.method?.toUpperCase(), config.url, config.params, config.data);
|
||||
console.log("📋 요청 헤더:", config.headers);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
|
|
@ -147,7 +118,6 @@ apiClient.interceptors.request.use(
|
|||
// 응답 인터셉터
|
||||
apiClient.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
console.log("✅ API 응답:", response.status, response.config.url, response.data);
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
|
|
@ -194,7 +164,6 @@ apiClient.interceptors.response.use(
|
|||
|
||||
// 401 에러 시 토큰 제거 및 로그인 페이지로 리다이렉트
|
||||
if (status === 401 && typeof window !== "undefined") {
|
||||
console.log("🔄 401 에러 감지 - 토큰 제거 및 로그인 페이지로 리다이렉트");
|
||||
localStorage.removeItem("authToken");
|
||||
|
||||
// 로그인 페이지가 아닌 경우에만 리다이렉트
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ export interface ConditionNode {
|
|||
id: string; // 고유 ID
|
||||
type: "condition" | "group-start" | "group-end";
|
||||
field?: string;
|
||||
operator_type?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
operator?: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value?: string | number | boolean;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR"; // 다음 조건과의 논리 연산자
|
||||
|
|
@ -195,7 +195,56 @@ export interface JsonDataFlowDiagram {
|
|||
tables: string[];
|
||||
};
|
||||
node_positions?: NodePositions;
|
||||
category?: string; // 연결 종류 ("simple-key", "data-save", "external-call")
|
||||
category?: Array<{
|
||||
id: string;
|
||||
category: string;
|
||||
}>;
|
||||
control?: Array<{
|
||||
id: string;
|
||||
triggerType: "insert" | "update" | "delete";
|
||||
conditions: Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
field?: string;
|
||||
operator?: string;
|
||||
value?: unknown;
|
||||
logicalOperator?: string;
|
||||
groupId?: string;
|
||||
groupLevel?: number;
|
||||
}>;
|
||||
}>;
|
||||
plan?: Array<{
|
||||
id: string;
|
||||
sourceTable: string;
|
||||
actions: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
conditions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
field?: string;
|
||||
operator?: string;
|
||||
value?: unknown;
|
||||
logicalOperator?: string;
|
||||
groupId?: string;
|
||||
groupLevel?: number;
|
||||
}>;
|
||||
fieldMappings: Array<{
|
||||
sourceTable?: string;
|
||||
sourceField: string;
|
||||
targetTable?: string;
|
||||
targetField: string;
|
||||
defaultValue?: string;
|
||||
transformFunction?: string;
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string;
|
||||
delimiter: string;
|
||||
targetField: string;
|
||||
};
|
||||
}>;
|
||||
}>;
|
||||
company_code: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
|
|
@ -211,7 +260,7 @@ export interface JsonRelationship {
|
|||
fromColumns: string[];
|
||||
toColumns: string[];
|
||||
connectionType: "simple-key" | "data-save" | "external-call";
|
||||
settings?: Record<string, unknown>;
|
||||
// settings 제거 - relationships는 순수 연결 정보만 저장
|
||||
}
|
||||
|
||||
export interface CreateDiagramRequest {
|
||||
|
|
@ -230,11 +279,16 @@ export interface CreateDiagramRequest {
|
|||
control?: Array<{
|
||||
id: string; // relationships의 id와 동일
|
||||
triggerType: "insert" | "update" | "delete";
|
||||
conditions?: Array<{
|
||||
field: string;
|
||||
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
|
||||
value: unknown;
|
||||
conditions: Array<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
field?: string;
|
||||
operator?: string;
|
||||
value?: unknown;
|
||||
dataType?: string;
|
||||
logicalOperator?: "AND" | "OR";
|
||||
groupId?: string;
|
||||
groupLevel?: number;
|
||||
}>;
|
||||
}>;
|
||||
// 🔥 저장 액션 - relationships의 id와 동일한 id 사용
|
||||
|
|
@ -251,15 +305,22 @@ export interface CreateDiagramRequest {
|
|||
targetTable?: string;
|
||||
targetField: string;
|
||||
defaultValue?: string;
|
||||
transformFunction?: string;
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string;
|
||||
delimiter: string;
|
||||
targetField: string;
|
||||
};
|
||||
conditions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
field: string;
|
||||
operator_type: string;
|
||||
value: unknown;
|
||||
field?: string;
|
||||
operator?: string;
|
||||
value?: unknown;
|
||||
dataType?: string;
|
||||
logicalOperator?: string;
|
||||
groupId?: string;
|
||||
groupLevel?: number;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
|
|
@ -629,9 +690,8 @@ export class DataFlowAPI {
|
|||
to_table_name: rel.toTable,
|
||||
from_column_name: rel.fromColumns.join(","),
|
||||
to_column_name: rel.toColumns.join(","),
|
||||
connection_type: (jsonDiagram.category as "simple-key" | "data-save" | "external-call") || "simple-key", // 관계도의 category 사용
|
||||
connection_type: rel.connectionType || "simple-key", // 각 관계의 connectionType 사용
|
||||
company_code: companyCode, // 실제 사용자 회사 코드 사용
|
||||
settings: rel.settings || {},
|
||||
created_at: jsonDiagram.created_at,
|
||||
updated_at: jsonDiagram.updated_at,
|
||||
created_by: jsonDiagram.created_by,
|
||||
|
|
|
|||
|
|
@ -79,12 +79,7 @@ export const menuApi = {
|
|||
// 관리자 메뉴 목록 조회
|
||||
getAdminMenus: async (): Promise<ApiResponse<MenuItem[]>> => {
|
||||
const response = await apiClient.get("/admin/menus");
|
||||
console.log("=== API 응답 데이터 ===");
|
||||
console.log("전체 응답:", response);
|
||||
console.log("응답 데이터:", response.data);
|
||||
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
||||
console.log("첫 번째 메뉴 원본 데이터:", response.data.data[0]);
|
||||
console.log("첫 번째 메뉴 키들:", Object.keys(response.data.data[0]));
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
|
|
@ -145,12 +140,9 @@ export const menuApi = {
|
|||
menuCode?: string;
|
||||
keyType?: string;
|
||||
}): Promise<ApiResponse<LangKey[]>> => {
|
||||
console.log("🔍 다국어 키 목록 조회 API 호출:", "/multilang/keys", params);
|
||||
|
||||
try {
|
||||
// Node.js 백엔드의 실제 라우팅과 일치하도록 수정
|
||||
const response = await apiClient.get("/multilang/keys", { params });
|
||||
console.log("✅ 다국어 키 목록 조회 성공:", response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error("❌ 다국어 키 목록 조회 실패:", error);
|
||||
|
|
|
|||
|
|
@ -317,7 +317,6 @@ export class ComponentRegistry {
|
|||
static clear(): void {
|
||||
this.components.clear();
|
||||
this.eventListeners.length = 0;
|
||||
console.log("🧹 컴포넌트 레지스트리 초기화 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ class LegacyComponentRegistry {
|
|||
// 컴포넌트 렌더러 등록
|
||||
register(componentType: string, renderer: ComponentRenderer) {
|
||||
this.renderers.set(componentType, renderer);
|
||||
console.log(`🔧 레거시 컴포넌트 렌더러 등록: ${componentType}`);
|
||||
}
|
||||
|
||||
// 컴포넌트 렌더러 조회
|
||||
|
|
@ -51,11 +50,6 @@ class LegacyComponentRegistry {
|
|||
// 컴포넌트 타입이 등록되어 있는지 확인
|
||||
has(componentType: string): boolean {
|
||||
const result = this.renderers.has(componentType);
|
||||
console.log(`🔍 LegacyComponentRegistry.has("${componentType}"):`, {
|
||||
result,
|
||||
availableKeys: Array.from(this.renderers.keys()),
|
||||
mapSize: this.renderers.size,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
@ -94,17 +88,6 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
// 컴포넌트 타입 추출 - 새 시스템에서는 componentType 속성 사용, 레거시는 type 사용
|
||||
const componentType = (component as any).componentType || component.type;
|
||||
|
||||
console.log("🔍 컴포넌트 타입 추출:", {
|
||||
componentId: component.id,
|
||||
componentConfigType: component.componentConfig?.type,
|
||||
componentType: component.type,
|
||||
componentTypeProp: (component as any).componentType,
|
||||
finalComponentType: componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
propsScreenId: props.screenId,
|
||||
propsTableName: props.tableName,
|
||||
});
|
||||
|
||||
// 레이아웃 컴포넌트 처리
|
||||
if (componentType === "layout") {
|
||||
return (
|
||||
|
|
@ -123,52 +106,16 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
|
|||
);
|
||||
}
|
||||
|
||||
console.log("🎯 DynamicComponentRenderer:", {
|
||||
componentId: component.id,
|
||||
componentType,
|
||||
componentConfig: component.componentConfig,
|
||||
newSystemRegistered: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
legacySystemRegistered: legacyComponentRegistry.getRegisteredTypes(),
|
||||
hasLegacyRenderer: legacyComponentRegistry.has(componentType),
|
||||
actualLegacyRenderer: legacyComponentRegistry.get(componentType),
|
||||
legacyMapSize: legacyComponentRegistry.getRegisteredTypes().length,
|
||||
});
|
||||
|
||||
// 1. 새 컴포넌트 시스템에서 먼저 조회
|
||||
const newComponent = ComponentRegistry.getComponent(componentType);
|
||||
console.log("🔍 새 컴포넌트 시스템 조회:", {
|
||||
componentType,
|
||||
found: !!newComponent,
|
||||
component: newComponent,
|
||||
registeredTypes: ComponentRegistry.getAllComponents().map((c) => c.id),
|
||||
});
|
||||
|
||||
if (newComponent) {
|
||||
console.log("✨ 새 컴포넌트 시스템에서 발견:", componentType);
|
||||
|
||||
// 새 컴포넌트 시스템으로 렌더링
|
||||
try {
|
||||
const NewComponentRenderer = newComponent.component;
|
||||
if (NewComponentRenderer) {
|
||||
console.log("🔧 컴포넌트 렌더링 props:", {
|
||||
componentType,
|
||||
componentId: component.id,
|
||||
screenId: props.screenId,
|
||||
tableName: props.tableName,
|
||||
onRefresh: !!props.onRefresh,
|
||||
onClose: !!props.onClose,
|
||||
});
|
||||
// React 전용 props 필터링
|
||||
const {
|
||||
isInteractive,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
selectedScreen,
|
||||
onZoneComponentDrop,
|
||||
onZoneClick,
|
||||
componentConfig,
|
||||
...safeProps
|
||||
} = props;
|
||||
const { isInteractive, formData, onFormDataChange, ...safeProps } = props;
|
||||
|
||||
return (
|
||||
<NewComponentRenderer
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ export class WebTypeRegistry {
|
|||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`✅ 웹타입 등록: ${definition.id} (${definition.name})`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -42,7 +41,6 @@ export class WebTypeRegistry {
|
|||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`❌ 웹타입 등록 해제: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -129,7 +127,6 @@ export class WebTypeRegistry {
|
|||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`✅ 버튼 액션 등록: ${definition.id} (${definition.name})`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -144,7 +141,6 @@ export class WebTypeRegistry {
|
|||
data: definition,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
console.log(`❌ 버튼 액션 등록 해제: ${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -246,7 +242,6 @@ export class WebTypeRegistry {
|
|||
this.webTypes.clear();
|
||||
this.buttonActions.clear();
|
||||
this.eventListeners.length = 0;
|
||||
console.log("🧹 레지스트리 초기화됨");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -279,5 +274,3 @@ export class WebTypeRegistry {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -386,8 +386,6 @@ export function initializeWebTypeRegistry() {
|
|||
},
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
console.log("웹타입 레지스트리 초기화 완료:", WebTypeRegistry.getAllWebTypes().length, "개 웹타입 등록됨");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -44,8 +44,6 @@ export class SessionManager {
|
|||
this.checkTimer = setInterval(() => {
|
||||
this.checkSession();
|
||||
}, this.config.checkInterval);
|
||||
|
||||
console.log("세션 모니터링 시작됨");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -63,7 +61,6 @@ export class SessionManager {
|
|||
}
|
||||
|
||||
this.removeActivityListeners();
|
||||
console.log("세션 모니터링 중지됨");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -119,7 +116,6 @@ export class SessionManager {
|
|||
* 세션 만료 처리
|
||||
*/
|
||||
private handleSessionExpiry() {
|
||||
console.log("세션이 만료되었습니다");
|
||||
this.stop();
|
||||
this.callbacks.onExpiry?.();
|
||||
}
|
||||
|
|
@ -244,7 +240,6 @@ export const tokenSync = {
|
|||
// 토큰 상태 확인
|
||||
checkToken: () => {
|
||||
const token = localStorage.getItem("authToken");
|
||||
console.log("🔍 토큰 상태 확인:", token ? "존재" : "없음");
|
||||
return !!token;
|
||||
},
|
||||
|
||||
|
|
@ -254,7 +249,6 @@ export const tokenSync = {
|
|||
if (token) {
|
||||
// sessionStorage에도 복사
|
||||
sessionStorage.setItem("authToken", token);
|
||||
console.log("🔄 토큰 강제 동기화 완료");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -265,7 +259,6 @@ export const tokenSync = {
|
|||
const sessionToken = sessionStorage.getItem("authToken");
|
||||
if (sessionToken) {
|
||||
localStorage.setItem("authToken", sessionToken);
|
||||
console.log("🔄 sessionStorage에서 토큰 복원 완료");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
|
@ -286,14 +279,11 @@ export const tokenSync = {
|
|||
|
||||
// 만료 시간 확인
|
||||
if (payload.exp && payload.exp < now) {
|
||||
console.log("❌ 토큰 만료됨");
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log("✅ 토큰 유효성 검증 통과");
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log("❌ 토큰 유효성 검증 실패:", error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
import { ConditionNode } from "@/lib/api/dataflow";
|
||||
|
||||
// 연결 정보 타입
|
||||
export interface ConnectionInfo {
|
||||
fromNode: {
|
||||
id: string;
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
};
|
||||
toNode: {
|
||||
id: string;
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
};
|
||||
fromColumn?: string;
|
||||
toColumn?: string;
|
||||
selectedColumnsData?: {
|
||||
[tableName: string]: {
|
||||
displayName: string;
|
||||
columns: string[];
|
||||
};
|
||||
};
|
||||
existingRelationship?: {
|
||||
relationshipName: string;
|
||||
connectionType: string;
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// 연결 설정 타입
|
||||
export interface ConnectionConfig {
|
||||
relationshipName: string;
|
||||
connectionType: "simple-key" | "data-save" | "external-call";
|
||||
fromColumnName: string;
|
||||
toColumnName: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// 단순 키값 연결 설정
|
||||
export interface SimpleKeySettings {
|
||||
notes: string;
|
||||
}
|
||||
|
||||
// 데이터 저장 설정
|
||||
export interface DataSaveSettings {
|
||||
actions: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
conditions?: ConditionNode[];
|
||||
fieldMappings: Array<{
|
||||
sourceTable?: string;
|
||||
sourceField: string;
|
||||
targetTable?: string;
|
||||
targetField: string;
|
||||
defaultValue?: string;
|
||||
transformFunction?: string;
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string; // 분할할 소스 필드
|
||||
delimiter: string; // 구분자 (예: ",")
|
||||
targetField: string; // 분할된 값이 들어갈 필드
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
// 외부 호출 설정
|
||||
export interface ExternalCallSettings {
|
||||
callType: "rest-api" | "email" | "webhook" | "ftp" | "queue";
|
||||
apiUrl?: string;
|
||||
httpMethod?: "GET" | "POST" | "PUT" | "DELETE";
|
||||
headers?: string;
|
||||
bodyTemplate?: string;
|
||||
}
|
||||
|
||||
// ConnectionSetupModal Props 타입
|
||||
export interface ConnectionSetupModalProps {
|
||||
isOpen: boolean;
|
||||
connection: ConnectionInfo | null;
|
||||
companyCode: string;
|
||||
onConfirm: (relationship: any) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
import { JsonRelationship, TableRelationship, DataFlowDiagram } from "@/lib/api/dataflow";
|
||||
|
||||
// 테이블 노드 데이터 타입 정의
|
||||
export interface TableNodeData extends Record<string, unknown> {
|
||||
table: {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
columns: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
onColumnClick: (tableName: string, columnName: string) => void;
|
||||
selectedColumns: string[];
|
||||
connectedColumns?: {
|
||||
[columnName: string]: { direction: "source" | "target" | "both" };
|
||||
};
|
||||
}
|
||||
|
||||
// 내부에서 사용할 확장된 JsonRelationship 타입 (connectionType 포함)
|
||||
export interface ExtendedJsonRelationship extends JsonRelationship {
|
||||
connectionType: "simple-key" | "data-save" | "external-call";
|
||||
settings?: {
|
||||
control?: {
|
||||
triggerType?: "insert" | "update" | "delete";
|
||||
conditionTree?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
field?: string;
|
||||
operator?: string;
|
||||
value?: unknown;
|
||||
logicalOperator?: string;
|
||||
groupId?: string;
|
||||
groupLevel?: number;
|
||||
}>;
|
||||
};
|
||||
actions?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
actionType: "insert" | "update" | "delete" | "upsert";
|
||||
conditions?: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
field?: string;
|
||||
operator?: string;
|
||||
value?: unknown;
|
||||
logicalOperator?: string;
|
||||
groupId?: string;
|
||||
groupLevel?: number;
|
||||
}>;
|
||||
fieldMappings: Array<{
|
||||
sourceTable?: string;
|
||||
sourceField: string;
|
||||
targetTable?: string;
|
||||
targetField: string;
|
||||
defaultValue?: string;
|
||||
}>;
|
||||
splitConfig?: {
|
||||
sourceField: string;
|
||||
delimiter: string;
|
||||
targetField: string;
|
||||
};
|
||||
}>;
|
||||
notes?: string;
|
||||
apiCall?: {
|
||||
url: string;
|
||||
method: string;
|
||||
headers: Array<{ key: string; value: string }>;
|
||||
body: string;
|
||||
successCriteria: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
// DataFlowDesigner Props 타입
|
||||
export interface DataFlowDesignerProps {
|
||||
companyCode?: string;
|
||||
onSave?: (relationships: TableRelationship[]) => void;
|
||||
selectedDiagram?: DataFlowDiagram | string | null;
|
||||
diagramId?: number;
|
||||
relationshipId?: string; // 하위 호환성 유지
|
||||
onBackToList?: () => void;
|
||||
onDiagramNameUpdate?: (diagramName: string) => void;
|
||||
}
|
||||
|
||||
// 연결 정보 타입
|
||||
export interface ConnectionInfo {
|
||||
fromNode: { id: string; tableName: string; displayName: string };
|
||||
toNode: { id: string; tableName: string; displayName: string };
|
||||
fromColumn?: string;
|
||||
toColumn?: string;
|
||||
selectedColumnsData?: {
|
||||
[tableName: string]: {
|
||||
displayName: string;
|
||||
columns: string[];
|
||||
};
|
||||
};
|
||||
existingRelationship?: {
|
||||
relationshipName: string;
|
||||
connectionType: string;
|
||||
settings?: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
// 선택된 엣지 정보 타입
|
||||
export interface SelectedEdgeInfo {
|
||||
relationshipId: string;
|
||||
relationshipName: string;
|
||||
fromTable: string;
|
||||
toTable: string;
|
||||
fromColumns: string[];
|
||||
toColumns: string[];
|
||||
connectionType: string;
|
||||
connectionInfo: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
import { ConditionNode } from "@/lib/api/dataflow";
|
||||
|
||||
/**
|
||||
* 고유 ID 생성 함수
|
||||
*/
|
||||
export const generateConditionId = (): string => {
|
||||
return `cond_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 그룹 ID 생성 함수
|
||||
*/
|
||||
export const generateGroupId = (): string => {
|
||||
return `group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 액션 그룹 ID 생성 함수
|
||||
*/
|
||||
export const generateActionGroupId = (): string => {
|
||||
return `action_group_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 열린 그룹 찾기
|
||||
*/
|
||||
export const findOpenGroups = (conditions: ConditionNode[]) => {
|
||||
const openGroups: Array<{ groupId: string; groupLevel: number }> = [];
|
||||
|
||||
for (const condition of conditions) {
|
||||
if (condition.type === "group-start") {
|
||||
openGroups.push({
|
||||
groupId: condition.groupId!,
|
||||
groupLevel: condition.groupLevel!,
|
||||
});
|
||||
} else if (condition.type === "group-end") {
|
||||
// 해당 그룹 제거
|
||||
const groupIndex = openGroups.findIndex((g) => g.groupId === condition.groupId);
|
||||
if (groupIndex !== -1) {
|
||||
openGroups.splice(groupIndex, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return openGroups;
|
||||
};
|
||||
|
||||
/**
|
||||
* 다음 그룹 레벨 계산
|
||||
*/
|
||||
export const getNextGroupLevel = (conditions: ConditionNode[]): number => {
|
||||
const openGroups = findOpenGroups(conditions);
|
||||
return openGroups.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* 현재 조건의 그룹 레벨 계산
|
||||
*/
|
||||
export const getCurrentGroupLevel = (conditions: ConditionNode[], conditionIndex: number): number => {
|
||||
let level = 0;
|
||||
for (let i = 0; i < conditionIndex; i++) {
|
||||
const condition = conditions[i];
|
||||
if (condition.type === "group-start") {
|
||||
level++;
|
||||
} else if (condition.type === "group-end") {
|
||||
level--;
|
||||
}
|
||||
}
|
||||
return level;
|
||||
};
|
||||
|
||||
/**
|
||||
* 조건부 연결인지 확인하는 헬퍼 함수
|
||||
*/
|
||||
export const isConditionalConnection = (connectionType: string): boolean => {
|
||||
return connectionType === "data-save" || connectionType === "external-call";
|
||||
};
|
||||
|
||||
/**
|
||||
* 데이터 타입에 따른 입력 타입 결정
|
||||
*/
|
||||
export const getInputTypeForDataType = (dataType: string): "text" | "number" | "datetime-local" | "date" | "time" => {
|
||||
const lowerDataType = dataType?.toLowerCase() || "string";
|
||||
|
||||
if (lowerDataType.includes("timestamp") || lowerDataType.includes("datetime")) {
|
||||
return "datetime-local";
|
||||
} else if (lowerDataType.includes("date")) {
|
||||
return "date";
|
||||
} else if (lowerDataType.includes("time")) {
|
||||
return "time";
|
||||
} else if (
|
||||
lowerDataType.includes("int") ||
|
||||
lowerDataType.includes("numeric") ||
|
||||
lowerDataType.includes("decimal") ||
|
||||
lowerDataType.includes("float") ||
|
||||
lowerDataType.includes("double")
|
||||
) {
|
||||
return "number";
|
||||
} else {
|
||||
return "text";
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
// 데이터플로우 관련 유틸리티 함수들
|
||||
|
||||
/**
|
||||
* 고유 ID 생성 함수
|
||||
*/
|
||||
export const generateUniqueId = (prefix: string, diagramId?: number): string => {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
return `${prefix}-${diagramId || timestamp}-${random}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 쌍별 관계 개수 계산
|
||||
*/
|
||||
export const calculateTableRelationshipCount = (relationships: Array<{ fromTable: string; toTable: string }>) => {
|
||||
const tableRelationshipCount: { [key: string]: number } = {};
|
||||
|
||||
relationships.forEach((rel) => {
|
||||
const tableKey = [rel.fromTable, rel.toTable].sort().join("-");
|
||||
tableRelationshipCount[tableKey] = (tableRelationshipCount[tableKey] || 0) + 1;
|
||||
});
|
||||
|
||||
return tableRelationshipCount;
|
||||
};
|
||||
|
||||
/**
|
||||
* 연결된 컬럼 정보 계산
|
||||
*/
|
||||
export const calculateConnectedColumns = (
|
||||
relationships: Array<{
|
||||
fromTable: string;
|
||||
toTable: string;
|
||||
fromColumns: string[];
|
||||
toColumns: string[];
|
||||
}>,
|
||||
) => {
|
||||
const connectedColumnsInfo: {
|
||||
[tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } };
|
||||
} = {};
|
||||
|
||||
relationships.forEach((rel) => {
|
||||
const { fromTable, toTable, fromColumns, toColumns } = rel;
|
||||
|
||||
// 소스 테이블의 컬럼들을 source로 표시
|
||||
if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {};
|
||||
fromColumns.forEach((col: string) => {
|
||||
if (connectedColumnsInfo[fromTable][col]) {
|
||||
connectedColumnsInfo[fromTable][col].direction = "both";
|
||||
} else {
|
||||
connectedColumnsInfo[fromTable][col] = { direction: "source" };
|
||||
}
|
||||
});
|
||||
|
||||
// 타겟 테이블의 컬럼들을 target으로 표시
|
||||
if (!connectedColumnsInfo[toTable]) connectedColumnsInfo[toTable] = {};
|
||||
toColumns.forEach((col: string) => {
|
||||
if (connectedColumnsInfo[toTable][col]) {
|
||||
connectedColumnsInfo[toTable][col].direction = "both";
|
||||
} else {
|
||||
connectedColumnsInfo[toTable][col] = { direction: "target" };
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return connectedColumnsInfo;
|
||||
};
|
||||
|
||||
/**
|
||||
* 노드 위치 추출
|
||||
*/
|
||||
export const extractNodePositions = (
|
||||
nodes: Array<{
|
||||
data: { table: { tableName: string } };
|
||||
position: { x: number; y: number };
|
||||
}>,
|
||||
): { [tableName: string]: { x: number; y: number } } => {
|
||||
const nodePositions: { [tableName: string]: { x: number; y: number } } = {};
|
||||
|
||||
nodes.forEach((node) => {
|
||||
if (node.data?.table?.tableName) {
|
||||
nodePositions[node.data.table.tableName] = {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return nodePositions;
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블명 목록 추출
|
||||
*/
|
||||
export const extractTableNames = (
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
data: { table: { tableName: string } };
|
||||
}>,
|
||||
): string[] => {
|
||||
return nodes
|
||||
.filter((node) => node.id.startsWith("table-"))
|
||||
.map((node) => node.data.table.tableName)
|
||||
.sort();
|
||||
};
|
||||
Loading…
Reference in New Issue