dev #46

Merged
kjs merged 344 commits from dev into main 2025-09-22 18:17:24 +09:00
19 changed files with 1456 additions and 335 deletions
Showing only changes of commit 3a96f9dc81 - Show all commits

View File

@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes";
import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes";
// import userRoutes from './routes/userRoutes';
// import menuRoutes from './routes/menuRoutes';
@ -125,6 +126,7 @@ app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/entity-reference", entityReferenceRoutes);
// app.use('/api/users', userRoutes);
// app.use('/api/menus', menuRoutes);

View File

@ -143,16 +143,36 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
message: "관계도가 성공적으로 생성되었습니다.",
});
} catch (error) {
logger.error("관계도 생성 실패:", error);
// 디버깅을 위한 에러 정보 출력
logger.error("에러 디버깅:", {
errorType: typeof error,
errorCode: (error as any)?.code,
errorMessage: error instanceof Error ? error.message : "Unknown error",
errorName: (error as any)?.name,
errorMeta: (error as any)?.meta,
});
// 중복 이름 에러 처리
if (error instanceof Error && error.message.includes("unique constraint")) {
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
const isDuplicateError =
(error && typeof error === "object" && (error as any).code === "P2002") || // Prisma unique constraint error code
(error instanceof Error &&
(error.message.includes("unique constraint") ||
error.message.includes("Unique constraint") ||
error.message.includes("duplicate key") ||
error.message.includes("UNIQUE constraint failed") ||
error.message.includes("unique_diagram_name_per_company")));
if (isDuplicateError) {
// 중복 에러는 콘솔에 로그 출력하지 않음
return res.status(409).json({
success: false,
message: "이미 존재하는 관계도 이름입니다.",
message: "중복된 이름입니다.",
});
}
// 다른 에러만 로그 출력
logger.error("관계도 생성 실패:", error);
return res.status(500).json({
success: false,
message: "관계도 생성 중 오류가 발생했습니다.",
@ -214,6 +234,25 @@ export const updateDataflowDiagram = async (req: Request, res: Response) => {
message: "관계도가 성공적으로 수정되었습니다.",
});
} catch (error) {
// 중복 이름 에러인지 먼저 확인 (로그 출력 전에)
const isDuplicateError =
(error && typeof error === "object" && (error as any).code === "P2002") || // Prisma unique constraint error code
(error instanceof Error &&
(error.message.includes("unique constraint") ||
error.message.includes("Unique constraint") ||
error.message.includes("duplicate key") ||
error.message.includes("UNIQUE constraint failed") ||
error.message.includes("unique_diagram_name_per_company")));
if (isDuplicateError) {
// 중복 에러는 콘솔에 로그 출력하지 않음
return res.status(409).json({
success: false,
message: "중복된 이름입니다.",
});
}
// 다른 에러만 로그 출력
logger.error("관계도 수정 실패:", error);
return res.status(500).json({
success: false,

View File

@ -0,0 +1,208 @@
import { Request, Response } from "express";
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
export interface EntityReferenceOption {
value: string;
label: string;
}
export interface EntityReferenceData {
options: EntityReferenceOption[];
referenceInfo: {
referenceTable: string;
referenceColumn: string;
displayColumn: string | null;
};
}
export interface CodeReferenceData {
options: EntityReferenceOption[];
codeCategory: string;
}
export class EntityReferenceController {
/**
*
* GET /api/entity-reference/:tableName/:columnName
*/
static async getEntityReferenceData(req: Request, res: Response) {
try {
const { tableName, columnName } = req.params;
const { limit = 100, search } = req.query;
logger.info(`엔티티 참조 데이터 조회 요청: ${tableName}.${columnName}`, {
limit,
search,
});
// 컬럼 정보 조회
const columnInfo = await prisma.column_labels.findFirst({
where: {
table_name: tableName,
column_name: columnName,
},
});
if (!columnInfo) {
return res.status(404).json({
success: false,
message: `컬럼 정보를 찾을 수 없습니다: ${tableName}.${columnName}`,
});
}
// webType 확인
if (columnInfo.web_type !== "entity") {
return res.status(400).json({
success: false,
message: `컬럼 '${tableName}.${columnName}'은 entity 타입이 아닙니다. webType: ${columnInfo.web_type}`,
});
}
// column_labels에서 직접 참조 정보 가져오기
const referenceTable = columnInfo.reference_table;
const referenceColumn = columnInfo.reference_column;
const displayColumn = columnInfo.display_column || "name";
// entity 타입인데 참조 테이블 정보가 없으면 오류
if (!referenceTable || !referenceColumn) {
return res.status(400).json({
success: false,
message: `Entity 타입 컬럼 '${tableName}.${columnName}'에 참조 테이블 정보가 설정되지 않았습니다. column_labels에서 reference_table과 reference_column을 확인해주세요.`,
});
}
// 참조 테이블이 실제로 존재하는지 확인
try {
await prisma.$queryRawUnsafe(`SELECT 1 FROM ${referenceTable} LIMIT 1`);
logger.info(
`Entity 참조 설정: ${tableName}.${columnName} -> ${referenceTable}.${referenceColumn} (display: ${displayColumn})`
);
} catch (error) {
logger.error(
`참조 테이블 '${referenceTable}'이 존재하지 않습니다:`,
error
);
return res.status(400).json({
success: false,
message: `참조 테이블 '${referenceTable}'이 존재하지 않습니다. column_labels의 reference_table 설정을 확인해주세요.`,
});
}
// 동적 쿼리로 참조 데이터 조회
let query = `SELECT ${referenceColumn}, ${displayColumn} as display_name FROM ${referenceTable}`;
const queryParams: any[] = [];
// 검색 조건 추가
if (search) {
query += ` WHERE ${displayColumn} ILIKE $1`;
queryParams.push(`%${search}%`);
}
query += ` ORDER BY ${displayColumn} LIMIT $${queryParams.length + 1}`;
queryParams.push(Number(limit));
logger.info(`실행할 쿼리: ${query}`, {
queryParams,
referenceTable,
referenceColumn,
displayColumn,
});
const referenceData = await prisma.$queryRawUnsafe(query, ...queryParams);
// 옵션 형태로 변환
const options: EntityReferenceOption[] = (referenceData as any[]).map(
(row) => ({
value: String(row[referenceColumn]),
label: String(row.display_name || row[referenceColumn]),
})
);
logger.info(`엔티티 참조 데이터 조회 완료: ${options.length}개 항목`);
return res.json({
success: true,
data: {
options,
referenceInfo: {
referenceTable,
referenceColumn,
displayColumn,
},
},
});
} catch (error) {
logger.error("엔티티 참조 데이터 조회 실패:", error);
return res.status(500).json({
success: false,
message: "엔티티 참조 데이터 조회 중 오류가 발생했습니다.",
});
}
}
/**
*
* GET /api/entity-reference/code/:codeCategory
*/
static async getCodeData(req: Request, res: Response) {
try {
const { codeCategory } = req.params;
const { limit = 100, search } = req.query;
logger.info(`공통 코드 데이터 조회 요청: ${codeCategory}`, {
limit,
search,
});
// code_info 테이블에서 코드 데이터 조회
let whereCondition: any = {
code_category: codeCategory,
is_active: "Y",
};
if (search) {
whereCondition.code_name = {
contains: String(search),
mode: "insensitive",
};
}
const codeData = await prisma.code_info.findMany({
where: whereCondition,
select: {
code_value: true,
code_name: true,
},
orderBy: {
code_name: "asc",
},
take: Number(limit),
});
// 옵션 형태로 변환
const options: EntityReferenceOption[] = codeData.map((code) => ({
value: code.code_value,
label: code.code_name,
}));
logger.info(`공통 코드 데이터 조회 완료: ${options.length}개 항목`);
return res.json({
success: true,
data: {
options,
codeCategory,
},
});
} catch (error) {
logger.error("공통 코드 데이터 조회 실패:", error);
return res.status(500).json({
success: false,
message: "공통 코드 데이터 조회 중 오류가 발생했습니다.",
});
}
}
}

View File

@ -0,0 +1,27 @@
import { Router } from "express";
import { EntityReferenceController } from "../controllers/entityReferenceController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
/**
* GET /api/entity-reference/code/:codeCategory
*
*/
router.get(
"/code/:codeCategory",
authenticateToken,
EntityReferenceController.getCodeData
);
/**
* GET /api/entity-reference/:tableName/:columnName
*
*/
router.get(
"/:tableName/:columnName",
authenticateToken,
EntityReferenceController.getEntityReferenceData
);
export default router;

View File

@ -19,6 +19,7 @@ export interface ControlAction {
id: string;
name: string;
actionType: "insert" | "update" | "delete";
logicalOperator?: "AND" | "OR"; // 액션 간 논리 연산자 (첫 번째 액션 제외)
conditions: ControlCondition[];
fieldMappings: {
sourceField?: string;
@ -136,17 +137,41 @@ export class DataflowControlService {
};
}
// 액션 실행
// 액션 실행 (논리 연산자 지원)
const executedActions = [];
const errors = [];
let previousActionSuccess = false;
let shouldSkipRemainingActions = false;
for (let i = 0; i < targetPlan.actions.length; i++) {
const action = targetPlan.actions[i];
for (const action of targetPlan.actions) {
try {
// 논리 연산자에 따른 실행 여부 결정
if (
i > 0 &&
action.logicalOperator === "OR" &&
previousActionSuccess
) {
console.log(
`⏭️ OR 조건으로 인해 액션 건너뛰기: ${action.name} (이전 액션 성공)`
);
continue;
}
if (shouldSkipRemainingActions && action.logicalOperator === "AND") {
console.log(
`⏭️ 이전 액션 실패로 인해 AND 체인 액션 건너뛰기: ${action.name}`
);
continue;
}
console.log(`⚡ 액션 실행: ${action.name} (${action.actionType})`);
console.log(`📋 액션 상세 정보:`, {
actionId: action.id,
actionName: action.name,
actionType: action.actionType,
logicalOperator: action.logicalOperator,
conditions: action.conditions,
fieldMappings: action.fieldMappings,
});
@ -163,6 +188,10 @@ export class DataflowControlService {
console.log(
`⚠️ 액션 조건 미충족: ${actionConditionResult.reason}`
);
previousActionSuccess = false;
if (action.logicalOperator === "AND") {
shouldSkipRemainingActions = true;
}
continue;
}
}
@ -173,11 +202,19 @@ export class DataflowControlService {
actionName: action.name,
result: actionResult,
});
previousActionSuccess = true;
shouldSkipRemainingActions = false; // 성공했으므로 다시 실행 가능
} catch (error) {
console.error(`❌ 액션 실행 오류: ${action.name}`, error);
const errorMessage =
error instanceof Error ? error.message : String(error);
errors.push(`액션 '${action.name}' 실행 오류: ${errorMessage}`);
previousActionSuccess = false;
if (action.logicalOperator === "AND") {
shouldSkipRemainingActions = true;
}
}
}

View File

@ -4,7 +4,7 @@ import React, { useState, useEffect } from "react";
import { Plus, Search, Pencil, Trash2, Database } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
@ -23,6 +23,7 @@ import {
ExternalDbConnectionAPI,
ExternalDbConnection,
ExternalDbConnectionFilter,
ConnectionTestRequest,
} from "@/lib/api/externalDbConnection";
import { ExternalDbConnectionModal } from "@/components/admin/ExternalDbConnectionModal";
@ -56,6 +57,8 @@ export default function ExternalConnectionsPage() {
const [supportedDbTypes, setSupportedDbTypes] = useState<Array<{ value: string; label: string }>>([]);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [connectionToDelete, setConnectionToDelete] = useState<ExternalDbConnection | null>(null);
const [testingConnections, setTestingConnections] = useState<Set<number>>(new Set());
const [testResults, setTestResults] = useState<Map<number, boolean>>(new Map());
// 데이터 로딩
const loadConnections = async () => {
@ -160,6 +163,57 @@ export default function ExternalConnectionsPage() {
setConnectionToDelete(null);
};
// 연결 테스트
const handleTestConnection = async (connection: ExternalDbConnection) => {
if (!connection.id) return;
setTestingConnections((prev) => new Set(prev).add(connection.id!));
try {
const testData: ConnectionTestRequest = {
db_type: connection.db_type,
host: connection.host,
port: connection.port,
database_name: connection.database_name,
username: connection.username,
password: connection.password,
connection_timeout: connection.connection_timeout,
ssl_enabled: connection.ssl_enabled,
};
const result = await ExternalDbConnectionAPI.testConnection(testData);
setTestResults((prev) => new Map(prev).set(connection.id!, result.success));
if (result.success) {
toast({
title: "연결 성공",
description: `${connection.connection_name} 연결이 성공했습니다.`,
});
} else {
toast({
title: "연결 실패",
description: `${connection.connection_name} 연결에 실패했습니다.`,
variant: "destructive",
});
}
} catch (error) {
console.error("연결 테스트 오류:", error);
setTestResults((prev) => new Map(prev).set(connection.id!, false));
toast({
title: "연결 테스트 오류",
description: "연결 테스트 중 오류가 발생했습니다.",
variant: "destructive",
});
} finally {
setTestingConnections((prev) => {
const newSet = new Set(prev);
newSet.delete(connection.id!);
return newSet;
});
}
};
// 모달 저장 처리
const handleModalSave = () => {
setIsModalOpen(false);
@ -264,6 +318,7 @@ export default function ExternalConnectionsPage() {
<TableHead className="w-[120px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="w-[120px] text-right"></TableHead>
</TableRow>
</TableHeader>
@ -271,14 +326,7 @@ export default function ExternalConnectionsPage() {
{connections.map((connection) => (
<TableRow key={connection.id} className="hover:bg-gray-50">
<TableCell>
<div>
<div className="font-medium">{connection.connection_name}</div>
{connection.description && (
<div className="max-w-[180px] truncate text-sm text-gray-500" title={connection.description}>
{connection.description}
</div>
)}
</div>
<div className="font-medium">{connection.connection_name}</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
@ -298,6 +346,27 @@ export default function ExternalConnectionsPage() {
<TableCell className="text-sm">
{connection.created_date ? new Date(connection.created_date).toLocaleDateString() : "N/A"}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection(connection)}
disabled={testingConnections.has(connection.id!)}
className="h-7 px-2 text-xs"
>
{testingConnections.has(connection.id!) ? "테스트 중..." : "테스트"}
</Button>
{testResults.has(connection.id!) && (
<Badge
variant={testResults.get(connection.id!) ? "default" : "destructive"}
className="text-xs text-white"
>
{testResults.get(connection.id!) ? "성공" : "실패"}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button

View File

@ -2,16 +2,24 @@
import React, { useState, useEffect, useCallback } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Link } from "lucide-react";
import { Link, CheckCircle } from "lucide-react";
import { DataFlowAPI, TableRelationship, TableInfo, ColumnInfo, ConditionNode } from "@/lib/api/dataflow";
import {
ConnectionConfig,
SimpleKeySettings,
DataSaveSettings,
ExternalCallSettings,
SimpleExternalCallSettings,
ConnectionSetupModalProps,
} from "@/types/connectionTypes";
@ -22,7 +30,7 @@ import { ConnectionTypeSelector } from "./connection/ConnectionTypeSelector";
import { SimpleKeySettings as SimpleKeySettingsComponent } from "./connection/SimpleKeySettings";
import { DataSaveSettings as DataSaveSettingsComponent } from "./connection/DataSaveSettings";
import { SimpleExternalCallSettings as ExternalCallSettingsComponent } from "./connection/SimpleExternalCallSettings";
import toast from "react-hot-toast";
import { toast } from "sonner";
export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
isOpen,
@ -61,6 +69,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
const [selectedFromColumns, setSelectedFromColumns] = useState<string[]>([]);
const [selectedToColumns, setSelectedToColumns] = useState<string[]>([]);
const [tableColumnsCache, setTableColumnsCache] = useState<{ [tableName: string]: ColumnInfo[] }>({});
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [createdConnectionName, setCreatedConnectionName] = useState("");
const [pendingRelationshipData, setPendingRelationshipData] = useState<TableRelationship | null>(null);
// 조건 관리 훅 사용
const {
@ -465,11 +476,10 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
},
};
toast.success("관계가 생성되었습니다!");
// 부모 컴포넌트로 관계 데이터 전달 (DB 저장 없이)
onConfirm(relationshipData);
handleCancel(); // 모달 닫기
// 성공 모달 표시를 위한 상태 설정
setCreatedConnectionName(config.relationshipName);
setPendingRelationshipData(relationshipData);
setShowSuccessModal(true);
};
const handleCancel = () => {
@ -482,6 +492,19 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
onCancel();
};
const handleSuccessModalClose = () => {
setShowSuccessModal(false);
setCreatedConnectionName("");
// 저장된 관계 데이터를 부모에게 전달
if (pendingRelationshipData) {
onConfirm(pendingRelationshipData);
setPendingRelationshipData(null);
}
handleCancel(); // 원래 모달도 닫기
};
// 연결 종류별 설정 패널 렌더링
const renderConnectionTypeSettings = () => {
switch (config.connectionType) {
@ -619,60 +642,83 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
if (!connection) return null;
return (
<Dialog open={isOpen} onOpenChange={handleCancel}>
<DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Link className="h-4 w-4" />
</DialogTitle>
</DialogHeader>
<>
<Dialog open={isOpen} onOpenChange={handleCancel}>
<DialogContent className="max-h-[80vh] max-w-3xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Link className="h-4 w-4" />
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* 기본 연결 설정 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="relationshipName"> </Label>
<Input
id="relationshipName"
value={config.relationshipName}
onChange={(e) => setConfig({ ...config, relationshipName: e.target.value })}
placeholder="employee_id_department_id_연결"
className="text-sm"
/>
<div className="space-y-4">
{/* 기본 연결 설정 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="relationshipName"> </Label>
<Input
id="relationshipName"
value={config.relationshipName}
onChange={(e) => setConfig({ ...config, relationshipName: e.target.value })}
placeholder="employee_id_department_id_연결"
className="text-sm"
/>
</div>
</div>
{/* 연결 종류 선택 */}
<ConnectionTypeSelector config={config} onConfigChange={setConfig} />
{/* 조건부 연결을 위한 조건 설정 */}
{isConditionalConnection(config.connectionType) && (
<ConditionalSettings
conditions={conditions}
fromTableColumns={fromTableColumns}
fromTableName={selectedFromTable || connection.fromNode.tableName}
onAddCondition={addCondition}
onAddGroupStart={addGroupStart}
onAddGroupEnd={addGroupEnd}
onUpdateCondition={updateCondition}
onRemoveCondition={removeCondition}
getCurrentGroupLevel={getCurrentGroupLevel}
/>
)}
{/* 연결 종류별 상세 설정 */}
{renderConnectionTypeSettings()}
</div>
{/* 연결 종류 선택 */}
<ConnectionTypeSelector config={config} onConfigChange={setConfig} />
{/* 조건부 연결을 위한 조건 설정 */}
{isConditionalConnection(config.connectionType) && (
<ConditionalSettings
conditions={conditions}
fromTableColumns={fromTableColumns}
onAddCondition={addCondition}
onAddGroupStart={addGroupStart}
onAddGroupEnd={addGroupEnd}
onUpdateCondition={updateCondition}
onRemoveCondition={removeCondition}
getCurrentGroupLevel={getCurrentGroupLevel}
/>
)}
{/* 연결 종류별 상세 설정 */}
{renderConnectionTypeSettings()}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm} disabled={isButtonDisabled()}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm} disabled={isButtonDisabled()}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
</AlertDialogTitle>
<AlertDialogDescription className="text-base">
<span className="font-medium text-green-600">{createdConnectionName}</span> .
<br />
<span className="mt-2 block text-sm text-gray-500">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleSuccessModalClose}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@ -606,10 +606,10 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
// 관계도 저장 함수
const handleSaveDiagram = useCallback(
async (diagramName: string) => {
async (diagramName: string): Promise<{ success: boolean; error?: string }> => {
if (nodes.length === 0) {
toast.error("저장할 테이블이 없습니다.");
return;
return { success: false, error: "저장할 테이블이 없습니다." };
}
setIsSaving(true);
@ -669,6 +669,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
id: action.id as string,
name: action.name as string,
actionType: action.actionType as "insert" | "update" | "delete" | "upsert",
logicalOperator: action.logicalOperator as "AND" | "OR" | undefined, // 논리 연산자 추가
fieldMappings: ((action.fieldMappings as Record<string, unknown>[]) || []).map(
(mapping: Record<string, unknown>) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@ -704,12 +705,49 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
setCurrentDiagramName(newDiagram.diagram_name);
}
toast.success(`관계도 "${diagramName}"가 성공적으로 저장되었습니다.`);
setHasUnsavedChanges(false);
setShowSaveModal(false);
// 성공 모달은 SaveDiagramModal에서 처리하므로 여기서는 toast 제거
return { success: true };
} catch (error) {
console.error("관계도 저장 실패:", error);
toast.error("관계도 저장 중 오류가 발생했습니다.");
// 에러 메시지 분석
let errorMessage = "관계도 저장 중 오류가 발생했습니다.";
let isDuplicateError = false;
// Axios 에러 처리
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as any;
if (axiosError.response?.status === 409) {
// 중복 이름 에러 (409 Conflict)
errorMessage = "중복된 이름입니다.";
isDuplicateError = true;
} else if (axiosError.response?.data?.message) {
// 백엔드에서 제공한 에러 메시지 사용
if (axiosError.response.data.message.includes("중복된 이름입니다")) {
errorMessage = "중복된 이름입니다.";
isDuplicateError = true;
} else {
errorMessage = axiosError.response.data.message;
}
}
} else if (error instanceof Error) {
if (
error.message.includes("중복") ||
error.message.includes("duplicate") ||
error.message.includes("already exists")
) {
errorMessage = "중복된 이름입니다.";
isDuplicateError = true;
} else {
errorMessage = error.message;
}
}
// 중복 에러가 아닌 경우만 콘솔에 로그 출력
if (!isDuplicateError) {
console.error("관계도 저장 실패:", error);
}
return { success: false, error: errorMessage };
} finally {
setIsSaving(false);
}

View File

@ -2,17 +2,27 @@
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CheckCircle } from "lucide-react";
import { JsonRelationship } from "@/lib/api/dataflow";
interface SaveDiagramModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (diagramName: string) => void;
onSave: (diagramName: string) => Promise<{ success: boolean; error?: string }>;
relationships: JsonRelationship[];
defaultName?: string;
isLoading?: boolean;
@ -28,13 +38,15 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
}) => {
const [diagramName, setDiagramName] = useState(defaultName);
const [nameError, setNameError] = useState("");
const [showSuccessModal, setShowSuccessModal] = useState(false);
const [savedDiagramName, setSavedDiagramName] = useState("");
// defaultName이 변경될 때마다 diagramName 업데이트
useEffect(() => {
setDiagramName(defaultName);
}, [defaultName]);
const handleSave = () => {
const handleSave = async () => {
const trimmedName = diagramName.trim();
if (!trimmedName) {
@ -53,7 +65,39 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
}
setNameError("");
onSave(trimmedName);
try {
// 부모에게 저장 요청하고 결과 받기
const result = await onSave(trimmedName);
if (result.success) {
// 성공 시 성공 모달 표시
setSavedDiagramName(trimmedName);
setShowSuccessModal(true);
} else {
// 실패 시 에러 메시지 표시
if (
result.error?.includes("중복된 이름입니다") ||
result.error?.includes("중복") ||
result.error?.includes("duplicate") ||
result.error?.includes("already exists")
) {
setNameError("중복된 이름입니다.");
} else {
setNameError(result.error || "저장 중 오류가 발생했습니다.");
}
}
} catch (error) {
// 중복 에러가 아닌 경우만 콘솔에 로그 출력
const isDuplicateError =
error && typeof error === "object" && "response" in error && (error as any).response?.status === 409;
if (!isDuplicateError) {
console.error("저장 오류:", error);
}
setNameError("저장 중 오류가 발생했습니다.");
}
};
const handleClose = () => {
@ -64,6 +108,12 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
}
};
const handleSuccessModalClose = () => {
setShowSuccessModal(false);
setSavedDiagramName("");
handleClose(); // 원래 모달도 닫기
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading) {
handleSave();
@ -76,136 +126,160 @@ const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
).sort();
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">📊 </DialogTitle>
</DialogHeader>
<>
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">📊 </DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 관계도 이름 입력 */}
<div className="space-y-2">
<Label htmlFor="diagram-name" className="text-sm font-medium">
*
</Label>
<Input
id="diagram-name"
value={diagramName}
onChange={(e) => {
setDiagramName(e.target.value);
if (nameError) setNameError("");
}}
onKeyPress={handleKeyPress}
placeholder="예: 사용자-부서 관계도"
disabled={isLoading}
className={nameError ? "border-red-500 focus:border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
<div className="space-y-6">
{/* 관계도 이름 입력 */}
<div className="space-y-2">
<Label htmlFor="diagram-name" className="text-sm font-medium">
*
</Label>
<Input
id="diagram-name"
value={diagramName}
onChange={(e) => {
setDiagramName(e.target.value);
if (nameError) setNameError("");
}}
onKeyPress={handleKeyPress}
placeholder="예: 사용자-부서 관계도"
disabled={isLoading}
className={nameError ? "border-red-500 focus:border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
{/* 관계 요약 정보 */}
<div className="grid grid-cols-3 gap-4 rounded-lg bg-gray-50 p-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{relationships.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{connectedTables.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)}
{/* 관계 요약 정보 */}
<div className="grid grid-cols-3 gap-4 rounded-lg bg-gray-50 p-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{relationships.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-sm text-gray-600"> </div>
</div>
</div>
{/* 연결된 테이블 목록 */}
{connectedTables.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{connectedTables.map((table) => (
<Badge key={table} variant="outline" className="text-xs">
{table}
</Badge>
))}
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{connectedTables.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)}
</div>
</CardContent>
</Card>
)}
<div className="text-sm text-gray-600"> </div>
</div>
</div>
{/* 관계 목록 미리보기 */}
{relationships.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-60 space-y-3 overflow-y-auto">
{relationships.map((relationship, index) => (
<div
key={relationship.id || index}
className="flex items-center justify-between rounded-lg border bg-white p-3 hover:bg-gray-50"
>
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="text-xs">
{relationship.connectionType || "simple-key"}
</Badge>
<span className="font-medium">
{relationship.relationshipName || `${relationship.fromTable}${relationship.toTable}`}
</span>
</div>
<div className="mt-1 text-xs text-gray-600">
{relationship.fromTable} {relationship.toTable}
</div>
</div>
<Badge variant="outline" className="text-xs">
{relationship.connectionType}
{/* 연결된 테이블 목록 */}
{connectedTables.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{connectedTables.map((table) => (
<Badge key={table} variant="outline" className="text-xs">
{table}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 관계가 없는 경우 안내 */}
{relationships.length === 0 && (
<div className="py-8 text-center text-gray-500">
<div className="mb-2 text-4xl">📭</div>
<div className="text-sm"> .</div>
<div className="mt-1 text-xs text-gray-400"> .</div>
</div>
)}
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button
onClick={handleSave}
disabled={isLoading || relationships.length === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
...
</div>
) : (
"💾 저장하기"
))}
</div>
</CardContent>
</Card>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 관계 목록 미리보기 */}
{relationships.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-60 space-y-3 overflow-y-auto">
{relationships.map((relationship, index) => (
<div
key={relationship.id || index}
className="flex items-center justify-between rounded-lg border bg-white p-3 hover:bg-gray-50"
>
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="text-xs">
{relationship.connectionType || "simple-key"}
</Badge>
<span className="font-medium">
{relationship.relationshipName || `${relationship.fromTable}${relationship.toTable}`}
</span>
</div>
<div className="mt-1 text-xs text-gray-600">
{relationship.fromTable} {relationship.toTable}
</div>
</div>
<Badge variant="outline" className="text-xs">
{relationship.connectionType}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 관계가 없는 경우 안내 */}
{relationships.length === 0 && (
<div className="py-8 text-center text-gray-500">
<div className="mb-2 text-4xl">📭</div>
<div className="text-sm"> .</div>
<div className="mt-1 text-xs text-gray-400"> .</div>
</div>
)}
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button
onClick={handleSave}
disabled={isLoading || relationships.length === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
...
</div>
) : (
"저장하기"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 저장 성공 알림 모달 */}
<AlertDialog open={showSuccessModal} onOpenChange={setShowSuccessModal}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-500" />
</AlertDialogTitle>
<AlertDialogDescription className="text-base">
<span className="font-medium text-green-600">{savedDiagramName}</span> .
<br />
<span className="mt-2 block text-sm text-gray-500">
.
</span>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleSuccessModalClose}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@ -7,10 +7,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Trash2 } from "lucide-react";
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
import { getInputTypeForDataType } from "@/utils/connectionUtils";
import { WebTypeInput } from "./WebTypeInput";
interface ConditionRendererProps {
conditions: ConditionNode[];
fromTableColumns: ColumnInfo[];
fromTableName?: string;
onUpdateCondition: (index: number, field: keyof ConditionNode, value: string) => void;
onRemoveCondition: (index: number) => void;
getCurrentGroupLevel: (index: number) => number;
@ -19,41 +21,43 @@ interface ConditionRendererProps {
export const ConditionRenderer: React.FC<ConditionRendererProps> = ({
conditions,
fromTableColumns,
fromTableName,
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 {
if (!selectedColumn) {
// 컬럼이 선택되지 않은 경우 기본 input
return (
<Input
type={inputType}
placeholder={inputType === "number" ? "숫자" : "값"}
type="text"
placeholder="값"
value={String(condition.value || "")}
onChange={(e) => onUpdateCondition(index, "value", e.target.value)}
className="h-8 flex-1 text-xs"
/>
);
}
// 테이블명 정보를 포함한 컬럼 객체 생성
const columnWithTableName = {
...selectedColumn,
tableName: fromTableName,
};
// WebType 기반 input 사용
return (
<WebTypeInput
column={columnWithTableName}
value={String(condition.value || "")}
onChange={(value) => onUpdateCondition(index, "value", value)}
className="h-8 flex-1 text-xs"
placeholder="값"
/>
);
};
return (

View File

@ -10,6 +10,7 @@ import { ConditionRenderer } from "./ConditionRenderer";
interface ConditionalSettingsProps {
conditions: ConditionNode[];
fromTableColumns: ColumnInfo[];
fromTableName?: string;
onAddCondition: () => void;
onAddGroupStart: () => void;
onAddGroupEnd: () => void;
@ -21,6 +22,7 @@ interface ConditionalSettingsProps {
export const ConditionalSettings: React.FC<ConditionalSettingsProps> = ({
conditions,
fromTableColumns,
fromTableName,
onAddCondition,
onAddGroupStart,
onAddGroupEnd,
@ -57,6 +59,7 @@ export const ConditionalSettings: React.FC<ConditionalSettingsProps> = ({
<ConditionRenderer
conditions={conditions}
fromTableColumns={fromTableColumns}
fromTableName={fromTableName}
onUpdateCondition={onUpdateCondition}
onRemoveCondition={onRemoveCondition}
getCurrentGroupLevel={getCurrentGroupLevel}

View File

@ -0,0 +1,422 @@
"use client";
import React, { useState, useEffect, useCallback, useMemo } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Calendar } from "@/components/ui/calendar";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Button } from "@/components/ui/button";
import { CalendarIcon, Upload, Loader2 } from "lucide-react";
import { format } from "date-fns";
import { ko } from "date-fns/locale";
import { ColumnInfo } from "@/lib/api/dataflow";
import { EntityReferenceAPI, EntityReferenceOption } from "@/lib/api/entityReference";
interface WebTypeInputProps {
column: ColumnInfo;
value: string | undefined;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
tableName?: string; // 테이블명을 별도로 전달받음
}
export const WebTypeInput: React.FC<WebTypeInputProps> = ({
column,
value,
onChange,
className = "",
placeholder,
tableName,
}) => {
// tableName은 props 또는 column.tableName에서 가져옴
const effectiveTableName = tableName || (column as ColumnInfo & { tableName?: string }).tableName;
const webType = column.webType || "text";
const [entityOptions, setEntityOptions] = useState<EntityReferenceOption[]>([]);
const [codeOptions, setCodeOptions] = useState<EntityReferenceOption[]>([]);
const [loading, setLoading] = useState(false);
// detailSettings 안전하게 파싱 (메모이제이션)
const { detailSettings, fallbackCodeCategory } = useMemo(() => {
let parsedSettings: Record<string, unknown> = {};
let fallbackCategory = "";
if (column.detailSettings && typeof column.detailSettings === "string") {
// JSON 형태인지 확인 ('{' 또는 '[' 로 시작하는지)
const trimmed = column.detailSettings.trim();
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
try {
parsedSettings = JSON.parse(column.detailSettings);
} catch {
parsedSettings = {};
}
} else {
// JSON이 아닌 일반 문자열인 경우, code 타입이면 codeCategory로 사용
if (webType === "code") {
// "공통코드: 상태" 형태에서 실제 코드 추출 시도
if (column.detailSettings.includes(":")) {
const parts = column.detailSettings.split(":");
if (parts.length >= 2) {
fallbackCategory = parts[1].trim();
} else {
fallbackCategory = column.detailSettings;
}
} else {
fallbackCategory = column.detailSettings;
}
}
parsedSettings = {};
}
} else if (column.detailSettings && typeof column.detailSettings === "object") {
parsedSettings = column.detailSettings;
}
return { detailSettings: parsedSettings, fallbackCodeCategory: fallbackCategory };
}, [column.detailSettings, webType]);
const loadEntityData = useCallback(async () => {
try {
setLoading(true);
// entity 타입은 반드시 effectiveTableName과 columnName이 있어야 함
if (!effectiveTableName || !column.columnName) {
throw new Error("Entity 타입에는 tableName과 columnName이 필요합니다.");
}
const data = await EntityReferenceAPI.getEntityReferenceData(effectiveTableName, column.columnName, {
limit: 100,
});
setEntityOptions(data.options);
} catch {
setEntityOptions([]);
} finally {
setLoading(false);
}
}, [effectiveTableName, column.columnName]);
const loadCodeData = useCallback(async () => {
try {
setLoading(true);
const codeCategory = column.codeCategory || (detailSettings.codeCategory as string) || fallbackCodeCategory;
if (codeCategory) {
const data = await EntityReferenceAPI.getCodeReferenceData(codeCategory, { limit: 100 });
setCodeOptions(data.options);
}
} catch {
setCodeOptions([]);
} finally {
setLoading(false);
}
}, [column.codeCategory, detailSettings.codeCategory, fallbackCodeCategory]);
// webType에 따른 데이터 로드
useEffect(() => {
// 디버깅: entity 타입 필드 정보 확인
if (column.columnName === "manager_name" || webType === "entity") {
console.log("🔍 Entity 필드 디버깅:", {
columnName: column.columnName,
webType: webType,
tableName: tableName,
effectiveTableName: effectiveTableName,
referenceTable: column.referenceTable,
referenceColumn: column.referenceColumn,
displayColumn: (column as any).displayColumn,
shouldLoadEntity: webType === "entity" && effectiveTableName && column.columnName,
});
}
if (webType === "entity" && effectiveTableName && column.columnName) {
// entity 타입: 다른 테이블 참조
console.log("🚀 Entity 데이터 로드 시작:", effectiveTableName, column.columnName);
loadEntityData();
} else if (webType === "code" && (column.codeCategory || detailSettings.codeCategory || fallbackCodeCategory)) {
// code 타입: code_info 테이블에서 공통 코드 조회
loadCodeData();
}
// text 타입: 일반 텍스트 입력
// file 타입: 파일 업로드
}, [
webType,
effectiveTableName,
column.columnName,
column.codeCategory,
column.referenceTable,
column.referenceColumn,
(column as any).displayColumn,
tableName,
fallbackCodeCategory,
detailSettings.codeCategory,
loadEntityData,
loadCodeData,
]);
// 날짜/시간 타입일 때 기본값으로 현재 날짜/시간 설정
useEffect(() => {
const dateTimeTypes = ["date", "datetime", "timestamp"];
// 컬럼명이나 데이터 타입으로 날짜 필드 판단
const isDateColumn =
dateTimeTypes.includes(webType) ||
column.columnName?.toLowerCase().includes("date") ||
column.columnName?.toLowerCase().includes("time") ||
column.columnName === "regdate" ||
column.columnName === "created_at" ||
column.columnName === "updated_at";
if (isDateColumn && (!value || value === "")) {
const now = new Date();
let formattedValue = "";
if (webType === "date") {
// 데이터베이스 타입이나 컬럼명으로 시간 포함 여부 판단
const isTimestampType =
column.dataType?.toLowerCase().includes("timestamp") ||
column.columnName?.toLowerCase().includes("time") ||
column.columnName === "regdate" ||
column.columnName === "created_at" ||
column.columnName === "updated_at";
if (isTimestampType) {
formattedValue = format(now, "yyyy-MM-dd HH:mm:ss");
} else {
formattedValue = format(now, "yyyy-MM-dd");
}
} else {
// 컬럼명 기반 판단 시에도 시간 포함
formattedValue = format(now, "yyyy-MM-dd HH:mm:ss");
}
onChange(formattedValue);
}
}, [webType, value, onChange, column.columnName, column.dataType]);
// 공통 props
const commonProps = {
value: value || "",
className,
};
// WebType별 렌더링 (column_labels의 webType을 정확히 따름)
const actualWebType = webType;
switch (actualWebType) {
case "text":
return (
<Input
{...commonProps}
type="text"
placeholder={placeholder || "텍스트 입력"}
onChange={(e) => onChange(e.target.value)}
/>
);
case "number":
return (
<Input
{...commonProps}
type="number"
placeholder={placeholder || "숫자 입력"}
onChange={(e) => onChange(e.target.value)}
min={detailSettings.min as number}
max={detailSettings.max as number}
step={(detailSettings.step as string) || "any"}
/>
);
case "date":
// 데이터베이스 타입이나 컬럼명으로 시간 포함 여부 판단
const isTimestampType =
column.dataType?.toLowerCase().includes("timestamp") ||
column.columnName?.toLowerCase().includes("time") ||
column.columnName === "regdate" ||
column.columnName === "created_at" ||
column.columnName === "updated_at";
if (isTimestampType) {
// timestamp 타입이면 datetime-local input 사용 (시간까지 입력 가능)
const datetimeValue = value ? value.replace(" ", "T").substring(0, 16) : "";
return (
<Input
{...commonProps}
type="datetime-local"
value={datetimeValue}
onChange={(e) => {
const inputValue = e.target.value;
// datetime-local 형식 (YYYY-MM-DDTHH:mm)을 DB 형식 (YYYY-MM-DD HH:mm:ss)으로 변환
const formattedValue = inputValue ? `${inputValue.replace("T", " ")}:00` : "";
onChange(formattedValue);
}}
placeholder={placeholder || "날짜와 시간 선택"}
/>
);
} else {
// 순수 date 타입이면 달력 팝업 사용
const dateValue = value ? new Date(value) : undefined;
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={`justify-start text-left font-normal ${className} ${!value && "text-muted-foreground"}`}
>
<CalendarIcon className="mr-2 h-4 w-4" />
{dateValue ? format(dateValue, "PPP", { locale: ko }) : placeholder || "날짜 선택"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dateValue}
onSelect={(date) => onChange(date ? format(date, "yyyy-MM-dd") : "")}
initialFocus
/>
</PopoverContent>
</Popover>
);
}
case "textarea":
return (
<Textarea
{...commonProps}
placeholder={placeholder || "여러 줄 텍스트 입력"}
onChange={(e) => onChange(e.target.value)}
rows={(detailSettings.rows as number) || 3}
/>
);
case "select":
const selectOptions = (detailSettings.options as { value: string; label?: string }[]) || [];
return (
<Select value={value || ""} onValueChange={onChange}>
<SelectTrigger className={className}>
<SelectValue placeholder={placeholder || "선택하세요"} />
</SelectTrigger>
<SelectContent>
{selectOptions.map((option: { value: string; label?: string }) => (
<SelectItem key={option.value} value={option.value}>
{option.label || option.value}
</SelectItem>
))}
</SelectContent>
</Select>
);
case "checkbox":
return (
<div className={`flex items-center space-x-2 ${className}`}>
<Checkbox
id={`checkbox-${column.columnName}`}
checked={value === "true" || value === "1"}
onCheckedChange={(checked) => onChange(checked ? "true" : "false")}
/>
<Label htmlFor={`checkbox-${column.columnName}`} className="text-sm">
{(detailSettings.label as string) || column.columnLabel || column.columnName}
</Label>
</div>
);
case "radio":
const radioOptions = (detailSettings.options as { value: string; label?: string }[]) || [];
return (
<RadioGroup value={value || ""} onValueChange={onChange} className={className}>
{radioOptions.map((option: { value: string; label?: string }) => (
<div key={option.value} className="flex items-center space-x-2">
<RadioGroupItem value={option.value} id={`radio-${column.columnName}-${option.value}`} />
<Label htmlFor={`radio-${column.columnName}-${option.value}`} className="text-sm">
{option.label || option.value}
</Label>
</div>
))}
</RadioGroup>
);
case "code":
// 공통코드 선택 - 실제 API에서 코드 목록 가져옴
const codeCategory = column.codeCategory || (detailSettings.codeCategory as string) || fallbackCodeCategory;
return (
<Select value={value || ""} onValueChange={onChange} disabled={loading}>
<SelectTrigger className={className}>
<SelectValue placeholder={loading ? "코드 로딩 중..." : placeholder || `${codeCategory || "코드"} 선택`} />
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</SelectTrigger>
<SelectContent>
{codeOptions.length === 0 && !loading ? (
<SelectItem value="__no_data__" disabled>
</SelectItem>
) : (
codeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
case "entity":
// 엔티티 참조 - 실제 참조 테이블에서 데이터 가져옴
const referenceTable = column.referenceTable || (detailSettings.referenceTable as string);
return (
<Select value={value || ""} onValueChange={onChange} disabled={loading}>
<SelectTrigger className={className}>
<SelectValue placeholder={loading ? "데이터 로딩 중..." : placeholder || `${referenceTable} 선택`} />
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
</SelectTrigger>
<SelectContent>
{entityOptions.length === 0 && !loading ? (
<SelectItem value="__no_data__" disabled>
</SelectItem>
) : (
entityOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))
)}
</SelectContent>
</Select>
);
case "file":
return (
<div className={`space-y-2 ${className}`}>
<Input
type="file"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
onChange(file.name); // 실제로는 파일 업로드 처리 필요
}
}}
accept={detailSettings.accept as string}
multiple={detailSettings.multiple as boolean}
/>
{value && (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Upload className="h-4 w-4" />
<span> : {value}</span>
</div>
)}
</div>
);
default:
// 기본적으로 text input 사용
return (
<Input
{...commonProps}
type="text"
placeholder={placeholder || "값 입력"}
onChange={(e) => onChange(e.target.value)}
/>
);
}
};

View File

@ -16,6 +16,7 @@ import { Trash2 } from "lucide-react";
import { ConditionNode, ColumnInfo } from "@/lib/api/dataflow";
import { DataSaveSettings } from "@/types/connectionTypes";
import { getInputTypeForDataType } from "@/utils/connectionUtils";
import { WebTypeInput } from "../condition/WebTypeInput";
interface ActionConditionRendererProps {
condition: ConditionNode;
@ -70,32 +71,37 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
// 선택된 테이블 타입에 따라 컬럼 찾기
const targetColumns = condition.tableType === "from" ? fromTableColumns : toTableColumns;
const selectedColumn = targetColumns.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 {
if (!selectedColumn) {
// 컬럼이 선택되지 않은 경우 기본 input
return (
<Input
type={inputType}
placeholder={inputType === "number" ? "숫자" : "값"}
type="text"
placeholder="값"
value={String(condition.value || "")}
onChange={(e) => updateCondition("value", e.target.value)}
className="h-6 flex-1 text-xs"
/>
);
}
// 테이블명 정보를 포함한 컬럼 객체 생성
const tableName = condition.tableType === "from" ? fromTableName : toTableName;
const columnWithTableName = {
...selectedColumn,
tableName: tableName,
};
// WebType 기반 input 사용
return (
<WebTypeInput
column={columnWithTableName}
value={String(condition.value || "")}
onChange={(value) => updateCondition("value", value)}
className="h-6 flex-1 text-xs"
placeholder="값"
/>
);
};
// 그룹 시작 렌더링

View File

@ -6,6 +6,7 @@ import { Badge } from "@/components/ui/badge";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ColumnInfo } from "@/lib/api/dataflow";
import { getInputTypeForDataType } from "@/utils/connectionUtils";
import { WebTypeInput } from "../condition/WebTypeInput";
interface ColumnMapping {
toColumnName: string;
@ -303,14 +304,13 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
{!isMapped && onDefaultValueChange && (
<div className="mt-2">
<Input
type={getInputTypeForDataType(column.dataType?.toLowerCase() || "string")}
placeholder="기본값 입력..."
<WebTypeInput
column={column}
value={mapping?.defaultValue || ""}
onChange={(e) => onDefaultValueChange(column.columnName, e.target.value)}
onChange={(value) => onDefaultValueChange(column.columnName, value)}
className="h-6 border-gray-200 text-xs focus:border-green-400 focus:ring-0"
onClick={(e) => e.stopPropagation()}
disabled={isSelected || !!oppositeSelectedColumn}
placeholder="기본값 입력..."
tableName={tableName}
/>
</div>
)}

View File

@ -38,6 +38,8 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
id: `action_${settings.actions.length + 1}`,
name: `액션 ${settings.actions.length + 1}`,
actionType: "insert" as const,
// 첫 번째 액션이 아니면 기본적으로 AND 연산자 추가
...(settings.actions.length > 0 && { logicalOperator: "AND" as const }),
fieldMappings: [],
conditions: [],
splitConfig: {
@ -60,6 +62,12 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
const removeAction = (actionIndex: number) => {
const newActions = settings.actions.filter((_, i) => i !== actionIndex);
// 첫 번째 액션을 삭제했다면, 새로운 첫 번째 액션의 logicalOperator 제거
if (actionIndex === 0 && newActions.length > 0) {
delete newActions[0].logicalOperator;
}
onSettingsChange({ ...settings, actions: newActions });
};
@ -87,104 +95,132 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
) : (
<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 key={action.id}>
{/* 첫 번째 액션이 아닌 경우 논리 연산자 표시 */}
{actionIndex > 0 && (
<div className="mb-2 flex items-center justify-center">
<div className="flex items-center gap-2 rounded-lg bg-gray-100 px-3 py-1">
<span className="text-xs text-gray-600"> :</span>
<Select
value={action.logicalOperator || "AND"}
onValueChange={(value: "AND" | "OR") => updateAction(actionIndex, "logicalOperator", value)}
>
<SelectTrigger className="h-8 w-20 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* 액션별 개별 실행 조건 */}
<ActionConditionsSection
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
/>
{/* 데이터 분할 설정 - DELETE 액션은 제외 */}
{action.actionType !== "delete" && (
<ActionSplitConfig
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
/>
)}
{/* 필드 매핑 - DELETE 액션은 제외 */}
{action.actionType !== "delete" && (
<ActionFieldMappings
<div 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}
availableTables={availableTables}
tableColumnsCache={tableColumnsCache}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
/>
)}
{/* DELETE 액션일 때 안내 메시지 */}
{action.actionType === "delete" && (
<div className="mt-3">
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700">
<div className="flex items-start gap-2">
<span></span>
<div>
<div className="font-medium">DELETE </div>
<div className="mt-1">
DELETE <strong></strong> .
<br />
설정: 불필요 ( )
<br />
매핑: 불필요 ( )
<br />
.
{/* 데이터 분할 설정 - DELETE 액션은 제외 */}
{action.actionType !== "delete" && (
<ActionSplitConfig
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
/>
)}
{/* 필드 매핑 - DELETE 액션은 제외 */}
{action.actionType !== "delete" && (
<ActionFieldMappings
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
availableTables={availableTables}
tableColumnsCache={tableColumnsCache}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
/>
)}
{/* DELETE 액션일 때 안내 메시지 */}
{action.actionType === "delete" && (
<div className="mt-3">
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700">
<div className="flex items-start gap-2">
<span></span>
<div>
<div className="font-medium">DELETE </div>
<div className="mt-1">
DELETE <strong></strong> .
<br />
설정: 불필요 ( )
<br />
매핑: 불필요 ( )
<br />
.
</div>
</div>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
))}
</div>

View File

@ -126,9 +126,9 @@ apiClient.interceptors.response.use(
// 409 에러 (중복 데이터)는 조용하게 처리
if (status === 409) {
// 중복 검사 API 완전히 조용하게 처리
if (url?.includes("/check-duplicate")) {
// 중복 검사 정상적인 비즈니스 로직이므로 콘솔 출력 없음
// 중복 검사 API와 관계도 저장은 완전히 조용하게 처리
if (url?.includes("/check-duplicate") || url?.includes("/dataflow-diagrams")) {
// 중복 검사와 관계도 중복 이름은 정상적인 비즈니스 로직이므로 콘솔 출력 없음
return Promise.reject(error);
}

View File

@ -824,7 +824,15 @@ export class DataFlowAPI {
return response.data.data as JsonDataFlowDiagram;
} catch (error) {
console.error("JSON 관계도 생성 오류:", error);
// 409 에러(중복 이름)는 콘솔 로그 출력하지 않음
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as any;
if (axiosError.response?.status !== 409) {
console.error("JSON 관계도 생성 오류:", error);
}
} else {
console.error("JSON 관계도 생성 오류:", error);
}
throw error;
}
}
@ -859,7 +867,15 @@ export class DataFlowAPI {
return response.data.data as JsonDataFlowDiagram;
} catch (error) {
console.error("JSON 관계도 수정 오류:", error);
// 409 에러(중복 이름)는 콘솔 로그 출력하지 않음
if (error && typeof error === "object" && "response" in error) {
const axiosError = error as any;
if (axiosError.response?.status !== 409) {
console.error("JSON 관계도 수정 오류:", error);
}
} else {
console.error("JSON 관계도 수정 오류:", error);
}
throw error;
}
}

View File

@ -0,0 +1,93 @@
import { apiClient } from "./client";
export interface EntityReferenceOption {
value: string;
label: string;
}
export interface EntityReferenceData {
options: EntityReferenceOption[];
referenceInfo: {
referenceTable: string;
referenceColumn: string;
displayColumn: string | null;
};
}
export interface CodeReferenceData {
options: EntityReferenceOption[];
codeCategory: string;
}
export interface ApiResponse<T> {
success: boolean;
message: string;
data?: T;
error?: string;
}
export class EntityReferenceAPI {
/**
*
*/
static async getEntityReferenceData(
tableName: string,
columnName: string,
options: {
limit?: number;
search?: string;
} = {},
): Promise<EntityReferenceData> {
try {
const params = new URLSearchParams();
if (options.limit) params.append("limit", options.limit.toString());
if (options.search) params.append("search", options.search);
const queryString = params.toString();
const url = `/entity-reference/${tableName}/${columnName}${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get<ApiResponse<EntityReferenceData>>(url);
if (!response.data.success || !response.data.data) {
throw new Error(response.data.message || "엔티티 참조 데이터 조회에 실패했습니다.");
}
return response.data.data;
} catch (error) {
console.error("엔티티 참조 데이터 조회 오류:", error);
throw error instanceof Error ? error : new Error("엔티티 참조 데이터 조회 중 오류가 발생했습니다.");
}
}
/**
*
*/
static async getCodeReferenceData(
codeCategory: string,
options: {
limit?: number;
search?: string;
} = {},
): Promise<CodeReferenceData> {
try {
const params = new URLSearchParams();
if (options.limit) params.append("limit", options.limit.toString());
if (options.search) params.append("search", options.search);
const queryString = params.toString();
const url = `/entity-reference/code/${codeCategory}${queryString ? `?${queryString}` : ""}`;
const response = await apiClient.get<ApiResponse<CodeReferenceData>>(url);
if (!response.data.success || !response.data.data) {
throw new Error(response.data.message || "공통 코드 데이터 조회에 실패했습니다.");
}
return response.data.data;
} catch (error) {
console.error("공통 코드 데이터 조회 오류:", error);
throw error instanceof Error ? error : new Error("공통 코드 데이터 조회 중 오류가 발생했습니다.");
}
}
}

View File

@ -48,6 +48,7 @@ export interface DataSaveSettings {
id: string;
name: string;
actionType: "insert" | "update" | "delete" | "upsert";
logicalOperator?: "AND" | "OR"; // 액션 간 논리 연산자 (첫 번째 액션 제외)
conditions?: ConditionNode[];
fieldMappings: Array<{
sourceTable?: string;