Compare commits
113 Commits
95c98cbda3
...
26649b78f3
| Author | SHA1 | Date |
|---|---|---|
|
|
26649b78f3 | |
|
|
75865e2283 | |
|
|
87bec6760a | |
|
|
f88bedc0ab | |
|
|
a53940cff9 | |
|
|
cabaada5b8 | |
|
|
c9e5e2ee72 | |
|
|
faf201dfc6 | |
|
|
3672bbd997 | |
|
|
534d174234 | |
|
|
c99936cef0 | |
|
|
0dc4d53876 | |
|
|
39e3aa14cb | |
|
|
3d48c0f7cb | |
|
|
f73229eeeb | |
|
|
cf909cded6 | |
|
|
fbb42dd83c | |
|
|
b6eaaed85e | |
|
|
7a10b2652c | |
|
|
2dea3cfaa0 | |
|
|
51dea84bc5 | |
|
|
df64841c1e | |
|
|
25cf0b77a1 | |
|
|
b6f93e686d | |
|
|
d1b2e6c010 | |
|
|
32024a6d70 | |
|
|
9d5ac1716d | |
|
|
6e41fdf039 | |
|
|
8046c2a2e0 | |
|
|
2849f7e116 | |
|
|
ec35ca303f | |
|
|
3ec2fe7ca6 | |
|
|
2d8f5a184d | |
|
|
0a134d85c2 | |
|
|
1315cfedc4 | |
|
|
1274f58c3c | |
|
|
71eb308bba | |
|
|
f456ab89e8 | |
|
|
7828b5e073 | |
|
|
43476ae376 | |
|
|
f2e66766c9 | |
|
|
68308efd22 | |
|
|
559c3fc98f | |
|
|
25c54fae68 | |
|
|
f7b9a5db1c | |
|
|
8efdb93a1c | |
|
|
1eff6730b4 | |
|
|
1760703150 | |
|
|
e48cc4decc | |
|
|
57c4e8317d | |
|
|
258bd80201 | |
|
|
9223a50672 | |
|
|
c67d8ddbdd | |
|
|
c27817b4f3 | |
|
|
c076cd0b14 | |
|
|
0455b1ee43 | |
|
|
af75b91371 | |
|
|
29b6143bca | |
|
|
97c16a0895 | |
|
|
fb9199a38a | |
|
|
f4b32d92be | |
|
|
37e018b33c | |
|
|
9097ab8b68 | |
|
|
0743786f9b | |
|
|
a219878288 | |
|
|
734e78c2da | |
|
|
4e1e5b0d51 | |
|
|
27e33e27d1 | |
|
|
c23d372bcd | |
|
|
c9c416d6fd | |
|
|
fdc476a9e0 | |
|
|
db25b0435f | |
|
|
ae616ae611 | |
|
|
c52937c22d | |
|
|
e697acb2c9 | |
|
|
b32b05a76c | |
|
|
ae23cfdc2b | |
|
|
f19db38973 | |
|
|
c4b92f0710 | |
|
|
ed908b2330 | |
|
|
7d801c0a2b | |
|
|
3f76d16afe | |
|
|
114928ca4f | |
|
|
dfa642798e | |
|
|
d83264181c | |
|
|
f8be19c49f | |
|
|
d01ade4e4f | |
|
|
a1ddf4678d | |
|
|
172ecf34b3 | |
|
|
722a413916 | |
|
|
43cdacb194 | |
|
|
46aa81ce6f | |
|
|
771dc8cf56 | |
|
|
c5c6d9239c | |
|
|
ae23a4408e | |
|
|
1c00ee28e8 | |
|
|
62d36abb65 | |
|
|
2ee4dd0b58 | |
|
|
12087cbdd7 | |
|
|
dfac694e4d | |
|
|
de97c40517 | |
|
|
7cefc39b74 | |
|
|
7a588b47f6 | |
|
|
579d4224d5 | |
|
|
6a221d3e7e | |
|
|
aad1a7b447 | |
|
|
21caac8c63 | |
|
|
93e5331d6c | |
|
|
bd72f7892b | |
|
|
213f482a6f | |
|
|
4b52d6aed8 | |
|
|
ede881f8ff | |
|
|
7fc3111c4d |
|
|
@ -22,7 +22,7 @@
|
|||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"mailparser": "^3.7.5",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -995,6 +996,15 @@
|
|||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@azure/msal-node/node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
|
||||
|
|
@ -7740,48 +7750,52 @@
|
|||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "3.7.4",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.4.tgz",
|
||||
"integrity": "sha512-Beh4yyR4jLq3CZZ32asajByrXnW8dLyKCAQD3WvtTiBnMtFWhxO+wa93F6sJNjDmfjxXs4NRNjw3XAGLqZR3Vg==",
|
||||
"version": "3.7.5",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.7.5.tgz",
|
||||
"integrity": "sha512-o59RgZC+4SyCOn4xRH1mtRiZ1PbEmi6si6Ufnd3tbX/V9zmZN1qcqu8xbXY62H6CwIclOT3ppm5u/wV2nujn4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encoding-japanese": "2.2.0",
|
||||
"he": "1.2.0",
|
||||
"html-to-text": "9.0.5",
|
||||
"iconv-lite": "0.6.3",
|
||||
"iconv-lite": "0.7.0",
|
||||
"libmime": "5.3.7",
|
||||
"linkify-it": "5.0.0",
|
||||
"mailsplit": "5.4.5",
|
||||
"nodemailer": "7.0.4",
|
||||
"mailsplit": "5.4.6",
|
||||
"nodemailer": "7.0.9",
|
||||
"punycode.js": "2.3.1",
|
||||
"tlds": "1.259.0"
|
||||
"tlds": "1.260.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
|
||||
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser/node_modules/nodemailer": {
|
||||
"version": "7.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.4.tgz",
|
||||
"integrity": "sha512-9O00Vh89/Ld2EcVCqJ/etd7u20UhME0f/NToPfArwPEe1Don1zy4mAIz6ariRr7mJ2RDxtaDzN0WJVdVXPtZaw==",
|
||||
"version": "7.0.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.9.tgz",
|
||||
"integrity": "sha512-9/Qm0qXIByEP8lEV2qOqcAW7bRpL8CR9jcTwk3NBnHJNmP9fIJ86g2fgmIXqHY+nj55ZEMwWqYAT2QTDpRUYiQ==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mailsplit": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.5.tgz",
|
||||
"integrity": "sha512-oMfhmvclR689IIaQmIcR5nODnZRRVwAKtqFT407TIvmhX2OLUBnshUTcxzQBt3+96sZVDud9NfSe1NxAkUNXEQ==",
|
||||
"version": "5.4.6",
|
||||
"resolved": "https://registry.npmjs.org/mailsplit/-/mailsplit-5.4.6.tgz",
|
||||
"integrity": "sha512-M+cqmzaPG/mEiCDmqQUz8L177JZLZmXAUpq38owtpq2xlXlTSw+kntnxRt2xsxVFFV6+T8Mj/U0l5s7s6e0rNw==",
|
||||
"license": "(MIT OR EUPL-1.1+)",
|
||||
"dependencies": {
|
||||
"libbase64": "1.3.0",
|
||||
|
|
@ -9800,9 +9814,9 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tlds": {
|
||||
"version": "1.259.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.259.0.tgz",
|
||||
"integrity": "sha512-AldGGlDP0PNgwppe2quAvuBl18UcjuNtOnDuUkqhd6ipPqrYYBt3aTxK1QTsBVknk97lS2JcafWMghjGWFtunw==",
|
||||
"version": "1.260.0",
|
||||
"resolved": "https://registry.npmjs.org/tlds/-/tlds-1.260.0.tgz",
|
||||
"integrity": "sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tlds": "bin.js"
|
||||
|
|
@ -10170,12 +10184,16 @@
|
|||
}
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "8.3.2",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
|
||||
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
|
||||
"version": "13.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz",
|
||||
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/v8-compile-cache-lib": {
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@
|
|||
"imap": "^0.8.19",
|
||||
"joi": "^17.11.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mailparser": "^3.7.4",
|
||||
"mailparser": "^3.7.5",
|
||||
"mssql": "^11.0.1",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.15.0",
|
||||
|
|
@ -45,6 +45,7 @@
|
|||
"oracledb": "^6.9.0",
|
||||
"pg": "^8.16.3",
|
||||
"redis": "^4.6.10",
|
||||
"uuid": "^13.0.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ import externalCallRoutes from "./routes/externalCallRoutes";
|
|||
import externalCallConfigRoutes from "./routes/externalCallConfigRoutes";
|
||||
import dataflowExecutionRoutes from "./routes/dataflowExecutionRoutes";
|
||||
import dashboardRoutes from "./routes/dashboardRoutes";
|
||||
import reportRoutes from "./routes/reportRoutes";
|
||||
import openApiProxyRoutes from "./routes/openApiProxyRoutes"; // 날씨/환율 API
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -76,21 +78,30 @@ app.use(compression());
|
|||
app.use(express.json({ limit: "50mb" }));
|
||||
app.use(express.urlencoded({ extended: true, limit: "50mb" }));
|
||||
|
||||
// 정적 파일 서빙 전에 CORS 미들웨어 추가 (OPTIONS 요청 처리)
|
||||
app.options("/uploads/*", (req, res) => {
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||
res.sendStatus(200);
|
||||
});
|
||||
|
||||
// 정적 파일 서빙 (업로드된 파일들)
|
||||
app.use(
|
||||
"/uploads",
|
||||
express.static(path.join(process.cwd(), "uploads"), {
|
||||
setHeaders: (res, path) => {
|
||||
// 파일 서빙 시 CORS 헤더 설정
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
},
|
||||
})
|
||||
(req, res, next) => {
|
||||
// 모든 정적 파일 요청에 CORS 헤더 추가
|
||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||
res.setHeader(
|
||||
"Access-Control-Allow-Headers",
|
||||
"Content-Type, Authorization"
|
||||
);
|
||||
res.setHeader("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
res.setHeader("Cache-Control", "public, max-age=3600");
|
||||
next();
|
||||
},
|
||||
express.static(path.join(process.cwd(), "uploads"))
|
||||
);
|
||||
|
||||
// CORS 설정 - environment.ts에서 이미 올바른 형태로 처리됨
|
||||
|
|
@ -194,6 +205,8 @@ app.use("/api/external-calls", externalCallRoutes);
|
|||
app.use("/api/external-call-configs", externalCallConfigRoutes);
|
||||
app.use("/api/dataflow", dataflowExecutionRoutes);
|
||||
app.use("/api/dashboards", dashboardRoutes);
|
||||
app.use("/api/admin/reports", reportRoutes);
|
||||
app.use("/api/open-api", openApiProxyRoutes); // 날씨/환율 외부 API
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -2,12 +2,19 @@ import multer from 'multer';
|
|||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
// 업로드 디렉토리 경로
|
||||
const UPLOAD_DIR = path.join(__dirname, '../../uploads/mail-attachments');
|
||||
// 업로드 디렉토리 경로 (운영: /app/uploads/mail-attachments, 개발: 프로젝트 루트)
|
||||
const UPLOAD_DIR = process.env.NODE_ENV === 'production'
|
||||
? '/app/uploads/mail-attachments'
|
||||
: path.join(process.cwd(), 'uploads', 'mail-attachments');
|
||||
|
||||
// 디렉토리 생성 (없으면)
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
try {
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('메일 첨부파일 디렉토리 생성 실패:', error);
|
||||
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
|
||||
}
|
||||
|
||||
// 간단한 파일명 정규화 함수 (한글-분석.txt 방식)
|
||||
|
|
|
|||
|
|
@ -236,11 +236,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
if (fieldMap[searchField as string]) {
|
||||
if (searchField === "tel") {
|
||||
whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`);
|
||||
whereConditions.push(
|
||||
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
|
||||
);
|
||||
queryParams.push(`%${searchValue}%`);
|
||||
paramIndex++;
|
||||
} else {
|
||||
whereConditions.push(`${fieldMap[searchField as string]} ILIKE $${paramIndex}`);
|
||||
whereConditions.push(
|
||||
`${fieldMap[searchField as string]} ILIKE $${paramIndex}`
|
||||
);
|
||||
queryParams.push(`%${searchValue}%`);
|
||||
paramIndex++;
|
||||
}
|
||||
|
|
@ -271,7 +275,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
|
||||
// 전화번호 검색
|
||||
if (search_tel && typeof search_tel === "string" && search_tel.trim()) {
|
||||
whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`);
|
||||
whereConditions.push(
|
||||
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
|
||||
);
|
||||
queryParams.push(`%${search_tel.trim()}%`);
|
||||
paramIndex++;
|
||||
hasAdvancedSearch = true;
|
||||
|
|
@ -305,9 +311,10 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 총 개수 조회
|
||||
const countQuery = `
|
||||
|
|
@ -345,7 +352,11 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||
`;
|
||||
|
||||
const users = await query<any>(usersQuery, [...queryParams, Number(countPerPage), offset]);
|
||||
const users = await query<any>(usersQuery, [
|
||||
...queryParams,
|
||||
Number(countPerPage),
|
||||
offset,
|
||||
]);
|
||||
|
||||
// 응답 데이터 가공
|
||||
const processedUsers = users.map((user) => ({
|
||||
|
|
@ -365,7 +376,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
|||
status: user.status || "active",
|
||||
companyCode: user.company_code || null,
|
||||
locale: user.locale || null,
|
||||
regDate: user.regdate ? new Date(user.regdate).toISOString().split("T")[0] : null,
|
||||
regDate: user.regdate
|
||||
? new Date(user.regdate).toISOString().split("T")[0]
|
||||
: null,
|
||||
}));
|
||||
|
||||
const response = {
|
||||
|
|
@ -498,10 +511,10 @@ export const setUserLocale = async (
|
|||
}
|
||||
|
||||
// Raw Query로 사용자 로케일 저장
|
||||
await query(
|
||||
"UPDATE user_info SET locale = $1 WHERE user_id = $2",
|
||||
[locale, req.user.userId]
|
||||
);
|
||||
await query("UPDATE user_info SET locale = $1 WHERE user_id = $2", [
|
||||
locale,
|
||||
req.user.userId,
|
||||
]);
|
||||
|
||||
logger.info("사용자 로케일을 데이터베이스에 저장 완료", {
|
||||
locale,
|
||||
|
|
@ -680,9 +693,13 @@ export async function getLangKeyList(
|
|||
langKey: row.lang_key,
|
||||
description: row.description,
|
||||
isActive: row.is_active,
|
||||
createdDate: row.created_date ? new Date(row.created_date).toISOString() : null,
|
||||
createdDate: row.created_date
|
||||
? new Date(row.created_date).toISOString()
|
||||
: null,
|
||||
createdBy: row.created_by,
|
||||
updatedDate: row.updated_date ? new Date(row.updated_date).toISOString() : null,
|
||||
updatedDate: row.updated_date
|
||||
? new Date(row.updated_date).toISOString()
|
||||
: null,
|
||||
updatedBy: row.updated_by,
|
||||
}));
|
||||
|
||||
|
|
@ -1008,8 +1025,20 @@ export async function saveMenu(
|
|||
const menuData = req.body;
|
||||
logger.info("메뉴 저장 요청", { menuData, user: req.user });
|
||||
|
||||
// 사용자의 company_code 확인
|
||||
if (!req.user?.companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "사용자의 회사 코드를 찾을 수 없습니다.",
|
||||
error: "Missing company_code",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Raw Query를 사용한 메뉴 저장
|
||||
const objid = Date.now(); // 고유 ID 생성
|
||||
const companyCode = req.user.companyCode;
|
||||
|
||||
const [savedMenu] = await query<any>(
|
||||
`INSERT INTO menu_info (
|
||||
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||
|
|
@ -1030,7 +1059,7 @@ export async function saveMenu(
|
|||
new Date(),
|
||||
menuData.status || "active",
|
||||
menuData.systemName || null,
|
||||
menuData.companyCode || "*",
|
||||
companyCode,
|
||||
menuData.langKey || null,
|
||||
menuData.langKeyDesc || null,
|
||||
]
|
||||
|
|
@ -1079,6 +1108,18 @@ export async function updateMenu(
|
|||
user: req.user,
|
||||
});
|
||||
|
||||
// 사용자의 company_code 확인
|
||||
if (!req.user?.companyCode) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "사용자의 회사 코드를 찾을 수 없습니다.",
|
||||
error: "Missing company_code",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const companyCode = req.user.companyCode;
|
||||
|
||||
// Raw Query를 사용한 메뉴 수정
|
||||
const [updatedMenu] = await query<any>(
|
||||
`UPDATE menu_info SET
|
||||
|
|
@ -1106,7 +1147,7 @@ export async function updateMenu(
|
|||
menuData.menuDesc || null,
|
||||
menuData.status || "active",
|
||||
menuData.systemName || null,
|
||||
menuData.companyCode || "*",
|
||||
companyCode,
|
||||
menuData.langKey || null,
|
||||
menuData.langKeyDesc || null,
|
||||
Number(menuId),
|
||||
|
|
@ -1356,9 +1397,10 @@ export const getDepartmentList = async (
|
|||
paramIndex++;
|
||||
}
|
||||
|
||||
const whereClause = whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
const whereClause =
|
||||
whereConditions.length > 0
|
||||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
const departments = await query<any>(
|
||||
`SELECT
|
||||
|
|
@ -1970,7 +2012,9 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
|||
);
|
||||
|
||||
// 기존 사용자인지 새 사용자인지 확인 (regdate로 판단)
|
||||
const isUpdate = savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||
const isUpdate =
|
||||
savedUser.regdate &&
|
||||
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||
|
||||
logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", {
|
||||
userId: userData.userId,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,539 @@
|
|||
/**
|
||||
* 리포트 관리 컨트롤러
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import reportService from "../services/reportService";
|
||||
import {
|
||||
CreateReportRequest,
|
||||
UpdateReportRequest,
|
||||
SaveLayoutRequest,
|
||||
CreateTemplateRequest,
|
||||
} from "../types/report";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
export class ReportController {
|
||||
/**
|
||||
* 리포트 목록 조회
|
||||
* GET /api/admin/reports
|
||||
*/
|
||||
async getReports(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const {
|
||||
page = "1",
|
||||
limit = "20",
|
||||
searchText = "",
|
||||
reportType = "",
|
||||
useYn = "Y",
|
||||
sortBy = "created_at",
|
||||
sortOrder = "DESC",
|
||||
} = req.query;
|
||||
|
||||
const result = await reportService.getReports({
|
||||
page: parseInt(page as string, 10),
|
||||
limit: parseInt(limit as string, 10),
|
||||
searchText: searchText as string,
|
||||
reportType: reportType as string,
|
||||
useYn: useYn as string,
|
||||
sortBy: sortBy as string,
|
||||
sortOrder: sortOrder as "ASC" | "DESC",
|
||||
});
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 상세 조회
|
||||
* GET /api/admin/reports/:reportId
|
||||
*/
|
||||
async getReportById(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
|
||||
const report = await reportService.getReportById(reportId);
|
||||
|
||||
if (!report) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: report,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 생성
|
||||
* POST /api/admin/reports
|
||||
*/
|
||||
async createReport(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data: CreateReportRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.reportNameKor || !data.reportType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "리포트명과 리포트 타입은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const reportId = await reportService.createReport(data, userId);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
reportId,
|
||||
},
|
||||
message: "리포트가 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 수정
|
||||
* PUT /api/admin/reports/:reportId
|
||||
*/
|
||||
async updateReport(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
const data: UpdateReportRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
const success = await reportService.updateReport(reportId, data, userId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "수정할 내용이 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "리포트가 수정되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 삭제
|
||||
* DELETE /api/admin/reports/:reportId
|
||||
*/
|
||||
async deleteReport(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
|
||||
const success = await reportService.deleteReport(reportId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "리포트가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 리포트 복사
|
||||
* POST /api/admin/reports/:reportId/copy
|
||||
*/
|
||||
async copyReport(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
const newReportId = await reportService.copyReport(reportId, userId);
|
||||
|
||||
if (!newReportId) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "리포트를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
reportId: newReportId,
|
||||
},
|
||||
message: "리포트가 복사되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 조회
|
||||
* GET /api/admin/reports/:reportId/layout
|
||||
*/
|
||||
async getLayout(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
|
||||
const layout = await reportService.getLayout(reportId);
|
||||
|
||||
if (!layout) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "레이아웃을 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
// components JSON 파싱
|
||||
const layoutData = {
|
||||
...layout,
|
||||
components: layout.components ? JSON.parse(layout.components) : [],
|
||||
};
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: layoutData,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 저장
|
||||
* PUT /api/admin/reports/:reportId/layout
|
||||
*/
|
||||
async saveLayout(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
const data: SaveLayoutRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (
|
||||
!data.canvasWidth ||
|
||||
!data.canvasHeight ||
|
||||
!data.pageOrientation ||
|
||||
!data.components
|
||||
) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "필수 레이아웃 정보가 누락되었습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await reportService.saveLayout(reportId, data, userId);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "레이아웃이 저장되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 목록 조회
|
||||
* GET /api/admin/reports/templates
|
||||
*/
|
||||
async getTemplates(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const templates = await reportService.getTemplates();
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: templates,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 생성
|
||||
* POST /api/admin/reports/templates
|
||||
*/
|
||||
async createTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const data: CreateTemplateRequest = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!data.templateNameKor || !data.templateType) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명과 템플릿 타입은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const templateId = await reportService.createTemplate(data, userId);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
templateId,
|
||||
},
|
||||
message: "템플릿이 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 리포트를 템플릿으로 저장
|
||||
* POST /api/admin/reports/:reportId/save-as-template
|
||||
*/
|
||||
async saveAsTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId } = req.params;
|
||||
const { templateNameKor, templateNameEng, description } = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!templateNameKor) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const templateId = await reportService.saveAsTemplate(
|
||||
reportId,
|
||||
templateNameKor,
|
||||
templateNameEng,
|
||||
description,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
templateId,
|
||||
},
|
||||
message: "템플릿이 저장되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
* POST /api/admin/reports/templates/create-from-layout
|
||||
*/
|
||||
async createTemplateFromLayout(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const {
|
||||
templateNameKor,
|
||||
templateNameEng,
|
||||
templateType,
|
||||
description,
|
||||
layoutConfig,
|
||||
defaultQueries = [],
|
||||
} = req.body;
|
||||
const userId = (req as any).user?.userId || "SYSTEM";
|
||||
|
||||
// 필수 필드 검증
|
||||
if (!templateNameKor) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "템플릿명은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!layoutConfig) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "레이아웃 설정은 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const templateId = await reportService.createTemplateFromLayout(
|
||||
templateNameKor,
|
||||
templateNameEng,
|
||||
templateType || "GENERAL",
|
||||
description,
|
||||
layoutConfig,
|
||||
defaultQueries,
|
||||
userId
|
||||
);
|
||||
|
||||
return res.status(201).json({
|
||||
success: true,
|
||||
data: {
|
||||
templateId,
|
||||
},
|
||||
message: "템플릿이 생성되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 삭제
|
||||
* DELETE /api/admin/reports/templates/:templateId
|
||||
*/
|
||||
async deleteTemplate(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { templateId } = req.params;
|
||||
|
||||
const success = await reportService.deleteTemplate(templateId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "템플릿을 찾을 수 없거나 시스템 템플릿입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "템플릿이 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행
|
||||
* POST /api/admin/reports/:reportId/queries/:queryId/execute
|
||||
*/
|
||||
async executeQuery(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
const { reportId, queryId } = req.params;
|
||||
const { parameters = {}, sqlQuery, externalConnectionId } = req.body;
|
||||
|
||||
const result = await reportService.executeQuery(
|
||||
reportId,
|
||||
queryId,
|
||||
parameters,
|
||||
sqlQuery,
|
||||
externalConnectionId
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: result,
|
||||
});
|
||||
} catch (error: any) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: error.message || "쿼리 실행에 실패했습니다.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 연결 목록 조회 (활성화된 것만)
|
||||
* GET /api/admin/reports/external-connections
|
||||
*/
|
||||
async getExternalConnections(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
) {
|
||||
try {
|
||||
const { ExternalDbConnectionService } = await import(
|
||||
"../services/externalDbConnectionService"
|
||||
);
|
||||
|
||||
const result = await ExternalDbConnectionService.getConnections({
|
||||
is_active: "Y",
|
||||
company_code: req.body.companyCode || "",
|
||||
});
|
||||
|
||||
return res.json(result);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일 업로드
|
||||
* POST /api/admin/reports/upload-image
|
||||
*/
|
||||
async uploadImage(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "이미지 파일이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const companyCode = req.body.companyCode || "SYSTEM";
|
||||
const file = req.file;
|
||||
|
||||
// 파일 저장 경로 생성
|
||||
const uploadDir = path.join(
|
||||
process.cwd(),
|
||||
"uploads",
|
||||
`company_${companyCode}`,
|
||||
"reports"
|
||||
);
|
||||
|
||||
// 디렉토리가 없으면 생성
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 고유한 파일명 생성 (타임스탬프 + 원본 파일명)
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, "_");
|
||||
const fileName = `${timestamp}_${safeFileName}`;
|
||||
const filePath = path.join(uploadDir, fileName);
|
||||
|
||||
// 파일 저장
|
||||
fs.writeFileSync(filePath, file.buffer);
|
||||
|
||||
// 웹에서 접근 가능한 URL 반환
|
||||
const fileUrl = `/uploads/company_${companyCode}/reports/${fileName}`;
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
fileName,
|
||||
fileUrl,
|
||||
originalName: file.originalname,
|
||||
size: file.size,
|
||||
mimeType: file.mimetype,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new ReportController();
|
||||
|
|
@ -103,15 +103,34 @@ export class OracleConnector implements DatabaseConnector {
|
|||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 쿼리 타입 확인 (DML인지 SELECT인지)
|
||||
// 쿼리 타입 확인
|
||||
const isDML = /^\s*(INSERT|UPDATE|DELETE|MERGE)/i.test(query);
|
||||
const isCOMMIT = /^\s*COMMIT/i.test(query);
|
||||
const isROLLBACK = /^\s*ROLLBACK/i.test(query);
|
||||
|
||||
// 🔥 COMMIT/ROLLBACK 명령은 직접 실행
|
||||
if (isCOMMIT || isROLLBACK) {
|
||||
if (isCOMMIT) {
|
||||
await (this.connection as any).commit();
|
||||
console.log("✅ Oracle COMMIT 실행됨");
|
||||
} else {
|
||||
await (this.connection as any).rollback();
|
||||
console.log("⚠️ Oracle ROLLBACK 실행됨");
|
||||
}
|
||||
return {
|
||||
rows: [],
|
||||
rowCount: 0,
|
||||
fields: [],
|
||||
affectedRows: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Oracle XE 21c 쿼리 실행 옵션
|
||||
const options: any = {
|
||||
outFormat: (oracledb as any).OUT_FORMAT_OBJECT, // OBJECT format
|
||||
maxRows: 10000, // XE 제한 고려
|
||||
fetchArraySize: 100,
|
||||
autoCommit: isDML, // ✅ DML 쿼리는 자동 커밋
|
||||
autoCommit: false, // 🔥 수동으로 COMMIT 제어하도록 변경
|
||||
};
|
||||
|
||||
console.log("Oracle 쿼리 실행:", {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
import axios, { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector';
|
||||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import axios, { AxiosInstance, AxiosResponse } from "axios";
|
||||
import {
|
||||
DatabaseConnector,
|
||||
ConnectionConfig,
|
||||
QueryResult,
|
||||
} from "../interfaces/DatabaseConnector";
|
||||
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||
|
||||
export interface RestApiConfig {
|
||||
baseUrl: string;
|
||||
|
|
@ -26,10 +30,10 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
baseURL: config.baseUrl,
|
||||
timeout: config.timeout || 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
// 요청/응답 인터셉터 설정
|
||||
|
|
@ -40,11 +44,13 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
// 요청 인터셉터
|
||||
this.httpClient.interceptors.request.use(
|
||||
(config) => {
|
||||
console.log(`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`);
|
||||
console.log(
|
||||
`[RestApiConnector] 요청: ${config.method?.toUpperCase()} ${config.url}`
|
||||
);
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 요청 오류:', error);
|
||||
console.error("[RestApiConnector] 요청 오류:", error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
@ -52,11 +58,17 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
// 응답 인터셉터
|
||||
this.httpClient.interceptors.response.use(
|
||||
(response) => {
|
||||
console.log(`[RestApiConnector] 응답: ${response.status} ${response.statusText}`);
|
||||
console.log(
|
||||
`[RestApiConnector] 응답: ${response.status} ${response.statusText}`
|
||||
);
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[RestApiConnector] 응답 오류:', error.response?.status, error.response?.statusText);
|
||||
console.error(
|
||||
"[RestApiConnector] 응답 오류:",
|
||||
error.response?.status,
|
||||
error.response?.statusText
|
||||
);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
|
@ -65,16 +77,23 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
async connect(): Promise<void> {
|
||||
try {
|
||||
// 연결 테스트 - 기본 엔드포인트 호출
|
||||
await this.httpClient.get('/health', { timeout: 5000 });
|
||||
await this.httpClient.get("/health", { timeout: 5000 });
|
||||
console.log(`[RestApiConnector] 연결 성공: ${this.config.baseUrl}`);
|
||||
} catch (error) {
|
||||
// health 엔드포인트가 없을 수 있으므로 404는 정상으로 처리
|
||||
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||
console.log(`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`);
|
||||
console.log(
|
||||
`[RestApiConnector] 연결 성공 (health 엔드포인트 없음): ${this.config.baseUrl}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.error(`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`, error);
|
||||
throw new Error(`REST API 연결 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||
console.error(
|
||||
`[RestApiConnector] 연결 실패: ${this.config.baseUrl}`,
|
||||
error
|
||||
);
|
||||
throw new Error(
|
||||
`REST API 연결 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,39 +107,55 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
await this.connect();
|
||||
return {
|
||||
success: true,
|
||||
message: 'REST API 연결이 성공했습니다.',
|
||||
message: "REST API 연결이 성공했습니다.",
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
response_time: Date.now(),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : 'REST API 연결에 실패했습니다.',
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "REST API 연결에 실패했습니다.",
|
||||
details: {
|
||||
response_time: Date.now()
|
||||
}
|
||||
response_time: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async executeQuery(endpoint: string, method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', data?: any): Promise<QueryResult> {
|
||||
// 🔥 DatabaseConnector 인터페이스 호환용 executeQuery (사용하지 않음)
|
||||
async executeQuery(query: string, params?: any[]): Promise<QueryResult> {
|
||||
// REST API는 executeRequest를 사용해야 함
|
||||
throw new Error(
|
||||
"REST API Connector는 executeQuery를 지원하지 않습니다. executeRequest를 사용하세요."
|
||||
);
|
||||
}
|
||||
|
||||
// 🔥 실제 REST API 요청을 위한 메서드
|
||||
async executeRequest(
|
||||
endpoint: string,
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" = "GET",
|
||||
data?: any
|
||||
): Promise<QueryResult> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
let response: AxiosResponse;
|
||||
|
||||
// HTTP 메서드에 따른 요청 실행
|
||||
switch (method.toUpperCase()) {
|
||||
case 'GET':
|
||||
case "GET":
|
||||
response = await this.httpClient.get(endpoint);
|
||||
break;
|
||||
case 'POST':
|
||||
case "POST":
|
||||
response = await this.httpClient.post(endpoint, data);
|
||||
break;
|
||||
case 'PUT':
|
||||
case "PUT":
|
||||
response = await this.httpClient.put(endpoint, data);
|
||||
break;
|
||||
case 'DELETE':
|
||||
case "DELETE":
|
||||
response = await this.httpClient.delete(endpoint);
|
||||
break;
|
||||
default:
|
||||
|
|
@ -133,21 +168,36 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
console.log(`[RestApiConnector] 원본 응답 데이터:`, {
|
||||
type: typeof responseData,
|
||||
isArray: Array.isArray(responseData),
|
||||
keys: typeof responseData === 'object' ? Object.keys(responseData) : 'not object',
|
||||
responseData: responseData
|
||||
keys:
|
||||
typeof responseData === "object"
|
||||
? Object.keys(responseData)
|
||||
: "not object",
|
||||
responseData: responseData,
|
||||
});
|
||||
|
||||
// 응답 데이터 처리
|
||||
let rows: any[];
|
||||
if (Array.isArray(responseData)) {
|
||||
rows = responseData;
|
||||
} else if (responseData && responseData.data && Array.isArray(responseData.data)) {
|
||||
} else if (
|
||||
responseData &&
|
||||
responseData.data &&
|
||||
Array.isArray(responseData.data)
|
||||
) {
|
||||
// API 응답이 {success: true, data: [...]} 형태인 경우
|
||||
rows = responseData.data;
|
||||
} else if (responseData && responseData.data && typeof responseData.data === 'object') {
|
||||
} else if (
|
||||
responseData &&
|
||||
responseData.data &&
|
||||
typeof responseData.data === "object"
|
||||
) {
|
||||
// API 응답이 {success: true, data: {...}} 형태인 경우 (단일 객체)
|
||||
rows = [responseData.data];
|
||||
} else if (responseData && typeof responseData === 'object' && !Array.isArray(responseData)) {
|
||||
} else if (
|
||||
responseData &&
|
||||
typeof responseData === "object" &&
|
||||
!Array.isArray(responseData)
|
||||
) {
|
||||
// 단일 객체 응답인 경우
|
||||
rows = [responseData];
|
||||
} else {
|
||||
|
|
@ -156,8 +206,8 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
|
||||
console.log(`[RestApiConnector] 처리된 rows:`, {
|
||||
rowsLength: rows.length,
|
||||
firstRow: rows.length > 0 ? rows[0] : 'no data',
|
||||
allRows: rows
|
||||
firstRow: rows.length > 0 ? rows[0] : "no data",
|
||||
allRows: rows,
|
||||
});
|
||||
|
||||
console.log(`[RestApiConnector] API 호출 결과:`, {
|
||||
|
|
@ -165,22 +215,32 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
method,
|
||||
status: response.status,
|
||||
rowCount: rows.length,
|
||||
executionTime: `${executionTime}ms`
|
||||
executionTime: `${executionTime}ms`,
|
||||
});
|
||||
|
||||
return {
|
||||
rows: rows,
|
||||
rowCount: rows.length,
|
||||
fields: rows.length > 0 ? Object.keys(rows[0]).map(key => ({ name: key, type: 'string' })) : []
|
||||
fields:
|
||||
rows.length > 0
|
||||
? Object.keys(rows[0]).map((key) => ({ name: key, type: "string" }))
|
||||
: [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`, error);
|
||||
console.error(
|
||||
`[RestApiConnector] API 호출 오류 (${method} ${endpoint}):`,
|
||||
error
|
||||
);
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
throw new Error(`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`);
|
||||
throw new Error(
|
||||
`REST API 호출 실패: ${error.response?.status} ${error.response?.statusText}`
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`REST API 호출 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`);
|
||||
throw new Error(
|
||||
`REST API 호출 실패: ${error instanceof Error ? error.message : "알 수 없는 오류"}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -189,20 +249,20 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
// 일반적인 REST API 엔드포인트들을 반환
|
||||
return [
|
||||
{
|
||||
table_name: '/api/users',
|
||||
table_name: "/api/users",
|
||||
columns: [],
|
||||
description: '사용자 정보 API'
|
||||
description: "사용자 정보 API",
|
||||
},
|
||||
{
|
||||
table_name: '/api/data',
|
||||
table_name: "/api/data",
|
||||
columns: [],
|
||||
description: '기본 데이터 API'
|
||||
description: "기본 데이터 API",
|
||||
},
|
||||
{
|
||||
table_name: '/api/custom',
|
||||
table_name: "/api/custom",
|
||||
columns: [],
|
||||
description: '사용자 정의 엔드포인트'
|
||||
}
|
||||
description: "사용자 정의 엔드포인트",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -213,22 +273,25 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
async getColumns(endpoint: string): Promise<any[]> {
|
||||
try {
|
||||
// GET 요청으로 샘플 데이터를 가져와서 필드 구조 파악
|
||||
const result = await this.executeQuery(endpoint, 'GET');
|
||||
const result = await this.executeRequest(endpoint, "GET");
|
||||
|
||||
if (result.rows.length > 0) {
|
||||
const sampleRow = result.rows[0];
|
||||
return Object.keys(sampleRow).map(key => ({
|
||||
return Object.keys(sampleRow).map((key) => ({
|
||||
column_name: key,
|
||||
data_type: typeof sampleRow[key],
|
||||
is_nullable: 'YES',
|
||||
is_nullable: "YES",
|
||||
column_default: null,
|
||||
description: `${key} 필드`
|
||||
description: `${key} 필드`,
|
||||
}));
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error(`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`, error);
|
||||
console.error(
|
||||
`[RestApiConnector] 컬럼 정보 조회 오류 (${endpoint}):`,
|
||||
error
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -238,24 +301,29 @@ export class RestApiConnector implements DatabaseConnector {
|
|||
}
|
||||
|
||||
// REST API 전용 메서드들
|
||||
async getData(endpoint: string, params?: Record<string, any>): Promise<any[]> {
|
||||
const queryString = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
const result = await this.executeQuery(endpoint + queryString, 'GET');
|
||||
async getData(
|
||||
endpoint: string,
|
||||
params?: Record<string, any>
|
||||
): Promise<any[]> {
|
||||
const queryString = params
|
||||
? "?" + new URLSearchParams(params).toString()
|
||||
: "";
|
||||
const result = await this.executeRequest(endpoint + queryString, "GET");
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async postData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'POST', data);
|
||||
const result = await this.executeRequest(endpoint, "POST", data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async putData(endpoint: string, data: any): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'PUT', data);
|
||||
const result = await this.executeRequest(endpoint, "PUT", data);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
async deleteData(endpoint: string): Promise<any> {
|
||||
const result = await this.executeQuery(endpoint, 'DELETE');
|
||||
const result = await this.executeRequest(endpoint, "DELETE");
|
||||
return result.rows[0];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes';
|
||||
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
|
||||
|
||||
export interface ConnectionConfig {
|
||||
host: string;
|
||||
|
|
@ -15,13 +15,15 @@ export interface QueryResult {
|
|||
rows: any[];
|
||||
rowCount?: number;
|
||||
fields?: any[];
|
||||
affectedRows?: number; // MySQL/MariaDB용
|
||||
length?: number; // 배열 형태로 반환되는 경우
|
||||
}
|
||||
|
||||
export interface DatabaseConnector {
|
||||
connect(): Promise<void>;
|
||||
disconnect(): Promise<void>;
|
||||
testConnection(): Promise<ConnectionTestResult>;
|
||||
executeQuery(query: string): Promise<QueryResult>;
|
||||
executeQuery(query: string, params?: any[]): Promise<QueryResult>; // params 추가
|
||||
getTables(): Promise<TableInfo[]>;
|
||||
getColumns(tableName: string): Promise<any[]>; // 특정 테이블의 컬럼 정보 조회
|
||||
}
|
||||
|
|
@ -0,0 +1,231 @@
|
|||
import { Router, Request, Response } from "express";
|
||||
import {
|
||||
authenticateToken,
|
||||
AuthenticatedRequest,
|
||||
} from "../../middleware/authMiddleware";
|
||||
import { ExternalDbConnectionService } from "../../services/externalDbConnectionService";
|
||||
import { ExternalDbConnectionFilter } from "../../types/externalDbTypes";
|
||||
import logger from "../../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/tested
|
||||
* 노드 플로우용: 테스트에 성공한 외부 DB 커넥션 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/tested",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
logger.info("🔍 노드 플로우용 테스트 완료된 커넥션 조회 요청");
|
||||
|
||||
// 활성 상태의 외부 커넥션 조회
|
||||
const filter: ExternalDbConnectionFilter = {
|
||||
is_active: "Y",
|
||||
};
|
||||
|
||||
const externalConnections =
|
||||
await ExternalDbConnectionService.getConnections(filter);
|
||||
|
||||
if (!externalConnections.success) {
|
||||
return res.status(400).json(externalConnections);
|
||||
}
|
||||
|
||||
// 외부 커넥션들에 대해 연결 테스트 수행 (제한된 병렬 처리 + 타임아웃 관리)
|
||||
const validExternalConnections: any[] = [];
|
||||
const connections = externalConnections.data || [];
|
||||
const MAX_CONCURRENT = 3; // 최대 동시 연결 수
|
||||
const TIMEOUT_MS = 3000; // 타임아웃 3초
|
||||
|
||||
// 청크 단위로 처리 (최대 3개씩)
|
||||
for (let i = 0; i < connections.length; i += MAX_CONCURRENT) {
|
||||
const chunk = connections.slice(i, i + MAX_CONCURRENT);
|
||||
|
||||
const chunkResults = await Promise.allSettled(
|
||||
chunk.map(async (connection) => {
|
||||
let testPromise: Promise<any> | null = null;
|
||||
let timeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
try {
|
||||
// 타임아웃과 함께 테스트 실행
|
||||
testPromise = ExternalDbConnectionService.testConnectionById(
|
||||
connection.id!
|
||||
);
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => {
|
||||
reject(new Error("연결 테스트 타임아웃"));
|
||||
}, TIMEOUT_MS);
|
||||
});
|
||||
|
||||
const testResult = await Promise.race([
|
||||
testPromise,
|
||||
timeoutPromise,
|
||||
]);
|
||||
|
||||
// 타임아웃 정리
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
if (testResult.success) {
|
||||
return {
|
||||
id: connection.id,
|
||||
connection_name: connection.connection_name,
|
||||
description: connection.description,
|
||||
db_type: connection.db_type,
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database_name: connection.database_name,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
// 타임아웃 정리
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
|
||||
// 🔥 타임아웃 시 연결 강제 해제
|
||||
try {
|
||||
const { DatabaseConnectorFactory } = await import(
|
||||
"../../database/DatabaseConnectorFactory"
|
||||
);
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connection.id!,
|
||||
connection.db_type
|
||||
);
|
||||
logger.info(
|
||||
`🧹 타임아웃/실패로 인한 커넥션 정리 완료: ${connection.connection_name}`
|
||||
);
|
||||
} catch (cleanupError) {
|
||||
logger.warn(
|
||||
`커넥션 정리 실패 (ID: ${connection.id}):`,
|
||||
cleanupError instanceof Error
|
||||
? cleanupError.message
|
||||
: cleanupError
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`커넥션 테스트 실패 (ID: ${connection.id}):`,
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// fulfilled 결과만 수집
|
||||
chunkResults.forEach((result) => {
|
||||
if (result.status === "fulfilled" && result.value !== null) {
|
||||
validExternalConnections.push(result.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 다음 청크 처리 전 짧은 대기 (연결 풀 안정화)
|
||||
if (i + MAX_CONCURRENT < connections.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`✅ 테스트 성공한 커넥션: ${validExternalConnections.length}/${externalConnections.data?.length || 0}개`
|
||||
);
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: validExternalConnections,
|
||||
message: `테스트에 성공한 ${validExternalConnections.length}개의 커넥션을 조회했습니다.`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("노드 플로우용 커넥션 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/:id/tables
|
||||
* 특정 외부 DB의 테이블 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/:id/tables",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`🔍 외부 DB 테이블 목록 조회: connectionId=${id}`);
|
||||
|
||||
const result =
|
||||
await ExternalDbConnectionService.getTablesFromConnection(id);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error("외부 DB 테이블 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/dataflow/node-external-connections/:id/tables/:tableName/columns
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
router.get(
|
||||
"/:id/tables/:tableName/columns",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
const { tableName } = req.params;
|
||||
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "유효하지 않은 연결 ID입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
if (!tableName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "테이블명이 필요합니다.",
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`🔍 외부 DB 컬럼 목록 조회: connectionId=${id}, table=${tableName}`
|
||||
);
|
||||
|
||||
const result = await ExternalDbConnectionService.getColumnsFromConnection(
|
||||
id,
|
||||
tableName
|
||||
);
|
||||
|
||||
return res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error("외부 DB 컬럼 목록 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* 노드 기반 데이터 플로우 API
|
||||
*/
|
||||
|
||||
import { Router, Request, Response } from "express";
|
||||
import { query, queryOne } from "../../database/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { NodeFlowExecutionService } from "../../services/nodeFlowExecutionService";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* 플로우 목록 조회
|
||||
*/
|
||||
router.get("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const flows = await query(
|
||||
`
|
||||
SELECT
|
||||
flow_id as "flowId",
|
||||
flow_name as "flowName",
|
||||
flow_description as "flowDescription",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
FROM node_flows
|
||||
ORDER BY updated_at DESC
|
||||
`,
|
||||
[]
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: flows,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 목록 조회 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우 목록을 조회하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 상세 조회
|
||||
*/
|
||||
router.get("/:flowId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
const flow = await queryOne(
|
||||
`
|
||||
SELECT
|
||||
flow_id as "flowId",
|
||||
flow_name as "flowName",
|
||||
flow_description as "flowDescription",
|
||||
flow_data as "flowData",
|
||||
created_at as "createdAt",
|
||||
updated_at as "updatedAt"
|
||||
FROM node_flows
|
||||
WHERE flow_id = $1
|
||||
`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
if (!flow) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: "플로우를 찾을 수 없습니다.",
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
data: flow,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 조회 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 조회하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 저장 (신규)
|
||||
*/
|
||||
router.post("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowName, flowDescription, flowData } = req.body;
|
||||
|
||||
if (!flowName || !flowData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "플로우 이름과 데이터는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
const result = await queryOne(
|
||||
`
|
||||
INSERT INTO node_flows (flow_name, flow_description, flow_data)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING flow_id as "flowId"
|
||||
`,
|
||||
[flowName, flowDescription || "", flowData]
|
||||
);
|
||||
|
||||
logger.info(`플로우 저장 성공: ${result.flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 저장되었습니다.",
|
||||
data: {
|
||||
flowId: result.flowId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 저장 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 저장하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 수정
|
||||
*/
|
||||
router.put("/", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId, flowName, flowDescription, flowData } = req.body;
|
||||
|
||||
if (!flowId || !flowName || !flowData) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: "플로우 ID, 이름, 데이터는 필수입니다.",
|
||||
});
|
||||
}
|
||||
|
||||
await query(
|
||||
`
|
||||
UPDATE node_flows
|
||||
SET flow_name = $1,
|
||||
flow_description = $2,
|
||||
flow_data = $3,
|
||||
updated_at = NOW()
|
||||
WHERE flow_id = $4
|
||||
`,
|
||||
[flowName, flowDescription || "", flowData, flowId]
|
||||
);
|
||||
|
||||
logger.info(`플로우 수정 성공: ${flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 수정되었습니다.",
|
||||
data: {
|
||||
flowId,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 수정 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 수정하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 삭제
|
||||
*/
|
||||
router.delete("/:flowId", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
|
||||
await query(
|
||||
`
|
||||
DELETE FROM node_flows
|
||||
WHERE flow_id = $1
|
||||
`,
|
||||
[flowId]
|
||||
);
|
||||
|
||||
logger.info(`플로우 삭제 성공: ${flowId}`);
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: "플로우가 삭제되었습니다.",
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 삭제 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "플로우를 삭제하지 못했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 플로우 실행
|
||||
* POST /api/dataflow/node-flows/:flowId/execute
|
||||
*/
|
||||
router.post("/:flowId/execute", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { flowId } = req.params;
|
||||
const contextData = req.body;
|
||||
|
||||
logger.info(`🚀 플로우 실행 요청: flowId=${flowId}`, {
|
||||
contextDataKeys: Object.keys(contextData),
|
||||
});
|
||||
|
||||
// 플로우 실행
|
||||
const result = await NodeFlowExecutionService.executeFlow(
|
||||
parseInt(flowId, 10),
|
||||
contextData
|
||||
);
|
||||
|
||||
return res.json({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
data: result,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("플로우 실행 실패:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "플로우 실행 중 오류가 발생했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -21,6 +21,8 @@ import {
|
|||
testConditionalConnection,
|
||||
executeConditionalActions,
|
||||
} from "../controllers/conditionalConnectionController";
|
||||
import nodeFlowsRouter from "./dataflow/node-flows";
|
||||
import nodeExternalConnectionsRouter from "./dataflow/node-external-connections";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
|
|
@ -146,4 +148,16 @@ router.post("/diagrams/:diagramId/test-conditions", testConditionalConnection);
|
|||
*/
|
||||
router.post("/diagrams/:diagramId/execute-actions", executeConditionalActions);
|
||||
|
||||
/**
|
||||
* 노드 기반 플로우 관리
|
||||
* /api/dataflow/node-flows/*
|
||||
*/
|
||||
router.use("/node-flows", nodeFlowsRouter);
|
||||
|
||||
/**
|
||||
* 노드 플로우용 외부 DB 커넥션 관리
|
||||
* /api/dataflow/node-external-connections/*
|
||||
*/
|
||||
router.use("/node-external-connections", nodeExternalConnectionsRouter);
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
|
|
@ -85,6 +85,42 @@ router.get(
|
|||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/external-db-connections/pool-status
|
||||
* 연결 풀 상태 조회
|
||||
*/
|
||||
router.get(
|
||||
"/pool-status",
|
||||
authenticateToken,
|
||||
async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { ExternalDbConnectionPoolService } = await import(
|
||||
"../services/externalDbConnectionPoolService"
|
||||
);
|
||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||
const poolsStatus = poolService.getPoolsStatus();
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
data: {
|
||||
totalPools: poolsStatus.length,
|
||||
activePools: poolsStatus.filter((p) => p.activeConnections > 0)
|
||||
.length,
|
||||
pools: poolsStatus,
|
||||
},
|
||||
message: `${poolsStatus.length}개의 연결 풀 상태를 조회했습니다.`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("연결 풀 상태 조회 오류:", error);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: "서버 내부 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/external-db-connections/grouped
|
||||
* DB 타입별로 그룹화된 외부 DB 연결 목록 조회
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* OpenAPI 프록시 라우트
|
||||
* - 외부 API 호출을 프록시하는 라우트
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import { OpenApiProxyController } from '../controllers/openApiProxyController';
|
||||
// import { authenticateToken } from '../middleware/authMiddleware'; // 공개 API는 인증 불필요
|
||||
|
||||
const router = Router();
|
||||
const controller = new OpenApiProxyController();
|
||||
|
||||
// 날씨, 환율 등 공개 정보는 인증 없이 접근 가능
|
||||
// router.use(authenticateToken);
|
||||
|
||||
/**
|
||||
* GET /api/open-api/weather
|
||||
* 날씨 정보 조회 (인증 불필요)
|
||||
* Query: city (도시명, 기본값: Seoul)
|
||||
*/
|
||||
router.get('/weather', (req, res) => controller.getWeather(req, res));
|
||||
|
||||
/**
|
||||
* GET /api/open-api/exchange-rate
|
||||
* 환율 정보 조회
|
||||
* Query: base (기준 통화, 기본값: KRW), target (대상 통화, 기본값: USD)
|
||||
*/
|
||||
router.get('/exchange-rate', (req, res) => controller.getExchangeRate(req, res));
|
||||
|
||||
/**
|
||||
* POST /api/open-api/geocode
|
||||
* 주소를 좌표로 변환 (Geocoding)
|
||||
* Body: { address: string }
|
||||
*/
|
||||
router.post('/geocode', (req, res) => controller.geocode(req, res));
|
||||
|
||||
export default router;
|
||||
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import { Router } from "express";
|
||||
import reportController from "../controllers/reportController";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import multer from "multer";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Multer 설정 (메모리 저장)
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024, // 10MB 제한
|
||||
},
|
||||
fileFilter: (req, file, cb) => {
|
||||
// 이미지 파일만 허용
|
||||
const allowedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("이미지 파일만 업로드 가능합니다. (jpg, png, gif, webp)"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// 모든 리포트 API는 인증이 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 외부 DB 연결 목록 (구체적인 경로를 먼저 배치)
|
||||
router.get("/external-connections", (req, res, next) =>
|
||||
reportController.getExternalConnections(req, res, next)
|
||||
);
|
||||
|
||||
// 템플릿 관련 라우트
|
||||
router.get("/templates", (req, res, next) =>
|
||||
reportController.getTemplates(req, res, next)
|
||||
);
|
||||
router.post("/templates", (req, res, next) =>
|
||||
reportController.createTemplate(req, res, next)
|
||||
);
|
||||
// 레이아웃 데이터로 직접 템플릿 생성 (리포트 저장 불필요)
|
||||
router.post("/templates/create-from-layout", (req, res, next) =>
|
||||
reportController.createTemplateFromLayout(req, res, next)
|
||||
);
|
||||
router.delete("/templates/:templateId", (req, res, next) =>
|
||||
reportController.deleteTemplate(req, res, next)
|
||||
);
|
||||
|
||||
// 이미지 업로드 (구체적인 경로를 먼저 배치)
|
||||
router.post("/upload-image", upload.single("image"), (req, res, next) =>
|
||||
reportController.uploadImage(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 목록
|
||||
router.get("/", (req, res, next) =>
|
||||
reportController.getReports(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 생성
|
||||
router.post("/", (req, res, next) =>
|
||||
reportController.createReport(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 복사 (구체적인 경로를 먼저 배치)
|
||||
router.post("/:reportId/copy", (req, res, next) =>
|
||||
reportController.copyReport(req, res, next)
|
||||
);
|
||||
|
||||
// 템플릿으로 저장
|
||||
router.post("/:reportId/save-as-template", (req, res, next) =>
|
||||
reportController.saveAsTemplate(req, res, next)
|
||||
);
|
||||
|
||||
// 레이아웃 관련 라우트
|
||||
router.get("/:reportId/layout", (req, res, next) =>
|
||||
reportController.getLayout(req, res, next)
|
||||
);
|
||||
router.put("/:reportId/layout", (req, res, next) =>
|
||||
reportController.saveLayout(req, res, next)
|
||||
);
|
||||
|
||||
// 쿼리 실행
|
||||
router.post("/:reportId/queries/:queryId/execute", (req, res, next) =>
|
||||
reportController.executeQuery(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 상세
|
||||
router.get("/:reportId", (req, res, next) =>
|
||||
reportController.getReportById(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 수정
|
||||
router.put("/:reportId", (req, res, next) =>
|
||||
reportController.updateReport(req, res, next)
|
||||
);
|
||||
|
||||
// 리포트 삭제
|
||||
router.delete("/:reportId", (req, res, next) =>
|
||||
reportController.deleteReport(req, res, next)
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
|
@ -895,13 +895,18 @@ export class BatchExternalDbService {
|
|||
);
|
||||
}
|
||||
|
||||
// 데이터 조회
|
||||
const result = await connector.executeQuery(finalEndpoint, method);
|
||||
// 데이터 조회 (REST API는 executeRequest 사용)
|
||||
let result;
|
||||
if ((connector as any).executeRequest) {
|
||||
result = await (connector as any).executeRequest(finalEndpoint, method);
|
||||
} else {
|
||||
result = await connector.executeQuery(finalEndpoint);
|
||||
}
|
||||
let data = result.rows;
|
||||
|
||||
// 컬럼 필터링 (지정된 컬럼만 추출)
|
||||
if (columns && columns.length > 0) {
|
||||
data = data.map((row) => {
|
||||
data = data.map((row: any) => {
|
||||
const filteredRow: any = {};
|
||||
columns.forEach((col) => {
|
||||
if (row.hasOwnProperty(col)) {
|
||||
|
|
@ -1039,7 +1044,16 @@ export class BatchExternalDbService {
|
|||
);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, requestData);
|
||||
|
||||
await connector.executeQuery(finalEndpoint, method, requestData);
|
||||
// REST API는 executeRequest 사용
|
||||
if ((connector as any).executeRequest) {
|
||||
await (connector as any).executeRequest(
|
||||
finalEndpoint,
|
||||
method,
|
||||
requestData
|
||||
);
|
||||
} else {
|
||||
await connector.executeQuery(finalEndpoint);
|
||||
}
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
|
|
@ -1104,7 +1118,12 @@ export class BatchExternalDbService {
|
|||
);
|
||||
console.log(`[BatchExternalDbService] 전송할 데이터:`, record);
|
||||
|
||||
await connector.executeQuery(endpoint, method, record);
|
||||
// REST API는 executeRequest 사용
|
||||
if ((connector as any).executeRequest) {
|
||||
await (connector as any).executeRequest(endpoint, method, record);
|
||||
} else {
|
||||
await connector.executeQuery(endpoint);
|
||||
}
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
console.error(`REST API 레코드 전송 실패:`, error);
|
||||
|
|
|
|||
|
|
@ -61,23 +61,31 @@ export class BatchService {
|
|||
|
||||
// 배치 설정 조회 (매핑 포함 - 서브쿼리 사용)
|
||||
const batchConfigs = await query<any>(
|
||||
`SELECT bc.*,
|
||||
`SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule,
|
||||
bc.is_active, bc.company_code, bc.created_date, bc.created_by,
|
||||
bc.updated_date, bc.updated_by,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'mapping_id', bm.mapping_id,
|
||||
'batch_id', bm.batch_id,
|
||||
'source_column', bm.source_column,
|
||||
'target_column', bm.target_column,
|
||||
'transformation_rule', bm.transformation_rule
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'mapping_order', bm.mapping_order
|
||||
)
|
||||
) FILTER (WHERE bm.mapping_id IS NOT NULL),
|
||||
) FILTER (WHERE bm.id IS NOT NULL),
|
||||
'[]'
|
||||
) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.batch_id = bm.batch_id
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
${whereClause}
|
||||
GROUP BY bc.batch_id
|
||||
GROUP BY bc.id
|
||||
ORDER BY bc.is_active DESC, bc.batch_name ASC
|
||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
|
||||
[...values, limit, offset]
|
||||
|
|
@ -85,7 +93,7 @@ export class BatchService {
|
|||
|
||||
// 전체 개수 조회
|
||||
const countResult = await queryOne<{ count: string }>(
|
||||
`SELECT COUNT(DISTINCT bc.batch_id) as count
|
||||
`SELECT COUNT(DISTINCT bc.id) as count
|
||||
FROM batch_configs bc
|
||||
${whereClause}`,
|
||||
values
|
||||
|
|
@ -121,29 +129,34 @@ export class BatchService {
|
|||
): Promise<ApiResponse<BatchConfig>> {
|
||||
try {
|
||||
const batchConfig = await queryOne<any>(
|
||||
`SELECT bc.*,
|
||||
`SELECT bc.id, bc.batch_name, bc.description, bc.cron_schedule,
|
||||
bc.is_active, bc.company_code, bc.created_date, bc.created_by,
|
||||
bc.updated_date, bc.updated_by,
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'mapping_id', bm.mapping_id,
|
||||
'batch_id', bm.batch_id,
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id,
|
||||
'from_connection_type', bm.from_connection_type,
|
||||
'from_connection_id', bm.from_connection_id,
|
||||
'from_table_name', bm.from_table_name,
|
||||
'from_column_name', bm.from_column_name,
|
||||
'from_column_type', bm.from_column_type,
|
||||
'to_connection_type', bm.to_connection_type,
|
||||
'to_connection_id', bm.to_connection_id,
|
||||
'to_table_name', bm.to_table_name,
|
||||
'to_column_name', bm.to_column_name,
|
||||
'mapping_order', bm.mapping_order,
|
||||
'source_column', bm.source_column,
|
||||
'target_column', bm.target_column,
|
||||
'transformation_rule', bm.transformation_rule
|
||||
'to_column_type', bm.to_column_type,
|
||||
'mapping_order', bm.mapping_order
|
||||
)
|
||||
ORDER BY bm.from_table_name ASC, bm.from_column_name ASC, bm.mapping_order ASC
|
||||
) FILTER (WHERE bm.mapping_id IS NOT NULL),
|
||||
) FILTER (WHERE bm.id IS NOT NULL),
|
||||
'[]'
|
||||
) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.batch_id = bm.batch_id
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.id = $1
|
||||
GROUP BY bc.batch_id`,
|
||||
GROUP BY bc.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
|
|
@ -268,16 +281,16 @@ export class BatchService {
|
|||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'mapping_id', bm.mapping_id,
|
||||
'batch_id', bm.batch_id
|
||||
'id', bm.id,
|
||||
'batch_config_id', bm.batch_config_id
|
||||
)
|
||||
) FILTER (WHERE bm.mapping_id IS NOT NULL),
|
||||
) FILTER (WHERE bm.id IS NOT NULL),
|
||||
'[]'
|
||||
) as batch_mappings
|
||||
FROM batch_configs bc
|
||||
LEFT JOIN batch_mappings bm ON bc.batch_id = bm.batch_id
|
||||
LEFT JOIN batch_mappings bm ON bc.id = bm.batch_config_id
|
||||
WHERE bc.id = $1
|
||||
GROUP BY bc.batch_id`,
|
||||
GROUP BY bc.id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,436 @@
|
|||
// 외부 DB 연결 풀 관리 서비스
|
||||
// 작성일: 2025-01-13
|
||||
// 연결 풀 고갈 방지를 위한 중앙 관리 시스템
|
||||
|
||||
import { Pool } from "pg";
|
||||
import mysql from "mysql2/promise";
|
||||
import { ExternalDbConnection } from "../types/externalDbTypes";
|
||||
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||||
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||
import logger from "../utils/logger";
|
||||
|
||||
/**
|
||||
* 연결 풀 래퍼 인터페이스
|
||||
* 모든 DB 타입의 연결 풀을 통일된 방식으로 관리
|
||||
*/
|
||||
interface ConnectionPoolWrapper {
|
||||
pool: any; // 실제 연결 풀 객체
|
||||
dbType: string;
|
||||
connectionId: number;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date;
|
||||
activeConnections: number;
|
||||
maxConnections: number;
|
||||
|
||||
// 통일된 쿼리 실행 인터페이스
|
||||
query(sql: string, params?: any[]): Promise<any>;
|
||||
|
||||
// 연결 풀 종료
|
||||
disconnect(): Promise<void>;
|
||||
|
||||
// 연결 풀 상태 확인
|
||||
isHealthy(): boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostgreSQL 연결 풀 래퍼
|
||||
*/
|
||||
class PostgresPoolWrapper implements ConnectionPoolWrapper {
|
||||
pool: Pool;
|
||||
dbType = "postgresql";
|
||||
connectionId: number;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date;
|
||||
activeConnections = 0;
|
||||
maxConnections: number;
|
||||
|
||||
constructor(config: ExternalDbConnection) {
|
||||
this.connectionId = config.id!;
|
||||
this.createdAt = new Date();
|
||||
this.lastUsedAt = new Date();
|
||||
this.maxConnections = config.max_connections || 10;
|
||||
|
||||
this.pool = new Pool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database_name,
|
||||
user: config.username,
|
||||
password: config.password,
|
||||
max: this.maxConnections,
|
||||
min: 2, // 최소 연결 수
|
||||
idleTimeoutMillis: 30000, // 30초 동안 사용되지 않으면 연결 해제
|
||||
connectionTimeoutMillis: (config.connection_timeout || 30) * 1000,
|
||||
statement_timeout: (config.query_timeout || 60) * 1000,
|
||||
ssl: config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
|
||||
});
|
||||
|
||||
// 연결 풀 이벤트 리스너
|
||||
this.pool.on("connect", () => {
|
||||
this.activeConnections++;
|
||||
logger.debug(
|
||||
`[PostgreSQL] 새 연결 생성 (${this.activeConnections}/${this.maxConnections})`
|
||||
);
|
||||
});
|
||||
|
||||
this.pool.on("remove", () => {
|
||||
this.activeConnections--;
|
||||
logger.debug(
|
||||
`[PostgreSQL] 연결 제거 (${this.activeConnections}/${this.maxConnections})`
|
||||
);
|
||||
});
|
||||
|
||||
this.pool.on("error", (err) => {
|
||||
logger.error(`[PostgreSQL] 연결 풀 오류:`, err);
|
||||
});
|
||||
}
|
||||
|
||||
async query(sql: string, params?: any[]): Promise<any> {
|
||||
this.lastUsedAt = new Date();
|
||||
const result = await this.pool.query(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.pool.end();
|
||||
logger.info(`[PostgreSQL] 연결 풀 종료 (ID: ${this.connectionId})`);
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return (
|
||||
this.pool.totalCount > 0 && this.activeConnections < this.maxConnections
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MySQL/MariaDB 연결 풀 래퍼
|
||||
*/
|
||||
class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||
pool: mysql.Pool;
|
||||
dbType: string;
|
||||
connectionId: number;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date;
|
||||
activeConnections = 0;
|
||||
maxConnections: number;
|
||||
|
||||
constructor(config: ExternalDbConnection) {
|
||||
this.connectionId = config.id!;
|
||||
this.dbType = config.db_type;
|
||||
this.createdAt = new Date();
|
||||
this.lastUsedAt = new Date();
|
||||
this.maxConnections = config.max_connections || 10;
|
||||
|
||||
this.pool = mysql.createPool({
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
database: config.database_name,
|
||||
user: config.username,
|
||||
password: config.password,
|
||||
connectionLimit: this.maxConnections,
|
||||
waitForConnections: true,
|
||||
queueLimit: 0,
|
||||
connectTimeout: (config.connection_timeout || 30) * 1000,
|
||||
ssl:
|
||||
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
||||
});
|
||||
|
||||
// 연결 획득/해제 이벤트 추적
|
||||
this.pool.on("acquire", () => {
|
||||
this.activeConnections++;
|
||||
logger.debug(
|
||||
`[${this.dbType.toUpperCase()}] 연결 획득 (${this.activeConnections}/${this.maxConnections})`
|
||||
);
|
||||
});
|
||||
|
||||
this.pool.on("release", () => {
|
||||
this.activeConnections--;
|
||||
logger.debug(
|
||||
`[${this.dbType.toUpperCase()}] 연결 반환 (${this.activeConnections}/${this.maxConnections})`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async query(sql: string, params?: any[]): Promise<any> {
|
||||
this.lastUsedAt = new Date();
|
||||
const [rows] = await this.pool.execute(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
await this.pool.end();
|
||||
logger.info(
|
||||
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
||||
);
|
||||
}
|
||||
|
||||
isHealthy(): boolean {
|
||||
return this.activeConnections < this.maxConnections;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 DB 연결 풀 관리자
|
||||
* 싱글톤 패턴으로 구현하여 전역적으로 연결 풀 관리
|
||||
*/
|
||||
export class ExternalDbConnectionPoolService {
|
||||
private static instance: ExternalDbConnectionPoolService;
|
||||
private pools: Map<number, ConnectionPoolWrapper> = new Map();
|
||||
private readonly IDLE_TIMEOUT = 10 * 60 * 1000; // 10분 동안 사용되지 않으면 풀 제거
|
||||
private readonly HEALTH_CHECK_INTERVAL = 60 * 1000; // 1분마다 헬스 체크
|
||||
private healthCheckTimer?: NodeJS.Timeout;
|
||||
|
||||
private constructor() {
|
||||
this.startHealthCheck();
|
||||
logger.info("🔌 외부 DB 연결 풀 서비스 초기화 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 싱글톤 인스턴스 반환
|
||||
*/
|
||||
static getInstance(): ExternalDbConnectionPoolService {
|
||||
if (!ExternalDbConnectionPoolService.instance) {
|
||||
ExternalDbConnectionPoolService.instance =
|
||||
new ExternalDbConnectionPoolService();
|
||||
}
|
||||
return ExternalDbConnectionPoolService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 풀 가져오기 (없으면 생성)
|
||||
*/
|
||||
async getPool(connectionId: number): Promise<ConnectionPoolWrapper> {
|
||||
// 기존 풀이 있으면 반환
|
||||
if (this.pools.has(connectionId)) {
|
||||
const pool = this.pools.get(connectionId)!;
|
||||
pool.lastUsedAt = new Date();
|
||||
|
||||
// 헬스 체크
|
||||
if (!pool.isHealthy()) {
|
||||
logger.warn(
|
||||
`⚠️ 연결 풀 비정상 감지 (ID: ${connectionId}), 재생성 중...`
|
||||
);
|
||||
await this.removePool(connectionId);
|
||||
return this.createPool(connectionId);
|
||||
}
|
||||
|
||||
logger.debug(`✅ 기존 연결 풀 재사용 (ID: ${connectionId})`);
|
||||
return pool;
|
||||
}
|
||||
|
||||
// 새로운 풀 생성
|
||||
return this.createPool(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 연결 풀 생성
|
||||
*/
|
||||
private async createPool(
|
||||
connectionId: number
|
||||
): Promise<ConnectionPoolWrapper> {
|
||||
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
||||
|
||||
// DB 연결 정보 조회
|
||||
const connectionResult =
|
||||
await ExternalDbConnectionService.getConnectionById(connectionId);
|
||||
|
||||
if (!connectionResult.success || !connectionResult.data) {
|
||||
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
||||
}
|
||||
|
||||
const config = connectionResult.data;
|
||||
|
||||
// 비활성화된 연결은 사용 불가
|
||||
if (config.is_active !== "Y") {
|
||||
throw new Error(`비활성화된 연결입니다 (ID: ${connectionId})`);
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
try {
|
||||
config.password = PasswordEncryption.decrypt(config.password);
|
||||
} catch (error) {
|
||||
logger.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error);
|
||||
throw new Error("비밀번호 복호화에 실패했습니다");
|
||||
}
|
||||
|
||||
// DB 타입에 따라 적절한 풀 생성
|
||||
let pool: ConnectionPoolWrapper;
|
||||
|
||||
switch (config.db_type.toLowerCase()) {
|
||||
case "postgresql":
|
||||
pool = new PostgresPoolWrapper(config);
|
||||
break;
|
||||
|
||||
case "mysql":
|
||||
case "mariadb":
|
||||
pool = new MySQLPoolWrapper(config);
|
||||
break;
|
||||
|
||||
case "oracle":
|
||||
case "mssql":
|
||||
// TODO: Oracle과 MSSQL 지원 추가
|
||||
throw new Error(`${config.db_type}는 아직 지원되지 않습니다`);
|
||||
|
||||
default:
|
||||
throw new Error(`지원하지 않는 DB 타입: ${config.db_type}`);
|
||||
}
|
||||
|
||||
this.pools.set(connectionId, pool);
|
||||
logger.info(
|
||||
`✅ 연결 풀 생성 완료 (ID: ${connectionId}, 타입: ${config.db_type}, 최대: ${pool.maxConnections})`
|
||||
);
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 풀 제거
|
||||
*/
|
||||
async removePool(connectionId: number): Promise<void> {
|
||||
const pool = this.pools.get(connectionId);
|
||||
if (pool) {
|
||||
await pool.disconnect();
|
||||
this.pools.delete(connectionId);
|
||||
logger.info(`🗑️ 연결 풀 제거됨 (ID: ${connectionId})`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 쿼리 실행 (자동으로 연결 풀 관리)
|
||||
*/
|
||||
async executeQuery(
|
||||
connectionId: number,
|
||||
sql: string,
|
||||
params?: any[]
|
||||
): Promise<any> {
|
||||
const pool = await this.getPool(connectionId);
|
||||
|
||||
try {
|
||||
logger.debug(
|
||||
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
||||
);
|
||||
const result = await pool.query(sql, params);
|
||||
logger.debug(
|
||||
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 연결 테스트 (풀을 생성하지 않고 단순 연결만 테스트)
|
||||
*/
|
||||
async testConnection(connectionId: number): Promise<boolean> {
|
||||
try {
|
||||
const pool = await this.getPool(connectionId);
|
||||
|
||||
// 간단한 쿼리로 연결 테스트
|
||||
const testQuery =
|
||||
pool.dbType === "postgresql" ? "SELECT 1 as test" : "SELECT 1 as test";
|
||||
|
||||
await pool.query(testQuery);
|
||||
logger.info(`✅ 연결 테스트 성공 (ID: ${connectionId})`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error(`❌ 연결 테스트 실패 (ID: ${connectionId}):`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 주기적인 헬스 체크 및 유휴 풀 정리
|
||||
*/
|
||||
private startHealthCheck(): void {
|
||||
this.healthCheckTimer = setInterval(() => {
|
||||
const now = Date.now();
|
||||
|
||||
this.pools.forEach(async (pool, connectionId) => {
|
||||
const idleTime = now - pool.lastUsedAt.getTime();
|
||||
|
||||
// 유휴 시간 초과 시 풀 제거
|
||||
if (idleTime > this.IDLE_TIMEOUT) {
|
||||
logger.info(
|
||||
`🧹 유휴 연결 풀 정리 (ID: ${connectionId}, 유휴: ${Math.round(idleTime / 1000)}초)`
|
||||
);
|
||||
await this.removePool(connectionId);
|
||||
}
|
||||
|
||||
// 헬스 체크
|
||||
if (!pool.isHealthy()) {
|
||||
logger.warn(
|
||||
`⚠️ 비정상 연결 풀 감지 (ID: ${connectionId}), 재생성 예약`
|
||||
);
|
||||
await this.removePool(connectionId);
|
||||
}
|
||||
});
|
||||
|
||||
// 상태 로깅
|
||||
if (this.pools.size > 0) {
|
||||
logger.debug(
|
||||
`📊 연결 풀 상태: 총 ${this.pools.size}개, 활성: ${Array.from(this.pools.values()).filter((p) => p.activeConnections > 0).length}개`
|
||||
);
|
||||
}
|
||||
}, this.HEALTH_CHECK_INTERVAL);
|
||||
|
||||
logger.info("🔍 헬스 체크 타이머 시작 (간격: 1분)");
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 연결 풀 종료 (애플리케이션 종료 시 호출)
|
||||
*/
|
||||
async closeAll(): Promise<void> {
|
||||
logger.info(`🛑 모든 연결 풀 종료 중... (총 ${this.pools.size}개)`);
|
||||
|
||||
if (this.healthCheckTimer) {
|
||||
clearInterval(this.healthCheckTimer);
|
||||
}
|
||||
|
||||
const closePromises = Array.from(this.pools.keys()).map((connectionId) =>
|
||||
this.removePool(connectionId)
|
||||
);
|
||||
|
||||
await Promise.all(closePromises);
|
||||
logger.info("✅ 모든 연결 풀 종료 완료");
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 연결 풀 상태 조회
|
||||
*/
|
||||
getPoolsStatus(): Array<{
|
||||
connectionId: number;
|
||||
dbType: string;
|
||||
activeConnections: number;
|
||||
maxConnections: number;
|
||||
createdAt: Date;
|
||||
lastUsedAt: Date;
|
||||
idleSeconds: number;
|
||||
}> {
|
||||
const now = Date.now();
|
||||
|
||||
return Array.from(this.pools.entries()).map(([connectionId, pool]) => ({
|
||||
connectionId,
|
||||
dbType: pool.dbType,
|
||||
activeConnections: pool.activeConnections,
|
||||
maxConnections: pool.maxConnections,
|
||||
createdAt: pool.createdAt,
|
||||
lastUsedAt: pool.lastUsedAt,
|
||||
idleSeconds: Math.round((now - pool.lastUsedAt.getTime()) / 1000),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 애플리케이션 종료 시 연결 풀 정리
|
||||
process.on("SIGINT", async () => {
|
||||
logger.info("🛑 SIGINT 신호 수신, 연결 풀 정리 중...");
|
||||
await ExternalDbConnectionPoolService.getInstance().closeAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", async () => {
|
||||
logger.info("🛑 SIGTERM 신호 수신, 연결 풀 정리 중...");
|
||||
await ExternalDbConnectionPoolService.getInstance().closeAll();
|
||||
process.exit(0);
|
||||
});
|
||||
|
|
@ -1205,4 +1205,157 @@ export class ExternalDbConnectionService {
|
|||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 연결의 테이블 목록 조회
|
||||
*/
|
||||
static async getTablesFromConnection(
|
||||
connectionId: number
|
||||
): Promise<ApiResponse<TableInfo[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const password = connection.password
|
||||
? PasswordEncryption.decrypt(connection.password)
|
||||
: "";
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: password,
|
||||
connectionTimeoutMillis:
|
||||
connection.connection_timeout != null
|
||||
? connection.connection_timeout * 1000
|
||||
: undefined,
|
||||
queryTimeoutMillis:
|
||||
connection.query_timeout != null
|
||||
? connection.query_timeout * 1000
|
||||
: undefined,
|
||||
ssl:
|
||||
connection.ssl_enabled === "Y"
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
};
|
||||
|
||||
// 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type,
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
try {
|
||||
const tables = await connector.getTables();
|
||||
return {
|
||||
success: true,
|
||||
data: tables,
|
||||
message: `${tables.length}개의 테이블을 조회했습니다.`,
|
||||
};
|
||||
} finally {
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connectionId,
|
||||
connection.db_type
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("테이블 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "테이블 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 외부 DB 테이블의 컬럼 목록 조회
|
||||
*/
|
||||
static async getColumnsFromConnection(
|
||||
connectionId: number,
|
||||
tableName: string
|
||||
): Promise<ApiResponse<any[]>> {
|
||||
try {
|
||||
// 연결 정보 조회
|
||||
const connection = await queryOne<any>(
|
||||
`SELECT * FROM external_db_connections WHERE id = $1`,
|
||||
[connectionId]
|
||||
);
|
||||
|
||||
if (!connection) {
|
||||
return {
|
||||
success: false,
|
||||
message: `연결 ID ${connectionId}를 찾을 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
const password = connection.password
|
||||
? PasswordEncryption.decrypt(connection.password)
|
||||
: "";
|
||||
|
||||
// 연결 설정 준비
|
||||
const config = {
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
database: connection.database_name,
|
||||
user: connection.username,
|
||||
password: password,
|
||||
connectionTimeoutMillis:
|
||||
connection.connection_timeout != null
|
||||
? connection.connection_timeout * 1000
|
||||
: undefined,
|
||||
queryTimeoutMillis:
|
||||
connection.query_timeout != null
|
||||
? connection.query_timeout * 1000
|
||||
: undefined,
|
||||
ssl:
|
||||
connection.ssl_enabled === "Y"
|
||||
? { rejectUnauthorized: false }
|
||||
: false,
|
||||
};
|
||||
|
||||
// 커넥터 생성
|
||||
const connector = await DatabaseConnectorFactory.createConnector(
|
||||
connection.db_type,
|
||||
config,
|
||||
connectionId
|
||||
);
|
||||
|
||||
try {
|
||||
const columns = await connector.getColumns(tableName);
|
||||
return {
|
||||
success: true,
|
||||
data: columns,
|
||||
message: `${columns.length}개의 컬럼을 조회했습니다.`,
|
||||
};
|
||||
} finally {
|
||||
await DatabaseConnectorFactory.closeConnector(
|
||||
connectionId,
|
||||
connection.db_type
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("컬럼 목록 조회 실패:", error);
|
||||
return {
|
||||
success: false,
|
||||
message: "컬럼 목록 조회 중 오류가 발생했습니다.",
|
||||
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { encryptionService } from "./encryptionService";
|
||||
|
||||
export interface MailAccount {
|
||||
id: string;
|
||||
|
|
@ -12,7 +12,7 @@ export interface MailAccount {
|
|||
smtpUsername: string;
|
||||
smtpPassword: string; // 암호화된 비밀번호
|
||||
dailyLimit: number;
|
||||
status: 'active' | 'inactive' | 'suspended';
|
||||
status: "active" | "inactive" | "suspended";
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
|
@ -21,7 +21,11 @@ class MailAccountFileService {
|
|||
private accountsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.accountsDir = path.join(process.cwd(), 'uploads', 'mail-accounts');
|
||||
// 운영 환경에서는 /app/uploads/mail-accounts, 개발 환경에서는 프로젝트 루트
|
||||
this.accountsDir =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/app/uploads/mail-accounts"
|
||||
: path.join(process.cwd(), "uploads", "mail-accounts");
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
|
|
@ -29,7 +33,11 @@ class MailAccountFileService {
|
|||
try {
|
||||
await fs.access(this.accountsDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.accountsDir, { recursive: true });
|
||||
try {
|
||||
await fs.mkdir(this.accountsDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("메일 계정 디렉토리 생성 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,20 +50,21 @@ class MailAccountFileService {
|
|||
|
||||
try {
|
||||
const files = await fs.readdir(this.accountsDir);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
||||
|
||||
const accounts = await Promise.all(
|
||||
jsonFiles.map(async (file) => {
|
||||
const content = await fs.readFile(
|
||||
path.join(this.accountsDir, file),
|
||||
'utf-8'
|
||||
"utf-8"
|
||||
);
|
||||
return JSON.parse(content) as MailAccount;
|
||||
})
|
||||
);
|
||||
|
||||
return accounts.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
return accounts.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
|
|
@ -64,7 +73,7 @@ class MailAccountFileService {
|
|||
|
||||
async getAccountById(id: string): Promise<MailAccount | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.getAccountPath(id), 'utf-8');
|
||||
const content = await fs.readFile(this.getAccountPath(id), "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -72,7 +81,7 @@ class MailAccountFileService {
|
|||
}
|
||||
|
||||
async createAccount(
|
||||
data: Omit<MailAccount, 'id' | 'createdAt' | 'updatedAt'>
|
||||
data: Omit<MailAccount, "id" | "createdAt" | "updatedAt">
|
||||
): Promise<MailAccount> {
|
||||
const id = `account-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
|
@ -91,7 +100,7 @@ class MailAccountFileService {
|
|||
await fs.writeFile(
|
||||
this.getAccountPath(id),
|
||||
JSON.stringify(account, null, 2),
|
||||
'utf-8'
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
return account;
|
||||
|
|
@ -99,7 +108,7 @@ class MailAccountFileService {
|
|||
|
||||
async updateAccount(
|
||||
id: string,
|
||||
data: Partial<Omit<MailAccount, 'id' | 'createdAt'>>
|
||||
data: Partial<Omit<MailAccount, "id" | "createdAt">>
|
||||
): Promise<MailAccount | null> {
|
||||
const existing = await this.getAccountById(id);
|
||||
if (!existing) {
|
||||
|
|
@ -122,7 +131,7 @@ class MailAccountFileService {
|
|||
await fs.writeFile(
|
||||
this.getAccountPath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
return updated;
|
||||
|
|
@ -139,12 +148,12 @@ class MailAccountFileService {
|
|||
|
||||
async getAccountByEmail(email: string): Promise<MailAccount | null> {
|
||||
const accounts = await this.getAllAccounts();
|
||||
return accounts.find(a => a.email === email) || null;
|
||||
return accounts.find((a) => a.email === email) || null;
|
||||
}
|
||||
|
||||
async getActiveAccounts(): Promise<MailAccount[]> {
|
||||
const accounts = await this.getAllAccounts();
|
||||
return accounts.filter(a => a.status === 'active');
|
||||
return accounts.filter((a) => a.status === "active");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -156,4 +165,3 @@ class MailAccountFileService {
|
|||
}
|
||||
|
||||
export const mailAccountFileService = new MailAccountFileService();
|
||||
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@
|
|||
*/
|
||||
|
||||
// CommonJS 모듈이므로 require 사용
|
||||
const Imap = require('imap');
|
||||
import { simpleParser } from 'mailparser';
|
||||
import { mailAccountFileService } from './mailAccountFileService';
|
||||
import { encryptionService } from './encryptionService';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
const Imap = require("imap");
|
||||
import { simpleParser } from "mailparser";
|
||||
import { mailAccountFileService } from "./mailAccountFileService";
|
||||
import { encryptionService } from "./encryptionService";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
export interface ReceivedMail {
|
||||
id: string;
|
||||
|
|
@ -47,7 +47,11 @@ export class MailReceiveBasicService {
|
|||
private attachmentsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.attachmentsDir = path.join(process.cwd(), 'uploads', 'mail-attachments');
|
||||
// 운영 환경에서는 /app/uploads/mail-attachments, 개발 환경에서는 프로젝트 루트
|
||||
this.attachmentsDir =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/app/uploads/mail-attachments"
|
||||
: path.join(process.cwd(), "uploads", "mail-attachments");
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
|
|
@ -55,7 +59,11 @@ export class MailReceiveBasicService {
|
|||
try {
|
||||
await fs.access(this.attachmentsDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.attachmentsDir, { recursive: true });
|
||||
try {
|
||||
await fs.mkdir(this.attachmentsDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("메일 첨부파일 디렉토리 생성 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -90,10 +98,13 @@ export class MailReceiveBasicService {
|
|||
/**
|
||||
* 메일 계정으로 받은 메일 목록 조회
|
||||
*/
|
||||
async fetchMailList(accountId: string, limit: number = 50): Promise<ReceivedMail[]> {
|
||||
async fetchMailList(
|
||||
accountId: string,
|
||||
limit: number = 50
|
||||
): Promise<ReceivedMail[]> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
throw new Error("메일 계정을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
|
|
@ -119,14 +130,14 @@ export class MailReceiveBasicService {
|
|||
const timeout = setTimeout(() => {
|
||||
// console.error('❌ IMAP 연결 타임아웃 (30초)');
|
||||
imap.end();
|
||||
reject(new Error('IMAP 연결 타임아웃'));
|
||||
reject(new Error("IMAP 연결 타임아웃"));
|
||||
}, 30000);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.once("ready", () => {
|
||||
// console.log('✅ IMAP 연결 성공! INBOX 열기 시도...');
|
||||
clearTimeout(timeout);
|
||||
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
imap.openBox("INBOX", true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
// console.error('❌ INBOX 열기 실패:', err);
|
||||
imap.end();
|
||||
|
|
@ -147,7 +158,7 @@ export class MailReceiveBasicService {
|
|||
|
||||
// console.log(`📨 메일 가져오기 시작 - 범위: ${start}~${end}`);
|
||||
const fetch = imap.seq.fetch(`${start}:${end}`, {
|
||||
bodies: ['HEADER', 'TEXT'],
|
||||
bodies: ["HEADER", "TEXT"],
|
||||
struct: true,
|
||||
});
|
||||
|
||||
|
|
@ -156,20 +167,20 @@ export class MailReceiveBasicService {
|
|||
let processedCount = 0;
|
||||
const totalToProcess = end - start + 1;
|
||||
|
||||
fetch.on('message', (msg: any, seqno: any) => {
|
||||
fetch.on("message", (msg: any, seqno: any) => {
|
||||
// console.log(`📬 메일 #${seqno} 처리 시작`);
|
||||
let header: string = '';
|
||||
let body: string = '';
|
||||
let header: string = "";
|
||||
let body: string = "";
|
||||
let attributes: any = null;
|
||||
let bodiesReceived = 0;
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
msg.on("body", (stream: any, info: any) => {
|
||||
let buffer = "";
|
||||
stream.on("data", (chunk: any) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
});
|
||||
stream.once('end', () => {
|
||||
if (info.which === 'HEADER') {
|
||||
stream.once("end", () => {
|
||||
if (info.which === "HEADER") {
|
||||
header = buffer;
|
||||
} else {
|
||||
body = buffer;
|
||||
|
|
@ -178,31 +189,39 @@ export class MailReceiveBasicService {
|
|||
});
|
||||
});
|
||||
|
||||
msg.once('attributes', (attrs: any) => {
|
||||
msg.once("attributes", (attrs: any) => {
|
||||
attributes = attrs;
|
||||
});
|
||||
|
||||
msg.once('end', () => {
|
||||
msg.once("end", () => {
|
||||
// body 데이터를 모두 받을 때까지 대기
|
||||
const waitForBodies = setInterval(async () => {
|
||||
if (bodiesReceived >= 2 || (header && body)) {
|
||||
clearInterval(waitForBodies);
|
||||
|
||||
try {
|
||||
const parsed = await simpleParser(header + '\r\n\r\n' + body);
|
||||
const parsed = await simpleParser(
|
||||
header + "\r\n\r\n" + body
|
||||
);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
const fromAddress = Array.isArray(parsed.from)
|
||||
? parsed.from[0]
|
||||
: parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to)
|
||||
? parsed.to[0]
|
||||
: parsed.to;
|
||||
|
||||
const mail: ReceivedMail = {
|
||||
id: `${accountId}-${seqno}`,
|
||||
messageId: parsed.messageId || `${seqno}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
from: fromAddress?.text || "Unknown",
|
||||
to: toAddress?.text || "",
|
||||
subject: parsed.subject || "(제목 없음)",
|
||||
date: parsed.date || new Date(),
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
isRead: attributes?.flags?.includes('\\Seen') || false,
|
||||
preview: this.extractPreview(
|
||||
parsed.text || parsed.html || ""
|
||||
),
|
||||
isRead: attributes?.flags?.includes("\\Seen") || false,
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
};
|
||||
|
||||
|
|
@ -218,13 +237,13 @@ export class MailReceiveBasicService {
|
|||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
fetch.once("error", (fetchErr: any) => {
|
||||
// console.error('❌ 메일 fetch 에러:', fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
fetch.once("end", () => {
|
||||
// console.log(`📭 fetch 종료 - 처리 완료 대기 중... (현재: ${mails.length}개)`);
|
||||
|
||||
// 모든 메일 처리가 완료될 때까지 대기
|
||||
|
|
@ -253,13 +272,13 @@ export class MailReceiveBasicService {
|
|||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
imap.once("error", (imapErr: any) => {
|
||||
// console.error('❌ IMAP 연결 에러:', imapErr.message || imapErr);
|
||||
clearTimeout(timeout);
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
imap.once('end', () => {
|
||||
imap.once("end", () => {
|
||||
// console.log('🔌 IMAP 연결 종료');
|
||||
});
|
||||
|
||||
|
|
@ -273,20 +292,23 @@ export class MailReceiveBasicService {
|
|||
*/
|
||||
private extractPreview(text: string): string {
|
||||
// HTML 태그 제거
|
||||
const plainText = text.replace(/<[^>]*>/g, '');
|
||||
const plainText = text.replace(/<[^>]*>/g, "");
|
||||
// 공백 정리
|
||||
const cleaned = plainText.replace(/\s+/g, ' ').trim();
|
||||
const cleaned = plainText.replace(/\s+/g, " ").trim();
|
||||
// 최대 150자
|
||||
return cleaned.length > 150 ? cleaned.substring(0, 150) + '...' : cleaned;
|
||||
return cleaned.length > 150 ? cleaned.substring(0, 150) + "..." : cleaned;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 상세 조회
|
||||
*/
|
||||
async getMailDetail(accountId: string, seqno: number): Promise<MailDetail | null> {
|
||||
async getMailDetail(
|
||||
accountId: string,
|
||||
seqno: number
|
||||
): Promise<MailDetail | null> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
throw new Error("메일 계정을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
|
|
@ -304,97 +326,116 @@ export class MailReceiveBasicService {
|
|||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', false, (err: any, box: any) => {
|
||||
imap.once("ready", () => {
|
||||
imap.openBox("INBOX", false, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
console.log(`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`);
|
||||
console.log(
|
||||
`📬 INBOX 정보 - 전체 메일: ${box.messages.total}, 요청한 seqno: ${seqno}`
|
||||
);
|
||||
|
||||
if (seqno > box.messages.total || seqno < 1) {
|
||||
console.error(`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`);
|
||||
console.error(
|
||||
`❌ 유효하지 않은 seqno: ${seqno} (메일 총 개수: ${box.messages.total})`
|
||||
);
|
||||
imap.end();
|
||||
return resolve(null);
|
||||
}
|
||||
|
||||
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
||||
bodies: '',
|
||||
bodies: "",
|
||||
struct: true,
|
||||
});
|
||||
|
||||
let mailDetail: MailDetail | null = null;
|
||||
let parsingComplete = false;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
fetch.on("message", (msg: any, seqnum: any) => {
|
||||
console.log(`📨 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
msg.on("body", (stream: any, info: any) => {
|
||||
console.log(`📝 메일 본문 스트림 시작 - which: ${info.which}`);
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let buffer = "";
|
||||
stream.on("data", (chunk: any) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
console.log(`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
|
||||
stream.once("end", async () => {
|
||||
console.log(
|
||||
`✅ 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
||||
);
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
console.log(`✅ 메일 파싱 완료 - 제목: ${parsed.subject}`);
|
||||
|
||||
const fromAddress = Array.isArray(parsed.from) ? parsed.from[0] : parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to) ? parsed.to[0] : parsed.to;
|
||||
const ccAddress = Array.isArray(parsed.cc) ? parsed.cc[0] : parsed.cc;
|
||||
const bccAddress = Array.isArray(parsed.bcc) ? parsed.bcc[0] : parsed.bcc;
|
||||
const fromAddress = Array.isArray(parsed.from)
|
||||
? parsed.from[0]
|
||||
: parsed.from;
|
||||
const toAddress = Array.isArray(parsed.to)
|
||||
? parsed.to[0]
|
||||
: parsed.to;
|
||||
const ccAddress = Array.isArray(parsed.cc)
|
||||
? parsed.cc[0]
|
||||
: parsed.cc;
|
||||
const bccAddress = Array.isArray(parsed.bcc)
|
||||
? parsed.bcc[0]
|
||||
: parsed.bcc;
|
||||
|
||||
mailDetail = {
|
||||
id: `${accountId}-${seqnum}`,
|
||||
messageId: parsed.messageId || `${seqnum}`,
|
||||
from: fromAddress?.text || 'Unknown',
|
||||
to: toAddress?.text || '',
|
||||
from: fromAddress?.text || "Unknown",
|
||||
to: toAddress?.text || "",
|
||||
cc: ccAddress?.text,
|
||||
bcc: bccAddress?.text,
|
||||
subject: parsed.subject || '(제목 없음)',
|
||||
subject: parsed.subject || "(제목 없음)",
|
||||
date: parsed.date || new Date(),
|
||||
htmlBody: parsed.html || '',
|
||||
textBody: parsed.text || '',
|
||||
preview: this.extractPreview(parsed.text || parsed.html || ''),
|
||||
htmlBody: parsed.html || "",
|
||||
textBody: parsed.text || "",
|
||||
preview: this.extractPreview(
|
||||
parsed.text || parsed.html || ""
|
||||
),
|
||||
isRead: true, // 조회 시 읽음으로 표시
|
||||
hasAttachments: (parsed.attachments?.length || 0) > 0,
|
||||
attachments: (parsed.attachments || []).map((att: any) => ({
|
||||
filename: att.filename || 'unnamed',
|
||||
contentType: att.contentType || 'application/octet-stream',
|
||||
filename: att.filename || "unnamed",
|
||||
contentType:
|
||||
att.contentType || "application/octet-stream",
|
||||
size: att.size || 0,
|
||||
})),
|
||||
};
|
||||
parsingComplete = true;
|
||||
} catch (parseError) {
|
||||
console.error('메일 파싱 오류:', parseError);
|
||||
console.error("메일 파싱 오류:", parseError);
|
||||
parsingComplete = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// msg 전체가 처리되었을 때 이벤트
|
||||
msg.once('end', () => {
|
||||
msg.once("end", () => {
|
||||
console.log(`📮 메일 메시지 처리 완료 - seqnum: ${seqnum}`);
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
fetch.once("error", (fetchErr: any) => {
|
||||
console.error(`❌ Fetch 에러:`, fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
fetch.once("end", () => {
|
||||
console.log(`🏁 Fetch 종료 - parsingComplete: ${parsingComplete}`);
|
||||
|
||||
// 비동기 파싱이 완료될 때까지 대기
|
||||
const waitForParsing = setInterval(() => {
|
||||
if (parsingComplete) {
|
||||
clearInterval(waitForParsing);
|
||||
console.log(`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? '존재함' : 'null'}`);
|
||||
console.log(
|
||||
`✅ 파싱 완료 대기 종료 - mailDetail이 ${mailDetail ? "존재함" : "null"}`
|
||||
);
|
||||
imap.end();
|
||||
resolve(mailDetail);
|
||||
}
|
||||
|
|
@ -404,7 +445,7 @@ export class MailReceiveBasicService {
|
|||
setTimeout(() => {
|
||||
if (!parsingComplete) {
|
||||
clearInterval(waitForParsing);
|
||||
console.error('❌ 파싱 타임아웃');
|
||||
console.error("❌ 파싱 타임아웃");
|
||||
imap.end();
|
||||
resolve(mailDetail); // 타임아웃 시에도 현재 상태 반환
|
||||
}
|
||||
|
|
@ -413,7 +454,7 @@ export class MailReceiveBasicService {
|
|||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
imap.once("error", (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
|
|
@ -424,10 +465,13 @@ export class MailReceiveBasicService {
|
|||
/**
|
||||
* 메일을 읽음으로 표시
|
||||
*/
|
||||
async markAsRead(accountId: string, seqno: number): Promise<{ success: boolean; message: string }> {
|
||||
async markAsRead(
|
||||
accountId: string,
|
||||
seqno: number
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
throw new Error("메일 계정을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
|
|
@ -445,28 +489,28 @@ export class MailReceiveBasicService {
|
|||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', false, (err: any, box: any) => {
|
||||
imap.once("ready", () => {
|
||||
imap.openBox("INBOX", false, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
imap.seq.addFlags(seqno, ['\\Seen'], (flagErr: any) => {
|
||||
imap.seq.addFlags(seqno, ["\\Seen"], (flagErr: any) => {
|
||||
imap.end();
|
||||
if (flagErr) {
|
||||
reject(flagErr);
|
||||
} else {
|
||||
resolve({
|
||||
success: true,
|
||||
message: '메일을 읽음으로 표시했습니다.',
|
||||
message: "메일을 읽음으로 표시했습니다.",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
imap.once("error", (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
|
|
@ -477,11 +521,13 @@ export class MailReceiveBasicService {
|
|||
/**
|
||||
* IMAP 연결 테스트
|
||||
*/
|
||||
async testImapConnection(accountId: string): Promise<{ success: boolean; message: string }> {
|
||||
async testImapConnection(
|
||||
accountId: string
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
throw new Error("메일 계정을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
|
|
@ -501,25 +547,25 @@ export class MailReceiveBasicService {
|
|||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.once("ready", () => {
|
||||
imap.end();
|
||||
resolve({
|
||||
success: true,
|
||||
message: 'IMAP 연결 성공',
|
||||
message: "IMAP 연결 성공",
|
||||
});
|
||||
});
|
||||
|
||||
imap.once('error', (err: any) => {
|
||||
imap.once("error", (err: any) => {
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// 타임아웃 설정 (10초)
|
||||
const timeout = setTimeout(() => {
|
||||
imap.end();
|
||||
reject(new Error('연결 시간 초과'));
|
||||
reject(new Error("연결 시간 초과"));
|
||||
}, 10000);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.once("ready", () => {
|
||||
clearTimeout(timeout);
|
||||
});
|
||||
|
||||
|
|
@ -528,7 +574,7 @@ export class MailReceiveBasicService {
|
|||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : '알 수 없는 오류',
|
||||
message: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -552,7 +598,7 @@ export class MailReceiveBasicService {
|
|||
|
||||
try {
|
||||
const mails = await this.fetchMailList(account.id, 100);
|
||||
const todayMails = mails.filter(mail => {
|
||||
const todayMails = mails.filter((mail) => {
|
||||
const mailDate = new Date(mail.date);
|
||||
return mailDate >= today;
|
||||
});
|
||||
|
|
@ -565,7 +611,7 @@ export class MailReceiveBasicService {
|
|||
|
||||
return totalCount;
|
||||
} catch (error) {
|
||||
console.error('오늘 수신 메일 수 조회 실패:', error);
|
||||
console.error("오늘 수신 메일 수 조회 실패:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -577,10 +623,14 @@ export class MailReceiveBasicService {
|
|||
accountId: string,
|
||||
seqno: number,
|
||||
attachmentIndex: number
|
||||
): Promise<{ filePath: string; filename: string; contentType: string } | null> {
|
||||
): Promise<{
|
||||
filePath: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
} | null> {
|
||||
const account = await mailAccountFileService.getAccountById(accountId);
|
||||
if (!account) {
|
||||
throw new Error('메일 계정을 찾을 수 없습니다.');
|
||||
throw new Error("메일 계정을 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
// 비밀번호 복호화
|
||||
|
|
@ -598,39 +648,52 @@ export class MailReceiveBasicService {
|
|||
return new Promise((resolve, reject) => {
|
||||
const imap = this.createImapConnection(imapConfig);
|
||||
|
||||
imap.once('ready', () => {
|
||||
imap.openBox('INBOX', true, (err: any, box: any) => {
|
||||
imap.once("ready", () => {
|
||||
imap.openBox("INBOX", true, (err: any, box: any) => {
|
||||
if (err) {
|
||||
imap.end();
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const fetch = imap.seq.fetch(`${seqno}:${seqno}`, {
|
||||
bodies: '',
|
||||
bodies: "",
|
||||
struct: true,
|
||||
});
|
||||
|
||||
let attachmentResult: { filePath: string; filename: string; contentType: string } | null = null;
|
||||
let attachmentResult: {
|
||||
filePath: string;
|
||||
filename: string;
|
||||
contentType: string;
|
||||
} | null = null;
|
||||
let parsingComplete = false;
|
||||
|
||||
fetch.on('message', (msg: any, seqnum: any) => {
|
||||
fetch.on("message", (msg: any, seqnum: any) => {
|
||||
console.log(`📎 메일 메시지 이벤트 발생 - seqnum: ${seqnum}`);
|
||||
|
||||
msg.on('body', (stream: any, info: any) => {
|
||||
msg.on("body", (stream: any, info: any) => {
|
||||
console.log(`📎 메일 본문 스트림 시작`);
|
||||
let buffer = '';
|
||||
stream.on('data', (chunk: any) => {
|
||||
buffer += chunk.toString('utf8');
|
||||
let buffer = "";
|
||||
stream.on("data", (chunk: any) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
});
|
||||
stream.once('end', async () => {
|
||||
console.log(`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`);
|
||||
stream.once("end", async () => {
|
||||
console.log(
|
||||
`📎 메일 본문 스트림 종료 - 버퍼 크기: ${buffer.length}`
|
||||
);
|
||||
try {
|
||||
const parsed = await simpleParser(buffer);
|
||||
console.log(`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`);
|
||||
console.log(
|
||||
`📎 파싱 완료 - 첨부파일 개수: ${parsed.attachments?.length || 0}`
|
||||
);
|
||||
|
||||
if (parsed.attachments && parsed.attachments[attachmentIndex]) {
|
||||
if (
|
||||
parsed.attachments &&
|
||||
parsed.attachments[attachmentIndex]
|
||||
) {
|
||||
const attachment = parsed.attachments[attachmentIndex];
|
||||
console.log(`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`);
|
||||
console.log(
|
||||
`📎 첨부파일 발견 (index ${attachmentIndex}): ${attachment.filename}`
|
||||
);
|
||||
|
||||
// 안전한 파일명 생성
|
||||
const safeFilename = this.sanitizeFilename(
|
||||
|
|
@ -646,35 +709,40 @@ export class MailReceiveBasicService {
|
|||
|
||||
attachmentResult = {
|
||||
filePath,
|
||||
filename: attachment.filename || 'unnamed',
|
||||
contentType: attachment.contentType || 'application/octet-stream',
|
||||
filename: attachment.filename || "unnamed",
|
||||
contentType:
|
||||
attachment.contentType || "application/octet-stream",
|
||||
};
|
||||
parsingComplete = true;
|
||||
} else {
|
||||
console.log(`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`);
|
||||
console.log(
|
||||
`❌ 첨부파일 index ${attachmentIndex}를 찾을 수 없음 (총 ${parsed.attachments?.length || 0}개)`
|
||||
);
|
||||
parsingComplete = true;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('첨부파일 파싱 오류:', parseError);
|
||||
console.error("첨부파일 파싱 오류:", parseError);
|
||||
parsingComplete = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
fetch.once('error', (fetchErr: any) => {
|
||||
console.error('❌ fetch 오류:', fetchErr);
|
||||
fetch.once("error", (fetchErr: any) => {
|
||||
console.error("❌ fetch 오류:", fetchErr);
|
||||
imap.end();
|
||||
reject(fetchErr);
|
||||
});
|
||||
|
||||
fetch.once('end', () => {
|
||||
fetch.once("end", () => {
|
||||
console.log('📎 fetch.once("end") 호출됨 - 파싱 완료 대기 시작...');
|
||||
|
||||
// 파싱 완료를 기다림 (최대 5초)
|
||||
const checkComplete = setInterval(() => {
|
||||
if (parsingComplete) {
|
||||
console.log(`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
|
||||
console.log(
|
||||
`✅ 파싱 완료 확인 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
||||
);
|
||||
clearInterval(checkComplete);
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
|
|
@ -683,7 +751,9 @@ export class MailReceiveBasicService {
|
|||
|
||||
setTimeout(() => {
|
||||
clearInterval(checkComplete);
|
||||
console.log(`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? '있음' : '없음'}`);
|
||||
console.log(
|
||||
`⚠️ 타임아웃 - attachmentResult: ${attachmentResult ? "있음" : "없음"}`
|
||||
);
|
||||
imap.end();
|
||||
resolve(attachmentResult);
|
||||
}, 5000);
|
||||
|
|
@ -691,7 +761,7 @@ export class MailReceiveBasicService {
|
|||
});
|
||||
});
|
||||
|
||||
imap.once('error', (imapErr: any) => {
|
||||
imap.once("error", (imapErr: any) => {
|
||||
reject(imapErr);
|
||||
});
|
||||
|
||||
|
|
@ -704,9 +774,8 @@ export class MailReceiveBasicService {
|
|||
*/
|
||||
private sanitizeFilename(filename: string): string {
|
||||
return filename
|
||||
.replace(/[^a-zA-Z0-9가-힣.\-_]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/[^a-zA-Z0-9가-힣.\-_]/g, "_")
|
||||
.replace(/_{2,}/g, "_")
|
||||
.substring(0, 200); // 최대 길이 제한
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,79 +2,130 @@
|
|||
* 메일 발송 이력 관리 서비스 (파일 기반)
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import {
|
||||
SentMailHistory,
|
||||
SentMailListQuery,
|
||||
SentMailListResponse,
|
||||
AttachmentInfo,
|
||||
} from '../types/mailSentHistory';
|
||||
} from "../types/mailSentHistory";
|
||||
|
||||
const SENT_MAIL_DIR = path.join(__dirname, '../../data/mail-sent');
|
||||
// 운영 환경에서는 /app/data/mail-sent, 개발 환경에서는 프로젝트 루트의 data/mail-sent 사용
|
||||
const SENT_MAIL_DIR =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/app/data/mail-sent"
|
||||
: path.join(process.cwd(), "data", "mail-sent");
|
||||
|
||||
class MailSentHistoryService {
|
||||
constructor() {
|
||||
// 디렉토리 생성 (없으면)
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||
// 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
try {
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메일 발송 이력 디렉토리 생성 실패:", error);
|
||||
// 디렉토리가 이미 존재하거나 권한이 없어도 서비스는 계속 실행
|
||||
// 실제 파일 쓰기 시점에 에러 처리
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 저장
|
||||
*/
|
||||
async saveSentMail(data: Omit<SentMailHistory, 'id' | 'sentAt'>): Promise<SentMailHistory> {
|
||||
async saveSentMail(
|
||||
data: Omit<SentMailHistory, "id" | "sentAt">
|
||||
): Promise<SentMailHistory> {
|
||||
const history: SentMailHistory = {
|
||||
id: uuidv4(),
|
||||
sentAt: new Date().toISOString(),
|
||||
...data,
|
||||
};
|
||||
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), 'utf-8');
|
||||
try {
|
||||
// 디렉토리가 없으면 다시 시도
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
fs.mkdirSync(SENT_MAIL_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(SENT_MAIL_DIR, `${history.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(history, null, 2), "utf-8");
|
||||
|
||||
console.log("발송 이력 저장:", history.id);
|
||||
} catch (error) {
|
||||
console.error("발송 이력 저장 실패:", error);
|
||||
// 파일 저장 실패해도 history 객체는 반환 (메일 발송은 성공했으므로)
|
||||
}
|
||||
|
||||
console.log('💾 발송 이력 저장:', history.id);
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발송 이력 목록 조회 (필터링, 페이징)
|
||||
*/
|
||||
async getSentMailList(query: SentMailListQuery): Promise<SentMailListResponse> {
|
||||
async getSentMailList(
|
||||
query: SentMailListQuery
|
||||
): Promise<SentMailListResponse> {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
searchTerm = '',
|
||||
status = 'all',
|
||||
searchTerm = "",
|
||||
status = "all",
|
||||
accountId,
|
||||
startDate,
|
||||
endDate,
|
||||
sortBy = 'sentAt',
|
||||
sortOrder = 'desc',
|
||||
sortBy = "sentAt",
|
||||
sortOrder = "desc",
|
||||
} = query;
|
||||
|
||||
// 모든 발송 이력 파일 읽기
|
||||
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||
let allHistory: SentMailHistory[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
allHistory.push(history);
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
try {
|
||||
// 디렉토리가 없으면 빈 배열 반환
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
console.warn("메일 발송 이력 디렉토리가 없습니다:", SENT_MAIL_DIR);
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(SENT_MAIL_DIR)
|
||||
.filter((f) => f.endsWith(".json"));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
allHistory.push(history);
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("메일 발송 이력 조회 실패:", error);
|
||||
return {
|
||||
items: [],
|
||||
total: 0,
|
||||
page,
|
||||
limit,
|
||||
totalPages: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// 필터링
|
||||
let filtered = allHistory;
|
||||
|
||||
// 상태 필터
|
||||
if (status !== 'all') {
|
||||
if (status !== "all") {
|
||||
filtered = filtered.filter((h) => h.status === status);
|
||||
}
|
||||
|
||||
|
|
@ -107,15 +158,15 @@ class MailSentHistoryService {
|
|||
let aVal: any = a[sortBy];
|
||||
let bVal: any = b[sortBy];
|
||||
|
||||
if (sortBy === 'sentAt') {
|
||||
if (sortBy === "sentAt") {
|
||||
aVal = new Date(aVal).getTime();
|
||||
bVal = new Date(bVal).getTime();
|
||||
} else {
|
||||
aVal = aVal ? aVal.toLowerCase() : '';
|
||||
bVal = bVal ? bVal.toLowerCase() : '';
|
||||
aVal = aVal ? aVal.toLowerCase() : "";
|
||||
bVal = bVal ? bVal.toLowerCase() : "";
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
if (sortOrder === "asc") {
|
||||
return aVal > bVal ? 1 : -1;
|
||||
} else {
|
||||
return aVal < bVal ? 1 : -1;
|
||||
|
|
@ -149,10 +200,10 @@ class MailSentHistoryService {
|
|||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(content) as SentMailHistory;
|
||||
} catch (error) {
|
||||
console.error('발송 이력 읽기 실패:', error);
|
||||
console.error("발송 이력 읽기 실패:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -169,10 +220,10 @@ class MailSentHistoryService {
|
|||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log('🗑️ 발송 이력 삭제:', id);
|
||||
console.log("🗑️ 발송 이력 삭제:", id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('발송 이력 삭제 실패:', error);
|
||||
console.error("발송 이력 삭제 실패:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -188,34 +239,74 @@ class MailSentHistoryService {
|
|||
thisMonthCount: number;
|
||||
successRate: number;
|
||||
}> {
|
||||
const files = fs.readdirSync(SENT_MAIL_DIR).filter((f) => f.endsWith('.json'));
|
||||
let allHistory: SentMailHistory[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
|
||||
// 계정 필터
|
||||
if (!accountId || history.accountId === accountId) {
|
||||
allHistory.push(history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
try {
|
||||
// 디렉토리가 없으면 빈 통계 반환
|
||||
if (!fs.existsSync(SENT_MAIL_DIR)) {
|
||||
return {
|
||||
totalSent: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
todayCount: 0,
|
||||
thisMonthCount: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const files = fs
|
||||
.readdirSync(SENT_MAIL_DIR)
|
||||
.filter((f) => f.endsWith(".json"));
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const filePath = path.join(SENT_MAIL_DIR, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const history: SentMailHistory = JSON.parse(content);
|
||||
|
||||
// 계정 필터
|
||||
if (!accountId || history.accountId === accountId) {
|
||||
allHistory.push(history);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`발송 이력 파일 읽기 실패: ${file}`, error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("통계 조회 실패:", error);
|
||||
return {
|
||||
totalSent: 0,
|
||||
successCount: 0,
|
||||
failedCount: 0,
|
||||
todayCount: 0,
|
||||
thisMonthCount: 0,
|
||||
successRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).toISOString();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
const todayStart = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
).toISOString();
|
||||
const monthStart = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
1
|
||||
).toISOString();
|
||||
|
||||
const totalSent = allHistory.length;
|
||||
const successCount = allHistory.filter((h) => h.status === 'success').length;
|
||||
const failedCount = allHistory.filter((h) => h.status === 'failed').length;
|
||||
const successCount = allHistory.filter(
|
||||
(h) => h.status === "success"
|
||||
).length;
|
||||
const failedCount = allHistory.filter((h) => h.status === "failed").length;
|
||||
const todayCount = allHistory.filter((h) => h.sentAt >= todayStart).length;
|
||||
const thisMonthCount = allHistory.filter((h) => h.sentAt >= monthStart).length;
|
||||
const successRate = totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0;
|
||||
const thisMonthCount = allHistory.filter(
|
||||
(h) => h.sentAt >= monthStart
|
||||
).length;
|
||||
const successRate =
|
||||
totalSent > 0 ? Math.round((successCount / totalSent) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
|
|
@ -229,4 +320,3 @@ class MailSentHistoryService {
|
|||
}
|
||||
|
||||
export const mailSentHistoryService = new MailSentHistoryService();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
// MailComponent 인터페이스 정의
|
||||
export interface MailComponent {
|
||||
|
|
@ -30,7 +30,7 @@ export interface MailTemplate {
|
|||
queries: QueryConfig[];
|
||||
};
|
||||
recipientConfig?: {
|
||||
type: 'query' | 'manual';
|
||||
type: "query" | "manual";
|
||||
emailField?: string;
|
||||
nameField?: string;
|
||||
queryId?: string;
|
||||
|
|
@ -45,19 +45,26 @@ class MailTemplateFileService {
|
|||
private templatesDir: string;
|
||||
|
||||
constructor() {
|
||||
// uploads/mail-templates 디렉토리 사용
|
||||
this.templatesDir = path.join(process.cwd(), 'uploads', 'mail-templates');
|
||||
// 운영 환경에서는 /app/uploads/mail-templates, 개발 환경에서는 프로젝트 루트
|
||||
this.templatesDir =
|
||||
process.env.NODE_ENV === "production"
|
||||
? "/app/uploads/mail-templates"
|
||||
: path.join(process.cwd(), "uploads", "mail-templates");
|
||||
this.ensureDirectoryExists();
|
||||
}
|
||||
|
||||
/**
|
||||
* 템플릿 디렉토리 생성 (없으면)
|
||||
* 템플릿 디렉토리 생성 (없으면) - try-catch로 권한 에러 방지
|
||||
*/
|
||||
private async ensureDirectoryExists() {
|
||||
try {
|
||||
await fs.access(this.templatesDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.templatesDir, { recursive: true });
|
||||
try {
|
||||
await fs.mkdir(this.templatesDir, { recursive: true });
|
||||
} catch (error) {
|
||||
console.error("메일 템플릿 디렉토리 생성 실패:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -76,21 +83,22 @@ class MailTemplateFileService {
|
|||
|
||||
try {
|
||||
const files = await fs.readdir(this.templatesDir);
|
||||
const jsonFiles = files.filter(f => f.endsWith('.json'));
|
||||
const jsonFiles = files.filter((f) => f.endsWith(".json"));
|
||||
|
||||
const templates = await Promise.all(
|
||||
jsonFiles.map(async (file) => {
|
||||
const content = await fs.readFile(
|
||||
path.join(this.templatesDir, file),
|
||||
'utf-8'
|
||||
"utf-8"
|
||||
);
|
||||
return JSON.parse(content) as MailTemplate;
|
||||
})
|
||||
);
|
||||
|
||||
// 최신순 정렬
|
||||
return templates.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
return templates.sort(
|
||||
(a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch (error) {
|
||||
return [];
|
||||
|
|
@ -102,7 +110,7 @@ class MailTemplateFileService {
|
|||
*/
|
||||
async getTemplateById(id: string): Promise<MailTemplate | null> {
|
||||
try {
|
||||
const content = await fs.readFile(this.getTemplatePath(id), 'utf-8');
|
||||
const content = await fs.readFile(this.getTemplatePath(id), "utf-8");
|
||||
return JSON.parse(content);
|
||||
} catch {
|
||||
return null;
|
||||
|
|
@ -113,7 +121,7 @@ class MailTemplateFileService {
|
|||
* 템플릿 생성
|
||||
*/
|
||||
async createTemplate(
|
||||
data: Omit<MailTemplate, 'id' | 'createdAt' | 'updatedAt'>
|
||||
data: Omit<MailTemplate, "id" | "createdAt" | "updatedAt">
|
||||
): Promise<MailTemplate> {
|
||||
const id = `template-${Date.now()}`;
|
||||
const now = new Date().toISOString();
|
||||
|
|
@ -128,7 +136,7 @@ class MailTemplateFileService {
|
|||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(template, null, 2),
|
||||
'utf-8'
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
return template;
|
||||
|
|
@ -139,7 +147,7 @@ class MailTemplateFileService {
|
|||
*/
|
||||
async updateTemplate(
|
||||
id: string,
|
||||
data: Partial<Omit<MailTemplate, 'id' | 'createdAt'>>
|
||||
data: Partial<Omit<MailTemplate, "id" | "createdAt">>
|
||||
): Promise<MailTemplate | null> {
|
||||
try {
|
||||
const existing = await this.getTemplateById(id);
|
||||
|
|
@ -161,7 +169,7 @@ class MailTemplateFileService {
|
|||
await fs.writeFile(
|
||||
this.getTemplatePath(id),
|
||||
JSON.stringify(updated, null, 2),
|
||||
'utf-8'
|
||||
"utf-8"
|
||||
);
|
||||
|
||||
// console.log(`✅ 템플릿 저장 성공: ${id}`);
|
||||
|
|
@ -188,40 +196,41 @@ class MailTemplateFileService {
|
|||
* 템플릿을 HTML로 렌더링
|
||||
*/
|
||||
renderTemplateToHtml(components: MailComponent[]): string {
|
||||
let html = '<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
let html =
|
||||
'<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;">';
|
||||
|
||||
components.forEach(comp => {
|
||||
components.forEach((comp) => {
|
||||
const styles = Object.entries(comp.styles || {})
|
||||
.map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
|
||||
.join('; ');
|
||||
.join("; ");
|
||||
|
||||
switch (comp.type) {
|
||||
case 'text':
|
||||
html += `<div style="${styles}">${comp.content || ''}</div>`;
|
||||
case "text":
|
||||
html += `<div style="${styles}">${comp.content || ""}</div>`;
|
||||
break;
|
||||
case 'button':
|
||||
case "button":
|
||||
html += `<div style="text-align: center; ${styles}">
|
||||
<a href="${comp.url || '#'}"
|
||||
<a href="${comp.url || "#"}"
|
||||
style="display: inline-block; padding: 12px 24px; text-decoration: none;
|
||||
background-color: ${comp.styles?.backgroundColor || '#007bff'};
|
||||
color: ${comp.styles?.color || '#fff'};
|
||||
background-color: ${comp.styles?.backgroundColor || "#007bff"};
|
||||
color: ${comp.styles?.color || "#fff"};
|
||||
border-radius: 4px;">
|
||||
${comp.text || 'Button'}
|
||||
${comp.text || "Button"}
|
||||
</a>
|
||||
</div>`;
|
||||
break;
|
||||
case 'image':
|
||||
case "image":
|
||||
html += `<div style="${styles}">
|
||||
<img src="${comp.src || ''}" alt="" style="max-width: 100%; height: auto;" />
|
||||
<img src="${comp.src || ""}" alt="" style="max-width: 100%; height: auto;" />
|
||||
</div>`;
|
||||
break;
|
||||
case 'spacer':
|
||||
case "spacer":
|
||||
html += `<div style="height: ${comp.height || 20}px;"></div>`;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +238,7 @@ class MailTemplateFileService {
|
|||
* camelCase를 kebab-case로 변환
|
||||
*/
|
||||
private camelToKebab(str: string): string {
|
||||
return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
|
||||
return str.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -237,7 +246,7 @@ class MailTemplateFileService {
|
|||
*/
|
||||
async getTemplatesByCategory(category: string): Promise<MailTemplate[]> {
|
||||
const allTemplates = await this.getAllTemplates();
|
||||
return allTemplates.filter(t => t.category === category);
|
||||
return allTemplates.filter((t) => t.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -247,13 +256,13 @@ class MailTemplateFileService {
|
|||
const allTemplates = await this.getAllTemplates();
|
||||
const lowerKeyword = keyword.toLowerCase();
|
||||
|
||||
return allTemplates.filter(t =>
|
||||
t.name.toLowerCase().includes(lowerKeyword) ||
|
||||
t.subject.toLowerCase().includes(lowerKeyword) ||
|
||||
t.category?.toLowerCase().includes(lowerKeyword)
|
||||
return allTemplates.filter(
|
||||
(t) =>
|
||||
t.name.toLowerCase().includes(lowerKeyword) ||
|
||||
t.subject.toLowerCase().includes(lowerKeyword) ||
|
||||
t.category?.toLowerCase().includes(lowerKeyword)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const mailTemplateFileService = new MailTemplateFileService();
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* 리포트 관리 시스템 타입 정의
|
||||
*/
|
||||
|
||||
// 리포트 템플릿
|
||||
export interface ReportTemplate {
|
||||
template_id: string;
|
||||
template_name_kor: string;
|
||||
template_name_eng: string | null;
|
||||
template_type: string;
|
||||
is_system: string;
|
||||
thumbnail_url: string | null;
|
||||
description: string | null;
|
||||
layout_config: string | null;
|
||||
default_queries: string | null;
|
||||
use_yn: string;
|
||||
sort_order: number;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
updated_at: Date | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 마스터
|
||||
export interface ReportMaster {
|
||||
report_id: string;
|
||||
report_name_kor: string;
|
||||
report_name_eng: string | null;
|
||||
template_id: string | null;
|
||||
report_type: string;
|
||||
company_code: string | null;
|
||||
description: string | null;
|
||||
use_yn: string;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
updated_at: Date | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 레이아웃
|
||||
export interface ReportLayout {
|
||||
layout_id: string;
|
||||
report_id: string;
|
||||
canvas_width: number;
|
||||
canvas_height: number;
|
||||
page_orientation: string;
|
||||
margin_top: number;
|
||||
margin_bottom: number;
|
||||
margin_left: number;
|
||||
margin_right: number;
|
||||
components: string | null;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
updated_at: Date | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 쿼리
|
||||
export interface ReportQuery {
|
||||
query_id: string;
|
||||
report_id: string;
|
||||
query_name: string;
|
||||
query_type: "MASTER" | "DETAIL";
|
||||
sql_query: string;
|
||||
parameters: string[] | null;
|
||||
external_connection_id: number | null; // 외부 DB 연결 ID (NULL이면 내부 DB)
|
||||
display_order: number;
|
||||
created_at: Date;
|
||||
created_by: string | null;
|
||||
updated_at: Date | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
// 리포트 상세 (마스터 + 레이아웃 + 쿼리)
|
||||
export interface ReportDetail {
|
||||
report: ReportMaster;
|
||||
layout: ReportLayout | null;
|
||||
queries: ReportQuery[];
|
||||
}
|
||||
|
||||
// 리포트 목록 조회 파라미터
|
||||
export interface GetReportsParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchText?: string;
|
||||
reportType?: string;
|
||||
useYn?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
// 리포트 목록 응답
|
||||
export interface GetReportsResponse {
|
||||
items: ReportMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
// 리포트 생성 요청
|
||||
export interface CreateReportRequest {
|
||||
reportNameKor: string;
|
||||
reportNameEng?: string;
|
||||
templateId?: string;
|
||||
reportType: string;
|
||||
description?: string;
|
||||
companyCode?: string;
|
||||
}
|
||||
|
||||
// 리포트 수정 요청
|
||||
export interface UpdateReportRequest {
|
||||
reportNameKor?: string;
|
||||
reportNameEng?: string;
|
||||
reportType?: string;
|
||||
description?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
|
||||
// 레이아웃 저장 요청
|
||||
export interface SaveLayoutRequest {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
pageOrientation: string;
|
||||
marginTop: number;
|
||||
marginBottom: number;
|
||||
marginLeft: number;
|
||||
marginRight: number;
|
||||
components: any[];
|
||||
queries?: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: "MASTER" | "DETAIL";
|
||||
sqlQuery: string;
|
||||
parameters: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
// 템플릿 목록 응답
|
||||
export interface GetTemplatesResponse {
|
||||
system: ReportTemplate[];
|
||||
custom: ReportTemplate[];
|
||||
}
|
||||
|
||||
// 템플릿 생성 요청
|
||||
export interface CreateTemplateRequest {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
templateType: string;
|
||||
description?: string;
|
||||
layoutConfig?: any;
|
||||
defaultQueries?: any;
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ services:
|
|||
- DATABASE_URL=postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
- JWT_SECRET=ilshin-plm-super-secret-jwt-key-2024
|
||||
- JWT_EXPIRES_IN=24h
|
||||
- ENCRYPTION_KEY=ilshin-plm-encryption-key-2024-secure-32bytes
|
||||
- CORS_ORIGIN=http://localhost:9771
|
||||
- CORS_CREDENTIALS=true
|
||||
- LOG_LEVEL=debug
|
||||
|
|
@ -26,7 +27,18 @@ services:
|
|||
- pms-network
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/health", "||", "exit", "1"]
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:8080/health",
|
||||
"||",
|
||||
"exit",
|
||||
"1",
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 15s
|
||||
retries: 5
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
|
||||
# Base image (Debian-based for glibc + OpenSSL compatibility)
|
||||
FROM node:20-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
# Install OpenSSL, curl (for healthcheck), and required certs
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends openssl ca-certificates curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Dependencies stage (install production dependencies)
|
||||
FROM base AS deps
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --prefer-offline --no-audit && npm cache clean --force
|
||||
|
||||
# Build stage (compile TypeScript)
|
||||
FROM node:20-bookworm-slim AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --prefer-offline --no-audit && npm cache clean --force
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
# Runtime image
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# Copy production node_modules
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
# Copy built files
|
||||
COPY --from=build /app/dist ./dist
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Create logs, uploads, and data directories and set permissions (use existing node user with UID 1000)
|
||||
RUN mkdir -p logs \
|
||||
uploads/mail-attachments \
|
||||
uploads/mail-templates \
|
||||
uploads/mail-accounts \
|
||||
data/mail-sent && \
|
||||
chown -R node:node logs uploads data && \
|
||||
chmod -R 755 logs uploads data
|
||||
|
||||
EXPOSE 3001
|
||||
USER node
|
||||
CMD ["node", "dist/app.js"]
|
||||
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
version: "3.8"
|
||||
|
||||
services:
|
||||
# Node.js 백엔드
|
||||
backend:
|
||||
build:
|
||||
context: ../../backend-node
|
||||
dockerfile: ../docker/deploy/backend.Dockerfile
|
||||
container_name: pms-backend-prod
|
||||
restart: always
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: "3001"
|
||||
HOST: 0.0.0.0
|
||||
DATABASE_URL: postgresql://postgres:ph0909!!@39.117.244.52:11132/plm
|
||||
JWT_SECRET: ilshin-plm-super-secret-jwt-key-2024
|
||||
JWT_EXPIRES_IN: 24h
|
||||
CORS_ORIGIN: https://v1.vexplor.com
|
||||
CORS_CREDENTIALS: "true"
|
||||
LOG_LEVEL: info
|
||||
ENCRYPTION_KEY: ilshin-plm-mail-encryption-key-32characters-2024-secure
|
||||
volumes:
|
||||
- /home/vexplor/backend_data/uploads:/app/uploads
|
||||
- /home/vexplor/backend_data/data:/app/data
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.backend.rule=Host(`api.vexplor.com`)
|
||||
- traefik.http.routers.backend.entrypoints=websecure,web
|
||||
- traefik.http.routers.backend.tls=true
|
||||
- traefik.http.routers.backend.tls.certresolver=le
|
||||
- traefik.http.services.backend.loadbalancer.server.port=3001
|
||||
|
||||
# Next.js 프론트엔드
|
||||
frontend:
|
||||
build:
|
||||
context: ../../frontend
|
||||
dockerfile: ../docker/deploy/frontend.Dockerfile
|
||||
args:
|
||||
- NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
|
||||
container_name: pms-frontend-prod
|
||||
restart: always
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
NEXT_PUBLIC_API_URL: https://api.vexplor.com/api
|
||||
NEXT_TELEMETRY_DISABLED: "1"
|
||||
PORT: "3000"
|
||||
HOSTNAME: 0.0.0.0
|
||||
volumes:
|
||||
- /home/vexplor/frontend_data:/app/data
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.frontend.rule=Host(`v1.vexplor.com`)
|
||||
- traefik.http.routers.frontend.entrypoints=websecure,web
|
||||
- traefik.http.routers.frontend.tls=true
|
||||
- traefik.http.routers.frontend.tls.certresolver=le
|
||||
- traefik.http.services.frontend.loadbalancer.server.port=3000
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: toktork_server_default
|
||||
external: true
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
# Multi-stage build for Next.js
|
||||
FROM node:20-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# Disable telemetry during the build
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# 빌드 시 환경변수 설정 (ARG로 받아서 ENV로 설정)
|
||||
ARG NEXT_PUBLIC_API_URL=https://api.vexplor.com/api
|
||||
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
||||
|
||||
# Build the application
|
||||
ENV DISABLE_ESLINT_PLUGIN=true
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy the Next.js build output
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Production 모드에서는 .next 폴더 전체를 복사
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
|
||||
# node_modules 복사 (production dependencies)
|
||||
COPY --from=deps --chown=nextjs:nodejs /app/node_modules ./node_modules
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT 3000
|
||||
ENV HOSTNAME "0.0.0.0"
|
||||
|
||||
# Next.js start 명령어 사용
|
||||
CMD ["npm", "start"]
|
||||
|
||||
|
|
@ -0,0 +1,332 @@
|
|||
# 액션 노드 타겟 선택 시스템 개선 계획
|
||||
|
||||
## 📋 현재 문제점
|
||||
|
||||
### 1. 타겟 타입 구분 부재
|
||||
- INSERT/UPDATE/DELETE/UPSERT 액션 노드가 타겟 테이블만 선택 가능
|
||||
- 내부 DB인지, 외부 DB인지, REST API인지 구분 없음
|
||||
- 실행 시 항상 내부 DB로만 동작
|
||||
|
||||
### 2. 외부 시스템 연동 불가
|
||||
- 외부 DB에 데이터 저장 불가
|
||||
- 외부 REST API 호출 불가
|
||||
- 하이브리드 플로우 구성 불가 (내부 → 외부 데이터 전송)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 개선 목표
|
||||
|
||||
액션 노드에서 다음 3가지 타겟 타입을 선택할 수 있도록 개선:
|
||||
|
||||
### 1. 내부 데이터베이스 (Internal DB)
|
||||
- 현재 시스템의 PostgreSQL
|
||||
- 기존 동작 유지
|
||||
|
||||
### 2. 외부 데이터베이스 (External DB)
|
||||
- 외부 커넥션 관리에서 설정한 DB
|
||||
- PostgreSQL, MySQL, Oracle, MSSQL, MariaDB 지원
|
||||
|
||||
### 3. REST API
|
||||
- 외부 REST API 호출
|
||||
- HTTP 메서드: POST, PUT, PATCH, DELETE
|
||||
- 인증: None, Basic, Bearer Token, API Key
|
||||
|
||||
---
|
||||
|
||||
## 📐 타입 정의 확장
|
||||
|
||||
### TargetType 추가
|
||||
```typescript
|
||||
export type TargetType = "internal" | "external" | "api";
|
||||
|
||||
export interface BaseActionNodeData {
|
||||
displayName: string;
|
||||
targetType: TargetType; // 🔥 새로 추가
|
||||
|
||||
// targetType === "internal"
|
||||
targetTable?: string;
|
||||
targetTableLabel?: string;
|
||||
|
||||
// targetType === "external"
|
||||
externalConnectionId?: number;
|
||||
externalConnectionName?: string;
|
||||
externalDbType?: string;
|
||||
externalTargetTable?: string;
|
||||
externalTargetSchema?: string;
|
||||
|
||||
// targetType === "api"
|
||||
apiEndpoint?: string;
|
||||
apiMethod?: "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
apiAuthType?: "none" | "basic" | "bearer" | "apikey";
|
||||
apiAuthConfig?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
apiKey?: string;
|
||||
apiKeyHeader?: string;
|
||||
};
|
||||
apiHeaders?: Record<string, string>;
|
||||
apiBodyTemplate?: string; // JSON 템플릿
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI 설계
|
||||
|
||||
### 1. 타겟 타입 선택 (공통)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 타겟 타입 │
|
||||
│ ○ 내부 데이터베이스 (기본) │
|
||||
│ ○ 외부 데이터베이스 │
|
||||
│ ○ REST API │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2-A. 내부 DB 선택 시 (기존 UI 유지)
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 테이블 선택: [검색 가능 Combobox] │
|
||||
│ 필드 매핑: │
|
||||
│ • source_field → target_field │
|
||||
│ • ... │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2-B. 외부 DB 선택 시
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 외부 커넥션: [🐘 PostgreSQL - 운영DB]│
|
||||
│ 스키마: [public ▼] │
|
||||
│ 테이블: [users ▼] │
|
||||
│ 필드 매핑: │
|
||||
│ • source_field → target_field │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2-C. REST API 선택 시
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ API 엔드포인트: │
|
||||
│ [https://api.example.com/users] │
|
||||
│ │
|
||||
│ HTTP 메서드: [POST ▼] │
|
||||
│ │
|
||||
│ 인증 타입: [Bearer Token ▼] │
|
||||
│ Token: [••••••••••••••] │
|
||||
│ │
|
||||
│ 헤더 (선택): │
|
||||
│ Content-Type: application/json │
|
||||
│ + 헤더 추가 │
|
||||
│ │
|
||||
│ 바디 템플릿: │
|
||||
│ { │
|
||||
│ "name": "{{source.name}}", │
|
||||
│ "email": "{{source.email}}" │
|
||||
│ } │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 구현 단계
|
||||
|
||||
### Phase 1: 타입 정의 및 기본 UI (1-2시간)
|
||||
- [ ] `types/node-editor.ts`에 `TargetType` 추가
|
||||
- [ ] `InsertActionNodeData` 등 인터페이스 확장
|
||||
- [ ] 속성 패널에 타겟 타입 선택 라디오 버튼 추가
|
||||
|
||||
### Phase 2: 내부 DB 타입 (기존 유지)
|
||||
- [ ] `targetType === "internal"` 처리
|
||||
- [ ] 기존 로직 그대로 유지
|
||||
- [ ] 기본값으로 설정
|
||||
|
||||
### Phase 3: 외부 DB 타입 (2-3시간)
|
||||
- [ ] 외부 커넥션 선택 UI
|
||||
- [ ] 외부 테이블/컬럼 로드 (기존 API 재사용)
|
||||
- [ ] 백엔드: `nodeFlowExecutionService.ts`에 외부 DB 실행 로직 추가
|
||||
- [ ] `DatabaseConnectorFactory` 활용
|
||||
|
||||
### Phase 4: REST API 타입 (3-4시간)
|
||||
- [ ] API 엔드포인트 설정 UI
|
||||
- [ ] HTTP 메서드 선택
|
||||
- [ ] 인증 타입별 설정 UI
|
||||
- [ ] 바디 템플릿 에디터 (변수 치환 지원)
|
||||
- [ ] 백엔드: Axios를 사용한 API 호출 로직
|
||||
- [ ] 응답 처리 및 에러 핸들링
|
||||
|
||||
### Phase 5: 노드 시각화 개선 (1시간)
|
||||
- [ ] 노드에 타겟 타입 아이콘 표시
|
||||
- 내부 DB: 💾
|
||||
- 외부 DB: 🔌 + DB 타입 아이콘
|
||||
- REST API: 🌐
|
||||
- [ ] 노드 색상 구분
|
||||
|
||||
### Phase 6: 검증 및 테스트 (2시간)
|
||||
- [ ] 타겟 타입별 필수 값 검증
|
||||
- [ ] 연결 규칙 업데이트
|
||||
- [ ] 통합 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🔍 백엔드 실행 로직
|
||||
|
||||
### nodeFlowExecutionService.ts
|
||||
```typescript
|
||||
private static async executeInsertAction(
|
||||
node: FlowNode,
|
||||
inputData: any[],
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
const { targetType } = node.data;
|
||||
|
||||
switch (targetType) {
|
||||
case "internal":
|
||||
return this.executeInternalInsert(node, inputData);
|
||||
|
||||
case "external":
|
||||
return this.executeExternalInsert(node, inputData);
|
||||
|
||||
case "api":
|
||||
return this.executeApiInsert(node, inputData);
|
||||
|
||||
default:
|
||||
throw new Error(`지원하지 않는 타겟 타입: ${targetType}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 외부 DB INSERT
|
||||
private static async executeExternalInsert(
|
||||
node: FlowNode,
|
||||
inputData: any[]
|
||||
): Promise<any[]> {
|
||||
const { externalConnectionId, externalTargetTable, fieldMappings } = node.data;
|
||||
|
||||
const connector = await DatabaseConnectorFactory.getConnector(
|
||||
externalConnectionId!,
|
||||
node.data.externalDbType!
|
||||
);
|
||||
|
||||
const results = [];
|
||||
for (const row of inputData) {
|
||||
const values = fieldMappings.map(m => row[m.sourceField]);
|
||||
const columns = fieldMappings.map(m => m.targetField);
|
||||
|
||||
const result = await connector.executeQuery(
|
||||
`INSERT INTO ${externalTargetTable} (${columns.join(", ")}) VALUES (${...})`,
|
||||
values
|
||||
);
|
||||
results.push(result);
|
||||
}
|
||||
|
||||
await connector.disconnect();
|
||||
return results;
|
||||
}
|
||||
|
||||
// 🔥 REST API INSERT (POST)
|
||||
private static async executeApiInsert(
|
||||
node: FlowNode,
|
||||
inputData: any[]
|
||||
): Promise<any[]> {
|
||||
const {
|
||||
apiEndpoint,
|
||||
apiMethod,
|
||||
apiAuthType,
|
||||
apiAuthConfig,
|
||||
apiHeaders,
|
||||
apiBodyTemplate
|
||||
} = node.data;
|
||||
|
||||
const axios = require("axios");
|
||||
const headers = { ...apiHeaders };
|
||||
|
||||
// 인증 헤더 추가
|
||||
if (apiAuthType === "bearer" && apiAuthConfig?.token) {
|
||||
headers["Authorization"] = `Bearer ${apiAuthConfig.token}`;
|
||||
} else if (apiAuthType === "apikey" && apiAuthConfig?.apiKey) {
|
||||
headers[apiAuthConfig.apiKeyHeader || "X-API-Key"] = apiAuthConfig.apiKey;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const row of inputData) {
|
||||
// 템플릿 변수 치환
|
||||
const body = this.replaceTemplateVariables(apiBodyTemplate, row);
|
||||
|
||||
const response = await axios({
|
||||
method: apiMethod || "POST",
|
||||
url: apiEndpoint,
|
||||
headers,
|
||||
data: JSON.parse(body),
|
||||
});
|
||||
|
||||
results.push(response.data);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 우선순위
|
||||
|
||||
### High Priority
|
||||
1. **Phase 1**: 타입 정의 및 기본 UI
|
||||
2. **Phase 2**: 내부 DB 타입 (기존 유지)
|
||||
3. **Phase 3**: 외부 DB 타입
|
||||
|
||||
### Medium Priority
|
||||
4. **Phase 4**: REST API 타입
|
||||
5. **Phase 5**: 노드 시각화
|
||||
|
||||
### Low Priority
|
||||
6. **Phase 6**: 고급 기능 (재시도, 배치 처리 등)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 예상 효과
|
||||
|
||||
### 1. 유연성 증가
|
||||
- 다양한 시스템 간 데이터 연동 가능
|
||||
- 하이브리드 플로우 구성 (내부 → 외부 → API)
|
||||
|
||||
### 2. 사용 사례 확장
|
||||
```
|
||||
[사례 1] 내부 DB → 외부 DB 동기화
|
||||
TableSource(내부)
|
||||
→ DataTransform
|
||||
→ INSERT(외부 DB)
|
||||
|
||||
[사례 2] 내부 DB → REST API 전송
|
||||
TableSource(내부)
|
||||
→ DataTransform
|
||||
→ INSERT(REST API)
|
||||
|
||||
[사례 3] 복합 플로우
|
||||
TableSource(내부)
|
||||
→ INSERT(외부 DB)
|
||||
→ INSERT(REST API - 알림)
|
||||
```
|
||||
|
||||
### 3. 기존 기능과의 호환
|
||||
- 기본값: `targetType = "internal"`
|
||||
- 기존 플로우 마이그레이션 불필요
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 주의사항
|
||||
|
||||
### 1. 보안
|
||||
- API 인증 정보 암호화 저장
|
||||
- 민감 데이터 로깅 방지
|
||||
|
||||
### 2. 에러 핸들링
|
||||
- 외부 시스템 타임아웃 처리
|
||||
- 재시도 로직 (선택적)
|
||||
- 부분 실패 처리 (이미 구현됨)
|
||||
|
||||
### 3. 성능
|
||||
- 외부 DB 연결 풀 관리 (이미 구현됨)
|
||||
- REST API Rate Limiting 고려
|
||||
|
||||
|
|
@ -0,0 +1,591 @@
|
|||
# 리포트 디자이너 그리드 시스템 구현 계획
|
||||
|
||||
## 개요
|
||||
|
||||
현재 자유 배치 방식의 리포트 디자이너를 **그리드 기반 스냅 시스템**으로 전환합니다.
|
||||
안드로이드 홈 화면의 위젯 배치 방식과 유사하게, 모든 컴포넌트는 그리드에 맞춰서만 배치 및 크기 조절이 가능합니다.
|
||||
|
||||
## 목표
|
||||
|
||||
1. **정렬된 레이아웃**: 그리드 기반으로 요소들이 자동 정렬
|
||||
2. **Word/PDF 변환 개선**: 그리드 정보를 활용하여 정확한 문서 변환
|
||||
3. **직관적인 UI**: 그리드 시각화를 통한 명확한 배치 가이드
|
||||
4. **사용자 제어**: 그리드 크기, 가시성 등 사용자 설정 가능
|
||||
|
||||
## 핵심 개념
|
||||
|
||||
### 그리드 시스템
|
||||
|
||||
```typescript
|
||||
interface GridConfig {
|
||||
// 그리드 설정
|
||||
cellWidth: number; // 그리드 셀 너비 (px)
|
||||
cellHeight: number; // 그리드 셀 높이 (px)
|
||||
rows: number; // 세로 그리드 수 (계산값: pageHeight / cellHeight)
|
||||
columns: number; // 가로 그리드 수 (계산값: pageWidth / cellWidth)
|
||||
|
||||
// 표시 설정
|
||||
visible: boolean; // 그리드 표시 여부
|
||||
snapToGrid: boolean; // 그리드 스냅 활성화 여부
|
||||
|
||||
// 시각적 설정
|
||||
gridColor: string; // 그리드 선 색상
|
||||
gridOpacity: number; // 그리드 투명도 (0-1)
|
||||
}
|
||||
```
|
||||
|
||||
### 컴포넌트 위치/크기 (그리드 기반)
|
||||
|
||||
```typescript
|
||||
interface ComponentPosition {
|
||||
// 그리드 좌표 (셀 단위)
|
||||
gridX: number; // 시작 열 (0부터 시작)
|
||||
gridY: number; // 시작 행 (0부터 시작)
|
||||
gridWidth: number; // 차지하는 열 수
|
||||
gridHeight: number; // 차지하는 행 수
|
||||
|
||||
// 실제 픽셀 좌표 (계산값)
|
||||
x: number; // gridX * cellWidth
|
||||
y: number; // gridY * cellHeight
|
||||
width: number; // gridWidth * cellWidth
|
||||
height: number; // gridHeight * cellHeight
|
||||
}
|
||||
```
|
||||
|
||||
## 구현 단계
|
||||
|
||||
### Phase 1: 그리드 시스템 기반 구조
|
||||
|
||||
#### 1.1 타입 정의
|
||||
|
||||
- **파일**: `frontend/types/report.ts`
|
||||
- **내용**:
|
||||
- `GridConfig` 인터페이스 추가
|
||||
- `ComponentConfig`에 `gridX`, `gridY`, `gridWidth`, `gridHeight` 추가
|
||||
- `ReportPage`에 `gridConfig` 추가
|
||||
|
||||
#### 1.2 Context 확장
|
||||
|
||||
- **파일**: `frontend/contexts/ReportDesignerContext.tsx`
|
||||
- **내용**:
|
||||
- `gridConfig` 상태 추가
|
||||
- `updateGridConfig()` 함수 추가
|
||||
- `snapToGrid()` 유틸리티 함수 추가
|
||||
- 컴포넌트 추가/이동/리사이즈 시 그리드 스냅 적용
|
||||
|
||||
#### 1.3 그리드 계산 유틸리티
|
||||
|
||||
- **파일**: `frontend/lib/utils/gridUtils.ts` (신규)
|
||||
- **내용**:
|
||||
|
||||
```typescript
|
||||
// 픽셀 좌표 → 그리드 좌표 변환
|
||||
export function pixelToGrid(pixel: number, cellSize: number): number;
|
||||
|
||||
// 그리드 좌표 → 픽셀 좌표 변환
|
||||
export function gridToPixel(grid: number, cellSize: number): number;
|
||||
|
||||
// 컴포넌트 위치/크기를 그리드에 스냅
|
||||
export function snapComponentToGrid(
|
||||
component: ComponentConfig,
|
||||
gridConfig: GridConfig
|
||||
): ComponentConfig;
|
||||
|
||||
// 그리드 충돌 감지
|
||||
export function detectGridCollision(
|
||||
component: ComponentConfig,
|
||||
otherComponents: ComponentConfig[]
|
||||
): boolean;
|
||||
```
|
||||
|
||||
### Phase 2: 그리드 시각화
|
||||
|
||||
#### 2.1 그리드 레이어 컴포넌트
|
||||
|
||||
- **파일**: `frontend/components/report/designer/GridLayer.tsx` (신규)
|
||||
- **내용**:
|
||||
- Canvas 위에 그리드 선 렌더링
|
||||
- SVG 또는 Canvas API 사용
|
||||
- 그리드 크기/색상/투명도 적용
|
||||
- 줌/스크롤 시에도 정확한 위치 유지
|
||||
|
||||
```tsx
|
||||
interface GridLayerProps {
|
||||
gridConfig: GridConfig;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
}
|
||||
|
||||
export function GridLayer({
|
||||
gridConfig,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
}: GridLayerProps) {
|
||||
if (!gridConfig.visible) return null;
|
||||
|
||||
// SVG로 그리드 선 렌더링
|
||||
return (
|
||||
<svg className="absolute inset-0 pointer-events-none">
|
||||
{/* 세로 선 */}
|
||||
{Array.from({ length: gridConfig.columns + 1 }).map((_, i) => (
|
||||
<line
|
||||
key={`v-${i}`}
|
||||
x1={i * gridConfig.cellWidth}
|
||||
y1={0}
|
||||
x2={i * gridConfig.cellWidth}
|
||||
y2={pageHeight}
|
||||
stroke={gridConfig.gridColor}
|
||||
strokeOpacity={gridConfig.opacity}
|
||||
/>
|
||||
))}
|
||||
{/* 가로 선 */}
|
||||
{Array.from({ length: gridConfig.rows + 1 }).map((_, i) => (
|
||||
<line
|
||||
key={`h-${i}`}
|
||||
x1={0}
|
||||
y1={i * gridConfig.cellHeight}
|
||||
x2={pageWidth}
|
||||
y2={i * gridConfig.cellHeight}
|
||||
stroke={gridConfig.gridColor}
|
||||
strokeOpacity={gridConfig.opacity}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.2 Canvas 통합
|
||||
|
||||
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
|
||||
- **내용**:
|
||||
- `<GridLayer />` 추가
|
||||
- 컴포넌트 렌더링 시 그리드 기반 위치 사용
|
||||
|
||||
### Phase 3: 드래그 앤 드롭 스냅
|
||||
|
||||
#### 3.1 드래그 시 그리드 스냅
|
||||
|
||||
- **파일**: `frontend/components/report/designer/ReportDesignerCanvas.tsx`
|
||||
- **내용**:
|
||||
- `useDrop` 훅 수정
|
||||
- 드롭 위치를 그리드에 스냅
|
||||
- 실시간 스냅 가이드 표시
|
||||
|
||||
```typescript
|
||||
const [, drop] = useDrop({
|
||||
accept: ["TEXT", "LABEL", "TABLE", "SIGNATURE", "STAMP"],
|
||||
drop: (item: any, monitor) => {
|
||||
const offset = monitor.getClientOffset();
|
||||
if (!offset) return;
|
||||
|
||||
// 캔버스 상대 좌표 계산
|
||||
const canvasRect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!canvasRect) return;
|
||||
|
||||
let x = offset.x - canvasRect.left;
|
||||
let y = offset.y - canvasRect.top;
|
||||
|
||||
// 그리드 스냅 적용
|
||||
if (gridConfig.snapToGrid) {
|
||||
const gridX = Math.round(x / gridConfig.cellWidth);
|
||||
const gridY = Math.round(y / gridConfig.cellHeight);
|
||||
x = gridX * gridConfig.cellWidth;
|
||||
y = gridY * gridConfig.cellHeight;
|
||||
}
|
||||
|
||||
// 컴포넌트 추가
|
||||
addComponent({ type: item.type, x, y });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3.2 리사이즈 시 그리드 스냅
|
||||
|
||||
- **파일**: `frontend/components/report/designer/ComponentWrapper.tsx`
|
||||
- **내용**:
|
||||
- `react-resizable` 또는 `react-rnd`의 `snap` 설정 활용
|
||||
- 리사이즈 핸들 드래그 시 그리드 단위로만 크기 조절
|
||||
|
||||
```typescript
|
||||
<Rnd
|
||||
position={{ x: component.x, y: component.y }}
|
||||
size={{ width: component.width, height: component.height }}
|
||||
onDragStop={(e, d) => {
|
||||
let newX = d.x;
|
||||
let newY = d.y;
|
||||
|
||||
if (gridConfig.snapToGrid) {
|
||||
const gridX = Math.round(newX / gridConfig.cellWidth);
|
||||
const gridY = Math.round(newY / gridConfig.cellHeight);
|
||||
newX = gridX * gridConfig.cellWidth;
|
||||
newY = gridY * gridConfig.cellHeight;
|
||||
}
|
||||
|
||||
updateComponent(component.id, { x: newX, y: newY });
|
||||
}}
|
||||
onResizeStop={(e, direction, ref, delta, position) => {
|
||||
let newWidth = parseInt(ref.style.width);
|
||||
let newHeight = parseInt(ref.style.height);
|
||||
|
||||
if (gridConfig.snapToGrid) {
|
||||
const gridWidth = Math.round(newWidth / gridConfig.cellWidth);
|
||||
const gridHeight = Math.round(newHeight / gridConfig.cellHeight);
|
||||
newWidth = gridWidth * gridConfig.cellWidth;
|
||||
newHeight = gridHeight * gridConfig.cellHeight;
|
||||
}
|
||||
|
||||
updateComponent(component.id, {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
...position,
|
||||
});
|
||||
}}
|
||||
grid={
|
||||
gridConfig.snapToGrid
|
||||
? [gridConfig.cellWidth, gridConfig.cellHeight]
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
### Phase 4: 그리드 설정 UI
|
||||
|
||||
#### 4.1 그리드 설정 패널
|
||||
|
||||
- **파일**: `frontend/components/report/designer/GridSettingsPanel.tsx` (신규)
|
||||
- **내용**:
|
||||
- 그리드 크기 조절 (cellWidth, cellHeight)
|
||||
- 그리드 표시/숨김 토글
|
||||
- 스냅 활성화/비활성화 토글
|
||||
- 그리드 색상/투명도 조절
|
||||
|
||||
```tsx
|
||||
export function GridSettingsPanel() {
|
||||
const { gridConfig, updateGridConfig } = useReportDesigner();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">그리드 설정</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{/* 그리드 표시 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>그리드 표시</Label>
|
||||
<Switch
|
||||
checked={gridConfig.visible}
|
||||
onCheckedChange={(visible) => updateGridConfig({ visible })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 스냅 활성화 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>그리드 스냅</Label>
|
||||
<Switch
|
||||
checked={gridConfig.snapToGrid}
|
||||
onCheckedChange={(snapToGrid) => updateGridConfig({ snapToGrid })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 셀 크기 */}
|
||||
<div className="space-y-2">
|
||||
<Label>셀 너비 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={gridConfig.cellWidth}
|
||||
onChange={(e) =>
|
||||
updateGridConfig({ cellWidth: parseInt(e.target.value) })
|
||||
}
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>셀 높이 (px)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={gridConfig.cellHeight}
|
||||
onChange={(e) =>
|
||||
updateGridConfig({ cellHeight: parseInt(e.target.value) })
|
||||
}
|
||||
min={10}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 프리셋 */}
|
||||
<div className="space-y-2">
|
||||
<Label>프리셋</Label>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
const presets: Record<
|
||||
string,
|
||||
{ cellWidth: number; cellHeight: number }
|
||||
> = {
|
||||
fine: { cellWidth: 10, cellHeight: 10 },
|
||||
medium: { cellWidth: 20, cellHeight: 20 },
|
||||
coarse: { cellWidth: 50, cellHeight: 50 },
|
||||
};
|
||||
updateGridConfig(presets[value]);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="그리드 크기 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="fine">세밀 (10x10)</SelectItem>
|
||||
<SelectItem value="medium">중간 (20x20)</SelectItem>
|
||||
<SelectItem value="coarse">넓음 (50x50)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.2 툴바에 그리드 토글 추가
|
||||
|
||||
- **파일**: `frontend/components/report/designer/ReportDesignerToolbar.tsx`
|
||||
- **내용**:
|
||||
- 그리드 표시/숨김 버튼
|
||||
- 그리드 설정 모달 열기 버튼
|
||||
- 키보드 단축키 (`G` 키로 그리드 토글)
|
||||
|
||||
### Phase 5: Word 변환 개선
|
||||
|
||||
#### 5.1 그리드 기반 레이아웃 변환
|
||||
|
||||
- **파일**: `frontend/components/report/designer/ReportPreviewModal.tsx`
|
||||
- **내용**:
|
||||
- 그리드 정보를 활용하여 더 정확한 테이블 레이아웃 생성
|
||||
- 그리드 행/열을 Word 테이블의 행/열로 매핑
|
||||
|
||||
```typescript
|
||||
const handleDownloadWord = async () => {
|
||||
// 그리드 기반으로 컴포넌트 배치 맵 생성
|
||||
const gridMap: (ComponentConfig | null)[][] = Array(gridConfig.rows)
|
||||
.fill(null)
|
||||
.map(() => Array(gridConfig.columns).fill(null));
|
||||
|
||||
// 각 컴포넌트를 그리드 맵에 배치
|
||||
for (const component of components) {
|
||||
const gridX = Math.round(component.x / gridConfig.cellWidth);
|
||||
const gridY = Math.round(component.y / gridConfig.cellHeight);
|
||||
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
|
||||
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
|
||||
|
||||
// 컴포넌트가 차지하는 모든 셀에 참조 저장
|
||||
for (let y = gridY; y < gridY + gridHeight; y++) {
|
||||
for (let x = gridX; x < gridX + gridWidth; x++) {
|
||||
if (y < gridConfig.rows && x < gridConfig.columns) {
|
||||
gridMap[y][x] = component;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 그리드 맵을 Word 테이블로 변환
|
||||
const tableRows: TableRow[] = [];
|
||||
|
||||
for (let y = 0; y < gridConfig.rows; y++) {
|
||||
const cells: TableCell[] = [];
|
||||
let x = 0;
|
||||
|
||||
while (x < gridConfig.columns) {
|
||||
const component = gridMap[y][x];
|
||||
|
||||
if (!component) {
|
||||
// 빈 셀
|
||||
cells.push(new TableCell({ children: [new Paragraph("")] }));
|
||||
x++;
|
||||
} else {
|
||||
// 컴포넌트 셀
|
||||
const gridWidth = Math.round(component.width / gridConfig.cellWidth);
|
||||
const gridHeight = Math.round(component.height / gridConfig.cellHeight);
|
||||
|
||||
const cell = createTableCell(component, gridWidth, gridHeight);
|
||||
if (cell) cells.push(cell);
|
||||
|
||||
x += gridWidth;
|
||||
}
|
||||
}
|
||||
|
||||
if (cells.length > 0) {
|
||||
tableRows.push(new TableRow({ children: cells }));
|
||||
}
|
||||
}
|
||||
|
||||
// ... Word 문서 생성
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 6: 데이터 마이그레이션
|
||||
|
||||
#### 6.1 기존 레이아웃 자동 변환
|
||||
|
||||
- **파일**: `frontend/lib/utils/layoutMigration.ts` (신규)
|
||||
- **내용**:
|
||||
- 기존 절대 위치 데이터를 그리드 기반으로 변환
|
||||
- 가장 가까운 그리드 셀에 스냅
|
||||
- 마이그레이션 로그 생성
|
||||
|
||||
```typescript
|
||||
export function migrateLayoutToGrid(
|
||||
layout: ReportLayoutConfig,
|
||||
gridConfig: GridConfig
|
||||
): ReportLayoutConfig {
|
||||
return {
|
||||
...layout,
|
||||
pages: layout.pages.map((page) => ({
|
||||
...page,
|
||||
gridConfig,
|
||||
components: page.components.map((component) => {
|
||||
// 픽셀 좌표를 그리드 좌표로 변환
|
||||
const gridX = Math.round(component.x / gridConfig.cellWidth);
|
||||
const gridY = Math.round(component.y / gridConfig.cellHeight);
|
||||
const gridWidth = Math.max(
|
||||
1,
|
||||
Math.round(component.width / gridConfig.cellWidth)
|
||||
);
|
||||
const gridHeight = Math.max(
|
||||
1,
|
||||
Math.round(component.height / gridConfig.cellHeight)
|
||||
);
|
||||
|
||||
return {
|
||||
...component,
|
||||
gridX,
|
||||
gridY,
|
||||
gridWidth,
|
||||
gridHeight,
|
||||
x: gridX * gridConfig.cellWidth,
|
||||
y: gridY * gridConfig.cellHeight,
|
||||
width: gridWidth * gridConfig.cellWidth,
|
||||
height: gridHeight * gridConfig.cellHeight,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### 6.2 마이그레이션 UI
|
||||
|
||||
- **파일**: `frontend/components/report/designer/MigrationModal.tsx` (신규)
|
||||
- **내용**:
|
||||
- 기존 리포트 로드 시 마이그레이션 필요 여부 체크
|
||||
- 마이그레이션 전/후 미리보기
|
||||
- 사용자 확인 후 적용
|
||||
|
||||
## 데이터베이스 스키마 변경
|
||||
|
||||
### report_layout_pages 테이블
|
||||
|
||||
```sql
|
||||
ALTER TABLE report_layout_pages
|
||||
ADD COLUMN grid_cell_width INTEGER DEFAULT 20,
|
||||
ADD COLUMN grid_cell_height INTEGER DEFAULT 20,
|
||||
ADD COLUMN grid_visible BOOLEAN DEFAULT true,
|
||||
ADD COLUMN grid_snap_enabled BOOLEAN DEFAULT true,
|
||||
ADD COLUMN grid_color VARCHAR(7) DEFAULT '#e5e7eb',
|
||||
ADD COLUMN grid_opacity DECIMAL(3,2) DEFAULT 0.5;
|
||||
```
|
||||
|
||||
### report_layout_components 테이블
|
||||
|
||||
```sql
|
||||
ALTER TABLE report_layout_components
|
||||
ADD COLUMN grid_x INTEGER,
|
||||
ADD COLUMN grid_y INTEGER,
|
||||
ADD COLUMN grid_width INTEGER,
|
||||
ADD COLUMN grid_height INTEGER;
|
||||
|
||||
-- 기존 데이터 마이그레이션
|
||||
UPDATE report_layout_components
|
||||
SET
|
||||
grid_x = ROUND(position_x / 20.0),
|
||||
grid_y = ROUND(position_y / 20.0),
|
||||
grid_width = GREATEST(1, ROUND(width / 20.0)),
|
||||
grid_height = GREATEST(1, ROUND(height / 20.0))
|
||||
WHERE grid_x IS NULL;
|
||||
```
|
||||
|
||||
## 테스트 계획
|
||||
|
||||
### 단위 테스트
|
||||
|
||||
- `gridUtils.ts`의 모든 함수 테스트
|
||||
- 그리드 좌표 ↔ 픽셀 좌표 변환 정확성
|
||||
- 충돌 감지 로직
|
||||
|
||||
### 통합 테스트
|
||||
|
||||
- 드래그 앤 드롭 시 그리드 스냅 동작
|
||||
- 리사이즈 시 그리드 스냅 동작
|
||||
- 그리드 크기 변경 시 컴포넌트 재배치
|
||||
|
||||
### E2E 테스트
|
||||
|
||||
- 새 리포트 생성 및 그리드 설정
|
||||
- 기존 리포트 마이그레이션
|
||||
- Word 다운로드 시 레이아웃 정확성
|
||||
|
||||
## 예상 개발 일정
|
||||
|
||||
- **Phase 1**: 그리드 시스템 기반 구조 (2일)
|
||||
- **Phase 2**: 그리드 시각화 (1일)
|
||||
- **Phase 3**: 드래그 앤 드롭 스냅 (2일)
|
||||
- **Phase 4**: 그리드 설정 UI (1일)
|
||||
- **Phase 5**: Word 변환 개선 (2일)
|
||||
- **Phase 6**: 데이터 마이그레이션 (1일)
|
||||
- **테스트 및 디버깅**: (2일)
|
||||
|
||||
**총 예상 기간**: 11일
|
||||
|
||||
## 기술적 고려사항
|
||||
|
||||
### 성능 최적화
|
||||
|
||||
- 그리드 렌더링: SVG 대신 Canvas API 고려 (많은 셀의 경우)
|
||||
- 메모이제이션: 그리드 계산 결과 캐싱
|
||||
- 가상화: 큰 페이지에서 보이는 영역만 렌더링
|
||||
|
||||
### 사용자 경험
|
||||
|
||||
- 실시간 스냅 가이드: 드래그 중 스냅될 위치 미리 표시
|
||||
- 키보드 단축키: 방향키로 그리드 단위 이동, Shift+방향키로 픽셀 단위 미세 조정
|
||||
- 언두/리두: 그리드 스냅 적용 전/후 상태 저장
|
||||
|
||||
### 하위 호환성
|
||||
|
||||
- 기존 리포트는 자동 마이그레이션 제공
|
||||
- 마이그레이션 옵션: 자동 / 수동 선택 가능
|
||||
- 레거시 모드: 그리드 없이 자유 배치 가능 (옵션)
|
||||
|
||||
## 추가 기능 (향후 확장)
|
||||
|
||||
### 스마트 가이드
|
||||
|
||||
- 다른 컴포넌트와 정렬 시 가이드 라인 표시
|
||||
- 균등 간격 가이드
|
||||
|
||||
### 그리드 템플릿
|
||||
|
||||
- 자주 사용하는 그리드 레이아웃 템플릿 제공
|
||||
- 문서 종류별 프리셋 (계약서, 보고서, 송장 등)
|
||||
|
||||
### 그리드 병합
|
||||
|
||||
- 여러 그리드 셀을 하나로 병합
|
||||
- 복잡한 레이아웃 지원
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- Android Home Screen Widget System
|
||||
- Microsoft Word Table Layout
|
||||
- CSS Grid Layout
|
||||
- Figma Auto Layout
|
||||
|
|
@ -0,0 +1,288 @@
|
|||
# 기상청 Open API 키 발급 가이드 🇰🇷
|
||||
|
||||
## 📌 개요
|
||||
|
||||
날씨 위젯은 **공공데이터포털 기상청 API**를 사용합니다.
|
||||
- 🌐 **플랫폼**: https://www.data.go.kr
|
||||
- ✅ **완전 무료**
|
||||
- ✅ **일일 트래픽 제한 없음**
|
||||
- ✅ **실시간 한국 날씨 정보**
|
||||
|
||||
> **참고**: 기상청 API Hub (apihub.kma.go.kr)는 현재 접근 제한이 있어,
|
||||
> 공공데이터포털의 기상청 API를 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 API 키 발급 (5분 소요)
|
||||
|
||||
### 1️⃣ 공공데이터포털 회원가입
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr
|
||||
```
|
||||
|
||||
1. 우측 상단 **회원가입** 클릭
|
||||
2. 이메일 입력 및 인증
|
||||
3. 약관 동의 후 가입 완료
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ API 활용신청
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr/data/15084084/openapi.do
|
||||
```
|
||||
|
||||
**"기상청_단기예보 ((구)_동네예보) 조회서비스"** 페이지에서:
|
||||
|
||||
1. **활용신청** 버튼 클릭
|
||||
2. 활용 목적: `기타`
|
||||
3. 상세 기능 설명: `대시보드 날씨 위젯`
|
||||
4. 신청 완료
|
||||
|
||||
⚠️ **승인까지 약 2-3시간 소요** (즉시 승인되는 경우도 있음)
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ 인증키 확인
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr/mypage/myPageOpenAPI.do
|
||||
```
|
||||
|
||||
**마이페이지 > 오픈API > 인증키**에서:
|
||||
|
||||
1. **일반 인증키(Encoding)** 복사
|
||||
2. 긴 문자열 전체를 복사하세요!
|
||||
|
||||
**예시:**
|
||||
```
|
||||
aBc1234dEf5678gHi9012jKl3456mNo7890pQr1234sTu5678vWx9012yZa3456bCd7890==
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 변수 설정
|
||||
|
||||
### 방법 1: .env 파일 생성 (추천)
|
||||
|
||||
```bash
|
||||
# 1. .env 파일 생성
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 2. 다음 내용 입력:
|
||||
|
||||
```bash
|
||||
# Node 환경
|
||||
NODE_ENV=development
|
||||
|
||||
# 서버 포트
|
||||
PORT=8080
|
||||
|
||||
# 기상청 API 키 (발급받은 인증키를 여기에 붙여넣기)
|
||||
KMA_API_KEY=여기에_발급받은_인증키를_붙여넣으세요
|
||||
```
|
||||
|
||||
### 3. 저장 및 종료
|
||||
- `Ctrl + O` (저장)
|
||||
- `Enter` (확인)
|
||||
- `Ctrl + X` (종료)
|
||||
|
||||
---
|
||||
|
||||
### 방법 2: 명령어로 추가
|
||||
|
||||
```bash
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
|
||||
echo "KMA_API_KEY=여기에_발급받은_인증키_붙여넣기" >> .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 백엔드 재시작
|
||||
|
||||
```bash
|
||||
docker restart pms-backend-mac
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```bash
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 테스트
|
||||
|
||||
### 1. 브라우저에서 대시보드 접속
|
||||
```
|
||||
http://localhost:9771/admin/dashboard
|
||||
```
|
||||
|
||||
### 2. 날씨 위젯 드래그 앤 드롭
|
||||
- 오른쪽 사이드바에서 **☁️ 날씨 위젯** 드래그
|
||||
- 캔버스에 드롭
|
||||
- **실시간 한국 날씨** 표시 확인! 🎉
|
||||
|
||||
### 3. API 직접 테스트
|
||||
```bash
|
||||
curl "http://localhost:9771/api/open-api/weather?city=서울" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
**응답 예시:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"city": "서울",
|
||||
"country": "KR",
|
||||
"temperature": 18,
|
||||
"feelsLike": 16,
|
||||
"humidity": 65,
|
||||
"pressure": 1013,
|
||||
"weatherMain": "Clear",
|
||||
"weatherDescription": "맑음",
|
||||
"weatherIcon": "01d",
|
||||
"windSpeed": 3.5,
|
||||
"clouds": 10,
|
||||
"timestamp": "2025-10-13T07:30:00.000Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌍 지원 지역
|
||||
|
||||
### 한국 주요 도시
|
||||
- **서울** (Seoul)
|
||||
- **부산** (Busan)
|
||||
- **인천** (Incheon)
|
||||
- **대구** (Daegu)
|
||||
- **광주** (Gwangju)
|
||||
- **대전** (Daejeon)
|
||||
- **울산** (Ulsan)
|
||||
- **세종** (Sejong)
|
||||
- **수원** (Suwon)
|
||||
- **춘천** (Chuncheon)
|
||||
- **제주** (Jeju)
|
||||
|
||||
**영문/한글 모두 지원!**
|
||||
|
||||
---
|
||||
|
||||
## 🔧 트러블슈팅
|
||||
|
||||
### 1. "기상청 API 키가 설정되지 않았습니다" 오류
|
||||
|
||||
**원인**: `.env` 파일에 API 키가 없음
|
||||
|
||||
**해결방법**:
|
||||
```bash
|
||||
# .env 파일 확인
|
||||
cat /Users/leeheejin/ERP-node/backend-node/.env
|
||||
|
||||
# KMA_API_KEY가 있는지 확인
|
||||
# 없으면 위 "환경 변수 설정" 참고하여 추가
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. "기상청 API 오류: SERVICE_KEY_IS_NOT_REGISTERED_ERROR" 오류
|
||||
|
||||
**원인**: API 키가 아직 승인되지 않았거나 잘못된 키
|
||||
|
||||
**해결방법**:
|
||||
1. 공공데이터포털에서 승인 상태 확인
|
||||
2. **일반 인증키(Encoding)** 복사했는지 확인 (Decoding 아님!)
|
||||
3. 키 앞뒤에 공백 없는지 확인
|
||||
4. 백엔드 재시작
|
||||
|
||||
---
|
||||
|
||||
### 3. "지원하지 않는 지역입니다" 오류
|
||||
|
||||
**원인**: 등록되지 않은 도시명
|
||||
|
||||
**해결방법**:
|
||||
- 위 "지원 지역" 목록 참고
|
||||
- 영문 또는 한글 정확히 입력
|
||||
- 예: `서울`, `Seoul`, `부산`, `Busan`
|
||||
|
||||
---
|
||||
|
||||
### 4. API 키 재발급
|
||||
|
||||
공공데이터포털에서:
|
||||
1. **마이페이지 > 오픈API**
|
||||
2. 해당 API 찾기
|
||||
3. **상세보기 > 인증키 재발급**
|
||||
|
||||
---
|
||||
|
||||
## 📊 API 사용 현황 확인
|
||||
|
||||
```
|
||||
👉 https://www.data.go.kr/mypage/myPageOpenAPIStatView.do
|
||||
```
|
||||
|
||||
- 일일 트래픽: **무제한** ✅
|
||||
- 서비스 상태: 정상
|
||||
- 응답 속도: 평균 1초 이내
|
||||
|
||||
---
|
||||
|
||||
## 🎨 위젯 커스터마이징
|
||||
|
||||
### 기본 도시 변경
|
||||
|
||||
```typescript
|
||||
// DashboardDesigner.tsx
|
||||
case 'weather': return '부산'; // 원하는 도시로 변경
|
||||
```
|
||||
|
||||
### 새로고침 주기 변경
|
||||
|
||||
```typescript
|
||||
// WeatherWidget.tsx
|
||||
<WeatherWidget
|
||||
city="서울"
|
||||
refreshInterval={300000} // 5분 (밀리초)
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 참고 링크
|
||||
|
||||
- **공공데이터포털**: https://www.data.go.kr
|
||||
- **기상청 API 신청**: https://www.data.go.kr/data/15084084/openapi.do
|
||||
- **마이페이지(인증키)**: https://www.data.go.kr/mypage/myPageOpenAPI.do
|
||||
- **기상청 공식 사이트**: https://www.weather.go.kr
|
||||
|
||||
---
|
||||
|
||||
## 💡 FAQ
|
||||
|
||||
**Q: 승인이 언제 되나요?**
|
||||
A: 보통 **2-3시간**, 빠르면 즉시 승인됩니다.
|
||||
|
||||
**Q: 유료인가요?**
|
||||
A: **완전 무료**입니다! 트래픽 제한도 없어요.
|
||||
|
||||
**Q: 해외 도시도 되나요?**
|
||||
A: 아니요, 기상청 API는 **한국 지역만** 지원합니다.
|
||||
|
||||
**Q: 실시간인가요?**
|
||||
A: 실시간 관측 데이터를 제공합니다 (매시간 업데이트).
|
||||
|
||||
---
|
||||
|
||||
✅ **설정 완료 후 대시보드에서 실시간 한국 날씨를 확인하세요!** 🌤️
|
||||
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
# 날씨 위젯 API 키 설정 가이드 🌤️
|
||||
|
||||
## 📌 개요
|
||||
|
||||
날씨 위젯을 사용하려면 **OpenWeatherMap API 키**가 필요합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🔑 OpenWeatherMap API 키 발급
|
||||
|
||||
### 1. 회원가입
|
||||
- 사이트: https://openweathermap.org/api
|
||||
- 무료 플랜 선택 (Free Plan)
|
||||
- 하루 **60회** 무료 호출 가능 (충분함)
|
||||
|
||||
### 2. API 키 확인
|
||||
- 로그인 후 **API keys** 메뉴로 이동
|
||||
- 자동 생성된 API 키 복사
|
||||
- 또는 **Create Key** 버튼으로 새 키 생성
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 환경 변수 설정
|
||||
|
||||
### 백엔드 `.env` 파일 수정
|
||||
|
||||
```bash
|
||||
# backend-node/.env 파일 열기
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
vi .env
|
||||
```
|
||||
|
||||
### 다음 내용 추가:
|
||||
|
||||
```bash
|
||||
# OpenWeatherMap API 키
|
||||
OPENWEATHER_API_KEY=your_actual_api_key_here
|
||||
```
|
||||
|
||||
**예시:**
|
||||
```bash
|
||||
OPENWEATHER_API_KEY=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔄 백엔드 재시작
|
||||
|
||||
```bash
|
||||
docker restart pms-backend-mac
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```bash
|
||||
cd /Users/leeheejin/ERP-node/backend-node
|
||||
npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ 테스트
|
||||
|
||||
### 1. 브라우저에서 대시보드 설계 도구 접속
|
||||
```
|
||||
http://localhost:9771/admin/dashboard
|
||||
```
|
||||
|
||||
### 2. 날씨 위젯 드래그 앤 드롭
|
||||
- 오른쪽 사이드바에서 **☁️ 날씨 위젯** 찾기
|
||||
- 캔버스로 드래그 앤 드롭
|
||||
- 실시간 날씨 정보 표시 확인
|
||||
|
||||
### 3. API 직접 테스트
|
||||
```bash
|
||||
curl "http://localhost:9771/api/open-api/weather?city=Seoul" \
|
||||
-H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌍 지원 도시
|
||||
|
||||
### 한국 주요 도시
|
||||
- Seoul (서울)
|
||||
- Busan (부산)
|
||||
- Incheon (인천)
|
||||
- Daegu (대구)
|
||||
- Gwangju (광주)
|
||||
- Daejeon (대전)
|
||||
|
||||
### 해외 도시
|
||||
- Tokyo
|
||||
- New York
|
||||
- London
|
||||
- Paris
|
||||
- Singapore
|
||||
|
||||
---
|
||||
|
||||
## 🔧 트러블슈팅
|
||||
|
||||
### 1. "날씨 API 키가 유효하지 않습니다" 오류
|
||||
**원인**: API 키가 잘못되었거나 활성화되지 않음
|
||||
|
||||
**해결방법**:
|
||||
1. OpenWeatherMap 사이트에서 API 키 재확인
|
||||
2. 새로 발급한 키는 **2시간 후** 활성화됨 (대기 필요)
|
||||
3. `.env` 파일에 복사한 키가 정확한지 확인
|
||||
4. 백엔드 재시작
|
||||
|
||||
### 2. "도시를 찾을 수 없습니다" 오류
|
||||
**원인**: 도시명 철자 오류 또는 지원하지 않는 도시
|
||||
|
||||
**해결방법**:
|
||||
- 영문 도시명 사용 (Seoul, Busan 등)
|
||||
- OpenWeatherMap 도시 목록 확인: https://openweathermap.org/find
|
||||
|
||||
### 3. "CORS 오류" 발생
|
||||
**원인**: 프론트엔드-백엔드 통신 문제
|
||||
|
||||
**해결방법**:
|
||||
- 백엔드가 정상 실행 중인지 확인 (`docker ps`)
|
||||
- `backend-node/src/app.ts`의 CORS 설정 확인
|
||||
- 브라우저 개발자 도구에서 요청 URL 확인
|
||||
|
||||
---
|
||||
|
||||
## 📊 API 사용량 확인
|
||||
|
||||
- OpenWeatherMap 대시보드: https://home.openweathermap.org/api_keys
|
||||
- 무료 플랜: 하루 60회 (1분당 1회)
|
||||
- 위젯 새로고침 주기: **10분** (기본값)
|
||||
|
||||
---
|
||||
|
||||
## 🎨 커스터마이징
|
||||
|
||||
### 날씨 위젯 도시 변경
|
||||
```typescript
|
||||
// DashboardDesigner.tsx에서 요소 생성 시
|
||||
const newElement = {
|
||||
...
|
||||
content: "Tokyo", // 원하는 도시명으로 변경
|
||||
};
|
||||
```
|
||||
|
||||
### 새로고침 주기 변경
|
||||
```typescript
|
||||
// WeatherWidget.tsx에서
|
||||
<WeatherWidget
|
||||
city="Seoul"
|
||||
refreshInterval={300000} // 5분 (밀리초)
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 참고 링크
|
||||
|
||||
- OpenWeatherMap API 문서: https://openweathermap.org/current
|
||||
- 무료 API 키 발급: https://openweathermap.org/price
|
||||
- 지원 도시 검색: https://openweathermap.org/find
|
||||
|
||||
---
|
||||
|
||||
✅ **설정 완료 후 대시보드에서 실시간 날씨를 확인하세요!**
|
||||
|
||||
|
|
@ -0,0 +1,481 @@
|
|||
# 노드 구조 개선안 - FROM/TO 테이블 명확화
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🤔 검토 중
|
||||
|
||||
---
|
||||
|
||||
## 📋 문제 인식
|
||||
|
||||
### 현재 설계의 한계
|
||||
|
||||
```
|
||||
현재 플로우:
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
1. 타겟 테이블(orders)이 노드로 표현되지 않음
|
||||
2. InsertAction의 속성으로만 존재 → 시각적으로 불명확
|
||||
3. FROM(user_info)과 TO(orders)의 관계가 직관적이지 않음
|
||||
4. 타겟 테이블의 스키마 정보를 참조하기 어려움
|
||||
|
||||
---
|
||||
|
||||
## 💡 개선 방안
|
||||
|
||||
### 옵션 1: TableTarget 노드 추가 (권장 ⭐)
|
||||
|
||||
**새로운 플로우**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → TableTarget(orders) → InsertAction
|
||||
```
|
||||
|
||||
**노드 추가**:
|
||||
|
||||
- `TableTarget` - 타겟 테이블을 명시적으로 표현
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ FROM/TO가 시각적으로 명확
|
||||
- ✅ 타겟 테이블 스키마를 미리 로드 가능
|
||||
- ✅ FieldMapping에서 타겟 필드 자동 완성 가능
|
||||
- ✅ 데이터 흐름이 직관적
|
||||
|
||||
**단점**:
|
||||
|
||||
- ⚠️ 노드 개수 증가 (복잡도 증가)
|
||||
- ⚠️ 기존 설계와 호환성 문제
|
||||
|
||||
---
|
||||
|
||||
### 옵션 2: Action 노드에 Target 속성 유지 (현재 방식)
|
||||
|
||||
**현재 플로우 유지**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**개선 방법**:
|
||||
|
||||
- Action 노드에서 타겟 테이블을 더 명확히 표시
|
||||
- 노드 UI에 타겟 테이블명을 크게 표시
|
||||
- Properties Panel에서 타겟 테이블 선택 시 스키마 정보 제공
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 기존 설계 유지 (구현 완료된 상태)
|
||||
- ✅ 노드 개수가 적음 (간결함)
|
||||
- ✅ 빠른 플로우 구성 가능
|
||||
|
||||
**단점**:
|
||||
|
||||
- ❌ 시각적으로 FROM/TO 관계가 불명확
|
||||
- ❌ FieldMapping 단계에서 타겟 필드 정보 접근이 어려움
|
||||
|
||||
---
|
||||
|
||||
### 옵션 3: 가상 노드 자동 표시 (신규 제안 ⭐⭐)
|
||||
|
||||
**개념**:
|
||||
Action 노드에서 targetTable 속성을 설정하면, **시각적으로만** 타겟 테이블 노드를 자동 생성
|
||||
|
||||
**실제 플로우 (저장되는 구조)**:
|
||||
|
||||
```
|
||||
TableSource(user_info) → FieldMapping → InsertAction(targetTable: "orders")
|
||||
```
|
||||
|
||||
**시각적 표시 (화면에 보이는 모습)**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping
|
||||
→ InsertAction(targetTable: "orders")
|
||||
→ 👻 orders (가상 노드, 자동 생성)
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- 가상 노드는 선택/이동/삭제 불가능
|
||||
- 반투명하게 표시하여 가상임을 명확히 표시
|
||||
- Action 노드의 targetTable 속성 변경 시 자동 업데이트
|
||||
- 저장 시에는 가상 노드 제외
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 사용자는 기존대로 사용 (노드 추가 불필요)
|
||||
- ✅ 시각적으로 FROM/TO 관계 명확
|
||||
- ✅ 기존 설계 100% 유지
|
||||
- ✅ 구현 복잡도 낮음
|
||||
- ✅ 기존 플로우와 완벽 호환
|
||||
|
||||
**단점**:
|
||||
|
||||
- ⚠️ 가상 노드의 상호작용 제한 필요
|
||||
- ⚠️ "왜 클릭이 안 되지?" 혼란 가능성
|
||||
- ⚠️ 가상 노드 렌더링 로직 추가
|
||||
|
||||
---
|
||||
|
||||
### 옵션 4: 하이브리드 방식
|
||||
|
||||
**조건부 사용**:
|
||||
|
||||
```
|
||||
// 단순 케이스: TableTarget 생략
|
||||
TableSource → FieldMapping → InsertAction(targetTable 지정)
|
||||
|
||||
// 복잡한 케이스: TableTarget 사용
|
||||
TableSource → FieldMapping → TableTarget → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- ✅ 유연성 제공
|
||||
- ✅ 단순/복잡한 케이스 모두 대응
|
||||
|
||||
**단점**:
|
||||
|
||||
- ❌ 사용자 혼란 가능성
|
||||
- ❌ 검증 로직 복잡
|
||||
|
||||
---
|
||||
|
||||
## 🎯 권장 방안 비교
|
||||
|
||||
### 옵션 재평가
|
||||
|
||||
| 항목 | 옵션 1<br/>(TableTarget) | 옵션 2<br/>(현재 방식) | 옵션 3<br/>(가상 노드) ⭐ |
|
||||
| ----------------- | ------------------------ | ---------------------- | ------------------------- |
|
||||
| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| **사용자 편의성** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **자동 완성** | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ |
|
||||
| **유지보수성** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ |
|
||||
| **학습 곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
### 최종 권장: **옵션 3 (가상 노드 자동 표시)** ⭐⭐
|
||||
|
||||
**선택 이유**:
|
||||
|
||||
1. ✅ **최고의 시각적 명확성** - FROM/TO 관계가 한눈에 보임
|
||||
2. ✅ **사용자 편의성** - 기존 방식 그대로, 노드 추가 불필요
|
||||
3. ✅ **완벽한 호환성** - 기존 플로우 수정 불필요
|
||||
4. ✅ **낮은 학습 곡선** - 새로운 노드 타입 학습 불필요
|
||||
5. ✅ **적절한 구현 복잡도** - React Flow의 커스텀 렌더링 활용
|
||||
|
||||
**구현 방식**:
|
||||
|
||||
```typescript
|
||||
// Action 노드가 있으면 자동으로 가상 타겟 노드 생성
|
||||
function generateVirtualTargetNodes(nodes: FlowNode[]): VirtualNode[] {
|
||||
return nodes
|
||||
.filter((node) => isActionNode(node.type) && node.data.targetTable)
|
||||
.map((actionNode) => ({
|
||||
id: `virtual-target-${actionNode.id}`,
|
||||
type: "virtualTarget",
|
||||
position: {
|
||||
x: actionNode.position.x,
|
||||
y: actionNode.position.y + 150,
|
||||
},
|
||||
data: {
|
||||
tableName: actionNode.data.targetTable,
|
||||
sourceActionId: actionNode.id,
|
||||
isVirtual: true,
|
||||
},
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 대안: 옵션 1 (TableTarget 추가)
|
||||
|
||||
### 새로운 노드 타입 추가
|
||||
|
||||
#### TableTarget 노드
|
||||
|
||||
**타입**: `tableTarget`
|
||||
|
||||
**데이터 구조**:
|
||||
|
||||
```typescript
|
||||
interface TableTargetNodeData {
|
||||
tableName: string; // 타겟 테이블명
|
||||
schema?: string; // 스키마 (선택)
|
||||
columns?: Array<{
|
||||
// 타겟 컬럼 정보
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
primaryKey: boolean;
|
||||
}>;
|
||||
displayName?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- 입력: FieldMapping, DataTransform 등에서 받음
|
||||
- 출력: Action 노드로 전달
|
||||
- 타겟 테이블 스키마를 미리 로드하여 검증 가능
|
||||
|
||||
**시각적 표현**:
|
||||
|
||||
```
|
||||
┌────────────────────┐
|
||||
│ 📊 Table Target │
|
||||
├────────────────────┤
|
||||
│ orders │
|
||||
│ schema: public │
|
||||
├────────────────────┤
|
||||
│ 컬럼: │
|
||||
│ • order_id (PK) │
|
||||
│ • customer_id │
|
||||
│ • order_date │
|
||||
│ • total_amount │
|
||||
└────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 개선된 연결 규칙
|
||||
|
||||
#### TableTarget 추가 시 연결 규칙
|
||||
|
||||
**허용되는 연결**:
|
||||
|
||||
```
|
||||
✅ FieldMapping → TableTarget
|
||||
✅ DataTransform → TableTarget
|
||||
✅ Condition → TableTarget
|
||||
✅ TableTarget → InsertAction
|
||||
✅ TableTarget → UpdateAction
|
||||
✅ TableTarget → UpsertAction
|
||||
```
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ TableSource → TableTarget (직접 연결 불가)
|
||||
❌ TableTarget → DeleteAction (DELETE는 타겟 불필요)
|
||||
❌ TableTarget → TableTarget
|
||||
```
|
||||
|
||||
**새로운 검증 규칙**:
|
||||
|
||||
1. Action 노드는 TableTarget 또는 targetTable 속성 중 하나 필수
|
||||
2. TableTarget이 있으면 Action의 targetTable 속성 무시
|
||||
3. FieldMapping 이후에 TableTarget이 오면 자동 필드 매칭 제안
|
||||
|
||||
---
|
||||
|
||||
### 실제 사용 예시
|
||||
|
||||
#### 예시 1: 단순 데이터 복사
|
||||
|
||||
**기존 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping(user_id → customer_id, user_name → name)
|
||||
→ InsertAction(targetTable: "customers")
|
||||
```
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ FieldMapping(user_id → customer_id)
|
||||
→ TableTarget(customers)
|
||||
→ InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- customers 테이블 스키마를 FieldMapping에서 참조 가능
|
||||
- 필드 자동 완성 제공
|
||||
|
||||
---
|
||||
|
||||
#### 예시 2: 조건부 데이터 처리
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
TableSource(user_info)
|
||||
→ Condition(age >= 18)
|
||||
├─ TRUE → TableTarget(adult_users) → InsertAction
|
||||
└─ FALSE → TableTarget(minor_users) → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- TRUE/FALSE 분기마다 다른 타겟 테이블 명확히 표시
|
||||
|
||||
---
|
||||
|
||||
#### 예시 3: 멀티 소스 + 단일 타겟
|
||||
|
||||
**개선 방식**:
|
||||
|
||||
```
|
||||
┌─ TableSource(users) ────┐
|
||||
│ ↓
|
||||
└─ ExternalDB(orders) ─→ FieldMapping → TableTarget(user_orders) → InsertAction
|
||||
```
|
||||
|
||||
**장점**:
|
||||
|
||||
- 여러 소스에서 데이터를 받아 하나의 타겟으로 통합
|
||||
- 타겟 테이블이 시각적으로 명확
|
||||
|
||||
---
|
||||
|
||||
## 🔧 구현 계획
|
||||
|
||||
### Phase 1: TableTarget 노드 구현
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ `TableTargetNodeData` 인터페이스 정의
|
||||
2. ✅ `TableTargetNode.tsx` 컴포넌트 생성
|
||||
3. ✅ `TableTargetProperties.tsx` 속성 패널 생성
|
||||
4. ✅ Node Palette에 추가
|
||||
5. ✅ FlowEditor에 등록
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 연결 규칙 업데이트
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ `validateConnection`에 TableTarget 규칙 추가
|
||||
2. ✅ Action 노드가 TableTarget 입력을 받도록 수정
|
||||
3. ✅ 검증 로직 업데이트
|
||||
|
||||
**예상 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 자동 필드 매핑 개선
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ TableTarget이 연결되면 타겟 스키마 자동 로드
|
||||
2. ✅ FieldMapping에서 타겟 필드 자동 완성 제공
|
||||
3. ✅ 필드 타입 호환성 검증
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 기존 플로우 마이그레이션
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 기존 InsertAction의 targetTable을 TableTarget으로 변환
|
||||
2. ✅ 자동 마이그레이션 스크립트 작성
|
||||
3. ✅ 호환성 유지 모드 제공
|
||||
|
||||
**예상 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
## 🤔 고려사항
|
||||
|
||||
### 1. 기존 플로우와의 호환성
|
||||
|
||||
**문제**: 이미 저장된 플로우는 TableTarget 없이 구성됨
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
- **옵션 A**: 자동 마이그레이션
|
||||
|
||||
- 플로우 로드 시 InsertAction의 targetTable을 TableTarget 노드로 변환
|
||||
- 기존 데이터는 보존
|
||||
|
||||
- **옵션 B**: 호환성 모드
|
||||
- TableTarget 없이도 동작하도록 유지
|
||||
- 새 플로우만 TableTarget 사용 권장
|
||||
|
||||
**권장**: 옵션 B (호환성 모드)
|
||||
|
||||
---
|
||||
|
||||
### 2. 사용자 경험
|
||||
|
||||
**우려**: 노드가 하나 더 추가되어 복잡해짐
|
||||
|
||||
**완화 방안**:
|
||||
|
||||
- 템플릿 제공: "TableSource → FieldMapping → TableTarget → InsertAction" 세트를 템플릿으로 제공
|
||||
- 자동 생성: InsertAction 생성 시 TableTarget 자동 생성 옵션
|
||||
- 가이드: 처음 사용자를 위한 튜토리얼
|
||||
|
||||
---
|
||||
|
||||
### 3. 성능
|
||||
|
||||
**우려**: TableTarget이 스키마를 로드하면 성능 저하 가능성
|
||||
|
||||
**완화 방안**:
|
||||
|
||||
- 캐싱: 한 번 로드한 스키마는 캐싱
|
||||
- 지연 로딩: 필요할 때만 스키마 로드
|
||||
- 백그라운드 로딩: 비동기로 스키마 로드
|
||||
|
||||
---
|
||||
|
||||
## 📊 비교 분석
|
||||
|
||||
| 항목 | 옵션 1 (TableTarget) | 옵션 2 (현재 방식) |
|
||||
| ------------------- | -------------------- | ------------------ |
|
||||
| **시각적 명확성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **구현 복잡도** | ⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| **사용자 학습곡선** | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
| **자동 완성 지원** | ⭐⭐⭐⭐⭐ | ⭐⭐ |
|
||||
| **유지보수성** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ |
|
||||
| **기존 호환성** | ⭐⭐ | ⭐⭐⭐⭐⭐ |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
### 권장 사항: **옵션 1 (TableTarget 추가)**
|
||||
|
||||
**이유**:
|
||||
|
||||
1. ✅ 데이터 흐름이 시각적으로 명확
|
||||
2. ✅ 스키마 기반 자동 완성 가능
|
||||
3. ✅ 향후 확장성 우수
|
||||
4. ✅ 복잡한 데이터 흐름에서 특히 유용
|
||||
|
||||
**단계적 도입**:
|
||||
|
||||
- Phase 1: TableTarget 노드 추가 (선택 사항)
|
||||
- Phase 2: 기존 방식과 공존
|
||||
- Phase 3: 사용자 피드백 수집
|
||||
- Phase 4: 장기적으로 TableTarget 방식 권장
|
||||
|
||||
---
|
||||
|
||||
## 📝 다음 단계
|
||||
|
||||
1. **의사 결정**: 옵션 1 vs 옵션 2 선택
|
||||
2. **프로토타입**: TableTarget 노드 간단히 구현
|
||||
3. **테스트**: 실제 사용 시나리오로 검증
|
||||
4. **문서화**: 사용 가이드 작성
|
||||
5. **배포**: 단계적 릴리스
|
||||
|
||||
---
|
||||
|
||||
**피드백 환영**: 이 설계에 대한 의견을 주시면 개선하겠습니다! 💬
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,939 @@
|
|||
# 노드 시스템 - 버튼 통합 호환성 분석
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🔍 분석 완료
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [현재 시스템 분석](#현재-시스템-분석)
|
||||
3. [호환성 분석](#호환성-분석)
|
||||
4. [통합 전략](#통합-전략)
|
||||
5. [마이그레이션 계획](#마이그레이션-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
화면관리의 버튼 컴포넌트에 할당된 기존 제어 시스템을 새로운 노드 기반 제어 시스템으로 전환하기 위한 호환성 분석
|
||||
|
||||
### 비교 대상
|
||||
|
||||
- **현재**: `relationshipId` 기반 제어 시스템
|
||||
- **신규**: `flowId` 기반 노드 제어 시스템
|
||||
|
||||
---
|
||||
|
||||
## 현재 시스템 분석
|
||||
|
||||
### 1. 데이터 구조
|
||||
|
||||
#### ButtonDataflowConfig
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "relationship" | "none";
|
||||
|
||||
relationshipConfig?: {
|
||||
relationshipId: string; // 🔑 핵심: 관계 ID
|
||||
relationshipName: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
executionOptions?: ExecutionOptions;
|
||||
}
|
||||
```
|
||||
|
||||
#### 관계 데이터 구조
|
||||
|
||||
```typescript
|
||||
{
|
||||
relationshipId: "rel-123",
|
||||
conditions: [
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
],
|
||||
actionGroups: [
|
||||
{
|
||||
name: "메인 액션",
|
||||
actions: [
|
||||
{
|
||||
type: "database",
|
||||
operation: "INSERT",
|
||||
tableName: "users",
|
||||
fields: [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. 버튼 클릭 │
|
||||
│ OptimizedButtonComponent.tsx │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. executeButtonAction() │
|
||||
│ ImprovedButtonActionExecutor.ts │
|
||||
│ - executionPlan 생성 │
|
||||
│ - before/after/replace 구분 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. executeControls() │
|
||||
│ - relationshipId로 관계 조회 │
|
||||
│ - 조건 검증 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. evaluateConditions() │
|
||||
│ - formData 검증 │
|
||||
│ - selectedRowsData 검증 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. executeDataAction() │
|
||||
│ - INSERT/UPDATE/DELETE 실행 │
|
||||
│ - 순차적 액션 실행 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 데이터 전달 방식
|
||||
|
||||
#### 입력 데이터
|
||||
|
||||
```typescript
|
||||
{
|
||||
formData: {
|
||||
name: "김철수",
|
||||
email: "test@example.com",
|
||||
status: "active"
|
||||
},
|
||||
selectedRowsData: [
|
||||
{ id: 1, name: "이영희" },
|
||||
{ id: 2, name: "박민수" }
|
||||
],
|
||||
context: {
|
||||
buttonId: "btn-1",
|
||||
screenId: 123,
|
||||
companyCode: "COMPANY_A",
|
||||
userId: "user-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 액션 실행 시
|
||||
|
||||
```typescript
|
||||
// 각 액션에 전체 데이터 전달
|
||||
executeDataAction(action, {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 새로운 노드 시스템 분석
|
||||
|
||||
### 1. 데이터 구조
|
||||
|
||||
#### FlowData
|
||||
|
||||
```typescript
|
||||
interface FlowData {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
nodes: FlowNode[]; // 🔑 핵심: 노드 배열
|
||||
edges: FlowEdge[]; // 🔑 핵심: 연결 정보
|
||||
}
|
||||
```
|
||||
|
||||
#### 노드 예시
|
||||
|
||||
```typescript
|
||||
// 소스 노드
|
||||
{
|
||||
id: "source-1",
|
||||
type: "tableSource",
|
||||
data: {
|
||||
tableName: "users",
|
||||
schema: "public",
|
||||
outputFields: [...]
|
||||
}
|
||||
}
|
||||
|
||||
// 조건 노드
|
||||
{
|
||||
id: "condition-1",
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: [{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}],
|
||||
logic: "AND"
|
||||
}
|
||||
}
|
||||
|
||||
// 액션 노드
|
||||
{
|
||||
id: "insert-1",
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: "users",
|
||||
fieldMappings: [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 연결 예시
|
||||
|
||||
```typescript
|
||||
// 엣지 (노드 간 연결)
|
||||
{
|
||||
id: "edge-1",
|
||||
source: "source-1",
|
||||
target: "condition-1"
|
||||
},
|
||||
{
|
||||
id: "edge-2",
|
||||
source: "condition-1",
|
||||
target: "insert-1",
|
||||
sourceHandle: "true" // TRUE 분기
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 흐름
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ 1. 버튼 클릭 │
|
||||
│ FlowEditor 또는 Button Component │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 2. executeFlow() │
|
||||
│ - flowId로 플로우 조회 │
|
||||
│ - nodes + edges 로드 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 3. topologicalSort() │
|
||||
│ - 노드 의존성 분석 │
|
||||
│ - 실행 순서 결정 │
|
||||
│ Result: [["source"], ["insert", "update"]] │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 4. executeLevel() │
|
||||
│ - 같은 레벨 노드 병렬 실행 │
|
||||
│ - Promise.allSettled 사용 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 5. executeNode() │
|
||||
│ - 부모 노드 상태 확인 │
|
||||
│ - 실패 시 스킵 │
|
||||
└─────────────┬───────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────┐
|
||||
│ 6. executeActionWithTransaction() │
|
||||
│ - 독립 트랜잭션 시작 │
|
||||
│ - 액션 실행 │
|
||||
│ - 성공 시 커밋, 실패 시 롤백 │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 데이터 전달 방식
|
||||
|
||||
#### ExecutionContext
|
||||
|
||||
```typescript
|
||||
{
|
||||
sourceData: [
|
||||
{ id: 1, name: "김철수", status: "active" },
|
||||
{ id: 2, name: "이영희", status: "inactive" }
|
||||
],
|
||||
nodeResults: Map<string, NodeResult> {
|
||||
"source-1" => { status: "success", data: [...] },
|
||||
"condition-1" => { status: "success", data: true },
|
||||
"insert-1" => { status: "success", data: { insertedCount: 1 } }
|
||||
},
|
||||
executionOrder: ["source-1", "condition-1", "insert-1"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 노드 실행 시
|
||||
|
||||
```typescript
|
||||
// 부모 노드 결과 전달
|
||||
const inputData = prepareInputData(node, parents, context);
|
||||
|
||||
// 부모가 하나면 부모의 결과 데이터
|
||||
// 부모가 여러 개면 모든 부모의 데이터 병합
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 호환성 분석
|
||||
|
||||
### ✅ 호환 가능한 부분
|
||||
|
||||
#### 1. 조건 검증
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: [
|
||||
{
|
||||
field: "status",
|
||||
operator: "equals",
|
||||
value: "active"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **조건 구조가 거의 동일** → 마이그레이션 쉬움
|
||||
|
||||
---
|
||||
|
||||
#### 2. 액션 실행
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "database",
|
||||
operation: "INSERT",
|
||||
tableName: "users",
|
||||
fields: [
|
||||
{ name: "name", value: "김철수" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: "users",
|
||||
fieldMappings: [
|
||||
{ sourceField: "name", targetField: "name" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **액션 개념이 동일** → 필드명만 변환하면 됨
|
||||
|
||||
---
|
||||
|
||||
#### 3. 데이터 소스
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
controlDataSource: "form" | "table-selection" | "both";
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
type: "tableSource", // 테이블 선택 데이터
|
||||
// 또는
|
||||
type: "manualInput", // 폼 데이터
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **소스 타입 매핑 가능**
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 차이점 및 주의사항
|
||||
|
||||
#### 1. 실행 타이밍
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```
|
||||
노드 그래프 자체가 실행 순서를 정의
|
||||
타이밍은 노드 연결로 표현됨
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- `before/after` 개념이 노드에 없음
|
||||
- 버튼의 기본 액션과 제어를 어떻게 조합할지?
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```
|
||||
Option A: 버튼 액션을 노드로 표현
|
||||
Button → [Before Nodes] → [Button Action Node] → [After Nodes]
|
||||
|
||||
Option B: 실행 시점 지정
|
||||
flowConfig: {
|
||||
flowId: 123,
|
||||
timing: "before" | "after" | "replace"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 2. ActionGroups vs 병렬 실행
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
actionGroups: [
|
||||
{
|
||||
name: "그룹1",
|
||||
actions: [action1, action2], // 순차 실행
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```
|
||||
소스
|
||||
↓
|
||||
├─→ INSERT (병렬)
|
||||
├─→ UPDATE (병렬)
|
||||
└─→ DELETE (병렬)
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- 현재는 "그룹 내 순차, 그룹 간 조건부"
|
||||
- 신규는 "레벨별 병렬, 연쇄 중단"
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```
|
||||
노드 연결로 순차/병렬 표현:
|
||||
|
||||
순차: INSERT → UPDATE → DELETE
|
||||
병렬: Source → INSERT
|
||||
→ UPDATE
|
||||
→ DELETE
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 3. 데이터 전달 방식
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
// 모든 액션에 동일한 데이터 전달
|
||||
executeDataAction(action, {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
context,
|
||||
});
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
// 부모 노드 결과를 자식에게 전달
|
||||
const inputData = parentResult.data || sourceData;
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
|
||||
- 현재는 "원본 데이터 공유"
|
||||
- 신규는 "결과 데이터 체이닝"
|
||||
|
||||
**해결 방안**:
|
||||
|
||||
```typescript
|
||||
// 버튼 실행 시 초기 데이터 설정
|
||||
context.sourceData = {
|
||||
formData,
|
||||
selectedRowsData,
|
||||
};
|
||||
|
||||
// 각 노드는 필요에 따라 선택
|
||||
- formData 사용
|
||||
- 부모 결과 사용
|
||||
- 둘 다 사용
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 4. 컨텍스트 정보
|
||||
|
||||
**현재**:
|
||||
|
||||
```typescript
|
||||
{
|
||||
buttonId: "btn-1",
|
||||
screenId: 123,
|
||||
companyCode: "COMPANY_A",
|
||||
userId: "user-1"
|
||||
}
|
||||
```
|
||||
|
||||
**신규**:
|
||||
|
||||
```typescript
|
||||
// ExecutionContext에 추가 필요
|
||||
{
|
||||
sourceData: [...],
|
||||
nodeResults: Map(),
|
||||
// 🆕 추가 필요
|
||||
buttonContext?: {
|
||||
buttonId: string,
|
||||
screenId: number,
|
||||
companyCode: string,
|
||||
userId: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**결론**: ✅ **컨텍스트 확장 가능**
|
||||
|
||||
---
|
||||
|
||||
## 통합 전략
|
||||
|
||||
### 전략 1: 하이브리드 방식 (권장 ⭐⭐⭐)
|
||||
|
||||
#### 개념
|
||||
|
||||
버튼 설정에서 `relationshipId` 대신 `flowId`를 저장하고, 기존 타이밍 개념 유지
|
||||
|
||||
#### 버튼 설정
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "flow"; // 🆕 신규 모드
|
||||
|
||||
flowConfig?: {
|
||||
flowId: number; // 🔑 노드 플로우 ID
|
||||
flowName: string;
|
||||
executionTiming: "before" | "after" | "replace"; // 기존 유지
|
||||
contextData?: Record<string, any>;
|
||||
};
|
||||
|
||||
controlDataSource?: "form" | "table-selection" | "both";
|
||||
}
|
||||
```
|
||||
|
||||
#### 실행 로직
|
||||
|
||||
```typescript
|
||||
async function executeButtonWithFlow(
|
||||
buttonConfig: ButtonDataflowConfig,
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext
|
||||
) {
|
||||
const { flowConfig } = buttonConfig;
|
||||
|
||||
// 1. 플로우 조회
|
||||
const flow = await getNodeFlow(flowConfig.flowId);
|
||||
|
||||
// 2. 초기 데이터 준비
|
||||
const executionContext: ExecutionContext = {
|
||||
sourceData: prepareSourceData(formData, context),
|
||||
nodeResults: new Map(),
|
||||
executionOrder: [],
|
||||
buttonContext: {
|
||||
// 🆕 버튼 컨텍스트 추가
|
||||
buttonId: context.buttonId,
|
||||
screenId: context.screenId,
|
||||
companyCode: context.companyCode,
|
||||
userId: context.userId,
|
||||
},
|
||||
};
|
||||
|
||||
// 3. 타이밍에 따라 실행
|
||||
switch (flowConfig.executionTiming) {
|
||||
case "before":
|
||||
await executeFlow(flow, executionContext);
|
||||
await executeOriginalButtonAction(buttonConfig, context);
|
||||
break;
|
||||
|
||||
case "after":
|
||||
await executeOriginalButtonAction(buttonConfig, context);
|
||||
await executeFlow(flow, executionContext);
|
||||
break;
|
||||
|
||||
case "replace":
|
||||
await executeFlow(flow, executionContext);
|
||||
break;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 소스 데이터 준비
|
||||
|
||||
```typescript
|
||||
function prepareSourceData(
|
||||
formData: Record<string, any>,
|
||||
context: ButtonExecutionContext
|
||||
): any[] {
|
||||
const { controlDataSource, selectedRowsData } = context;
|
||||
|
||||
switch (controlDataSource) {
|
||||
case "form":
|
||||
return [formData]; // 폼 데이터를 배열로
|
||||
|
||||
case "table-selection":
|
||||
return selectedRowsData || []; // 테이블 선택 데이터
|
||||
|
||||
case "both":
|
||||
return [
|
||||
{ source: "form", data: formData },
|
||||
{ source: "table", data: selectedRowsData },
|
||||
];
|
||||
|
||||
default:
|
||||
return [formData];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 전략 2: 완전 전환 방식
|
||||
|
||||
#### 개념
|
||||
|
||||
버튼 액션 자체를 노드로 표현 (버튼 = 플로우 트리거)
|
||||
|
||||
#### 플로우 구조
|
||||
|
||||
```
|
||||
ManualInput (formData)
|
||||
↓
|
||||
Condition (status == "active")
|
||||
↓
|
||||
┌─┴─┐
|
||||
TRUE FALSE
|
||||
↓ ↓
|
||||
INSERT CANCEL
|
||||
↓
|
||||
ButtonAction (원래 버튼 액션)
|
||||
```
|
||||
|
||||
#### 장점
|
||||
|
||||
- ✅ 시스템 단순화 (노드만 존재)
|
||||
- ✅ 시각적으로 명확
|
||||
- ✅ 유연한 워크플로우
|
||||
|
||||
#### 단점
|
||||
|
||||
- ⚠️ 기존 버튼 개념 변경
|
||||
- ⚠️ 마이그레이션 복잡
|
||||
- ⚠️ UI 학습 곡선
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 계획
|
||||
|
||||
### Phase 1: 하이브리드 지원
|
||||
|
||||
#### 목표
|
||||
|
||||
기존 `relationshipId` 방식과 새로운 `flowId` 방식 모두 지원
|
||||
|
||||
#### 작업
|
||||
|
||||
1. **ButtonDataflowConfig 확장**
|
||||
|
||||
```typescript
|
||||
interface ButtonDataflowConfig {
|
||||
controlMode: "relationship" | "flow" | "none";
|
||||
|
||||
// 기존 (하위 호환)
|
||||
relationshipConfig?: {
|
||||
relationshipId: string;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
};
|
||||
|
||||
// 🆕 신규
|
||||
flowConfig?: {
|
||||
flowId: number;
|
||||
executionTiming: "before" | "after" | "replace";
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
2. **실행 로직 분기**
|
||||
|
||||
```typescript
|
||||
if (buttonConfig.controlMode === "flow") {
|
||||
await executeButtonWithFlow(buttonConfig, formData, context);
|
||||
} else if (buttonConfig.controlMode === "relationship") {
|
||||
await executeButtonWithRelationship(buttonConfig, formData, context);
|
||||
}
|
||||
```
|
||||
|
||||
3. **UI 업데이트**
|
||||
|
||||
- 버튼 설정에 "제어 방식 선택" 추가
|
||||
- "기존 관계" vs "노드 플로우" 선택 가능
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 마이그레이션 도구
|
||||
|
||||
#### 관계 → 플로우 변환기
|
||||
|
||||
```typescript
|
||||
async function migrateRelationshipToFlow(
|
||||
relationshipId: string
|
||||
): Promise<number> {
|
||||
// 1. 기존 관계 조회
|
||||
const relationship = await getRelationship(relationshipId);
|
||||
|
||||
// 2. 노드 생성
|
||||
const nodes: FlowNode[] = [];
|
||||
const edges: FlowEdge[] = [];
|
||||
|
||||
// 소스 노드 (formData 또는 table)
|
||||
const sourceNode = {
|
||||
id: "source-1",
|
||||
type: "manualInput",
|
||||
data: { fields: extractFields(relationship) },
|
||||
};
|
||||
nodes.push(sourceNode);
|
||||
|
||||
// 조건 노드
|
||||
if (relationship.conditions.length > 0) {
|
||||
const conditionNode = {
|
||||
id: "condition-1",
|
||||
type: "condition",
|
||||
data: {
|
||||
conditions: relationship.conditions,
|
||||
logic: relationship.logic || "AND",
|
||||
},
|
||||
};
|
||||
nodes.push(conditionNode);
|
||||
edges.push({ id: "e1", source: "source-1", target: "condition-1" });
|
||||
}
|
||||
|
||||
// 액션 노드들
|
||||
let lastNodeId =
|
||||
relationship.conditions.length > 0 ? "condition-1" : "source-1";
|
||||
|
||||
relationship.actionGroups.forEach((group, groupIdx) => {
|
||||
group.actions.forEach((action, actionIdx) => {
|
||||
const actionNodeId = `action-${groupIdx}-${actionIdx}`;
|
||||
const actionNode = convertActionToNode(action, actionNodeId);
|
||||
nodes.push(actionNode);
|
||||
|
||||
edges.push({
|
||||
id: `e-${actionNodeId}`,
|
||||
source: lastNodeId,
|
||||
target: actionNodeId,
|
||||
});
|
||||
|
||||
// 순차 실행인 경우
|
||||
if (group.sequential) {
|
||||
lastNodeId = actionNodeId;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 플로우 저장
|
||||
const flowData = {
|
||||
flowName: `Migrated: ${relationship.name}`,
|
||||
flowDescription: `Migrated from relationship ${relationshipId}`,
|
||||
flowData: JSON.stringify({ nodes, edges }),
|
||||
};
|
||||
|
||||
const { flowId } = await createNodeFlow(flowData);
|
||||
|
||||
// 4. 버튼 설정 업데이트
|
||||
await updateButtonConfig(relationshipId, {
|
||||
controlMode: "flow",
|
||||
flowConfig: {
|
||||
flowId,
|
||||
executionTiming: relationship.timing || "before",
|
||||
},
|
||||
});
|
||||
|
||||
return flowId;
|
||||
}
|
||||
```
|
||||
|
||||
#### 액션 변환 로직
|
||||
|
||||
```typescript
|
||||
function convertActionToNode(action: DataflowAction, nodeId: string): FlowNode {
|
||||
switch (action.operation) {
|
||||
case "INSERT":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "insertAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
fieldMappings: action.fields.map((f) => ({
|
||||
sourceField: f.name,
|
||||
targetField: f.name,
|
||||
staticValue: f.type === "static" ? f.value : undefined,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
case "UPDATE":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "updateAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
whereConditions: action.conditions,
|
||||
fieldMappings: action.fields.map((f) => ({
|
||||
sourceField: f.name,
|
||||
targetField: f.name,
|
||||
})),
|
||||
},
|
||||
};
|
||||
|
||||
case "DELETE":
|
||||
return {
|
||||
id: nodeId,
|
||||
type: "deleteAction",
|
||||
data: {
|
||||
targetTable: action.tableName,
|
||||
whereConditions: action.conditions,
|
||||
},
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported operation: ${action.operation}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 완전 전환
|
||||
|
||||
#### 목표
|
||||
|
||||
모든 버튼이 노드 플로우 방식 사용
|
||||
|
||||
#### 작업
|
||||
|
||||
1. **마이그레이션 스크립트 실행**
|
||||
|
||||
```sql
|
||||
-- 모든 관계를 플로우로 변환
|
||||
SELECT migrate_all_relationships_to_flows();
|
||||
```
|
||||
|
||||
2. **UI에서 관계 모드 제거**
|
||||
|
||||
```typescript
|
||||
// controlMode에서 "relationship" 제거
|
||||
type ControlMode = "flow" | "none";
|
||||
```
|
||||
|
||||
3. **레거시 코드 정리**
|
||||
|
||||
- `executeButtonWithRelationship()` 제거
|
||||
- `RelationshipService` 제거 (또는 읽기 전용)
|
||||
|
||||
---
|
||||
|
||||
## 결론
|
||||
|
||||
### ✅ 호환 가능
|
||||
|
||||
노드 시스템과 버튼 제어 시스템은 **충분히 호환 가능**합니다!
|
||||
|
||||
### 🎯 권장 방안
|
||||
|
||||
**하이브리드 방식 (전략 1)**으로 점진적 마이그레이션
|
||||
|
||||
#### 이유
|
||||
|
||||
1. ✅ **기존 시스템 유지** - 서비스 중단 없음
|
||||
2. ✅ **점진적 전환** - 리스크 최소화
|
||||
3. ✅ **유연성** - 두 방식 모두 활용 가능
|
||||
4. ✅ **학습 곡선** - 사용자가 천천히 적응
|
||||
|
||||
### 📋 다음 단계
|
||||
|
||||
1. **Phase 1 구현** (예상: 2일)
|
||||
|
||||
- `ButtonDataflowConfig` 확장
|
||||
- `executeButtonWithFlow()` 구현
|
||||
- UI 선택 옵션 추가
|
||||
|
||||
2. **Phase 2 도구 개발** (예상: 1일)
|
||||
|
||||
- 마이그레이션 스크립트
|
||||
- 자동 변환 로직
|
||||
|
||||
3. **Phase 3 전환** (예상: 1일)
|
||||
- 데이터 마이그레이션
|
||||
- 레거시 제거
|
||||
|
||||
### 총 소요 시간
|
||||
|
||||
**약 4일**
|
||||
|
||||
---
|
||||
|
||||
**참고 문서**:
|
||||
|
||||
- [노드\_실행\_엔진\_설계.md](./노드_실행_엔진_설계.md)
|
||||
- [노드\_기반\_제어\_시스템\_개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md)
|
||||
|
|
@ -0,0 +1,617 @@
|
|||
# 노드 실행 엔진 설계
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: ✅ 확정
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [실행 방식](#실행-방식)
|
||||
3. [데이터 흐름](#데이터-흐름)
|
||||
4. [오류 처리](#오류-처리)
|
||||
5. [구현 계획](#구현-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
노드 기반 데이터 플로우의 실행 엔진을 설계하여:
|
||||
|
||||
- 효율적인 병렬 처리
|
||||
- 안정적인 오류 처리
|
||||
- 명확한 데이터 흐름
|
||||
|
||||
### 핵심 원칙
|
||||
|
||||
1. **독립적 트랜잭션**: 각 액션 노드는 독립적인 트랜잭션
|
||||
2. **부분 실패 허용**: 일부 실패해도 성공한 노드는 커밋
|
||||
3. **연쇄 중단**: 부모 노드 실패 시 자식 노드 스킵
|
||||
4. **병렬 실행**: 의존성 없는 노드는 병렬 실행
|
||||
|
||||
---
|
||||
|
||||
## 실행 방식
|
||||
|
||||
### 1. 기본 구조
|
||||
|
||||
```typescript
|
||||
interface ExecutionContext {
|
||||
sourceData: any[]; // 원본 데이터
|
||||
nodeResults: Map<string, NodeResult>; // 각 노드 실행 결과
|
||||
executionOrder: string[]; // 실행 순서
|
||||
}
|
||||
|
||||
interface NodeResult {
|
||||
nodeId: string;
|
||||
status: "pending" | "success" | "failed" | "skipped";
|
||||
data?: any;
|
||||
error?: Error;
|
||||
startTime: number;
|
||||
endTime?: number;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 실행 단계
|
||||
|
||||
#### Step 1: 위상 정렬 (Topological Sort)
|
||||
|
||||
노드 간 의존성을 파악하여 실행 순서 결정
|
||||
|
||||
```typescript
|
||||
function topologicalSort(nodes: FlowNode[], edges: FlowEdge[]): string[][] {
|
||||
// DAG(Directed Acyclic Graph) 순회
|
||||
// 같은 레벨의 노드들은 배열로 그룹화
|
||||
|
||||
return [
|
||||
["tableSource-1"], // Level 0: 소스
|
||||
["insert-1", "update-1", "delete-1"], // Level 1: 병렬 실행 가능
|
||||
["update-2"], // Level 2: insert-1에 의존
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: 레벨별 실행
|
||||
|
||||
```typescript
|
||||
async function executeFlow(
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[]
|
||||
): Promise<ExecutionResult> {
|
||||
const levels = topologicalSort(nodes, edges);
|
||||
const context: ExecutionContext = {
|
||||
sourceData: [],
|
||||
nodeResults: new Map(),
|
||||
executionOrder: [],
|
||||
};
|
||||
|
||||
for (const level of levels) {
|
||||
// 같은 레벨의 노드들은 병렬 실행
|
||||
await executeLevel(level, nodes, context);
|
||||
}
|
||||
|
||||
return generateExecutionReport(context);
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3: 레벨 내 병렬 실행
|
||||
|
||||
```typescript
|
||||
async function executeLevel(
|
||||
nodeIds: string[],
|
||||
nodes: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): Promise<void> {
|
||||
// Promise.allSettled로 병렬 실행
|
||||
const results = await Promise.allSettled(
|
||||
nodeIds.map((nodeId) => executeNode(nodeId, nodes, context))
|
||||
);
|
||||
|
||||
// 결과 저장
|
||||
results.forEach((result, index) => {
|
||||
const nodeId = nodeIds[index];
|
||||
if (result.status === "fulfilled") {
|
||||
context.nodeResults.set(nodeId, result.value);
|
||||
} else {
|
||||
context.nodeResults.set(nodeId, {
|
||||
nodeId,
|
||||
status: "failed",
|
||||
error: result.reason,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 데이터 흐름
|
||||
|
||||
### 1. 소스 노드 실행
|
||||
|
||||
```typescript
|
||||
async function executeSourceNode(node: TableSourceNode): Promise<any[]> {
|
||||
const { tableName, schema, whereConditions } = node.data;
|
||||
|
||||
// 데이터베이스 쿼리 실행
|
||||
const query = buildSelectQuery(tableName, schema, whereConditions);
|
||||
const data = await executeQuery(query);
|
||||
|
||||
return data;
|
||||
}
|
||||
```
|
||||
|
||||
**결과 예시**:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "id": 1, "name": "김철수", "age": 30 },
|
||||
{ "id": 2, "name": "이영희", "age": 25 },
|
||||
{ "id": 3, "name": "박민수", "age": 35 }
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 액션 노드 실행
|
||||
|
||||
#### 데이터 전달 방식
|
||||
|
||||
```typescript
|
||||
async function executeNode(
|
||||
nodeId: string,
|
||||
nodes: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): Promise<NodeResult> {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
const parents = getParentNodes(nodeId, edges);
|
||||
|
||||
// 1️⃣ 부모 노드 상태 확인
|
||||
const parentFailed = parents.some((p) => {
|
||||
const parentResult = context.nodeResults.get(p.id);
|
||||
return parentResult?.status === "failed";
|
||||
});
|
||||
|
||||
if (parentFailed) {
|
||||
return {
|
||||
nodeId,
|
||||
status: "skipped",
|
||||
error: new Error("Parent node failed"),
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
// 2️⃣ 입력 데이터 준비
|
||||
const inputData = prepareInputData(node, parents, context);
|
||||
|
||||
// 3️⃣ 액션 실행 (독립 트랜잭션)
|
||||
return await executeActionWithTransaction(node, inputData);
|
||||
}
|
||||
```
|
||||
|
||||
#### 입력 데이터 준비
|
||||
|
||||
```typescript
|
||||
function prepareInputData(
|
||||
node: FlowNode,
|
||||
parents: FlowNode[],
|
||||
context: ExecutionContext
|
||||
): any {
|
||||
if (parents.length === 0) {
|
||||
// 소스 노드
|
||||
return null;
|
||||
} else if (parents.length === 1) {
|
||||
// 단일 부모: 부모의 결과 데이터 전달
|
||||
const parentResult = context.nodeResults.get(parents[0].id);
|
||||
return parentResult?.data || context.sourceData;
|
||||
} else {
|
||||
// 다중 부모: 모든 부모의 데이터 병합
|
||||
return parents.map((p) => {
|
||||
const result = context.nodeResults.get(p.id);
|
||||
return result?.data || context.sourceData;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 병렬 실행 예시
|
||||
|
||||
```
|
||||
TableSource
|
||||
(100개 레코드)
|
||||
↓
|
||||
┌──────┼──────┐
|
||||
↓ ↓ ↓
|
||||
INSERT UPDATE DELETE
|
||||
(독립) (독립) (독립)
|
||||
```
|
||||
|
||||
**실행 과정**:
|
||||
|
||||
```typescript
|
||||
// 1. TableSource 실행
|
||||
const sourceData = await executeTableSource();
|
||||
// → [100개 레코드]
|
||||
|
||||
// 2. 병렬 실행 (Promise.allSettled)
|
||||
const results = await Promise.allSettled([
|
||||
executeInsertAction(insertNode, sourceData),
|
||||
executeUpdateAction(updateNode, sourceData),
|
||||
executeDeleteAction(deleteNode, sourceData),
|
||||
]);
|
||||
|
||||
// 3. 각 액션은 독립 트랜잭션
|
||||
// - INSERT 실패 → INSERT만 롤백
|
||||
// - UPDATE 성공 → UPDATE 커밋
|
||||
// - DELETE 성공 → DELETE 커밋
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 연쇄 실행 예시
|
||||
|
||||
```
|
||||
TableSource
|
||||
↓
|
||||
INSERT
|
||||
❌ (실패)
|
||||
↓
|
||||
UPDATE-2
|
||||
⏭️ (스킵)
|
||||
```
|
||||
|
||||
**실행 과정**:
|
||||
|
||||
```typescript
|
||||
// 1. TableSource 실행
|
||||
const sourceData = await executeTableSource();
|
||||
// → 성공 ✅
|
||||
|
||||
// 2. INSERT 실행
|
||||
const insertResult = await executeInsertAction(insertNode, sourceData);
|
||||
// → 실패 ❌ (롤백됨)
|
||||
|
||||
// 3. UPDATE-2 실행 시도
|
||||
const parentFailed = insertResult.status === "failed";
|
||||
if (parentFailed) {
|
||||
return {
|
||||
status: "skipped",
|
||||
reason: "Parent INSERT failed",
|
||||
};
|
||||
// → 스킬 ⏭️
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 오류 처리
|
||||
|
||||
### 1. 독립 트랜잭션
|
||||
|
||||
각 액션 노드는 자체 트랜잭션을 가짐
|
||||
|
||||
```typescript
|
||||
async function executeActionWithTransaction(
|
||||
node: FlowNode,
|
||||
inputData: any
|
||||
): Promise<NodeResult> {
|
||||
// 트랜잭션 시작
|
||||
const transaction = await db.beginTransaction();
|
||||
|
||||
try {
|
||||
const result = await performAction(node, inputData, transaction);
|
||||
|
||||
// 성공 시 커밋
|
||||
await transaction.commit();
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
status: "success",
|
||||
data: result,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
} catch (error) {
|
||||
// 실패 시 롤백
|
||||
await transaction.rollback();
|
||||
|
||||
return {
|
||||
nodeId: node.id,
|
||||
status: "failed",
|
||||
error: error,
|
||||
startTime: Date.now(),
|
||||
endTime: Date.now(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 부분 실패 허용
|
||||
|
||||
```typescript
|
||||
// Promise.allSettled 사용
|
||||
const results = await Promise.allSettled([action1(), action2(), action3()]);
|
||||
|
||||
// 결과 수집
|
||||
const summary = {
|
||||
total: results.length,
|
||||
success: results.filter((r) => r.status === "fulfilled").length,
|
||||
failed: results.filter((r) => r.status === "rejected").length,
|
||||
details: results,
|
||||
};
|
||||
```
|
||||
|
||||
**예시 결과**:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": 3,
|
||||
"success": 2,
|
||||
"failed": 1,
|
||||
"details": [
|
||||
{ "status": "rejected", "reason": "Duplicate key error" },
|
||||
{ "status": "fulfilled", "value": { "updatedCount": 100 } },
|
||||
{ "status": "fulfilled", "value": { "deletedCount": 50 } }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 연쇄 중단
|
||||
|
||||
부모 노드 실패 시 자식 노드 자동 스킵
|
||||
|
||||
```typescript
|
||||
function shouldSkipNode(node: FlowNode, context: ExecutionContext): boolean {
|
||||
const parents = getParentNodes(node.id);
|
||||
|
||||
return parents.some((parent) => {
|
||||
const parentResult = context.nodeResults.get(parent.id);
|
||||
return parentResult?.status === "failed";
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 오류 메시지
|
||||
|
||||
```typescript
|
||||
interface ExecutionError {
|
||||
nodeId: string;
|
||||
nodeName: string;
|
||||
errorType: "validation" | "execution" | "connection" | "timeout";
|
||||
message: string;
|
||||
details?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
```
|
||||
|
||||
**오류 메시지 예시**:
|
||||
|
||||
```json
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT 액션",
|
||||
"errorType": "execution",
|
||||
"message": "Duplicate key error: 'email' already exists",
|
||||
"details": {
|
||||
"table": "users",
|
||||
"constraint": "users_email_unique",
|
||||
"value": "test@example.com"
|
||||
},
|
||||
"timestamp": 1704182400000
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Phase 1: 기본 실행 엔진 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 위상 정렬 알고리즘 구현
|
||||
2. ✅ 레벨별 실행 로직
|
||||
3. ✅ Promise.allSettled 기반 병렬 실행
|
||||
4. ✅ 독립 트랜잭션 처리
|
||||
5. ✅ 연쇄 중단 로직
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 소스 노드 실행 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ TableSource 실행기
|
||||
2. ✅ ExternalDBSource 실행기
|
||||
3. ✅ RestAPISource 실행기
|
||||
4. ✅ 데이터 캐싱
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 액션 노드 실행 (우선순위: 높음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ INSERT 액션 실행기
|
||||
2. ✅ UPDATE 액션 실행기
|
||||
3. ✅ DELETE 액션 실행기
|
||||
4. ✅ UPSERT 액션 실행기
|
||||
5. ✅ 필드 매핑 적용
|
||||
|
||||
**예상 시간**: 2일
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 변환 노드 실행 (우선순위: 중간)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ FieldMapping 실행기
|
||||
2. ✅ DataTransform 실행기
|
||||
3. ✅ Condition 분기 처리
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 오류 처리 및 모니터링 (우선순위: 중간)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ✅ 상세 오류 메시지
|
||||
2. ✅ 실행 결과 리포트
|
||||
3. ✅ 실행 로그 저장
|
||||
4. ✅ 실시간 진행 상태 표시
|
||||
|
||||
**예상 시간**: 1일
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: 최적화 (우선순위: 낮음)
|
||||
|
||||
**작업 항목**:
|
||||
|
||||
1. ⏳ 데이터 스트리밍 (대용량 데이터)
|
||||
2. ⏳ 배치 처리 최적화
|
||||
3. ⏳ 병렬 처리 튜닝
|
||||
4. ⏳ 캐싱 전략
|
||||
|
||||
**예상 시간**: 2일
|
||||
|
||||
---
|
||||
|
||||
## 실행 결과 예시
|
||||
|
||||
### 성공 케이스
|
||||
|
||||
```json
|
||||
{
|
||||
"flowId": "flow-123",
|
||||
"flowName": "사용자 데이터 동기화",
|
||||
"status": "completed",
|
||||
"startTime": "2025-01-02T10:00:00Z",
|
||||
"endTime": "2025-01-02T10:00:05Z",
|
||||
"duration": 5000,
|
||||
"nodes": [
|
||||
{
|
||||
"nodeId": "source-1",
|
||||
"nodeName": "TableSource",
|
||||
"status": "success",
|
||||
"recordCount": 100,
|
||||
"duration": 500
|
||||
},
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT",
|
||||
"status": "success",
|
||||
"insertedCount": 100,
|
||||
"duration": 2000
|
||||
},
|
||||
{
|
||||
"nodeId": "update-1",
|
||||
"nodeName": "UPDATE",
|
||||
"status": "success",
|
||||
"updatedCount": 80,
|
||||
"duration": 1500
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 3,
|
||||
"success": 3,
|
||||
"failed": 0,
|
||||
"skipped": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 부분 실패 케이스
|
||||
|
||||
```json
|
||||
{
|
||||
"flowId": "flow-124",
|
||||
"flowName": "데이터 처리",
|
||||
"status": "partial_success",
|
||||
"startTime": "2025-01-02T11:00:00Z",
|
||||
"endTime": "2025-01-02T11:00:08Z",
|
||||
"duration": 8000,
|
||||
"nodes": [
|
||||
{
|
||||
"nodeId": "source-1",
|
||||
"nodeName": "TableSource",
|
||||
"status": "success",
|
||||
"recordCount": 100
|
||||
},
|
||||
{
|
||||
"nodeId": "insert-1",
|
||||
"nodeName": "INSERT",
|
||||
"status": "failed",
|
||||
"error": "Duplicate key error",
|
||||
"details": "email 'test@example.com' already exists"
|
||||
},
|
||||
{
|
||||
"nodeId": "update-2",
|
||||
"nodeName": "UPDATE-2",
|
||||
"status": "skipped",
|
||||
"reason": "Parent INSERT failed"
|
||||
},
|
||||
{
|
||||
"nodeId": "update-1",
|
||||
"nodeName": "UPDATE",
|
||||
"status": "success",
|
||||
"updatedCount": 50
|
||||
},
|
||||
{
|
||||
"nodeId": "delete-1",
|
||||
"nodeName": "DELETE",
|
||||
"status": "success",
|
||||
"deletedCount": 20
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total": 5,
|
||||
"success": 3,
|
||||
"failed": 1,
|
||||
"skipped": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. ✅ 데이터 처리 방식 확정 (완료)
|
||||
2. ⏳ 실행 엔진 구현 시작
|
||||
3. ⏳ 테스트 케이스 작성
|
||||
4. ⏳ UI에서 실행 결과 표시
|
||||
|
||||
---
|
||||
|
||||
**참고 문서**:
|
||||
|
||||
- [노드*기반*제어*시스템*개선\_계획.md](./노드_기반_제어_시스템_개선_계획.md)
|
||||
- [노드*연결*규칙\_설계.md](./노드_연결_규칙_설계.md)
|
||||
- [노드*구조*개선안.md](./노드_구조_개선안.md)
|
||||
|
|
@ -0,0 +1,431 @@
|
|||
# 노드 연결 규칙 설계
|
||||
|
||||
**작성일**: 2025-01-02
|
||||
**버전**: 1.0
|
||||
**상태**: 🔄 설계 중
|
||||
|
||||
---
|
||||
|
||||
## 📋 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [노드 분류](#노드-분류)
|
||||
3. [연결 규칙 매트릭스](#연결-규칙-매트릭스)
|
||||
4. [상세 연결 규칙](#상세-연결-규칙)
|
||||
5. [구현 계획](#구현-계획)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 목적
|
||||
|
||||
노드 간 연결 가능 여부를 명확히 정의하여:
|
||||
|
||||
- 사용자의 실수 방지
|
||||
- 논리적으로 올바른 플로우만 생성 가능
|
||||
- 명확한 오류 메시지 제공
|
||||
|
||||
### 기본 원칙
|
||||
|
||||
1. **데이터 흐름 방향**: 소스 → 변환 → 액션
|
||||
2. **타입 안전성**: 출력과 입력 타입이 호환되어야 함
|
||||
3. **논리적 정합성**: 의미 없는 연결 방지
|
||||
|
||||
---
|
||||
|
||||
## 노드 분류
|
||||
|
||||
### 1. 데이터 소스 노드 (Source)
|
||||
|
||||
**역할**: 데이터를 생성하는 시작점
|
||||
|
||||
- `tableSource` - 내부 테이블
|
||||
- `externalDBSource` - 외부 DB
|
||||
- `restAPISource` - REST API
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 출력만 가능 (소스 핸들)
|
||||
- ❌ 입력 불가능
|
||||
- 플로우의 시작점
|
||||
|
||||
---
|
||||
|
||||
### 2. 변환/조건 노드 (Transform)
|
||||
|
||||
**역할**: 데이터를 가공하거나 흐름을 제어
|
||||
|
||||
#### 2.1 데이터 변환
|
||||
|
||||
- `fieldMapping` - 필드 매핑
|
||||
- `dataTransform` - 데이터 변환
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ✅ 출력 가능 (소스 핸들)
|
||||
- 중간 파이프라인 역할
|
||||
|
||||
#### 2.2 조건 분기
|
||||
|
||||
- `condition` - 조건 분기
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ✅ 출력 가능 (TRUE/FALSE 2개의 소스 핸들)
|
||||
- 흐름을 분기
|
||||
|
||||
---
|
||||
|
||||
### 3. 액션 노드 (Action)
|
||||
|
||||
**역할**: 실제 데이터베이스 작업 수행
|
||||
|
||||
- `insertAction` - INSERT
|
||||
- `updateAction` - UPDATE
|
||||
- `deleteAction` - DELETE
|
||||
- `upsertAction` - UPSERT
|
||||
|
||||
**특징**:
|
||||
|
||||
- ✅ 입력 가능 (타겟 핸들)
|
||||
- ⚠️ 출력 제한적 (성공/실패 결과만)
|
||||
- 플로우의 종착점 또는 중간 액션
|
||||
|
||||
---
|
||||
|
||||
### 4. 유틸리티 노드 (Utility)
|
||||
|
||||
**역할**: 보조적인 기능 제공
|
||||
|
||||
- `log` - 로그 출력
|
||||
- `comment` - 주석
|
||||
|
||||
**특징**:
|
||||
|
||||
- `log`: 입력/출력 모두 가능 (패스스루)
|
||||
- `comment`: 연결 불가능 (독립 노드)
|
||||
|
||||
---
|
||||
|
||||
## 연결 규칙 매트릭스
|
||||
|
||||
### 출력(From) → 입력(To) 연결 가능 여부
|
||||
|
||||
| From ↓ / To → | tableSource | externalDB | restAPI | condition | fieldMapping | dataTransform | insert | update | delete | upsert | log | comment |
|
||||
| ----------------- | ----------- | ---------- | ------- | --------- | ------------ | ------------- | ------ | ------ | ------ | ------ | --- | ------- |
|
||||
| **tableSource** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **externalDB** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **restAPI** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **condition** | ❌ | ❌ | ❌ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **fieldMapping** | ❌ | ❌ | ❌ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **dataTransform** | ❌ | ❌ | ❌ | ✅ | ✅ | ⚠️ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **insert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **update** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **delete** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **upsert** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ⚠️ | ⚠️ | ⚠️ | ⚠️ | ✅ | ❌ |
|
||||
| **log** | ❌ | ❌ | ❌ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ❌ |
|
||||
| **comment** | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
|
||||
**범례**:
|
||||
|
||||
- ✅ 허용
|
||||
- ❌ 금지
|
||||
- ⚠️ 조건부 허용 (경고 메시지와 함께)
|
||||
|
||||
---
|
||||
|
||||
## 상세 연결 규칙
|
||||
|
||||
### 규칙 1: 소스 노드는 입력을 받을 수 없음
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 어떤 노드 → tableSource
|
||||
❌ 어떤 노드 → externalDBSource
|
||||
❌ 어떤 노드 → restAPISource
|
||||
```
|
||||
|
||||
**이유**: 소스 노드는 데이터의 시작점이므로 외부 입력이 의미 없음
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 2: 소스 노드끼리 연결 불가
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ tableSource → externalDBSource
|
||||
❌ restAPISource → tableSource
|
||||
```
|
||||
|
||||
**이유**: 소스 노드는 독립적으로 데이터를 생성하므로 서로 연결 불필요
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"소스 노드끼리는 연결할 수 없습니다. 각 소스는 독립적으로 동작합니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 3: Comment 노드는 연결 불가
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 어떤 노드 → comment
|
||||
❌ comment → 어떤 노드
|
||||
```
|
||||
|
||||
**이유**: Comment는 설명 전용 노드로 데이터 흐름에 영향을 주지 않음
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"주석 노드는 연결할 수 없습니다. 주석은 플로우 설명 용도로만 사용됩니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 4: 동일한 타입의 변환 노드 연속 연결 경고
|
||||
|
||||
**경고가 필요한 연결**:
|
||||
|
||||
```
|
||||
⚠️ fieldMapping → fieldMapping
|
||||
⚠️ dataTransform → dataTransform
|
||||
⚠️ condition → condition
|
||||
```
|
||||
|
||||
**이유**: 논리적으로 가능하지만 비효율적일 수 있음
|
||||
|
||||
**경고 메시지**:
|
||||
|
||||
```
|
||||
"동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나의 노드로 통합하는 것이 효율적입니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 5: 액션 노드 연속 연결 경고
|
||||
|
||||
**경고가 필요한 연결**:
|
||||
|
||||
```
|
||||
⚠️ insertAction → updateAction
|
||||
⚠️ updateAction → deleteAction
|
||||
⚠️ deleteAction → insertAction
|
||||
```
|
||||
|
||||
**이유**: 트랜잭션 관리나 성능에 영향을 줄 수 있음
|
||||
|
||||
**경고 메시지**:
|
||||
|
||||
```
|
||||
"액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 6: 자기 자신에게 연결 금지
|
||||
|
||||
**금지되는 연결**:
|
||||
|
||||
```
|
||||
❌ 모든 노드 → 자기 자신
|
||||
```
|
||||
|
||||
**이유**: 무한 루프 방지
|
||||
|
||||
**오류 메시지**:
|
||||
|
||||
```
|
||||
"노드는 자기 자신에게 연결할 수 없습니다."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 규칙 7: Log 노드는 패스스루
|
||||
|
||||
**허용되는 연결**:
|
||||
|
||||
```
|
||||
✅ 모든 노드 → log → 모든 노드 (소스 제외)
|
||||
```
|
||||
|
||||
**특징**:
|
||||
|
||||
- Log 노드는 데이터를 그대로 전달
|
||||
- 디버깅 및 모니터링 용도
|
||||
- 데이터 흐름에 영향 없음
|
||||
|
||||
---
|
||||
|
||||
## 구현 계획
|
||||
|
||||
### Phase 1: 기본 금지 규칙 (우선순위: 높음)
|
||||
|
||||
**구현 위치**: `frontend/lib/stores/flowEditorStore.ts` - `validateConnection` 함수
|
||||
|
||||
```typescript
|
||||
function validateConnection(
|
||||
connection: Connection,
|
||||
nodes: FlowNode[]
|
||||
): { valid: boolean; error?: string } {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) {
|
||||
return { valid: false, error: "노드를 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
// 규칙 1: 소스 노드는 입력을 받을 수 없음
|
||||
if (isSourceNode(targetNode.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error:
|
||||
"소스 노드는 입력을 받을 수 없습니다. 소스 노드는 플로우의 시작점입니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 2: 소스 노드끼리 연결 불가
|
||||
if (isSourceNode(sourceNode.type) && isSourceNode(targetNode.type)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "소스 노드끼리는 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 3: Comment 노드는 연결 불가
|
||||
if (sourceNode.type === "comment" || targetNode.type === "comment") {
|
||||
return {
|
||||
valid: false,
|
||||
error: "주석 노드는 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
// 규칙 6: 자기 자신에게 연결 금지
|
||||
if (connection.source === connection.target) {
|
||||
return {
|
||||
valid: false,
|
||||
error: "노드는 자기 자신에게 연결할 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
```
|
||||
|
||||
**예상 작업 시간**: 30분
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 경고 규칙 (우선순위: 중간)
|
||||
|
||||
**구현 방법**: 연결은 허용하되 경고 표시
|
||||
|
||||
```typescript
|
||||
function getConnectionWarning(
|
||||
connection: Connection,
|
||||
nodes: FlowNode[]
|
||||
): string | null {
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) return null;
|
||||
|
||||
// 규칙 4: 동일한 타입의 변환 노드 연속 연결
|
||||
if (sourceNode.type === targetNode.type && isTransformNode(sourceNode.type)) {
|
||||
return "동일한 타입의 변환 노드가 연속으로 연결되었습니다. 하나로 통합하는 것이 효율적입니다.";
|
||||
}
|
||||
|
||||
// 규칙 5: 액션 노드 연속 연결
|
||||
if (isActionNode(sourceNode.type) && isActionNode(targetNode.type)) {
|
||||
return "액션 노드가 연속으로 연결되었습니다. 트랜잭션과 성능을 고려해주세요.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
**UI 구현**:
|
||||
|
||||
- 경고 아이콘을 연결선 위에 표시
|
||||
- 호버 시 경고 메시지 툴팁 표시
|
||||
|
||||
**예상 작업 시간**: 1시간
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 시각적 피드백 (우선순위: 낮음)
|
||||
|
||||
**기능**:
|
||||
|
||||
1. 드래그 중 호환 가능한 노드 하이라이트
|
||||
2. 불가능한 연결 시도 시 빨간색 표시
|
||||
3. 경고가 있는 연결은 노란색 표시
|
||||
|
||||
**예상 작업 시간**: 2시간
|
||||
|
||||
---
|
||||
|
||||
## 테스트 케이스
|
||||
|
||||
### 금지 테스트
|
||||
|
||||
- [ ] tableSource → tableSource (금지)
|
||||
- [ ] fieldMapping → comment (금지)
|
||||
- [ ] 자기 자신 → 자기 자신 (금지)
|
||||
|
||||
### 경고 테스트
|
||||
|
||||
- [ ] fieldMapping → fieldMapping (경고)
|
||||
- [ ] insertAction → updateAction (경고)
|
||||
|
||||
### 정상 테스트
|
||||
|
||||
- [ ] tableSource → fieldMapping → insertAction
|
||||
- [ ] externalDBSource → condition → (TRUE) → updateAction
|
||||
- [ ] restAPISource → log → dataTransform → upsertAction
|
||||
|
||||
---
|
||||
|
||||
## 향후 확장
|
||||
|
||||
### 추가 고려사항
|
||||
|
||||
1. **핸들별 제약**:
|
||||
|
||||
- Condition 노드의 TRUE/FALSE 출력 구분
|
||||
- 특정 핸들만 특정 노드 타입과 연결 가능
|
||||
|
||||
2. **데이터 타입 검증**:
|
||||
|
||||
- 숫자 필드만 계산 노드로 연결 가능
|
||||
- 문자열 필드만 텍스트 변환 노드로 연결 가능
|
||||
|
||||
3. **순서 제약**:
|
||||
- UPDATE/DELETE 전에 반드시 SELECT 필요
|
||||
- 특정 변환 순서 강제
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 버전 | 날짜 | 변경 내용 | 작성자 |
|
||||
| ---- | ---------- | --------- | ------ |
|
||||
| 1.0 | 2025-01-02 | 초안 작성 | AI |
|
||||
|
||||
---
|
||||
|
||||
**다음 단계**: Phase 1 구현 시작
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,358 @@
|
|||
# 리포트 관리 시스템 구현 진행 상황
|
||||
|
||||
## 프로젝트 개요
|
||||
|
||||
동적 리포트 디자이너 시스템 구현
|
||||
|
||||
- 사용자가 드래그 앤 드롭으로 리포트 레이아웃 설계
|
||||
- SQL 쿼리 연동으로 실시간 데이터 표시
|
||||
- 미리보기 및 인쇄 기능
|
||||
|
||||
---
|
||||
|
||||
## 완료된 작업 ✅
|
||||
|
||||
### 1. 데이터베이스 설계 및 구축
|
||||
|
||||
- [x] `report_template` 테이블 생성 (18개 초기 템플릿)
|
||||
- [x] `report_master` 테이블 생성 (리포트 메타 정보)
|
||||
- [x] `report_layout` 테이블 생성 (레이아웃 JSON)
|
||||
- [x] `report_query` 테이블 생성 (쿼리 정의)
|
||||
|
||||
**파일**: `db/report_schema.sql`, `db/report_query_schema.sql`
|
||||
|
||||
### 2. 백엔드 API 구현
|
||||
|
||||
- [x] 리포트 CRUD API (생성, 조회, 수정, 삭제)
|
||||
- [x] 템플릿 조회 API
|
||||
- [x] 레이아웃 저장/조회 API
|
||||
- [x] 쿼리 실행 API (파라미터 지원)
|
||||
- [x] 리포트 복사 API
|
||||
- [x] Raw SQL 기반 구현 (Prisma 대신 pg 사용)
|
||||
|
||||
**파일**:
|
||||
|
||||
- `backend-node/src/types/report.ts`
|
||||
- `backend-node/src/services/reportService.ts`
|
||||
- `backend-node/src/controllers/reportController.ts`
|
||||
- `backend-node/src/routes/reportRoutes.ts`
|
||||
|
||||
### 3. 프론트엔드 - 리포트 목록 페이지
|
||||
|
||||
- [x] 리포트 리스트 조회 및 표시
|
||||
- [x] 검색 기능
|
||||
- [x] 페이지네이션
|
||||
- [x] 새 리포트 생성 (디자이너로 이동)
|
||||
- [x] 수정/복사/삭제 액션 버튼
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/app/(main)/admin/report/page.tsx`
|
||||
- `frontend/components/report/ReportListTable.tsx`
|
||||
- `frontend/hooks/useReportList.ts`
|
||||
|
||||
### 4. 프론트엔드 - 리포트 디자이너 기본 구조
|
||||
|
||||
- [x] Context 기반 상태 관리 (`ReportDesignerContext`)
|
||||
- [x] 툴바 (저장, 미리보기, 초기화, 뒤로가기)
|
||||
- [x] 3단 레이아웃 (좌측 팔레트 / 중앙 캔버스 / 우측 속성)
|
||||
- [x] "new" 리포트 처리 (저장 시 생성)
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/contexts/ReportDesignerContext.tsx`
|
||||
- `frontend/app/(main)/admin/report/designer/[reportId]/page.tsx`
|
||||
- `frontend/components/report/designer/ReportDesignerToolbar.tsx`
|
||||
|
||||
### 5. 컴포넌트 팔레트 및 캔버스
|
||||
|
||||
- [x] 드래그 가능한 컴포넌트 목록 (텍스트, 레이블, 테이블)
|
||||
- [x] 드래그 앤 드롭으로 캔버스에 컴포넌트 배치
|
||||
- [x] 컴포넌트 이동 (드래그)
|
||||
- [x] 컴포넌트 크기 조절 (리사이즈 핸들)
|
||||
- [x] 컴포넌트 선택 및 삭제
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/components/report/designer/ComponentPalette.tsx`
|
||||
- `frontend/components/report/designer/ReportDesignerCanvas.tsx`
|
||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
||||
|
||||
### 6. 쿼리 관리 시스템
|
||||
|
||||
- [x] 쿼리 추가/수정/삭제 (마스터/디테일)
|
||||
- [x] SQL 파라미터 자동 감지 ($1, $2 등)
|
||||
- [x] 파라미터 타입 선택 (text, number, date)
|
||||
- [x] 파라미터 입력값 검증
|
||||
- [x] 쿼리 실행 및 결과 표시
|
||||
- [x] "new" 리포트에서도 쿼리 실행 가능
|
||||
- [x] 실행 결과를 Context에 저장
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/components/report/designer/QueryManager.tsx`
|
||||
- `frontend/contexts/ReportDesignerContext.tsx` (QueryResult 관리)
|
||||
|
||||
### 7. 데이터 바인딩 시스템
|
||||
|
||||
- [x] 속성 패널에서 컴포넌트-쿼리 연결
|
||||
- [x] 텍스트/레이블: 쿼리 + 필드 선택
|
||||
- [x] 테이블: 쿼리 선택 (모든 필드 자동 표시)
|
||||
- [x] 캔버스에서 실제 데이터 표시 (바인딩된 필드의 값)
|
||||
- [x] 실행 결과가 없으면 `{필드명}` 표시
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
||||
|
||||
### 8. 미리보기 및 내보내기
|
||||
|
||||
- [x] 미리보기 모달
|
||||
- [x] 실제 쿼리 데이터로 렌더링
|
||||
- [x] 편집용 UI 제거 (순수 데이터만 표시)
|
||||
- [x] 브라우저 인쇄 기능
|
||||
- [x] PDF 다운로드 (브라우저 네이티브 인쇄 기능)
|
||||
- [x] WORD 다운로드 (docx 라이브러리)
|
||||
- [x] 파일명 자동 생성 (리포트명\_날짜)
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/components/report/designer/ReportPreviewModal.tsx`
|
||||
|
||||
**사용 라이브러리**:
|
||||
|
||||
- `docx`: WORD 문서 생성 (PDF는 브라우저 기본 기능 사용)
|
||||
|
||||
### 9. 템플릿 시스템
|
||||
|
||||
- [x] 시스템 템플릿 적용 (발주서, 청구서, 기본)
|
||||
- [x] 템플릿별 기본 컴포넌트 자동 배치
|
||||
- [x] 템플릿별 기본 쿼리 자동 생성
|
||||
- [x] 사용자 정의 템플릿 저장 기능
|
||||
- [x] 사용자 정의 템플릿 목록 조회
|
||||
- [x] 사용자 정의 템플릿 삭제
|
||||
- [x] 사용자 정의 템플릿 적용 (백엔드 연동)
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/contexts/ReportDesignerContext.tsx` (템플릿 적용 로직)
|
||||
- `frontend/components/report/designer/TemplatePalette.tsx`
|
||||
- `frontend/components/report/designer/SaveAsTemplateModal.tsx`
|
||||
- `backend-node/src/services/reportService.ts` (createTemplateFromLayout)
|
||||
|
||||
### 10. 외부 DB 연동
|
||||
|
||||
- [x] 쿼리별 외부 DB 연결 선택
|
||||
- [x] 외부 DB 연결 목록 조회 API
|
||||
- [x] 쿼리 실행 시 외부 DB 지원
|
||||
- [x] 내부/외부 DB 선택 UI
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/components/report/designer/QueryManager.tsx`
|
||||
- `backend-node/src/services/reportService.ts` (executeQuery with external DB)
|
||||
|
||||
### 11. 컴포넌트 스타일링
|
||||
|
||||
- [x] 폰트 크기 설정
|
||||
- [x] 폰트 색상 설정 (컬러피커)
|
||||
- [x] 폰트 굵기 (보통/굵게)
|
||||
- [x] 텍스트 정렬 (좌/중/우)
|
||||
- [x] 배경색 설정 (투명 옵션 포함)
|
||||
- [x] 테두리 설정 (두께, 색상)
|
||||
- [x] 캔버스 및 미리보기에 스타일 반영
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
||||
|
||||
### 12. 레이아웃 도구 (완료!)
|
||||
|
||||
- [x] **Grid Snap**: 10px 단위 그리드에 자동 정렬
|
||||
- [x] **정렬 가이드라인**: 드래그 시 빨간색 가이드라인 표시
|
||||
- [x] **복사/붙여넣기**: Ctrl+C/V로 컴포넌트 복사 (20px 오프셋)
|
||||
- [x] **Undo/Redo**: 히스토리 관리 (Ctrl+Z / Ctrl+Shift+Z)
|
||||
- [x] **컴포넌트 정렬**: 좌/우/상/하/가로중앙/세로중앙 정렬
|
||||
- [x] **컴포넌트 배치**: 가로/세로 균등 배치 (3개 이상)
|
||||
- [x] **크기 조정**: 같은 너비/높이/크기로 조정 (2개 이상)
|
||||
- [x] **화살표 키 이동**: 1px 이동, Shift+화살표 10px 이동
|
||||
- [x] **레이어 관리**: 맨 앞/뒤, 한 단계 앞/뒤 (Z-Index 조정)
|
||||
- [x] **컴포넌트 잠금**: 편집/이동/삭제 방지, 🔒 표시
|
||||
- [x] **눈금자 표시**: 가로/세로 mm 단위 눈금자
|
||||
- [x] **컴포넌트 그룹화**: 여러 컴포넌트를 그룹으로 묶어 함께 이동, 👥 표시
|
||||
|
||||
**파일**:
|
||||
|
||||
- `frontend/contexts/ReportDesignerContext.tsx` (레이아웃 도구 로직)
|
||||
- `frontend/components/report/designer/ReportDesignerToolbar.tsx` (버튼 UI)
|
||||
- `frontend/components/report/designer/ReportDesignerCanvas.tsx` (Grid, 가이드라인)
|
||||
- `frontend/components/report/designer/CanvasComponent.tsx` (잠금, 그룹)
|
||||
- `frontend/components/report/designer/Ruler.tsx` (눈금자 컴포넌트)
|
||||
|
||||
---
|
||||
|
||||
## 진행 중인 작업 🚧
|
||||
|
||||
없음 (모든 레이아웃 도구 구현 완료!)
|
||||
|
||||
---
|
||||
|
||||
## 남은 작업 (우선순위순) 📋
|
||||
|
||||
### Phase 1: 추가 컴포넌트 ✅ 완료!
|
||||
|
||||
1. **이미지 컴포넌트** ✅
|
||||
|
||||
- [x] 파일 업로드 (multer, 10MB 제한)
|
||||
- [x] 회사별 디렉토리 분리 저장
|
||||
- [x] 맞춤 방식 (contain/cover/fill/none)
|
||||
- [x] CORS 설정으로 이미지 로딩
|
||||
- [x] 캔버스 및 미리보기 렌더링
|
||||
- 로고, 서명, 도장 등에 활용
|
||||
|
||||
2. **구분선 컴포넌트 (Divider)** ✅
|
||||
|
||||
- [x] 가로/세로 방향 선택
|
||||
- [x] 선 두께 (lineWidth) 독립 속성
|
||||
- [x] 선 색상 (lineColor) 독립 속성
|
||||
- [x] 선 스타일 (solid/dashed/dotted/double)
|
||||
- [x] 캔버스 및 미리보기 렌더링
|
||||
|
||||
**파일**:
|
||||
- `backend-node/src/controllers/reportController.ts` (uploadImage)
|
||||
- `backend-node/src/routes/reportRoutes.ts` (multer 설정)
|
||||
- `frontend/types/report.ts` (이미지/구분선 속성)
|
||||
- `frontend/components/report/designer/ComponentPalette.tsx`
|
||||
- `frontend/components/report/designer/CanvasComponent.tsx`
|
||||
- `frontend/components/report/designer/ReportDesignerRightPanel.tsx`
|
||||
- `frontend/components/report/designer/ReportPreviewModal.tsx`
|
||||
- `frontend/lib/api/client.ts` (getFullImageUrl)
|
||||
|
||||
3. **차트 컴포넌트** (선택사항) ⬅️ 다음 권장 작업
|
||||
- 막대 차트
|
||||
- 선 차트
|
||||
- 원형 차트
|
||||
- 쿼리 데이터 연동
|
||||
|
||||
### Phase 2: 고급 기능
|
||||
|
||||
4. **조건부 서식**
|
||||
|
||||
- 특정 조건에 따른 스타일 변경
|
||||
- 값 범위에 따른 색상 표시
|
||||
- 수식 기반 표시/숨김
|
||||
|
||||
5. **쿼리 관리 개선**
|
||||
- 쿼리 미리보기 개선 (테이블 형태)
|
||||
- 쿼리 저장/불러오기
|
||||
- 쿼리 템플릿
|
||||
|
||||
### Phase 3: 성능 및 보안
|
||||
|
||||
6. **성능 최적화**
|
||||
|
||||
- 쿼리 결과 캐싱
|
||||
- 대용량 데이터 페이징
|
||||
- 렌더링 최적화
|
||||
- 이미지 레이지 로딩
|
||||
|
||||
7. **권한 관리**
|
||||
- 리포트별 접근 권한
|
||||
- 수정 권한 분리
|
||||
- 템플릿 공유
|
||||
- 사용자별 리포트 목록 필터링
|
||||
|
||||
---
|
||||
|
||||
## 기술 스택
|
||||
|
||||
### 백엔드
|
||||
|
||||
- Node.js + TypeScript
|
||||
- Express.js
|
||||
- PostgreSQL (raw SQL)
|
||||
- pg (node-postgres)
|
||||
|
||||
### 프론트엔드
|
||||
|
||||
- Next.js 14 (App Router)
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Tailwind CSS
|
||||
- Shadcn UI
|
||||
- react-dnd (드래그 앤 드롭)
|
||||
|
||||
---
|
||||
|
||||
## 주요 아키텍처 결정
|
||||
|
||||
### 1. Context API 사용
|
||||
|
||||
- 리포트 디자이너의 복잡한 상태를 Context로 중앙 관리
|
||||
- 컴포넌트 간 prop drilling 방지
|
||||
|
||||
### 2. Raw SQL 사용
|
||||
|
||||
- Prisma 대신 직접 SQL 작성
|
||||
- 복잡한 쿼리와 트랜잭션 처리에 유리
|
||||
- 데이터베이스 제어 수준 향상
|
||||
|
||||
### 3. JSON 기반 레이아웃 저장
|
||||
|
||||
- 레이아웃을 JSONB로 DB에 저장
|
||||
- 버전 관리 용이
|
||||
- 유연한 스키마
|
||||
|
||||
### 4. 쿼리 실행 결과 메모리 관리
|
||||
|
||||
- Context에 쿼리 결과 저장
|
||||
- 컴포넌트에서 실시간 참조
|
||||
- 불필요한 API 호출 방지
|
||||
|
||||
---
|
||||
|
||||
## 참고 문서
|
||||
|
||||
- [리포트*관리*시스템\_설계.md](./리포트_관리_시스템_설계.md) - 초기 설계 문서
|
||||
- [레포트드자이너.html](../레포트드자이너.html) - 참조 프로토타입
|
||||
|
||||
---
|
||||
|
||||
## 다음 작업: 리포트 복사/삭제 테스트 및 검증
|
||||
|
||||
### 테스트 항목
|
||||
|
||||
1. **복사 기능 테스트**
|
||||
|
||||
- 리포트 복사 버튼 클릭
|
||||
- 복사된 리포트명 확인 (원본명 + "\_copy")
|
||||
- 복사된 리포트의 레이아웃 확인
|
||||
- 복사된 리포트의 쿼리 확인
|
||||
- 목록 자동 새로고침 확인
|
||||
|
||||
2. **삭제 기능 테스트**
|
||||
|
||||
- 삭제 버튼 클릭 시 확인 다이얼로그 표시
|
||||
- 취소 버튼 동작 확인
|
||||
- 삭제 실행 후 목록에서 제거 확인
|
||||
- Toast 메시지 표시 확인
|
||||
|
||||
3. **에러 처리 테스트**
|
||||
- 존재하지 않는 리포트 삭제 시도
|
||||
- 네트워크 오류 시 Toast 메시지
|
||||
- 로딩 중 버튼 비활성화 확인
|
||||
|
||||
### 추가 개선 사항
|
||||
|
||||
- [ ] 컴포넌트 복사 기능 (Ctrl+C/Ctrl+V)
|
||||
- [ ] 다중 선택 및 정렬 기능
|
||||
- [ ] 실행 취소/다시 실행 (Undo/Redo)
|
||||
- [ ] 사용자 정의 템플릿 저장
|
||||
|
||||
---
|
||||
|
||||
**최종 업데이트**: 2025-10-01
|
||||
**작성자**: AI Assistant
|
||||
**상태**: 이미지 & 구분선 컴포넌트 완료 (기본 컴포넌트 완료, 약 99% 완료)
|
||||
|
|
@ -0,0 +1,679 @@
|
|||
# 리포트 관리 시스템 설계
|
||||
|
||||
## 1. 프로젝트 개요
|
||||
|
||||
### 1.1 목적
|
||||
|
||||
ERP 시스템에서 다양한 업무 문서(발주서, 청구서, 거래명세서 등)를 동적으로 디자인하고 관리할 수 있는 리포트 관리 시스템을 구축합니다.
|
||||
|
||||
### 1.2 주요 기능
|
||||
|
||||
- 리포트 목록 조회 및 관리
|
||||
- 드래그 앤 드롭 기반 리포트 디자이너
|
||||
- 템플릿 관리 (기본 템플릿 + 사용자 정의 템플릿)
|
||||
- 쿼리 관리 (마스터/디테일)
|
||||
- 외부 DB 연동
|
||||
- 인쇄 및 내보내기 (PDF, WORD)
|
||||
- 미리보기 기능
|
||||
|
||||
## 2. 화면 구성
|
||||
|
||||
### 2.1 리포트 목록 화면 (`/admin/report`)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 리포트 관리 [+ 새 리포트] │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ 검색: [____________________] [검색] [초기화] │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ No │ 리포트명 │ 작성자 │ 수정일 │ 액션 │
|
||||
├────┼──────────────┼────────┼───────────┼────────────────────────┤
|
||||
│ 1 │ 발주서 양식 │ 홍길동 │ 2025-10-01 │ 수정 │ 복사 │ 삭제 │
|
||||
│ 2 │ 청구서 기본 │ 김철수 │ 2025-09-28 │ 수정 │ 복사 │ 삭제 │
|
||||
│ 3 │ 거래명세서 │ 이영희 │ 2025-09-25 │ 수정 │ 복사 │ 삭제 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**기능**
|
||||
|
||||
- 리포트 목록 조회 (페이징, 정렬, 검색)
|
||||
- 새 리포트 생성
|
||||
- 기존 리포트 수정
|
||||
- 리포트 복사
|
||||
- 리포트 삭제
|
||||
- 리포트 미리보기
|
||||
|
||||
### 2.2 리포트 디자이너 화면
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 리포트 디자이너 [저장] [미리보기] [초기화] [목록으로] │
|
||||
├──────┬────────────────────────────────────────────────┬──────────┤
|
||||
│ │ │ │
|
||||
│ 템플릿│ 작업 영역 (캔버스) │ 속성 패널 │
|
||||
│ │ │ │
|
||||
│ 컴포넌트│ [드래그 앤 드롭] │ 쿼리 관리 │
|
||||
│ │ │ │
|
||||
│ │ │ DB 연동 │
|
||||
└──────┴────────────────────────────────────────────────┴──────────┘
|
||||
```
|
||||
|
||||
### 2.3 미리보기 모달
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ 미리보기 [닫기] │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [리포트 내용 미리보기] │
|
||||
│ │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ [인쇄] [PDF] [WORD] │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 3. 데이터베이스 설계
|
||||
|
||||
### 3.1 테이블 구조
|
||||
|
||||
#### REPORT_TEMPLATE (리포트 템플릿)
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_template (
|
||||
template_id VARCHAR(50) PRIMARY KEY, -- 템플릿 ID
|
||||
template_name_kor VARCHAR(100) NOT NULL, -- 템플릿명 (한국어)
|
||||
template_name_eng VARCHAR(100), -- 템플릿명 (영어)
|
||||
template_type VARCHAR(30) NOT NULL, -- 템플릿 타입 (ORDER, INVOICE, STATEMENT, etc)
|
||||
is_system CHAR(1) DEFAULT 'N', -- 시스템 기본 템플릿 여부 (Y/N)
|
||||
thumbnail_url VARCHAR(500), -- 썸네일 이미지 경로
|
||||
description TEXT, -- 템플릿 설명
|
||||
layout_config TEXT, -- 레이아웃 설정 (JSON)
|
||||
default_queries TEXT, -- 기본 쿼리 (JSON)
|
||||
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
|
||||
sort_order INTEGER DEFAULT 0, -- 정렬 순서
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR(50)
|
||||
);
|
||||
```
|
||||
|
||||
#### REPORT_MASTER (리포트 마스터)
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_master (
|
||||
report_id VARCHAR(50) PRIMARY KEY, -- 리포트 ID
|
||||
report_name_kor VARCHAR(100) NOT NULL, -- 리포트명 (한국어)
|
||||
report_name_eng VARCHAR(100), -- 리포트명 (영어)
|
||||
template_id VARCHAR(50), -- 템플릿 ID (FK)
|
||||
report_type VARCHAR(30) NOT NULL, -- 리포트 타입
|
||||
company_code VARCHAR(20), -- 회사 코드
|
||||
description TEXT, -- 설명
|
||||
use_yn CHAR(1) DEFAULT 'Y', -- 사용 여부
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
FOREIGN KEY (template_id) REFERENCES report_template(template_id)
|
||||
);
|
||||
```
|
||||
|
||||
#### REPORT_LAYOUT (리포트 레이아웃)
|
||||
|
||||
```sql
|
||||
CREATE TABLE report_layout (
|
||||
layout_id VARCHAR(50) PRIMARY KEY, -- 레이아웃 ID
|
||||
report_id VARCHAR(50) NOT NULL, -- 리포트 ID (FK)
|
||||
canvas_width INTEGER DEFAULT 210, -- 캔버스 너비 (mm)
|
||||
canvas_height INTEGER DEFAULT 297, -- 캔버스 높이 (mm)
|
||||
page_orientation VARCHAR(10) DEFAULT 'portrait', -- 페이지 방향 (portrait/landscape)
|
||||
margin_top INTEGER DEFAULT 20, -- 상단 여백 (mm)
|
||||
margin_bottom INTEGER DEFAULT 20, -- 하단 여백 (mm)
|
||||
margin_left INTEGER DEFAULT 20, -- 좌측 여백 (mm)
|
||||
margin_right INTEGER DEFAULT 20, -- 우측 여백 (mm)
|
||||
components TEXT, -- 컴포넌트 배치 정보 (JSON)
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_at TIMESTAMP,
|
||||
updated_by VARCHAR(50),
|
||||
FOREIGN KEY (report_id) REFERENCES report_master(report_id)
|
||||
);
|
||||
```
|
||||
|
||||
## 4. 컴포넌트 목록
|
||||
|
||||
### 4.1 기본 컴포넌트
|
||||
|
||||
#### 텍스트 관련
|
||||
|
||||
- **Text Field**: 단일 라인 텍스트 입력/표시
|
||||
- **Text Area**: 여러 줄 텍스트 입력/표시
|
||||
- **Label**: 고정 라벨 텍스트
|
||||
- **Rich Text**: 서식이 있는 텍스트 (굵게, 기울임, 색상)
|
||||
|
||||
#### 숫자/날짜 관련
|
||||
|
||||
- **Number**: 숫자 표시 (통화 형식 지원)
|
||||
- **Date**: 날짜 표시 (형식 지정 가능)
|
||||
- **Date Time**: 날짜 + 시간 표시
|
||||
- **Calculate Field**: 계산 필드 (합계, 평균 등)
|
||||
|
||||
#### 테이블/그리드
|
||||
|
||||
- **Data Table**: 데이터 테이블 (디테일 쿼리 바인딩)
|
||||
- **Summary Table**: 요약 테이블
|
||||
- **Group Table**: 그룹핑 테이블
|
||||
|
||||
#### 이미지/그래픽
|
||||
|
||||
- **Image**: 이미지 표시 (로고, 서명 등)
|
||||
- **Line**: 구분선
|
||||
- **Rectangle**: 사각형 (테두리)
|
||||
|
||||
#### 특수 컴포넌트
|
||||
|
||||
- **Page Number**: 페이지 번호
|
||||
- **Current Date**: 현재 날짜/시간
|
||||
- **Company Info**: 회사 정보 (자동)
|
||||
- **Signature**: 서명란
|
||||
- **Stamp**: 도장란
|
||||
|
||||
### 4.2 컴포넌트 속성
|
||||
|
||||
각 컴포넌트는 다음 공통 속성을 가집니다:
|
||||
|
||||
```typescript
|
||||
interface ComponentBase {
|
||||
id: string; // 컴포넌트 ID
|
||||
type: string; // 컴포넌트 타입
|
||||
x: number; // X 좌표
|
||||
y: number; // Y 좌표
|
||||
width: number; // 너비
|
||||
height: number; // 높이
|
||||
zIndex: number; // Z-인덱스
|
||||
|
||||
// 스타일
|
||||
fontSize?: number; // 글자 크기
|
||||
fontFamily?: string; // 폰트
|
||||
fontWeight?: string; // 글자 굵기
|
||||
fontColor?: string; // 글자 색상
|
||||
backgroundColor?: string; // 배경색
|
||||
borderWidth?: number; // 테두리 두께
|
||||
borderColor?: string; // 테두리 색상
|
||||
borderRadius?: number; // 모서리 둥글기
|
||||
textAlign?: string; // 텍스트 정렬
|
||||
padding?: number; // 내부 여백
|
||||
|
||||
// 데이터 바인딩
|
||||
queryId?: string; // 연결된 쿼리 ID
|
||||
fieldName?: string; // 필드명
|
||||
defaultValue?: string; // 기본값
|
||||
format?: string; // 표시 형식
|
||||
|
||||
// 기타
|
||||
visible?: boolean; // 표시 여부
|
||||
printable?: boolean; // 인쇄 여부
|
||||
conditional?: string; // 조건부 표시 (수식)
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 템플릿 목록
|
||||
|
||||
### 5.1 기본 템플릿 (시스템)
|
||||
|
||||
#### 구매/발주 관련
|
||||
|
||||
- **발주서 (Purchase Order)**: 거래처에 발주하는 문서
|
||||
- **구매요청서 (Purchase Request)**: 내부 구매 요청 문서
|
||||
- **발주 확인서 (PO Confirmation)**: 발주 확인 문서
|
||||
|
||||
#### 판매/청구 관련
|
||||
|
||||
- **청구서 (Invoice)**: 고객에게 청구하는 문서
|
||||
- **견적서 (Quotation)**: 견적 제공 문서
|
||||
- **거래명세서 (Transaction Statement)**: 거래 내역 명세
|
||||
- **세금계산서 (Tax Invoice)**: 세금 계산서
|
||||
- **영수증 (Receipt)**: 영수 증빙 문서
|
||||
|
||||
#### 재고/입출고 관련
|
||||
|
||||
- **입고증 (Goods Receipt)**: 입고 증빙 문서
|
||||
- **출고증 (Delivery Note)**: 출고 증빙 문서
|
||||
- **재고 현황표 (Inventory Report)**: 재고 현황
|
||||
- **이동 전표 (Transfer Note)**: 재고 이동 문서
|
||||
|
||||
#### 생산 관련
|
||||
|
||||
- **작업지시서 (Work Order)**: 생산 작업 지시
|
||||
- **생산 일보 (Production Daily Report)**: 생산 일일 보고
|
||||
- **품질 검사표 (Quality Inspection)**: 품질 검사 기록
|
||||
- **불량 보고서 (Defect Report)**: 불량 보고
|
||||
|
||||
#### 회계/경영 관련
|
||||
|
||||
- **손익 계산서 (Income Statement)**: 손익 현황
|
||||
- **대차대조표 (Balance Sheet)**: 재무 상태
|
||||
- **현금 흐름표 (Cash Flow Statement)**: 현금 흐름
|
||||
- **급여 명세서 (Payroll Slip)**: 급여 내역
|
||||
|
||||
#### 일반 문서
|
||||
|
||||
- **기본 양식 (Basic Template)**: 빈 캔버스
|
||||
- **일반 보고서 (General Report)**: 일반 보고 양식
|
||||
- **목록 양식 (List Template)**: 목록형 양식
|
||||
|
||||
### 5.2 사용자 정의 템플릿
|
||||
|
||||
- 사용자가 직접 생성한 템플릿
|
||||
- 기본 템플릿을 복사하여 수정 가능
|
||||
- 회사별로 관리 가능
|
||||
|
||||
## 6. API 설계
|
||||
|
||||
### 6.1 리포트 목록 API
|
||||
|
||||
#### GET `/api/admin/reports`
|
||||
|
||||
리포트 목록 조회
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
interface GetReportsRequest {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
searchText?: string;
|
||||
reportType?: string;
|
||||
useYn?: string;
|
||||
sortBy?: string;
|
||||
sortOrder?: "ASC" | "DESC";
|
||||
}
|
||||
|
||||
// Response
|
||||
interface GetReportsResponse {
|
||||
items: ReportMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
}
|
||||
```
|
||||
|
||||
#### GET `/api/admin/reports/:reportId`
|
||||
|
||||
리포트 상세 조회
|
||||
|
||||
```typescript
|
||||
// Response
|
||||
interface ReportDetail {
|
||||
report: ReportMaster;
|
||||
layout: ReportLayout;
|
||||
queries: ReportQuery[];
|
||||
components: Component[];
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/admin/reports`
|
||||
|
||||
리포트 생성
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
interface CreateReportRequest {
|
||||
reportNameKor: string;
|
||||
reportNameEng?: string;
|
||||
templateId?: string;
|
||||
reportType: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// Response
|
||||
interface CreateReportResponse {
|
||||
reportId: string;
|
||||
message: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### PUT `/api/admin/reports/:reportId`
|
||||
|
||||
리포트 수정
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
interface UpdateReportRequest {
|
||||
reportNameKor?: string;
|
||||
reportNameEng?: string;
|
||||
reportType?: string;
|
||||
description?: string;
|
||||
useYn?: string;
|
||||
}
|
||||
```
|
||||
|
||||
#### DELETE `/api/admin/reports/:reportId`
|
||||
|
||||
리포트 삭제
|
||||
|
||||
#### POST `/api/admin/reports/:reportId/copy`
|
||||
|
||||
리포트 복사
|
||||
|
||||
### 6.2 템플릿 API
|
||||
|
||||
#### GET `/api/admin/reports/templates`
|
||||
|
||||
템플릿 목록 조회
|
||||
|
||||
```typescript
|
||||
// Response
|
||||
interface GetTemplatesResponse {
|
||||
system: ReportTemplate[]; // 시스템 템플릿
|
||||
custom: ReportTemplate[]; // 사용자 정의 템플릿
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/admin/reports/templates`
|
||||
|
||||
템플릿 생성 (사용자 정의)
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
interface CreateTemplateRequest {
|
||||
templateNameKor: string;
|
||||
templateNameEng?: string;
|
||||
templateType: string;
|
||||
description?: string;
|
||||
layoutConfig: any;
|
||||
defaultQueries?: any;
|
||||
}
|
||||
```
|
||||
|
||||
#### PUT `/api/admin/reports/templates/:templateId`
|
||||
|
||||
템플릿 수정
|
||||
|
||||
#### DELETE `/api/admin/reports/templates/:templateId`
|
||||
|
||||
템플릿 삭제
|
||||
|
||||
### 6.3 레이아웃 API
|
||||
|
||||
#### GET `/api/admin/reports/:reportId/layout`
|
||||
|
||||
레이아웃 조회
|
||||
|
||||
#### PUT `/api/admin/reports/:reportId/layout`
|
||||
|
||||
레이아웃 저장
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
interface SaveLayoutRequest {
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
pageOrientation: string;
|
||||
margins: {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
};
|
||||
components: Component[];
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 인쇄/내보내기 API
|
||||
|
||||
#### POST `/api/admin/reports/:reportId/preview`
|
||||
|
||||
미리보기 생성
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
interface PreviewRequest {
|
||||
parameters?: { [key: string]: any };
|
||||
format?: "HTML" | "PDF";
|
||||
}
|
||||
|
||||
// Response
|
||||
interface PreviewResponse {
|
||||
html?: string; // HTML 미리보기
|
||||
pdfUrl?: string; // PDF URL
|
||||
}
|
||||
```
|
||||
|
||||
#### POST `/api/admin/reports/:reportId/print`
|
||||
|
||||
인쇄 (PDF 생성)
|
||||
|
||||
```typescript
|
||||
// Request
|
||||
interface PrintRequest {
|
||||
parameters?: { [key: string]: any };
|
||||
format: "PDF" | "WORD" | "EXCEL";
|
||||
}
|
||||
|
||||
// Response
|
||||
interface PrintResponse {
|
||||
fileUrl: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 프론트엔드 구조
|
||||
|
||||
### 7.1 페이지 구조
|
||||
|
||||
```
|
||||
/admin/report
|
||||
├── ReportListPage.tsx # 리포트 목록 페이지
|
||||
├── ReportDesignerPage.tsx # 리포트 디자이너 페이지
|
||||
└── components/
|
||||
├── ReportList.tsx # 리포트 목록 테이블
|
||||
├── ReportSearchForm.tsx # 검색 폼
|
||||
├── TemplateSelector.tsx # 템플릿 선택기
|
||||
├── ComponentPalette.tsx # 컴포넌트 팔레트
|
||||
├── Canvas.tsx # 캔버스 영역
|
||||
├── ComponentRenderer.tsx # 컴포넌트 렌더러
|
||||
├── PropertyPanel.tsx # 속성 패널
|
||||
├── QueryManager.tsx # 쿼리 관리
|
||||
├── QueryCard.tsx # 쿼리 카드
|
||||
├── ConnectionManager.tsx # 외부 DB 연결 관리
|
||||
├── PreviewModal.tsx # 미리보기 모달
|
||||
└── PrintOptionsModal.tsx # 인쇄 옵션 모달
|
||||
```
|
||||
|
||||
### 7.2 상태 관리
|
||||
|
||||
```typescript
|
||||
interface ReportDesignerState {
|
||||
// 리포트 기본 정보
|
||||
report: ReportMaster | null;
|
||||
|
||||
// 레이아웃
|
||||
layout: ReportLayout | null;
|
||||
components: Component[];
|
||||
selectedComponentId: string | null;
|
||||
|
||||
// 쿼리
|
||||
queries: ReportQuery[];
|
||||
queryResults: { [queryId: string]: any[] };
|
||||
|
||||
// 외부 연결
|
||||
connections: ReportExternalConnection[];
|
||||
|
||||
// UI 상태
|
||||
isDragging: boolean;
|
||||
isResizing: boolean;
|
||||
showPreview: boolean;
|
||||
showPrintOptions: boolean;
|
||||
|
||||
// 히스토리 (Undo/Redo)
|
||||
history: {
|
||||
past: Component[][];
|
||||
present: Component[];
|
||||
future: Component[][];
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 8. 구현 우선순위
|
||||
|
||||
### Phase 1: 기본 기능 (2주)
|
||||
|
||||
- [ ] 데이터베이스 테이블 생성
|
||||
- [ ] 리포트 목록 화면
|
||||
- [ ] 리포트 CRUD API
|
||||
- [ ] 템플릿 목록 조회
|
||||
- [ ] 기본 템플릿 데이터 생성
|
||||
|
||||
### Phase 2: 디자이너 기본 (2주)
|
||||
|
||||
- [ ] 캔버스 구현
|
||||
- [ ] 컴포넌트 드래그 앤 드롭
|
||||
- [ ] 컴포넌트 선택/이동/크기 조절
|
||||
- [ ] 속성 패널 (기본)
|
||||
- [ ] 저장/불러오기
|
||||
|
||||
### Phase 3: 쿼리 관리 (1주)
|
||||
|
||||
- [ ] 쿼리 추가/수정/삭제
|
||||
- [ ] 파라미터 감지 및 입력
|
||||
- [ ] 쿼리 실행 (내부 DB)
|
||||
- [ ] 쿼리 결과를 컴포넌트에 바인딩
|
||||
|
||||
### Phase 4: 쿼리 관리 고급 (1주)
|
||||
|
||||
- [ ] 쿼리 필드 매핑
|
||||
- [ ] 컴포넌트와 데이터 바인딩
|
||||
- [ ] 파라미터 전달 및 처리
|
||||
|
||||
### Phase 5: 미리보기/인쇄 (1주)
|
||||
|
||||
- [ ] HTML 미리보기
|
||||
- [ ] PDF 생성
|
||||
- [ ] WORD 생성
|
||||
- [ ] 브라우저 인쇄
|
||||
|
||||
### Phase 6: 고급 기능 (2주)
|
||||
|
||||
- [ ] 템플릿 생성 기능
|
||||
- [ ] 컴포넌트 추가 (이미지, 서명, 도장)
|
||||
- [ ] 계산 필드
|
||||
- [ ] 조건부 표시
|
||||
- [ ] Undo/Redo
|
||||
- [ ] 다국어 지원
|
||||
|
||||
## 9. 기술 스택
|
||||
|
||||
### Backend
|
||||
|
||||
- **Node.js + TypeScript**: 백엔드 서버
|
||||
- **PostgreSQL**: 데이터베이스
|
||||
- **Prisma**: ORM
|
||||
- **Puppeteer**: PDF 생성
|
||||
- **docx**: WORD 생성
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Next.js + React**: 프론트엔드 프레임워크
|
||||
- **TypeScript**: 타입 안정성
|
||||
- **TailwindCSS**: 스타일링
|
||||
- **react-dnd**: 드래그 앤 드롭
|
||||
- **react-grid-layout**: 레이아웃 관리
|
||||
- **react-to-print**: 인쇄 기능
|
||||
- **react-pdf**: PDF 미리보기
|
||||
|
||||
## 10. 보안 고려사항
|
||||
|
||||
### 10.1 쿼리 실행 보안
|
||||
|
||||
- SELECT 쿼리만 허용 (INSERT, UPDATE, DELETE 금지)
|
||||
- 쿼리 결과 크기 제한 (최대 1000 rows)
|
||||
- 실행 시간 제한 (30초)
|
||||
- SQL 인젝션 방지 (파라미터 바인딩 강제)
|
||||
- 위험한 함수 차단 (DROP, TRUNCATE 등)
|
||||
|
||||
### 10.2 파일 보안
|
||||
|
||||
- 생성된 PDF/WORD 파일은 임시 디렉토리에 저장
|
||||
- 파일은 24시간 후 자동 삭제
|
||||
- 파일 다운로드 시 토큰 검증
|
||||
|
||||
### 10.3 접근 권한
|
||||
|
||||
- 리포트 생성/수정/삭제 권한 체크
|
||||
- 관리자만 템플릿 생성 가능
|
||||
- 사용자별 리포트 접근 제어
|
||||
|
||||
## 11. 성능 최적화
|
||||
|
||||
### 11.1 PDF 생성 최적화
|
||||
|
||||
- 백그라운드 작업으로 처리
|
||||
- 생성된 PDF는 CDN에 캐싱
|
||||
|
||||
### 11.2 프론트엔드 최적화
|
||||
|
||||
- 컴포넌트 가상화 (많은 컴포넌트 처리)
|
||||
- 디바운싱/쓰로틀링 (드래그 앤 드롭)
|
||||
- 이미지 레이지 로딩
|
||||
|
||||
### 11.3 데이터베이스 최적화
|
||||
|
||||
- 레이아웃 데이터는 JSON 형태로 저장
|
||||
- 리포트 목록 조회 시 인덱스 활용
|
||||
- 자주 사용하는 템플릿 캐싱
|
||||
|
||||
## 12. 테스트 계획
|
||||
|
||||
### 12.1 단위 테스트
|
||||
|
||||
- API 엔드포인트 테스트
|
||||
- 쿼리 파싱 테스트
|
||||
- PDF 생성 테스트
|
||||
|
||||
### 12.2 통합 테스트
|
||||
|
||||
- 리포트 생성 → 쿼리 실행 → PDF 생성 전체 플로우
|
||||
- 템플릿 적용 → 데이터 바인딩 테스트
|
||||
|
||||
### 12.3 UI 테스트
|
||||
|
||||
- 드래그 앤 드롭 동작 테스트
|
||||
- 컴포넌트 속성 변경 테스트
|
||||
|
||||
## 13. 향후 확장 계획
|
||||
|
||||
### 13.1 고급 기능
|
||||
|
||||
- 차트/그래프 컴포넌트
|
||||
- 조건부 서식 (색상 변경 등)
|
||||
- 그룹핑 및 집계 함수
|
||||
- 마스터-디테일 관계 자동 설정
|
||||
|
||||
### 13.2 협업 기능
|
||||
|
||||
- 리포트 공유
|
||||
- 버전 관리
|
||||
- 댓글 기능
|
||||
|
||||
### 13.3 자동화
|
||||
|
||||
- 스케줄링 (정기적 리포트 생성)
|
||||
- 이메일 자동 발송
|
||||
- 알림 설정
|
||||
|
||||
## 14. 참고 자료
|
||||
|
||||
### 14.1 유사 솔루션
|
||||
|
||||
- Crystal Reports
|
||||
- JasperReports
|
||||
- BIRT (Business Intelligence and Reporting Tools)
|
||||
- FastReport
|
||||
|
||||
### 14.2 라이브러리
|
||||
|
||||
- [react-grid-layout](https://github.com/react-grid-layout/react-grid-layout)
|
||||
- [react-dnd](https://react-dnd.github.io/react-dnd/)
|
||||
- [puppeteer](https://pptr.dev/)
|
||||
- [docx](https://docx.js.org/)
|
||||
|
|
@ -0,0 +1,371 @@
|
|||
# 리포트 문서 번호 자동 채번 시스템 설계
|
||||
|
||||
## 1. 개요
|
||||
|
||||
리포트 관리 시스템에 체계적인 문서 번호 자동 채번 시스템을 추가하여, 기업 환경에서 문서를 추적하고 관리할 수 있도록 합니다.
|
||||
|
||||
## 2. 문서 번호 형식
|
||||
|
||||
### 2.1 기본 형식
|
||||
|
||||
```
|
||||
{PREFIX}-{YEAR}-{SEQUENCE}
|
||||
예: RPT-2024-0001, INV-2024-0123
|
||||
```
|
||||
|
||||
### 2.2 확장 형식 (선택 사항)
|
||||
|
||||
```
|
||||
{PREFIX}-{DEPT_CODE}-{YEAR}-{SEQUENCE}
|
||||
예: RPT-SALES-2024-0001, INV-FIN-2024-0123
|
||||
```
|
||||
|
||||
### 2.3 구성 요소
|
||||
|
||||
- **PREFIX**: 문서 유형 접두사 (예: RPT, INV, PO, QT)
|
||||
- **DEPT_CODE**: 부서 코드 (선택 사항)
|
||||
- **YEAR**: 연도 (4자리)
|
||||
- **SEQUENCE**: 순차 번호 (0001부터 시작, 자릿수 설정 가능)
|
||||
|
||||
## 3. 데이터베이스 스키마
|
||||
|
||||
### 3.1 문서 번호 규칙 테이블
|
||||
|
||||
```sql
|
||||
-- 문서 번호 규칙 정의
|
||||
CREATE TABLE report_number_rules (
|
||||
rule_id SERIAL PRIMARY KEY,
|
||||
rule_name VARCHAR(100) NOT NULL, -- 규칙 이름
|
||||
prefix VARCHAR(20) NOT NULL, -- 접두사 (RPT, INV 등)
|
||||
use_dept_code BOOLEAN DEFAULT FALSE, -- 부서 코드 사용 여부
|
||||
use_year BOOLEAN DEFAULT TRUE, -- 연도 사용 여부
|
||||
sequence_length INTEGER DEFAULT 4, -- 순차 번호 자릿수
|
||||
reset_period VARCHAR(20) DEFAULT 'YEARLY', -- 초기화 주기 (YEARLY, MONTHLY, NEVER)
|
||||
separator VARCHAR(5) DEFAULT '-', -- 구분자
|
||||
description TEXT, -- 설명
|
||||
is_active BOOLEAN DEFAULT TRUE, -- 활성화 여부
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by VARCHAR(50),
|
||||
updated_by VARCHAR(50)
|
||||
);
|
||||
|
||||
-- 기본 데이터 삽입
|
||||
INSERT INTO report_number_rules (rule_name, prefix, description)
|
||||
VALUES ('리포트 문서 번호', 'RPT', '일반 리포트 문서 번호 규칙');
|
||||
```
|
||||
|
||||
### 3.2 문서 번호 시퀀스 테이블
|
||||
|
||||
```sql
|
||||
-- 문서 번호 시퀀스 관리 (연도/부서별 현재 번호)
|
||||
CREATE TABLE report_number_sequences (
|
||||
sequence_id SERIAL PRIMARY KEY,
|
||||
rule_id INTEGER NOT NULL REFERENCES report_number_rules(rule_id),
|
||||
dept_code VARCHAR(20), -- 부서 코드 (NULL 가능)
|
||||
year INTEGER NOT NULL, -- 연도
|
||||
current_number INTEGER DEFAULT 0, -- 현재 번호
|
||||
last_generated_at TIMESTAMP, -- 마지막 생성 시각
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE (rule_id, dept_code, year) -- 규칙+부서+연도 조합 유니크
|
||||
);
|
||||
```
|
||||
|
||||
### 3.3 리포트 테이블 수정
|
||||
|
||||
```sql
|
||||
-- 기존 report_layout 테이블에 컬럼 추가
|
||||
ALTER TABLE report_layout
|
||||
ADD COLUMN document_number VARCHAR(100), -- 생성된 문서 번호
|
||||
ADD COLUMN number_rule_id INTEGER REFERENCES report_number_rules(rule_id), -- 사용된 규칙
|
||||
ADD COLUMN number_generated_at TIMESTAMP; -- 번호 생성 시각
|
||||
|
||||
-- 문서 번호 인덱스 (검색 성능)
|
||||
CREATE INDEX idx_report_layout_document_number ON report_layout(document_number);
|
||||
```
|
||||
|
||||
### 3.4 문서 번호 이력 테이블 (감사용)
|
||||
|
||||
```sql
|
||||
-- 문서 번호 생성 이력
|
||||
CREATE TABLE report_number_history (
|
||||
history_id SERIAL PRIMARY KEY,
|
||||
report_id INTEGER REFERENCES report_layout(id),
|
||||
document_number VARCHAR(100) NOT NULL,
|
||||
rule_id INTEGER REFERENCES report_number_rules(rule_id),
|
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
generated_by VARCHAR(50),
|
||||
is_voided BOOLEAN DEFAULT FALSE, -- 번호 무효화 여부
|
||||
void_reason TEXT, -- 무효화 사유
|
||||
voided_at TIMESTAMP,
|
||||
voided_by VARCHAR(50)
|
||||
);
|
||||
|
||||
-- 문서 번호로 검색 인덱스
|
||||
CREATE INDEX idx_report_number_history_doc_number ON report_number_history(document_number);
|
||||
```
|
||||
|
||||
## 4. 백엔드 구현
|
||||
|
||||
### 4.1 서비스 레이어 (`reportNumberService.ts`)
|
||||
|
||||
```typescript
|
||||
export class ReportNumberService {
|
||||
// 문서 번호 생성
|
||||
static async generateNumber(
|
||||
ruleId: number,
|
||||
deptCode?: string
|
||||
): Promise<string>;
|
||||
|
||||
// 문서 번호 형식 검증
|
||||
static async validateNumber(documentNumber: string): Promise<boolean>;
|
||||
|
||||
// 문서 번호 중복 체크
|
||||
static async isDuplicate(documentNumber: string): Promise<boolean>;
|
||||
|
||||
// 문서 번호 무효화
|
||||
static async voidNumber(
|
||||
documentNumber: string,
|
||||
reason: string,
|
||||
userId: string
|
||||
): Promise<void>;
|
||||
|
||||
// 특정 규칙의 다음 번호 미리보기
|
||||
static async previewNextNumber(
|
||||
ruleId: number,
|
||||
deptCode?: string
|
||||
): Promise<string>;
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 컨트롤러 (`reportNumberController.ts`)
|
||||
|
||||
```typescript
|
||||
// GET /api/report/number-rules - 규칙 목록
|
||||
// GET /api/report/number-rules/:id - 규칙 상세
|
||||
// POST /api/report/number-rules - 규칙 생성
|
||||
// PUT /api/report/number-rules/:id - 규칙 수정
|
||||
// DELETE /api/report/number-rules/:id - 규칙 삭제
|
||||
|
||||
// POST /api/report/:reportId/generate-number - 문서 번호 생성
|
||||
// POST /api/report/number/preview - 다음 번호 미리보기
|
||||
// POST /api/report/number/void - 문서 번호 무효화
|
||||
// GET /api/report/number/history/:documentNumber - 문서 번호 이력
|
||||
```
|
||||
|
||||
### 4.3 핵심 로직 (번호 생성)
|
||||
|
||||
```typescript
|
||||
async generateNumber(ruleId: number, deptCode?: string): Promise<string> {
|
||||
// 1. 트랜잭션 시작
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// 2. 규칙 조회
|
||||
const rule = await this.getRule(ruleId);
|
||||
|
||||
// 3. 현재 연도/월
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
|
||||
// 4. 시퀀스 조회 또는 생성 (FOR UPDATE로 락)
|
||||
let sequence = await this.getSequence(ruleId, deptCode, year, true);
|
||||
|
||||
if (!sequence) {
|
||||
sequence = await this.createSequence(ruleId, deptCode, year);
|
||||
}
|
||||
|
||||
// 5. 다음 번호 계산
|
||||
const nextNumber = sequence.current_number + 1;
|
||||
|
||||
// 6. 문서 번호 생성
|
||||
const documentNumber = this.formatNumber(rule, deptCode, year, nextNumber);
|
||||
|
||||
// 7. 시퀀스 업데이트
|
||||
await this.updateSequence(sequence.sequence_id, nextNumber);
|
||||
|
||||
// 8. 커밋
|
||||
await client.query('COMMIT');
|
||||
|
||||
return documentNumber;
|
||||
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 번호 포맷팅
|
||||
private formatNumber(
|
||||
rule: NumberRule,
|
||||
deptCode: string | undefined,
|
||||
year: number,
|
||||
sequence: number
|
||||
): string {
|
||||
const parts = [rule.prefix];
|
||||
|
||||
if (rule.use_dept_code && deptCode) {
|
||||
parts.push(deptCode);
|
||||
}
|
||||
|
||||
if (rule.use_year) {
|
||||
parts.push(year.toString());
|
||||
}
|
||||
|
||||
// 0 패딩
|
||||
const paddedSequence = sequence.toString().padStart(rule.sequence_length, '0');
|
||||
parts.push(paddedSequence);
|
||||
|
||||
return parts.join(rule.separator);
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 프론트엔드 구현
|
||||
|
||||
### 5.1 문서 번호 규칙 관리 화면
|
||||
|
||||
**경로**: `/admin/report/number-rules`
|
||||
|
||||
**기능**:
|
||||
|
||||
- 규칙 목록 조회
|
||||
- 규칙 생성/수정/삭제
|
||||
- 규칙 미리보기 (다음 번호 확인)
|
||||
- 규칙 활성화/비활성화
|
||||
|
||||
### 5.2 리포트 목록 화면 수정
|
||||
|
||||
**변경 사항**:
|
||||
|
||||
- 문서 번호 컬럼 추가
|
||||
- 문서 번호로 검색 기능
|
||||
|
||||
### 5.3 리포트 저장 시 번호 생성
|
||||
|
||||
**위치**: `ReportDesignerContext.tsx` - `saveLayout` 함수
|
||||
|
||||
```typescript
|
||||
const saveLayout = async () => {
|
||||
// 1. 새 리포트인 경우 문서 번호 자동 생성
|
||||
if (reportId === "new" && !documentNumber) {
|
||||
const response = await fetch(`/api/report/generate-number`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ruleId: 1 }), // 기본 규칙
|
||||
});
|
||||
const { documentNumber: newNumber } = await response.json();
|
||||
setDocumentNumber(newNumber);
|
||||
}
|
||||
|
||||
// 2. 리포트 저장 (문서 번호 포함)
|
||||
await saveReport({ ...reportData, documentNumber });
|
||||
};
|
||||
```
|
||||
|
||||
### 5.4 문서 번호 표시 UI
|
||||
|
||||
**위치**: 디자이너 헤더
|
||||
|
||||
```tsx
|
||||
<div className="document-number">
|
||||
<Label>문서 번호</Label>
|
||||
<Badge variant="outline">{documentNumber || "저장 시 자동 생성"}</Badge>
|
||||
</div>
|
||||
```
|
||||
|
||||
## 6. 동시성 제어
|
||||
|
||||
### 6.1 문제점
|
||||
|
||||
여러 사용자가 동시에 문서 번호를 생성할 때 중복 발생 가능성
|
||||
|
||||
### 6.2 해결 방법
|
||||
|
||||
**PostgreSQL의 `FOR UPDATE` 사용**
|
||||
|
||||
```sql
|
||||
-- 시퀀스 조회 시 행 락 걸기
|
||||
SELECT * FROM report_number_sequences
|
||||
WHERE rule_id = $1 AND year = $2
|
||||
FOR UPDATE;
|
||||
```
|
||||
|
||||
**트랜잭션 격리 수준**
|
||||
|
||||
```typescript
|
||||
await client.query("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE");
|
||||
```
|
||||
|
||||
## 7. 테스트 시나리오
|
||||
|
||||
### 7.1 기본 기능 테스트
|
||||
|
||||
- [ ] 규칙 생성 → 문서 번호 생성 → 포맷 확인
|
||||
- [ ] 연속 생성 시 순차 번호 증가 확인
|
||||
- [ ] 연도 변경 시 시퀀스 초기화 확인
|
||||
|
||||
### 7.2 동시성 테스트
|
||||
|
||||
- [ ] 10명이 동시에 문서 번호 생성 → 중복 없음 확인
|
||||
- [ ] 동일 규칙으로 100개 생성 → 순차 번호 연속성 확인
|
||||
|
||||
### 7.3 에러 처리
|
||||
|
||||
- [ ] 존재하지 않는 규칙 ID → 에러 메시지
|
||||
- [ ] 비활성화된 규칙 사용 → 경고 메시지
|
||||
- [ ] 시퀀스 최대값 초과 → 관리자 알림
|
||||
|
||||
## 8. 구현 순서
|
||||
|
||||
### Phase 1: 데이터베이스 (1단계)
|
||||
|
||||
1. 테이블 생성 SQL 작성
|
||||
2. 마이그레이션 실행
|
||||
3. 기본 데이터 삽입
|
||||
|
||||
### Phase 2: 백엔드 (2단계)
|
||||
|
||||
1. `reportNumberService.ts` 구현
|
||||
2. `reportNumberController.ts` 구현
|
||||
3. 라우트 추가
|
||||
4. 단위 테스트
|
||||
|
||||
### Phase 3: 프론트엔드 (3단계)
|
||||
|
||||
1. 문서 번호 규칙 관리 화면
|
||||
2. 리포트 목록 화면 수정
|
||||
3. 디자이너 문서 번호 표시
|
||||
4. 저장 시 자동 생성 연동
|
||||
|
||||
### Phase 4: 테스트 및 최적화 (4단계)
|
||||
|
||||
1. 통합 테스트
|
||||
2. 동시성 테스트
|
||||
3. 성능 최적화
|
||||
4. 사용자 가이드 작성
|
||||
|
||||
## 9. 향후 확장
|
||||
|
||||
### 9.1 고급 기능
|
||||
|
||||
- 문서 번호 예약 기능
|
||||
- 번호 건너뛰기 허용 설정
|
||||
- 커스텀 포맷 지원 (정규식 기반)
|
||||
- 연/월/일 단위 초기화 선택
|
||||
|
||||
### 9.2 통합
|
||||
|
||||
- 승인 완료 시점에 최종 번호 확정
|
||||
- 외부 시스템과 번호 동기화
|
||||
- 바코드/QR 코드 자동 생성
|
||||
|
||||
## 10. 보안 고려사항
|
||||
|
||||
- 문서 번호 생성 권한 제한
|
||||
- 번호 무효화 감사 로그
|
||||
- 시퀀스 직접 수정 방지
|
||||
- API 호출 횟수 제한 (Rate Limiting)
|
||||
|
||||
|
|
@ -0,0 +1,388 @@
|
|||
# 리포트 페이지 관리 시스템 설계
|
||||
|
||||
## 1. 개요
|
||||
|
||||
리포트 디자이너에 다중 페이지 관리 기능을 추가하여 여러 페이지에 걸친 복잡한 문서를 작성할 수 있도록 합니다.
|
||||
|
||||
## 2. 주요 기능
|
||||
|
||||
### 2.1 페이지 관리
|
||||
|
||||
- 페이지 추가/삭제
|
||||
- 페이지 복사
|
||||
- 페이지 순서 변경 (드래그 앤 드롭)
|
||||
- 페이지 이름 지정
|
||||
|
||||
### 2.2 페이지 네비게이션
|
||||
|
||||
- 좌측 페이지 썸네일 패널
|
||||
- 페이지 간 전환 (클릭)
|
||||
- 이전/다음 페이지 이동
|
||||
- 페이지 번호 표시
|
||||
|
||||
### 2.3 페이지별 설정
|
||||
|
||||
- 페이지 크기 (A4, A3, Letter, 사용자 정의)
|
||||
- 페이지 방향 (세로/가로)
|
||||
- 여백 설정
|
||||
- 배경색
|
||||
|
||||
### 2.4 컴포넌트 관리
|
||||
|
||||
- 컴포넌트는 특정 페이지에 속함
|
||||
- 페이지 간 컴포넌트 복사/이동
|
||||
- 현재 페이지의 컴포넌트만 표시
|
||||
|
||||
## 3. 데이터베이스 스키마
|
||||
|
||||
### 3.1 기존 구조 활용 (변경 없음)
|
||||
|
||||
**report_layout 테이블의 layout_config (JSONB) 활용**
|
||||
|
||||
기존:
|
||||
|
||||
```json
|
||||
{
|
||||
"width": 210,
|
||||
"height": 297,
|
||||
"orientation": "portrait",
|
||||
"components": [...]
|
||||
}
|
||||
```
|
||||
|
||||
변경 후:
|
||||
|
||||
```json
|
||||
{
|
||||
"pages": [
|
||||
{
|
||||
"page_id": "page-uuid-1",
|
||||
"page_name": "표지",
|
||||
"page_order": 0,
|
||||
"width": 210,
|
||||
"height": 297,
|
||||
"orientation": "portrait",
|
||||
"margins": {
|
||||
"top": 20,
|
||||
"bottom": 20,
|
||||
"left": 20,
|
||||
"right": 20
|
||||
},
|
||||
"background_color": "#ffffff",
|
||||
"components": [
|
||||
{
|
||||
"id": "comp-1",
|
||||
"type": "text",
|
||||
"x": 100,
|
||||
"y": 50,
|
||||
...
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"page_id": "page-uuid-2",
|
||||
"page_name": "본문",
|
||||
"page_order": 1,
|
||||
"width": 210,
|
||||
"height": 297,
|
||||
"orientation": "portrait",
|
||||
"margins": { "top": 20, "bottom": 20, "left": 20, "right": 20 },
|
||||
"background_color": "#ffffff",
|
||||
"components": [...]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 마이그레이션 전략
|
||||
|
||||
기존 단일 페이지 리포트 자동 변환:
|
||||
|
||||
```typescript
|
||||
// 기존 구조 감지 시
|
||||
if (layoutConfig.components && !layoutConfig.pages) {
|
||||
// 자동으로 pages 구조로 변환
|
||||
layoutConfig = {
|
||||
pages: [
|
||||
{
|
||||
page_id: uuidv4(),
|
||||
page_name: "페이지 1",
|
||||
page_order: 0,
|
||||
width: layoutConfig.width || 210,
|
||||
height: layoutConfig.height || 297,
|
||||
orientation: layoutConfig.orientation || "portrait",
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
background_color: "#ffffff",
|
||||
components: layoutConfig.components,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 프론트엔드 구조
|
||||
|
||||
### 4.1 타입 정의 (types/report.ts)
|
||||
|
||||
```typescript
|
||||
export interface ReportPage {
|
||||
page_id: string;
|
||||
report_id: string;
|
||||
page_order: number;
|
||||
page_name: string;
|
||||
|
||||
// 페이지 설정
|
||||
width: number;
|
||||
height: number;
|
||||
orientation: 'portrait' | 'landscape';
|
||||
|
||||
// 여백
|
||||
margin_top: number;
|
||||
margin_bottom: number;
|
||||
margin_left: number;
|
||||
margin_right: number;
|
||||
|
||||
// 배경
|
||||
background_color: string;
|
||||
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface ComponentConfig {
|
||||
id: string;
|
||||
// page_id 불필요 (페이지의 components 배열에 포함됨)
|
||||
type: 'text' | 'label' | 'image' | 'table' | ...;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
// ... 기타 속성
|
||||
}
|
||||
|
||||
export interface ReportLayoutConfig {
|
||||
pages: ReportPage[];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 Context 구조 변경
|
||||
|
||||
```typescript
|
||||
interface ReportDesignerContextType {
|
||||
// 페이지 관리
|
||||
pages: ReportPage[];
|
||||
currentPageId: string | null;
|
||||
currentPage: ReportPage | null;
|
||||
|
||||
addPage: () => void;
|
||||
deletePage: (pageId: string) => void;
|
||||
duplicatePage: (pageId: string) => void;
|
||||
reorderPages: (sourceIndex: number, targetIndex: number) => void;
|
||||
selectPage: (pageId: string) => void;
|
||||
updatePage: (pageId: string, updates: Partial<ReportPage>) => void;
|
||||
|
||||
// 컴포넌트 (현재 페이지만)
|
||||
currentPageComponents: ComponentConfig[];
|
||||
|
||||
// ... 기존 기능들
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 UI 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ ReportDesignerToolbar (저장, 미리보기, 페이지 추가 등) │
|
||||
├──────────┬────────────────────────────────────┬─────────────┤
|
||||
│ │ │ │
|
||||
│ PageList │ ReportDesignerCanvas │ Right │
|
||||
│ (좌측) │ (현재 페이지만 표시) │ Panel │
|
||||
│ │ │ (속성) │
|
||||
│ - Page 1 │ ┌──────────────────────────┐ │ │
|
||||
│ - Page 2 │ │ │ │ │
|
||||
│ * Page 3 │ │ [컴포넌트들] │ │ │
|
||||
│ (현재) │ │ │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ [+ 추가] │ │ │
|
||||
│ │ 이전 | 다음 (페이지 네비게이션) │ │
|
||||
└──────────┴────────────────────────────────────┴─────────────┘
|
||||
```
|
||||
|
||||
## 5. 컴포넌트 구조
|
||||
|
||||
### 5.1 새 컴포넌트
|
||||
|
||||
#### PageListPanel.tsx
|
||||
|
||||
```typescript
|
||||
- 좌측 페이지 목록 패널
|
||||
- 페이지 썸네일 표시
|
||||
- 드래그 앤 드롭으로 순서 변경
|
||||
- 페이지 추가/삭제/복사 버튼
|
||||
- 현재 페이지 하이라이트
|
||||
```
|
||||
|
||||
#### PageNavigator.tsx
|
||||
|
||||
```typescript
|
||||
- 캔버스 하단의 페이지 네비게이션
|
||||
- 이전/다음 버튼
|
||||
- 현재 페이지 번호 표시
|
||||
- 페이지 점프 (1/5 형식)
|
||||
```
|
||||
|
||||
#### PageSettingsPanel.tsx
|
||||
|
||||
```typescript
|
||||
- 우측 패널 내 페이지 설정 섹션
|
||||
- 페이지 크기, 방향
|
||||
- 여백 설정
|
||||
- 배경색
|
||||
```
|
||||
|
||||
### 5.2 수정할 컴포넌트
|
||||
|
||||
#### ReportDesignerContext.tsx
|
||||
|
||||
- pages 상태 추가
|
||||
- currentPageId 상태 추가
|
||||
- 페이지 관리 함수들 추가
|
||||
- components를 currentPageComponents로 필터링
|
||||
|
||||
#### ReportDesignerCanvas.tsx
|
||||
|
||||
- currentPageComponents만 렌더링
|
||||
- 캔버스 크기를 currentPage 기준으로 설정
|
||||
- 컴포넌트 추가 시 page_id 포함
|
||||
|
||||
#### ReportDesignerToolbar.tsx
|
||||
|
||||
- "페이지 추가" 버튼 추가
|
||||
- 저장 시 pages도 함께 저장
|
||||
|
||||
#### ReportPreviewModal.tsx
|
||||
|
||||
- 모든 페이지 순서대로 미리보기
|
||||
- 페이지 구분선 표시
|
||||
- PDF 저장 시 모든 페이지 포함
|
||||
|
||||
## 6. API 엔드포인트
|
||||
|
||||
### 6.1 페이지 관리
|
||||
|
||||
```typescript
|
||||
// 페이지 목록 조회
|
||||
GET /api/report/:reportId/pages
|
||||
Response: { pages: ReportPage[] }
|
||||
|
||||
// 페이지 생성
|
||||
POST /api/report/:reportId/pages
|
||||
Body: { page_name, width, height, orientation, margins }
|
||||
Response: { page: ReportPage }
|
||||
|
||||
// 페이지 수정
|
||||
PUT /api/report/pages/:pageId
|
||||
Body: Partial<ReportPage>
|
||||
Response: { page: ReportPage }
|
||||
|
||||
// 페이지 삭제
|
||||
DELETE /api/report/pages/:pageId
|
||||
Response: { success: boolean }
|
||||
|
||||
// 페이지 순서 변경
|
||||
PUT /api/report/:reportId/pages/reorder
|
||||
Body: { pageOrders: Array<{ page_id, page_order }> }
|
||||
Response: { success: boolean }
|
||||
|
||||
// 페이지 복사
|
||||
POST /api/report/pages/:pageId/duplicate
|
||||
Response: { page: ReportPage }
|
||||
```
|
||||
|
||||
### 6.2 레이아웃 (기존 수정)
|
||||
|
||||
```typescript
|
||||
// 레이아웃 저장 (페이지별)
|
||||
PUT /api/report/:reportId/layout
|
||||
Body: {
|
||||
pages: ReportPage[],
|
||||
components: ComponentConfig[] // page_id 포함
|
||||
}
|
||||
```
|
||||
|
||||
## 7. 구현 단계
|
||||
|
||||
### Phase 1: DB 및 백엔드 (0.5일)
|
||||
|
||||
1. ✅ DB 스키마 생성
|
||||
2. ✅ API 엔드포인트 구현
|
||||
3. ✅ 기존 리포트 마이그레이션 (단일 페이지 생성)
|
||||
|
||||
### Phase 2: 타입 및 Context (0.5일)
|
||||
|
||||
1. ✅ 타입 정의 업데이트
|
||||
2. ✅ Context에 페이지 상태/함수 추가
|
||||
3. ✅ API 연동
|
||||
|
||||
### Phase 3: UI 컴포넌트 (1일)
|
||||
|
||||
1. ✅ PageListPanel 구현
|
||||
2. ✅ PageNavigator 구현
|
||||
3. ✅ PageSettingsPanel 구현
|
||||
|
||||
### Phase 4: 통합 및 수정 (1일)
|
||||
|
||||
1. ✅ Canvas에서 현재 페이지만 표시
|
||||
2. ✅ 컴포넌트 추가/수정 시 page_id 처리
|
||||
3. ✅ 미리보기에서 모든 페이지 표시
|
||||
4. ✅ PDF/WORD 저장에서 모든 페이지 처리
|
||||
|
||||
### Phase 5: 테스트 및 최적화 (0.5일)
|
||||
|
||||
1. ✅ 페이지 전환 성능 확인
|
||||
2. ✅ 썸네일 렌더링 최적화
|
||||
3. ✅ 버그 수정
|
||||
|
||||
**총 예상 기간: 3-4일**
|
||||
|
||||
## 8. 주의사항
|
||||
|
||||
### 8.1 성능 최적화
|
||||
|
||||
- 페이지 썸네일은 저해상도로 렌더링
|
||||
- 현재 페이지 컴포넌트만 DOM에 유지
|
||||
- 페이지 전환 시 애니메이션 최소화
|
||||
|
||||
### 8.2 호환성
|
||||
|
||||
- 기존 리포트는 자동으로 단일 페이지로 마이그레이션
|
||||
- 템플릿도 페이지 구조 포함
|
||||
|
||||
### 8.3 사용자 경험
|
||||
|
||||
- 페이지 삭제 시 확인 다이얼로그
|
||||
- 컴포넌트가 있는 페이지 삭제 시 경고
|
||||
- 페이지 순서 변경 시 즉시 반영
|
||||
|
||||
## 9. 추후 확장 기능
|
||||
|
||||
### 9.1 페이지 템플릿
|
||||
|
||||
- 자주 사용하는 페이지 레이아웃 저장
|
||||
- 페이지 추가 시 템플릿 선택
|
||||
|
||||
### 9.2 마스터 페이지
|
||||
|
||||
- 모든 페이지에 공통으로 적용되는 헤더/푸터
|
||||
- 페이지 번호 자동 삽입
|
||||
|
||||
### 9.3 페이지 연결
|
||||
|
||||
- 테이블 데이터가 여러 페이지에 자동 분할
|
||||
- 페이지 오버플로우 처리
|
||||
|
||||
## 10. 참고 자료
|
||||
|
||||
- 오즈리포트 메뉴얼
|
||||
- Crystal Reports 페이지 관리
|
||||
- Adobe InDesign 페이지 시스템
|
||||
|
|
@ -0,0 +1,491 @@
|
|||
# 외부 DB 연결 풀 관리 가이드
|
||||
|
||||
## 📋 개요
|
||||
|
||||
외부 DB 연결 풀 서비스는 여러 외부 데이터베이스와의 연결을 효율적으로 관리하여 **연결 풀 고갈을 방지**하고 성능을 최적화합니다.
|
||||
|
||||
### 주요 기능
|
||||
|
||||
- ✅ **자동 연결 풀 관리**: 연결 생성, 재사용, 정리 자동화
|
||||
- ✅ **연결 풀 고갈 방지**: 최대 연결 수 제한 및 모니터링
|
||||
- ✅ **유휴 연결 정리**: 10분 이상 사용되지 않은 풀 자동 제거
|
||||
- ✅ **헬스 체크**: 1분마다 모든 풀 상태 검사
|
||||
- ✅ **다중 DB 지원**: PostgreSQL, MySQL, MariaDB
|
||||
- ✅ **싱글톤 패턴**: 전역적으로 단일 인스턴스 사용
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ NodeFlowExecutionService │
|
||||
│ (외부 DB 소스/액션 노드) │
|
||||
└──────────────┬──────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ExternalDbConnectionPoolService │
|
||||
│ (싱글톤 인스턴스) │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────┐ │
|
||||
│ │ Connection Pool Map │ │
|
||||
│ │ ┌──────────────────────────┐ │ │
|
||||
│ │ │ ID: 1 → PostgresPool │ │ │
|
||||
│ │ │ ID: 2 → MySQLPool │ │ │
|
||||
│ │ │ ID: 3 → MariaDBPool │ │ │
|
||||
│ │ └──────────────────────────┘ │ │
|
||||
│ └─────────────────────────────────┘ │
|
||||
│ │
|
||||
│ - 자동 풀 생성/제거 │
|
||||
│ - 헬스 체크 (1분마다) │
|
||||
│ - 유휴 풀 정리 (10분) │
|
||||
└─────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────┐
|
||||
│ External Databases │
|
||||
│ - PostgreSQL │
|
||||
│ - MySQL │
|
||||
│ - MariaDB │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 연결 풀 설정
|
||||
|
||||
### PostgreSQL 연결 풀
|
||||
|
||||
```typescript
|
||||
{
|
||||
max: 10, // 최대 연결 수
|
||||
min: 2, // 최소 연결 수
|
||||
idleTimeoutMillis: 30000, // 30초 유휴 시 연결 해제
|
||||
connectionTimeoutMillis: 30000, // 연결 타임아웃 30초
|
||||
statement_timeout: 60000, // 쿼리 타임아웃 60초
|
||||
}
|
||||
```
|
||||
|
||||
### MySQL/MariaDB 연결 풀
|
||||
|
||||
```typescript
|
||||
{
|
||||
connectionLimit: 10, // 최대 연결 수
|
||||
waitForConnections: true,
|
||||
queueLimit: 0, // 대기열 무제한
|
||||
connectTimeout: 30000, // 연결 타임아웃 30초
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 연결 풀 라이프사이클
|
||||
|
||||
### 1. 풀 생성
|
||||
|
||||
```typescript
|
||||
// 첫 요청 시 자동 생성
|
||||
const pool = await poolService.getPool(connectionId);
|
||||
```
|
||||
|
||||
**생성 시점**:
|
||||
|
||||
- 외부 DB 소스 노드 첫 실행 시
|
||||
- 외부 DB 액션 노드 첫 실행 시
|
||||
|
||||
**생성 과정**:
|
||||
|
||||
1. DB 연결 정보 조회 (`external_db_connections` 테이블)
|
||||
2. 비밀번호 복호화
|
||||
3. DB 타입에 맞는 연결 풀 생성 (PostgreSQL, MySQL, MariaDB)
|
||||
4. 이벤트 리스너 등록 (연결 획득/해제 추적)
|
||||
|
||||
### 2. 풀 재사용
|
||||
|
||||
```typescript
|
||||
// 기존 풀이 있으면 재사용
|
||||
if (this.pools.has(connectionId)) {
|
||||
const pool = this.pools.get(connectionId)!;
|
||||
pool.lastUsedAt = new Date(); // 사용 시간 갱신
|
||||
return pool;
|
||||
}
|
||||
```
|
||||
|
||||
**재사용 조건**:
|
||||
|
||||
- 동일한 `connectionId`로 요청
|
||||
- 풀이 정상 상태 (`isHealthy()` 통과)
|
||||
|
||||
### 3. 자동 정리
|
||||
|
||||
**유휴 시간 초과 (10분)**:
|
||||
|
||||
```typescript
|
||||
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10분
|
||||
|
||||
if (now - pool.lastUsedAt.getTime() > IDLE_TIMEOUT) {
|
||||
await this.removePool(connectionId);
|
||||
}
|
||||
```
|
||||
|
||||
**헬스 체크 실패**:
|
||||
|
||||
```typescript
|
||||
if (!pool.isHealthy()) {
|
||||
await this.removePool(connectionId);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔍 헬스 체크 시스템
|
||||
|
||||
### 주기적 헬스 체크
|
||||
|
||||
```typescript
|
||||
const HEALTH_CHECK_INTERVAL = 60 * 1000; // 1분마다
|
||||
|
||||
setInterval(() => {
|
||||
this.pools.forEach(async (pool, connectionId) => {
|
||||
// 유휴 시간 체크
|
||||
const idleTime = now - pool.lastUsedAt.getTime();
|
||||
if (idleTime > IDLE_TIMEOUT) {
|
||||
await this.removePool(connectionId);
|
||||
}
|
||||
|
||||
// 헬스 체크
|
||||
if (!pool.isHealthy()) {
|
||||
await this.removePool(connectionId);
|
||||
}
|
||||
});
|
||||
}, HEALTH_CHECK_INTERVAL);
|
||||
```
|
||||
|
||||
### 헬스 체크 조건
|
||||
|
||||
#### PostgreSQL
|
||||
|
||||
```typescript
|
||||
isHealthy(): boolean {
|
||||
return this.pool.totalCount > 0
|
||||
&& this.activeConnections < this.maxConnections;
|
||||
}
|
||||
```
|
||||
|
||||
#### MySQL/MariaDB
|
||||
|
||||
```typescript
|
||||
isHealthy(): boolean {
|
||||
return this.activeConnections < this.maxConnections;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💻 사용 방법
|
||||
|
||||
### 1. 외부 DB 소스 노드에서 사용
|
||||
|
||||
```typescript
|
||||
// nodeFlowExecutionService.ts
|
||||
private static async executeExternalDBSource(
|
||||
node: FlowNode,
|
||||
context: ExecutionContext
|
||||
): Promise<any[]> {
|
||||
const { connectionId, tableName } = node.data;
|
||||
|
||||
// 연결 풀 서비스 사용
|
||||
const { ExternalDbConnectionPoolService } = await import(
|
||||
"./externalDbConnectionPoolService"
|
||||
);
|
||||
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||
|
||||
const sql = `SELECT * FROM ${tableName}`;
|
||||
const result = await poolService.executeQuery(connectionId, sql);
|
||||
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 외부 DB 액션 노드에서 사용
|
||||
|
||||
```typescript
|
||||
// 기존 createExternalConnector가 자동으로 연결 풀 사용
|
||||
const connector = await this.createExternalConnector(connectionId, dbType);
|
||||
|
||||
// executeQuery 호출 시 내부적으로 연결 풀 사용
|
||||
const result = await connector.executeQuery(sql, params);
|
||||
```
|
||||
|
||||
### 3. 연결 풀 상태 조회
|
||||
|
||||
**API 엔드포인트**:
|
||||
|
||||
```
|
||||
GET /api/external-db-connections/pool-status
|
||||
```
|
||||
|
||||
**응답 예시**:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"totalPools": 3,
|
||||
"activePools": 2,
|
||||
"pools": [
|
||||
{
|
||||
"connectionId": 1,
|
||||
"dbType": "postgresql",
|
||||
"activeConnections": 2,
|
||||
"maxConnections": 10,
|
||||
"createdAt": "2025-01-13T10:00:00.000Z",
|
||||
"lastUsedAt": "2025-01-13T10:05:00.000Z",
|
||||
"idleSeconds": 45
|
||||
},
|
||||
{
|
||||
"connectionId": 2,
|
||||
"dbType": "mysql",
|
||||
"activeConnections": 0,
|
||||
"maxConnections": 10,
|
||||
"createdAt": "2025-01-13T09:50:00.000Z",
|
||||
"lastUsedAt": "2025-01-13T09:55:00.000Z",
|
||||
"idleSeconds": 600
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": "3개의 연결 풀 상태를 조회했습니다."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🚨 연결 풀 고갈 방지 메커니즘
|
||||
|
||||
### 1. 최대 연결 수 제한
|
||||
|
||||
```typescript
|
||||
// 데이터베이스 설정 기준
|
||||
max_connections: config.max_connections || 10;
|
||||
```
|
||||
|
||||
각 외부 DB 연결마다 최대 연결 수를 설정하여 무제한 연결 방지.
|
||||
|
||||
### 2. 연결 재사용
|
||||
|
||||
```typescript
|
||||
// 동일한 connectionId 요청 시 기존 풀 재사용
|
||||
const pool = await poolService.getPool(connectionId);
|
||||
```
|
||||
|
||||
매번 새 연결을 생성하지 않고 기존 풀 재사용.
|
||||
|
||||
### 3. 자동 연결 해제
|
||||
|
||||
```typescript
|
||||
// PostgreSQL: 30초 유휴 시 자동 해제
|
||||
idleTimeoutMillis: 30000;
|
||||
```
|
||||
|
||||
사용되지 않는 연결은 자동으로 해제하여 리소스 절약.
|
||||
|
||||
### 4. 전역 풀 정리
|
||||
|
||||
```typescript
|
||||
// 10분 이상 미사용 풀 제거
|
||||
if (idleTime > IDLE_TIMEOUT) {
|
||||
await this.removePool(connectionId);
|
||||
}
|
||||
```
|
||||
|
||||
장시간 사용되지 않는 풀 자체를 제거.
|
||||
|
||||
### 5. 애플리케이션 종료 시 정리
|
||||
|
||||
```typescript
|
||||
process.on("SIGINT", async () => {
|
||||
await ExternalDbConnectionPoolService.getInstance().closeAll();
|
||||
process.exit(0);
|
||||
});
|
||||
```
|
||||
|
||||
프로세스 종료 시 모든 연결 정상 종료.
|
||||
|
||||
---
|
||||
|
||||
## 📈 모니터링 및 로깅
|
||||
|
||||
### 연결 이벤트 로깅
|
||||
|
||||
```typescript
|
||||
// 연결 획득
|
||||
pool.on("acquire", () => {
|
||||
logger.debug(`[PostgreSQL] 연결 획득 (2/10)`);
|
||||
});
|
||||
|
||||
// 연결 반환
|
||||
pool.on("release", () => {
|
||||
logger.debug(`[PostgreSQL] 연결 반환 (1/10)`);
|
||||
});
|
||||
|
||||
// 에러 발생
|
||||
pool.on("error", (err) => {
|
||||
logger.error(`[PostgreSQL] 연결 풀 오류:`, err);
|
||||
});
|
||||
```
|
||||
|
||||
### 정기 상태 로깅
|
||||
|
||||
```typescript
|
||||
// 1분마다 상태 출력
|
||||
logger.debug(`📊 연결 풀 상태: 총 3개, 활성: 2개`);
|
||||
```
|
||||
|
||||
### 주요 로그 메시지
|
||||
|
||||
| 레벨 | 메시지 | 의미 |
|
||||
| ------- | ---------------------------------------------------------- | --------------- |
|
||||
| `info` | `🔧 새 연결 풀 생성 중 (ID: 1)...` | 새 풀 생성 시작 |
|
||||
| `info` | `✅ 연결 풀 생성 완료 (ID: 1, 타입: postgresql, 최대: 10)` | 풀 생성 완료 |
|
||||
| `debug` | `✅ 기존 연결 풀 재사용 (ID: 1)` | 기존 풀 재사용 |
|
||||
| `info` | `🧹 유휴 연결 풀 정리 (ID: 2, 유휴: 620초)` | 유휴 풀 제거 |
|
||||
| `warn` | `⚠️ 연결 풀 비정상 감지 (ID: 3), 재생성 중...` | 헬스 체크 실패 |
|
||||
| `error` | `❌ 쿼리 실행 실패 (ID: 1)` | 쿼리 오류 |
|
||||
|
||||
---
|
||||
|
||||
## 🔒 보안 고려사항
|
||||
|
||||
### 1. 비밀번호 보호
|
||||
|
||||
```typescript
|
||||
// 비밀번호 복호화는 풀 생성 시에만 수행
|
||||
config.password = PasswordEncryption.decrypt(config.password);
|
||||
```
|
||||
|
||||
메모리에 평문 비밀번호를 최소한으로 유지.
|
||||
|
||||
### 2. 연결 정보 검증
|
||||
|
||||
```typescript
|
||||
if (config.is_active !== "Y") {
|
||||
throw new Error(`비활성화된 연결입니다 (ID: ${connectionId})`);
|
||||
}
|
||||
```
|
||||
|
||||
비활성화된 연결은 사용 불가.
|
||||
|
||||
### 3. 타임아웃 설정
|
||||
|
||||
```typescript
|
||||
connectionTimeoutMillis: 30000, // 30초
|
||||
statement_timeout: 60000, // 60초
|
||||
```
|
||||
|
||||
무한 대기 방지.
|
||||
|
||||
---
|
||||
|
||||
## 🐛 트러블슈팅
|
||||
|
||||
### 문제 1: 연결 풀 고갈
|
||||
|
||||
**증상**: "Connection pool exhausted" 오류
|
||||
|
||||
**원인**:
|
||||
|
||||
- 동시 요청이 최대 연결 수 초과
|
||||
- 쿼리가 너무 오래 실행되어 연결 점유
|
||||
|
||||
**해결**:
|
||||
|
||||
1. `max_connections` 값 증가 (`external_db_connections` 테이블)
|
||||
2. 쿼리 최적화 (인덱스, LIMIT 추가)
|
||||
3. `query_timeout` 값 조정
|
||||
|
||||
### 문제 2: 메모리 누수
|
||||
|
||||
**증상**: 메모리 사용량 지속 증가
|
||||
|
||||
**원인**:
|
||||
|
||||
- 연결 풀이 정리되지 않음
|
||||
- 헬스 체크 실패
|
||||
|
||||
**해결**:
|
||||
|
||||
1. 연결 풀 상태 확인: `GET /api/external-db-connections/pool-status`
|
||||
2. 수동 재시작으로 모든 풀 정리
|
||||
3. 로그에서 `🧹 유휴 연결 풀 정리` 메시지 확인
|
||||
|
||||
### 문제 3: 연결 시간 초과
|
||||
|
||||
**증상**: "Connection timeout" 오류
|
||||
|
||||
**원인**:
|
||||
|
||||
- DB 서버 응답 없음
|
||||
- 네트워크 문제
|
||||
- 방화벽 차단
|
||||
|
||||
**해결**:
|
||||
|
||||
1. DB 서버 상태 확인
|
||||
2. 네트워크 연결 확인
|
||||
3. `connection_timeout` 값 증가
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 설정 권장사항
|
||||
|
||||
### 소규모 시스템 (동시 사용자 < 50)
|
||||
|
||||
```typescript
|
||||
{
|
||||
max_connections: 5,
|
||||
connection_timeout: 30,
|
||||
query_timeout: 60,
|
||||
}
|
||||
```
|
||||
|
||||
### 중규모 시스템 (동시 사용자 50-200)
|
||||
|
||||
```typescript
|
||||
{
|
||||
max_connections: 10,
|
||||
connection_timeout: 30,
|
||||
query_timeout: 90,
|
||||
}
|
||||
```
|
||||
|
||||
### 대규모 시스템 (동시 사용자 > 200)
|
||||
|
||||
```typescript
|
||||
{
|
||||
max_connections: 20,
|
||||
connection_timeout: 60,
|
||||
query_timeout: 120,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 참고 자료
|
||||
|
||||
- [PostgreSQL Connection Pooling](https://node-postgres.com/features/pooling)
|
||||
- [MySQL Connection Pool](https://github.com/mysqljs/mysql#pooling-connections)
|
||||
- [Node.js Best Practices - Database Connection Management](https://github.com/goldbergyoni/nodebestpractices)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 결론
|
||||
|
||||
외부 DB 연결 풀 서비스는 다음을 보장합니다:
|
||||
|
||||
✅ **효율성**: 연결 재사용으로 성능 향상
|
||||
✅ **안정성**: 연결 풀 고갈 방지
|
||||
✅ **자동화**: 생성/정리/모니터링 자동화
|
||||
✅ **확장성**: 다중 DB 및 대규모 트래픽 지원
|
||||
|
||||
**최소한의 설정**으로 **최대한의 안정성**을 제공합니다! 🚀
|
||||
|
|
@ -4,7 +4,6 @@ import React, { useState, useEffect } from "react";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
|
|
@ -16,15 +15,8 @@ import {
|
|||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Play,
|
||||
Pause,
|
||||
Edit,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Globe
|
||||
Database
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
|
@ -33,6 +25,7 @@ import {
|
|||
BatchConfig,
|
||||
BatchMapping,
|
||||
} from "@/lib/api/batch";
|
||||
import BatchCard from "@/components/admin/BatchCard";
|
||||
|
||||
export default function BatchManagementPage() {
|
||||
const router = useRouter();
|
||||
|
|
@ -185,7 +178,7 @@ export default function BatchManagementPage() {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="container mx-auto p-4 space-y-2">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -203,7 +196,7 @@ export default function BatchManagementPage() {
|
|||
|
||||
{/* 검색 및 필터 */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<CardContent className="py-2">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
|
||||
|
|
@ -254,100 +247,21 @@ export default function BatchManagementPage() {
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-3">
|
||||
{batchConfigs.map((batch) => (
|
||||
<div key={batch.id} className="border rounded-lg p-6 space-y-4">
|
||||
{/* 배치 기본 정보 */}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<h3 className="text-lg font-semibold">{batch.batch_name}</h3>
|
||||
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'}>
|
||||
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
||||
</Badge>
|
||||
</div>
|
||||
{batch.description && (
|
||||
<p className="text-muted-foreground">{batch.description}</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-4 w-4" />
|
||||
<span>{batch.cron_schedule}</span>
|
||||
</div>
|
||||
<div>
|
||||
생성일: {new Date(batch.created_date).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => executeBatch(batch.id)}
|
||||
disabled={executingBatch === batch.id}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{executingBatch === batch.id ? (
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span>실행</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId: batch.id, currentStatus: batch.is_active });
|
||||
toggleBatchStatus(batch.id, batch.is_active);
|
||||
}}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
{batch.is_active === 'Y' ? (
|
||||
<Pause className="h-4 w-4" />
|
||||
) : (
|
||||
<Play className="h-4 w-4" />
|
||||
)}
|
||||
<span>{batch.is_active === 'Y' ? '비활성화' : '활성화'}</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/admin/batchmng/edit/${batch.id}`)}
|
||||
className="flex items-center space-x-1"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
<span>수정</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => deleteBatch(batch.id, batch.batch_name)}
|
||||
className="flex items-center space-x-1 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span>삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 정보 */}
|
||||
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
매핑 정보 ({batch.batch_mappings.length}개)
|
||||
</h4>
|
||||
<div className="text-sm">
|
||||
{getMappingSummary(batch.batch_mappings)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<BatchCard
|
||||
key={batch.id}
|
||||
batch={batch}
|
||||
executingBatch={executingBatch}
|
||||
onExecute={executeBatch}
|
||||
onToggleStatus={(batchId, currentStatus) => {
|
||||
console.log("🖱️ 비활성화/활성화 버튼 클릭:", { batchId, currentStatus });
|
||||
toggleBatchStatus(batchId, currentStatus);
|
||||
}}
|
||||
onEdit={(batchId) => router.push(`/admin/batchmng/edit/${batchId}`)}
|
||||
onDelete={deleteBatch}
|
||||
getMappingSummary={getMappingSummary}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 제어 시스템 페이지
|
||||
*/
|
||||
|
||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||
|
||||
export default function NodeEditorPage() {
|
||||
return (
|
||||
<div className="h-screen bg-gray-50">
|
||||
{/* 페이지 헤더 */}
|
||||
<div className="border-b bg-white p-4">
|
||||
<div className="mx-auto">
|
||||
<h1 className="text-2xl font-bold text-gray-900">제어 시스템</h1>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에디터 */}
|
||||
<FlowEditor />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -24,13 +24,13 @@ export default function DataFlowPage() {
|
|||
// 단계별 제목과 설명
|
||||
const stepConfig = {
|
||||
list: {
|
||||
title: "데이터 흐름 관계도 관리",
|
||||
description: "생성된 관계도들을 확인하고 관리하세요",
|
||||
title: "데이터 흐름 제어 관리",
|
||||
description: "생성된 제어들을 확인하고 관리하세요",
|
||||
icon: "📊",
|
||||
},
|
||||
design: {
|
||||
title: "새 관계도 설계",
|
||||
description: "테이블 간 데이터 관계를 시각적으로 설계하세요",
|
||||
title: "새 제어 설계",
|
||||
description: "테이블 간 데이터 제어를 시각적으로 설계하세요",
|
||||
icon: "🎨",
|
||||
},
|
||||
};
|
||||
|
|
@ -62,7 +62,7 @@ export default function DataFlowPage() {
|
|||
};
|
||||
|
||||
const handleSave = (relationships: TableRelationship[]) => {
|
||||
console.log("저장된 관계:", relationships);
|
||||
console.log("저장된 제어:", relationships);
|
||||
// 저장 후 목록으로 돌아가기 - 다음 렌더링 사이클로 지연
|
||||
setTimeout(() => {
|
||||
goToStep("list");
|
||||
|
|
@ -71,28 +71,28 @@ export default function DataFlowPage() {
|
|||
}, 0);
|
||||
};
|
||||
|
||||
// 관계도 수정 핸들러
|
||||
// 제어 수정 핸들러
|
||||
const handleDesignDiagram = async (diagram: DataFlowDiagram | null) => {
|
||||
if (diagram) {
|
||||
// 기존 관계도 수정 - 저장된 관계 정보 로드
|
||||
// 기존 제어 수정 - 저장된 제어 정보 로드
|
||||
try {
|
||||
console.log("📖 관계도 수정 모드:", diagram);
|
||||
console.log("📖 제어 수정 모드:", diagram);
|
||||
|
||||
// 저장된 관계 정보 로드
|
||||
// 저장된 제어 정보 로드
|
||||
const relationshipData = await loadDataflowRelationship(diagram.diagramId);
|
||||
console.log("✅ 관계 정보 로드 완료:", relationshipData);
|
||||
console.log("✅ 제어 정보 로드 완료:", relationshipData);
|
||||
|
||||
setEditingDiagram(diagram);
|
||||
setLoadedRelationshipData(relationshipData);
|
||||
goToNextStep("design");
|
||||
|
||||
toast.success(`"${diagram.diagramName}" 관계를 불러왔습니다.`);
|
||||
toast.success(`"${diagram.diagramName}" 제어를 불러왔습니다.`);
|
||||
} catch (error: any) {
|
||||
console.error("❌ 관계 정보 로드 실패:", error);
|
||||
toast.error(error.message || "관계 정보를 불러오는데 실패했습니다.");
|
||||
console.error("❌ 제어 정보 로드 실패:", error);
|
||||
toast.error(error.message || "제어 정보를 불러오는데 실패했습니다.");
|
||||
}
|
||||
} else {
|
||||
// 새 관계도 생성 - 현재 페이지에서 처리
|
||||
// 새 제어 생성 - 현재 페이지에서 처리
|
||||
setEditingDiagram(null);
|
||||
setLoadedRelationshipData(null);
|
||||
goToNextStep("design");
|
||||
|
|
@ -101,21 +101,21 @@ export default function DataFlowPage() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="container mx-auto space-y-4 p-4">
|
||||
<div className="mx-auto space-y-4 px-5 py-4">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-4 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">관계 관리</h1>
|
||||
<p className="mt-2 text-gray-600">테이블 간 데이터 관계를 시각적으로 설계하고 관리합니다</p>
|
||||
<h1 className="text-3xl font-bold text-gray-900">제어 관리</h1>
|
||||
<p className="mt-2 text-gray-600">테이블 간 데이터 제어를 시각적으로 설계하고 관리합니다</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 단계별 내용 */}
|
||||
<div className="space-y-6">
|
||||
{/* 관계도 목록 단계 */}
|
||||
{/* 제어 목록 단계 */}
|
||||
{currentStep === "list" && <DataFlowList onDesignDiagram={handleDesignDiagram} />}
|
||||
|
||||
{/* 관계도 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||
{/* 제어 설계 단계 - 🎨 새로운 UI 사용 */}
|
||||
{currentStep === "design" && (
|
||||
<DataConnectionDesigner
|
||||
onClose={() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { ReportDesignerToolbar } from "@/components/report/designer/ReportDesignerToolbar";
|
||||
import { PageListPanel } from "@/components/report/designer/PageListPanel";
|
||||
import { ReportDesignerLeftPanel } from "@/components/report/designer/ReportDesignerLeftPanel";
|
||||
import { ReportDesignerCanvas } from "@/components/report/designer/ReportDesignerCanvas";
|
||||
import { ReportDesignerRightPanel } from "@/components/report/designer/ReportDesignerRightPanel";
|
||||
import { ReportDesignerProvider } from "@/contexts/ReportDesignerContext";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function ReportDesignerPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const reportId = params.reportId as string;
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
const loadReport = async () => {
|
||||
// 'new'는 새 리포트 생성 모드
|
||||
if (reportId === "new") {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await reportApi.getReportById(reportId);
|
||||
if (!response.success) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "리포트를 찾을 수 없습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
router.push("/admin/report");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트를 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
router.push("/admin/report");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (reportId) {
|
||||
loadReport();
|
||||
}
|
||||
}, [reportId, router, toast]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<ReportDesignerProvider reportId={reportId}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-gray-50">
|
||||
{/* 상단 툴바 */}
|
||||
<ReportDesignerToolbar />
|
||||
|
||||
{/* 메인 영역 */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* 페이지 목록 패널 */}
|
||||
<PageListPanel />
|
||||
|
||||
{/* 좌측 패널 (템플릿, 컴포넌트) */}
|
||||
<ReportDesignerLeftPanel />
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<ReportDesignerCanvas />
|
||||
|
||||
{/* 우측 패널 (속성) */}
|
||||
<ReportDesignerRightPanel />
|
||||
</div>
|
||||
</div>
|
||||
</ReportDesignerProvider>
|
||||
</DndProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ReportListTable } from "@/components/report/ReportListTable";
|
||||
import { Plus, Search, RotateCcw } from "lucide-react";
|
||||
import { useReportList } from "@/hooks/useReportList";
|
||||
|
||||
export default function ReportManagementPage() {
|
||||
const router = useRouter();
|
||||
const [searchText, setSearchText] = useState("");
|
||||
|
||||
const { reports, total, page, limit, isLoading, refetch, setPage, handleSearch } = useReportList();
|
||||
|
||||
const handleSearchClick = () => {
|
||||
handleSearch(searchText);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchText("");
|
||||
handleSearch("");
|
||||
};
|
||||
|
||||
const handleCreateNew = () => {
|
||||
// 새 리포트는 'new'라는 특수 ID로 디자이너 진입
|
||||
router.push("/admin/report/designer/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="w-full max-w-none space-y-8 px-4 py-8">
|
||||
{/* 페이지 제목 */}
|
||||
<div className="flex items-center justify-between rounded-lg border bg-white p-6 shadow-sm">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">리포트 관리</h1>
|
||||
<p className="mt-2 text-gray-600">리포트를 생성하고 관리합니다</p>
|
||||
</div>
|
||||
<Button onClick={handleCreateNew} className="gap-2">
|
||||
<Plus className="h-4 w-4" />새 리포트
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-5 w-5" />
|
||||
검색
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="리포트명으로 검색..."
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearchClick();
|
||||
}
|
||||
}}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={handleSearchClick} className="gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
검색
|
||||
</Button>
|
||||
<Button onClick={handleReset} variant="outline" className="gap-2">
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 리포트 목록 */}
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="bg-gray-50/50">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
📋 리포트 목록
|
||||
<span className="text-muted-foreground text-sm font-normal">(총 {total}건)</span>
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
<ReportListTable
|
||||
reports={reports}
|
||||
total={total}
|
||||
page={page}
|
||||
limit={limit}
|
||||
isLoading={isLoading}
|
||||
onPageChange={setPage}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,13 +9,13 @@ import { Badge } from "@/components/ui/badge";
|
|||
*/
|
||||
export default function MainPage() {
|
||||
return (
|
||||
<div className="pt-10 space-y-6">
|
||||
<div className="space-y-6 pt-10">
|
||||
{/* 메인 컨텐츠 */}
|
||||
{/* Welcome Message */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-6 text-center">
|
||||
<h3 className="text-lg font-semibold">PLM 솔루션에 오신 것을 환영합니다!</h3>
|
||||
<h3 className="text-lg font-semibold">Vexolor에 오신 것을 환영합니다!</h3>
|
||||
<p className="text-muted-foreground">제품 수명 주기 관리 시스템을 통해 효율적인 업무를 시작하세요.</p>
|
||||
<div className="flex justify-center space-x-2">
|
||||
<Badge variant="secondary">Spring Boot</Badge>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,8 @@ export default function ScreenViewPage() {
|
|||
modalSize?: "sm" | "md" | "lg" | "xl" | "full";
|
||||
editData?: any;
|
||||
onSave?: () => void;
|
||||
modalTitle?: string;
|
||||
modalDescription?: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -67,6 +69,8 @@ export default function ScreenViewPage() {
|
|||
modalSize: event.detail.modalSize,
|
||||
editData: event.detail.editData,
|
||||
onSave: event.detail.onSave,
|
||||
modalTitle: event.detail.modalTitle,
|
||||
modalDescription: event.detail.modalDescription,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
|
@ -407,6 +411,8 @@ export default function ScreenViewPage() {
|
|||
modalSize={editModalConfig.modalSize}
|
||||
editData={editModalConfig.editData}
|
||||
onSave={editModalConfig.onSave}
|
||||
modalTitle={editModalConfig.modalTitle}
|
||||
modalDescription={editModalConfig.modalDescription}
|
||||
onDataChange={(changedFormData) => {
|
||||
console.log("📝 EditModal에서 데이터 변경 수신:", changedFormData);
|
||||
// 변경된 데이터를 메인 폼에 반영
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/* 서명용 손글씨 폰트 - 최상단에 위치해야 함 */
|
||||
@import url("https://fonts.googleapis.com/css2?family=Allura&family=Dancing+Script:wght@700&family=Great+Vibes&family=Pacifico&family=Satisfy&family=Caveat:wght@700&family=Permanent+Marker&family=Shadows+Into+Light&family=Kalam:wght@700&family=Patrick+Hand&family=Indie+Flower&family=Amatic+SC:wght@700&family=Covered+By+Your+Grace&family=Nanum+Brush+Script&family=Nanum+Pen+Script&family=Gaegu:wght@700&family=Gugi&family=Single+Day&family=Stylish&family=Sunflower:wght@700&family=Dokdo&display=swap");
|
||||
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ export const metadata: Metadata = {
|
|||
description: "제품 수명 주기 관리(PLM) 솔루션",
|
||||
keywords: ["WACE", "PLM", "Product Lifecycle Management", "WACE", "제품관리"],
|
||||
authors: [{ name: "WACE" }],
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
|
|
@ -37,10 +40,6 @@ export default function RootLayout({
|
|||
}>) {
|
||||
return (
|
||||
<html lang="ko" className="h-full">
|
||||
<head>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
</head>
|
||||
<body className={`${inter.variable} ${jetbrainsMono.variable} h-full bg-white font-sans antialiased`}>
|
||||
<div id="root" className="h-full">
|
||||
<QueryProvider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Play,
|
||||
Pause,
|
||||
Edit,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Database,
|
||||
ArrowRight,
|
||||
Globe,
|
||||
Calendar,
|
||||
Activity,
|
||||
Settings
|
||||
} from "lucide-react";
|
||||
import { BatchConfig } from "@/lib/api/batch";
|
||||
|
||||
interface BatchCardProps {
|
||||
batch: BatchConfig;
|
||||
executingBatch: number | null;
|
||||
onExecute: (batchId: number) => void;
|
||||
onToggleStatus: (batchId: number, currentStatus: string) => void;
|
||||
onEdit: (batchId: number) => void;
|
||||
onDelete: (batchId: number, batchName: string) => void;
|
||||
getMappingSummary: (mappings: any[]) => string;
|
||||
}
|
||||
|
||||
export default function BatchCard({
|
||||
batch,
|
||||
executingBatch,
|
||||
onExecute,
|
||||
onToggleStatus,
|
||||
onEdit,
|
||||
onDelete,
|
||||
getMappingSummary
|
||||
}: BatchCardProps) {
|
||||
// 상태에 따른 색상 및 스타일 결정
|
||||
const getStatusColor = () => {
|
||||
if (executingBatch === batch.id) return "bg-blue-50 border-blue-200";
|
||||
if (batch.is_active === 'Y') return "bg-green-50 border-green-200";
|
||||
return "bg-gray-50 border-gray-200";
|
||||
};
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (executingBatch === batch.id) {
|
||||
return <Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300 text-xs px-1.5 py-0.5 h-5">실행 중</Badge>;
|
||||
}
|
||||
return (
|
||||
<Badge variant={batch.is_active === 'Y' ? 'default' : 'secondary'} className="text-xs px-1.5 py-0.5 h-5">
|
||||
{batch.is_active === 'Y' ? '활성' : '비활성'}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className={`transition-all duration-200 hover:shadow-md ${getStatusColor()} h-fit`}>
|
||||
<CardContent className="p-3">
|
||||
{/* 헤더 섹션 */}
|
||||
<div className="mb-1.5">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center space-x-1 min-w-0 flex-1">
|
||||
<Settings className="h-2.5 w-2.5 text-gray-600 flex-shrink-0" />
|
||||
<h3 className="text-xs font-medium text-gray-900 truncate">{batch.batch_name}</h3>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 line-clamp-1 leading-tight h-3 flex items-start">
|
||||
{batch.description || '\u00A0'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 정보 섹션 */}
|
||||
<div className="space-y-1 mb-2">
|
||||
{/* 스케줄 정보 */}
|
||||
<div className="flex items-center space-x-1 text-xs">
|
||||
<Clock className="h-2.5 w-2.5 text-blue-600" />
|
||||
<span className="text-gray-600 truncate text-xs">{batch.cron_schedule}</span>
|
||||
</div>
|
||||
|
||||
{/* 생성일 정보 */}
|
||||
<div className="flex items-center space-x-1 text-xs">
|
||||
<Calendar className="h-2.5 w-2.5 text-green-600" />
|
||||
<span className="text-gray-600 text-xs">
|
||||
{new Date(batch.created_date).toLocaleDateString('ko-KR')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 매핑 정보 섹션 */}
|
||||
{batch.batch_mappings && batch.batch_mappings.length > 0 && (
|
||||
<div className="mb-2 p-1.5 bg-white rounded border border-gray-100">
|
||||
<div className="flex items-center space-x-1 mb-1">
|
||||
<Database className="h-2.5 w-2.5 text-purple-600" />
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
매핑 ({batch.batch_mappings.length})
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 line-clamp-1">
|
||||
{getMappingSummary(batch.batch_mappings)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 섹션 */}
|
||||
<div className="grid grid-cols-2 gap-1 pt-2 border-t border-gray-100">
|
||||
{/* 실행 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onExecute(batch.id)}
|
||||
disabled={executingBatch === batch.id}
|
||||
className="flex items-center justify-center space-x-1 bg-blue-50 hover:bg-blue-100 text-blue-700 border-blue-200 text-xs h-6"
|
||||
>
|
||||
{executingBatch === batch.id ? (
|
||||
<RefreshCw className="h-2.5 w-2.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="h-2.5 w-2.5" />
|
||||
)}
|
||||
<span>실행</span>
|
||||
</Button>
|
||||
|
||||
{/* 활성화/비활성화 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onToggleStatus(batch.id, batch.is_active)}
|
||||
className={`flex items-center justify-center space-x-1 text-xs h-6 ${
|
||||
batch.is_active === 'Y'
|
||||
? 'bg-orange-50 hover:bg-orange-100 text-orange-700 border-orange-200'
|
||||
: 'bg-green-50 hover:bg-green-100 text-green-700 border-green-200'
|
||||
}`}
|
||||
>
|
||||
{batch.is_active === 'Y' ? (
|
||||
<Pause className="h-2.5 w-2.5" />
|
||||
) : (
|
||||
<Play className="h-2.5 w-2.5" />
|
||||
)}
|
||||
<span>{batch.is_active === 'Y' ? '비활성' : '활성'}</span>
|
||||
</Button>
|
||||
|
||||
{/* 수정 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onEdit(batch.id)}
|
||||
className="flex items-center justify-center space-x-1 bg-gray-50 hover:bg-gray-100 text-gray-700 border-gray-200 text-xs h-6"
|
||||
>
|
||||
<Edit className="h-2.5 w-2.5" />
|
||||
<span>수정</span>
|
||||
</Button>
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete(batch.id, batch.batch_name)}
|
||||
className="flex items-center justify-center space-x-1 bg-red-50 hover:bg-red-100 text-red-700 border-red-200 text-xs h-6"
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5" />
|
||||
<span>삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 실행 중일 때 프로그레스 표시 */}
|
||||
{executingBatch === batch.id && (
|
||||
<div className="mt-2 pt-2 border-t border-blue-100">
|
||||
<div className="flex items-center space-x-1 text-xs text-blue-600">
|
||||
<Activity className="h-3 w-3 animate-pulse" />
|
||||
<span>실행 중...</span>
|
||||
</div>
|
||||
<div className="mt-1 w-full bg-blue-100 rounded-full h-1">
|
||||
<div className="bg-blue-600 h-1 rounded-full animate-pulse" style={{ width: '45%' }}></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,26 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { DashboardElement, QueryResult } from './types';
|
||||
import { ChartRenderer } from './charts/ChartRenderer';
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import { DashboardElement, QueryResult } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
// 위젯 동적 임포트
|
||||
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
const ExchangeWidget = dynamic(() => import("@/components/dashboard/widgets/ExchangeWidget"), {
|
||||
ssr: false,
|
||||
loading: () => <div className="flex h-full items-center justify-center text-sm text-gray-500">로딩 중...</div>,
|
||||
});
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
cellSize: number;
|
||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onSelect: (id: string | null) => void;
|
||||
|
|
@ -15,127 +29,182 @@ interface CanvasElementProps {
|
|||
|
||||
/**
|
||||
* 캔버스에 배치된 개별 요소 컴포넌트
|
||||
* - 드래그로 이동 가능
|
||||
* - 크기 조절 핸들
|
||||
* - 드래그로 이동 가능 (그리드 스냅)
|
||||
* - 크기 조절 핸들 (그리드 스냅)
|
||||
* - 삭제 버튼
|
||||
*/
|
||||
export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) {
|
||||
export function CanvasElement({
|
||||
element,
|
||||
isSelected,
|
||||
cellSize,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
}: CanvasElementProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
||||
const [resizeStart, setResizeStart] = useState({
|
||||
x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: ''
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
elementX: 0,
|
||||
elementY: 0,
|
||||
handle: "",
|
||||
});
|
||||
const [chartData, setChartData] = useState<QueryResult | null>(null);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 드래그/리사이즈 중 임시 위치/크기 (스냅 전)
|
||||
const [tempPosition, setTempPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [tempSize, setTempSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 요소 선택 처리
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
||||
if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(element.id);
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y
|
||||
});
|
||||
e.preventDefault();
|
||||
}, [element.id, element.position.x, element.position.y, onSelect]);
|
||||
|
||||
// 리사이즈 핸들 마우스다운
|
||||
const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: element.size.width,
|
||||
height: element.size.height,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y,
|
||||
handle
|
||||
});
|
||||
}, [element.size.width, element.size.height, element.position.x, element.position.y]);
|
||||
|
||||
// 마우스 이동 처리
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: {
|
||||
x: Math.max(0, dragStart.elementX + deltaX),
|
||||
y: Math.max(0, dragStart.elementY + deltaY)
|
||||
}
|
||||
});
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
const deltaY = e.clientY - resizeStart.y;
|
||||
|
||||
let newWidth = resizeStart.width;
|
||||
let newHeight = resizeStart.height;
|
||||
let newX = resizeStart.elementX;
|
||||
let newY = resizeStart.elementY;
|
||||
|
||||
switch (resizeStart.handle) {
|
||||
case 'se': // 오른쪽 아래
|
||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
||||
break;
|
||||
case 'sw': // 왼쪽 아래
|
||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
break;
|
||||
case 'ne': // 오른쪽 위
|
||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
case 'nw': // 왼쪽 위
|
||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
||||
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: Math.max(0, newX), y: Math.max(0, newY) },
|
||||
size: { width: newWidth, height: newHeight }
|
||||
onSelect(element.id);
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y,
|
||||
});
|
||||
}
|
||||
}, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]);
|
||||
e.preventDefault();
|
||||
},
|
||||
[element.id, element.position.x, element.position.y, onSelect],
|
||||
);
|
||||
|
||||
// 마우스 업 처리
|
||||
// 리사이즈 핸들 마우스다운
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent, handle: string) => {
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: element.size.width,
|
||||
height: element.size.height,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y,
|
||||
handle,
|
||||
});
|
||||
},
|
||||
[element.size.width, element.size.height, element.position.x, element.position.y],
|
||||
);
|
||||
|
||||
// 마우스 이동 처리 (그리드 스냅 적용)
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
// 임시 위치 계산 (스냅 안 됨)
|
||||
const rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
||||
|
||||
setTempPosition({ x: rawX, y: rawY });
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
const deltaY = e.clientY - resizeStart.y;
|
||||
|
||||
let newWidth = resizeStart.width;
|
||||
let newHeight = resizeStart.height;
|
||||
let newX = resizeStart.elementX;
|
||||
let newY = resizeStart.elementY;
|
||||
|
||||
const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
|
||||
|
||||
switch (resizeStart.handle) {
|
||||
case "se": // 오른쪽 아래
|
||||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||
break;
|
||||
case "sw": // 왼쪽 아래
|
||||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
break;
|
||||
case "ne": // 오른쪽 위
|
||||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
case "nw": // 왼쪽 위
|
||||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
}
|
||||
|
||||
// 임시 크기/위치 저장 (스냅 안 됨)
|
||||
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
|
||||
setTempSize({ width: newWidth, height: newHeight });
|
||||
}
|
||||
},
|
||||
[isDragging, isResizing, dragStart, resizeStart],
|
||||
);
|
||||
|
||||
// 마우스 업 처리 (그리드 스냅 적용)
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging && tempPosition) {
|
||||
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
});
|
||||
|
||||
setTempPosition(null);
|
||||
}
|
||||
|
||||
if (isResizing && tempPosition && tempSize) {
|
||||
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||
const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
|
||||
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
size: { width: snappedWidth, height: snappedHeight },
|
||||
});
|
||||
|
||||
setTempPosition(null);
|
||||
setTempSize(null);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
}, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]);
|
||||
|
||||
// 전역 마우스 이벤트 등록
|
||||
React.useEffect(() => {
|
||||
if (isDragging || isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// 데이터 로딩
|
||||
const loadChartData = useCallback(async () => {
|
||||
if (!element.dataSource?.query || element.type !== 'chart') {
|
||||
if (!element.dataSource?.query || element.type !== "chart") {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -144,7 +213,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
||||
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
// console.log('✅ 쿼리 실행 결과:', result);
|
||||
|
|
@ -153,7 +222,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0
|
||||
executionTime: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('❌ 데이터 로딩 오류:', error);
|
||||
|
|
@ -185,51 +254,56 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
|
||||
// 스타일 클래스 생성
|
||||
const getContentClass = () => {
|
||||
if (element.type === 'chart') {
|
||||
if (element.type === "chart") {
|
||||
switch (element.subtype) {
|
||||
case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600';
|
||||
case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500';
|
||||
case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400';
|
||||
default: return 'bg-gray-200';
|
||||
case "bar":
|
||||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||
case "pie":
|
||||
return "bg-gradient-to-br from-pink-400 to-red-500";
|
||||
case "line":
|
||||
return "bg-gradient-to-br from-blue-400 to-cyan-400";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
}
|
||||
} else if (element.type === 'widget') {
|
||||
} else if (element.type === "widget") {
|
||||
switch (element.subtype) {
|
||||
case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400';
|
||||
case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800';
|
||||
default: return 'bg-gray-200';
|
||||
case "exchange":
|
||||
return "bg-gradient-to-br from-pink-400 to-yellow-400";
|
||||
case "weather":
|
||||
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
}
|
||||
}
|
||||
return 'bg-gray-200';
|
||||
return "bg-gray-200";
|
||||
};
|
||||
|
||||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||
const displayPosition = tempPosition || element.position;
|
||||
const displaySize = tempSize || element.size;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={`
|
||||
absolute bg-white border-2 rounded-lg shadow-lg
|
||||
min-w-[150px] min-h-[150px] cursor-move
|
||||
${isSelected ? 'border-green-500 shadow-green-200' : 'border-gray-600'}
|
||||
`}
|
||||
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||
style={{
|
||||
left: element.position.x,
|
||||
top: element.position.y,
|
||||
width: element.size.width,
|
||||
height: element.size.height
|
||||
left: displayPosition.x,
|
||||
top: displayPosition.y,
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
padding: `${GRID_CONFIG.ELEMENT_PADDING}px`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-50 p-3 border-b border-gray-200 flex justify-between items-center cursor-move">
|
||||
<span className="font-bold text-sm text-gray-800">{element.title}</span>
|
||||
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
|
||||
<span className="text-sm font-bold text-gray-800">{element.title}</span>
|
||||
<div className="flex gap-1">
|
||||
{/* 설정 버튼 */}
|
||||
{onConfigure && (
|
||||
<button
|
||||
className="
|
||||
w-6 h-6 flex items-center justify-center
|
||||
text-gray-400 hover:bg-accent0 hover:text-white
|
||||
rounded transition-colors duration-200
|
||||
"
|
||||
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||
onClick={() => onConfigure(element)}
|
||||
title="설정"
|
||||
>
|
||||
|
|
@ -238,11 +312,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
)}
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
className="
|
||||
element-close w-6 h-6 flex items-center justify-center
|
||||
text-gray-400 hover:bg-destructive/100 hover:text-white
|
||||
rounded transition-colors duration-200
|
||||
"
|
||||
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||
onClick={handleRemove}
|
||||
title="삭제"
|
||||
>
|
||||
|
|
@ -252,14 +322,14 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="h-[calc(100%-45px)] relative">
|
||||
{element.type === 'chart' ? (
|
||||
<div className="relative h-[calc(100%-45px)]">
|
||||
{element.type === "chart" ? (
|
||||
// 차트 렌더링
|
||||
<div className="w-full h-full bg-white">
|
||||
<div className="h-full w-full bg-white">
|
||||
{isLoadingData ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -272,18 +342,27 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "weather" ? (
|
||||
// 날씨 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<WeatherWidget city={element.config?.city || "서울"} refreshInterval={600000} />
|
||||
</div>
|
||||
) : element.type === "widget" && element.subtype === "exchange" ? (
|
||||
// 환율 위젯 렌더링
|
||||
<div className="h-full w-full">
|
||||
<ExchangeWidget
|
||||
baseCurrency={element.config?.baseCurrency || "KRW"}
|
||||
targetCurrency={element.config?.targetCurrency || "USD"}
|
||||
refreshInterval={600000}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// 위젯 렌더링 (기존 방식)
|
||||
<div className={`
|
||||
w-full h-full p-5 flex items-center justify-center
|
||||
text-sm text-white font-medium text-center
|
||||
${getContentClass()}
|
||||
`}>
|
||||
// 기타 위젯 렌더링
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
|
||||
>
|
||||
<div>
|
||||
<div className="text-4xl mb-2">
|
||||
{element.type === 'widget' && element.subtype === 'exchange' && '💱'}
|
||||
{element.type === 'widget' && element.subtype === 'weather' && '☁️'}
|
||||
</div>
|
||||
<div className="mb-2 text-4xl">🔧</div>
|
||||
<div className="whitespace-pre-line">{element.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -304,7 +383,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
}
|
||||
|
||||
interface ResizeHandleProps {
|
||||
position: 'nw' | 'ne' | 'sw' | 'se';
|
||||
position: "nw" | "ne" | "sw" | "se";
|
||||
onMouseDown: (e: React.MouseEvent, handle: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -314,19 +393,20 @@ interface ResizeHandleProps {
|
|||
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
||||
const getPositionClass = () => {
|
||||
switch (position) {
|
||||
case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize';
|
||||
case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize';
|
||||
case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize';
|
||||
case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize';
|
||||
case "nw":
|
||||
return "top-[-5px] left-[-5px] cursor-nw-resize";
|
||||
case "ne":
|
||||
return "top-[-5px] right-[-5px] cursor-ne-resize";
|
||||
case "sw":
|
||||
return "bottom-[-5px] left-[-5px] cursor-sw-resize";
|
||||
case "se":
|
||||
return "bottom-[-5px] right-[-5px] cursor-se-resize";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
resize-handle absolute w-3 h-3 bg-green-500 border border-white
|
||||
${getPositionClass()}
|
||||
`}
|
||||
className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
|
||||
onMouseDown={(e) => onMouseDown(e, position)}
|
||||
/>
|
||||
);
|
||||
|
|
@ -337,55 +417,55 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
|||
*/
|
||||
function generateSampleData(query: string, chartType: string): QueryResult {
|
||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||||
const isMonthly = query.toLowerCase().includes('month');
|
||||
const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출');
|
||||
const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자');
|
||||
const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품');
|
||||
const isMonthly = query.toLowerCase().includes("month");
|
||||
const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출");
|
||||
const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자");
|
||||
const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품");
|
||||
|
||||
let columns: string[];
|
||||
let rows: Record<string, any>[];
|
||||
|
||||
if (isMonthly && isSales) {
|
||||
// 월별 매출 데이터
|
||||
columns = ['month', 'sales', 'order_count'];
|
||||
columns = ["month", "sales", "order_count"];
|
||||
rows = [
|
||||
{ month: '2024-01', sales: 1200000, order_count: 45 },
|
||||
{ month: '2024-02', sales: 1350000, order_count: 52 },
|
||||
{ month: '2024-03', sales: 1180000, order_count: 41 },
|
||||
{ month: '2024-04', sales: 1420000, order_count: 58 },
|
||||
{ month: '2024-05', sales: 1680000, order_count: 67 },
|
||||
{ month: '2024-06', sales: 1540000, order_count: 61 },
|
||||
{ month: "2024-01", sales: 1200000, order_count: 45 },
|
||||
{ month: "2024-02", sales: 1350000, order_count: 52 },
|
||||
{ month: "2024-03", sales: 1180000, order_count: 41 },
|
||||
{ month: "2024-04", sales: 1420000, order_count: 58 },
|
||||
{ month: "2024-05", sales: 1680000, order_count: 67 },
|
||||
{ month: "2024-06", sales: 1540000, order_count: 61 },
|
||||
];
|
||||
} else if (isUsers) {
|
||||
// 사용자 가입 추이
|
||||
columns = ['week', 'new_users'];
|
||||
columns = ["week", "new_users"];
|
||||
rows = [
|
||||
{ week: '2024-W10', new_users: 23 },
|
||||
{ week: '2024-W11', new_users: 31 },
|
||||
{ week: '2024-W12', new_users: 28 },
|
||||
{ week: '2024-W13', new_users: 35 },
|
||||
{ week: '2024-W14', new_users: 42 },
|
||||
{ week: '2024-W15', new_users: 38 },
|
||||
{ week: "2024-W10", new_users: 23 },
|
||||
{ week: "2024-W11", new_users: 31 },
|
||||
{ week: "2024-W12", new_users: 28 },
|
||||
{ week: "2024-W13", new_users: 35 },
|
||||
{ week: "2024-W14", new_users: 42 },
|
||||
{ week: "2024-W15", new_users: 38 },
|
||||
];
|
||||
} else if (isProducts) {
|
||||
// 상품별 판매량
|
||||
columns = ['product_name', 'total_sold', 'revenue'];
|
||||
columns = ["product_name", "total_sold", "revenue"];
|
||||
rows = [
|
||||
{ product_name: '스마트폰', total_sold: 156, revenue: 234000000 },
|
||||
{ product_name: '노트북', total_sold: 89, revenue: 178000000 },
|
||||
{ product_name: '태블릿', total_sold: 134, revenue: 67000000 },
|
||||
{ product_name: '이어폰', total_sold: 267, revenue: 26700000 },
|
||||
{ product_name: '스마트워치', total_sold: 98, revenue: 49000000 },
|
||||
{ product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
|
||||
{ product_name: "노트북", total_sold: 89, revenue: 178000000 },
|
||||
{ product_name: "태블릿", total_sold: 134, revenue: 67000000 },
|
||||
{ product_name: "이어폰", total_sold: 267, revenue: 26700000 },
|
||||
{ product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
|
||||
];
|
||||
} else {
|
||||
// 기본 샘플 데이터
|
||||
columns = ['category', 'value', 'count'];
|
||||
columns = ["category", "value", "count"];
|
||||
rows = [
|
||||
{ category: 'A', value: 100, count: 10 },
|
||||
{ category: 'B', value: 150, count: 15 },
|
||||
{ category: 'C', value: 120, count: 12 },
|
||||
{ category: 'D', value: 180, count: 18 },
|
||||
{ category: 'E', value: 90, count: 9 },
|
||||
{ category: "A", value: 100, count: 10 },
|
||||
{ category: "B", value: 150, count: 15 },
|
||||
{ category: "C", value: 120, count: 12 },
|
||||
{ category: "D", value: 180, count: 18 },
|
||||
{ category: "E", value: 90, count: 9 },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, useState, useCallback } from 'react';
|
||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from './types';
|
||||
import { CanvasElement } from './CanvasElement';
|
||||
import React, { forwardRef, useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||
import { CanvasElement } from "./CanvasElement";
|
||||
import { GRID_CONFIG, snapToGrid } from "./gridUtils";
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
elements: DashboardElement[];
|
||||
|
|
@ -17,17 +18,29 @@ interface DashboardCanvasProps {
|
|||
/**
|
||||
* 대시보드 캔버스 컴포넌트
|
||||
* - 드래그 앤 드롭 영역
|
||||
* - 그리드 배경
|
||||
* - 12 컬럼 그리드 배경
|
||||
* - 스냅 기능
|
||||
* - 요소 배치 및 관리
|
||||
*/
|
||||
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => {
|
||||
(
|
||||
{
|
||||
elements,
|
||||
selectedElement,
|
||||
onCreateElement,
|
||||
onUpdateElement,
|
||||
onRemoveElement,
|
||||
onSelectElement,
|
||||
onConfigureElement,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
// 드래그 오버 처리
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
|
|
@ -38,51 +51,71 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 드롭 처리
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
// 드롭 처리 (그리드 스냅 적용)
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
try {
|
||||
const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json'));
|
||||
try {
|
||||
const dragData: DragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||
|
||||
if (!ref || typeof ref === 'function') return;
|
||||
if (!ref || typeof ref === "function") return;
|
||||
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
// 캔버스 스크롤을 고려한 정확한 위치 계산
|
||||
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||
// 캔버스 스크롤을 고려한 정확한 위치 계산
|
||||
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||
|
||||
onCreateElement(dragData.type, dragData.subtype, x, y);
|
||||
} catch (error) {
|
||||
// console.error('드롭 데이터 파싱 오류:', error);
|
||||
}
|
||||
}, [ref, onCreateElement]);
|
||||
// 그리드에 스냅 (고정 셀 크기 사용)
|
||||
const snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE);
|
||||
const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE);
|
||||
|
||||
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
|
||||
} catch (error) {
|
||||
// console.error('드롭 데이터 파싱 오류:', error);
|
||||
}
|
||||
},
|
||||
[ref, onCreateElement],
|
||||
);
|
||||
|
||||
// 캔버스 클릭 시 선택 해제
|
||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectElement(null);
|
||||
}
|
||||
}, [onSelectElement]);
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectElement(null);
|
||||
}
|
||||
},
|
||||
[onSelectElement],
|
||||
);
|
||||
|
||||
// 고정 그리드 크기
|
||||
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
|
||||
|
||||
// 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장)
|
||||
const minCanvasHeight = Math.max(
|
||||
typeof window !== "undefined" ? window.innerHeight : 800,
|
||||
...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full min-h-full relative
|
||||
bg-gray-100
|
||||
bg-grid-pattern
|
||||
${isDragOver ? 'bg-accent' : ''}
|
||||
`}
|
||||
className={`relative rounded-lg bg-gray-50 shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
style={{
|
||||
width: `${GRID_CONFIG.CANVAS_WIDTH}px`,
|
||||
minHeight: `${minCanvasHeight}px`,
|
||||
// 12 컬럼 그리드 배경
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(200, 200, 200, 0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(200, 200, 200, 0.3) 1px, transparent 1px)
|
||||
linear-gradient(rgba(59, 130, 246, 0.15) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.15) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '20px 20px'
|
||||
backgroundSize: gridSize,
|
||||
backgroundPosition: "0 0",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -95,6 +128,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElement === element.id}
|
||||
cellSize={GRID_CONFIG.CELL_SIZE}
|
||||
onUpdate={onUpdateElement}
|
||||
onRemove={onRemoveElement}
|
||||
onSelect={onSelectElement}
|
||||
|
|
@ -103,7 +137,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DashboardCanvas.displayName = 'DashboardCanvas';
|
||||
DashboardCanvas.displayName = "DashboardCanvas";
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { DashboardCanvas } from './DashboardCanvas';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
import { DashboardToolbar } from './DashboardToolbar';
|
||||
import { ElementConfigModal } from './ElementConfigModal';
|
||||
import { DashboardElement, ElementType, ElementSubtype } from './types';
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { DashboardCanvas } from "./DashboardCanvas";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { DashboardToolbar } from "./DashboardToolbar";
|
||||
import { ElementConfigModal } from "./ElementConfigModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
/**
|
||||
* 대시보드 설계 도구 메인 컴포넌트
|
||||
* - 드래그 앤 드롭으로 차트/위젯 배치
|
||||
* - 그리드 기반 레이아웃 (12 컬럼)
|
||||
* - 요소 이동, 크기 조절, 삭제 기능
|
||||
* - 레이아웃 저장/불러오기 기능
|
||||
*/
|
||||
|
|
@ -19,14 +21,14 @@ export default function DashboardDesigner() {
|
|||
const [elementCounter, setElementCounter] = useState(0);
|
||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>('');
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const loadId = params.get('load');
|
||||
const loadId = params.get("load");
|
||||
|
||||
if (loadId) {
|
||||
loadDashboard(loadId);
|
||||
|
|
@ -39,7 +41,7 @@ export default function DashboardDesigner() {
|
|||
try {
|
||||
// console.log('🔄 대시보드 로딩:', id);
|
||||
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const dashboard = await dashboardApi.getDashboard(id);
|
||||
|
||||
// console.log('✅ 대시보드 로딩 완료:', dashboard);
|
||||
|
|
@ -63,55 +65,63 @@ export default function DashboardDesigner() {
|
|||
}, 0);
|
||||
setElementCounter(maxId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('❌ 대시보드 로딩 오류:', error);
|
||||
alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류'));
|
||||
alert(
|
||||
"대시보드를 불러오는 중 오류가 발생했습니다.\n\n" +
|
||||
(error instanceof Error ? error.message : "알 수 없는 오류"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 새로운 요소 생성
|
||||
const createElement = useCallback((
|
||||
type: ElementType,
|
||||
subtype: ElementSubtype,
|
||||
x: number,
|
||||
y: number
|
||||
) => {
|
||||
const newElement: DashboardElement = {
|
||||
id: `element-${elementCounter + 1}`,
|
||||
type,
|
||||
subtype,
|
||||
position: { x, y },
|
||||
size: { width: 250, height: 200 },
|
||||
title: getElementTitle(type, subtype),
|
||||
content: getElementContent(type, subtype)
|
||||
};
|
||||
// 새로운 요소 생성 (고정 그리드 기반 기본 크기)
|
||||
const createElement = useCallback(
|
||||
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
||||
// 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀
|
||||
const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 };
|
||||
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
|
||||
setElements(prev => [...prev, newElement]);
|
||||
setElementCounter(prev => prev + 1);
|
||||
setSelectedElement(newElement.id);
|
||||
}, [elementCounter]);
|
||||
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
|
||||
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
|
||||
|
||||
const newElement: DashboardElement = {
|
||||
id: `element-${elementCounter + 1}`,
|
||||
type,
|
||||
subtype,
|
||||
position: { x, y },
|
||||
size: { width: defaultWidth, height: defaultHeight },
|
||||
title: getElementTitle(type, subtype),
|
||||
content: getElementContent(type, subtype),
|
||||
};
|
||||
|
||||
setElements((prev) => [...prev, newElement]);
|
||||
setElementCounter((prev) => prev + 1);
|
||||
setSelectedElement(newElement.id);
|
||||
},
|
||||
[elementCounter],
|
||||
);
|
||||
|
||||
// 요소 업데이트
|
||||
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
||||
setElements(prev => prev.map(el =>
|
||||
el.id === id ? { ...el, ...updates } : el
|
||||
));
|
||||
setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
|
||||
}, []);
|
||||
|
||||
// 요소 삭제
|
||||
const removeElement = useCallback((id: string) => {
|
||||
setElements(prev => prev.filter(el => el.id !== id));
|
||||
if (selectedElement === id) {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
}, [selectedElement]);
|
||||
const removeElement = useCallback(
|
||||
(id: string) => {
|
||||
setElements((prev) => prev.filter((el) => el.id !== id));
|
||||
if (selectedElement === id) {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
},
|
||||
[selectedElement],
|
||||
);
|
||||
|
||||
// 전체 삭제
|
||||
const clearCanvas = useCallback(() => {
|
||||
if (window.confirm('모든 요소를 삭제하시겠습니까?')) {
|
||||
if (window.confirm("모든 요소를 삭제하시겠습니까?")) {
|
||||
setElements([]);
|
||||
setSelectedElement(null);
|
||||
setElementCounter(0);
|
||||
|
|
@ -129,22 +139,25 @@ export default function DashboardDesigner() {
|
|||
}, []);
|
||||
|
||||
// 요소 설정 저장
|
||||
const saveElementConfig = useCallback((updatedElement: DashboardElement) => {
|
||||
updateElement(updatedElement.id, updatedElement);
|
||||
}, [updateElement]);
|
||||
const saveElementConfig = useCallback(
|
||||
(updatedElement: DashboardElement) => {
|
||||
updateElement(updatedElement.id, updatedElement);
|
||||
},
|
||||
[updateElement],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(async () => {
|
||||
if (elements.length === 0) {
|
||||
alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.');
|
||||
alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
const elementsData = elements.map(el => ({
|
||||
const elementsData = elements.map((el) => ({
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
subtype: el.subtype,
|
||||
|
|
@ -153,7 +166,7 @@ export default function DashboardDesigner() {
|
|||
title: el.title,
|
||||
content: el.content,
|
||||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig
|
||||
chartConfig: el.chartConfig,
|
||||
}));
|
||||
|
||||
let savedDashboard;
|
||||
|
|
@ -162,26 +175,25 @@ export default function DashboardDesigner() {
|
|||
// 기존 대시보드 업데이트
|
||||
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
||||
elements: elementsData
|
||||
elements: elementsData,
|
||||
});
|
||||
|
||||
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
||||
|
||||
// 뷰어 페이지로 이동
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드');
|
||||
const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
|
||||
if (!title) return;
|
||||
|
||||
const description = prompt('대시보드 설명을 입력하세요 (선택사항):', '');
|
||||
const description = prompt("대시보드 설명을 입력하세요 (선택사항):", "");
|
||||
|
||||
const dashboardData = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
isPublic: false,
|
||||
elements: elementsData
|
||||
elements: elementsData,
|
||||
};
|
||||
|
||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
||||
|
|
@ -193,11 +205,10 @@ export default function DashboardDesigner() {
|
|||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('❌ 저장 오류:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
||||
}
|
||||
}, [elements, dashboardId]);
|
||||
|
|
@ -207,9 +218,9 @@ export default function DashboardDesigner() {
|
|||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
||||
<div className="mt-1 text-sm text-gray-500">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -218,28 +229,29 @@ export default function DashboardDesigner() {
|
|||
return (
|
||||
<div className="flex h-full bg-gray-50">
|
||||
{/* 캔버스 영역 */}
|
||||
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
|
||||
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
|
||||
{/* 편집 중인 대시보드 표시 */}
|
||||
{dashboardTitle && (
|
||||
<div className="absolute top-2 left-2 z-10 bg-accent0 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
|
||||
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
|
||||
📝 편집 중: {dashboardTitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DashboardToolbar
|
||||
onClearCanvas={clearCanvas}
|
||||
onSaveLayout={saveLayout}
|
||||
/>
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
elements={elements}
|
||||
selectedElement={selectedElement}
|
||||
onCreateElement={createElement}
|
||||
onUpdateElement={updateElement}
|
||||
onRemoveElement={removeElement}
|
||||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
/>
|
||||
<DashboardToolbar onClearCanvas={clearCanvas} onSaveLayout={saveLayout} />
|
||||
|
||||
{/* 캔버스 중앙 정렬 컨테이너 */}
|
||||
<div className="flex justify-center p-4">
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
elements={elements}
|
||||
selectedElement={selectedElement}
|
||||
onCreateElement={createElement}
|
||||
onUpdateElement={updateElement}
|
||||
onRemoveElement={removeElement}
|
||||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사이드바 */}
|
||||
|
|
@ -260,38 +272,52 @@ export default function DashboardDesigner() {
|
|||
|
||||
// 요소 제목 생성 헬퍼 함수
|
||||
function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
||||
if (type === 'chart') {
|
||||
if (type === "chart") {
|
||||
switch (subtype) {
|
||||
case 'bar': return '📊 바 차트';
|
||||
case 'pie': return '🥧 원형 차트';
|
||||
case 'line': return '📈 꺾은선 차트';
|
||||
default: return '📊 차트';
|
||||
case "bar":
|
||||
return "📊 바 차트";
|
||||
case "pie":
|
||||
return "🥧 원형 차트";
|
||||
case "line":
|
||||
return "📈 꺾은선 차트";
|
||||
default:
|
||||
return "📊 차트";
|
||||
}
|
||||
} else if (type === 'widget') {
|
||||
} else if (type === "widget") {
|
||||
switch (subtype) {
|
||||
case 'exchange': return '💱 환율 위젯';
|
||||
case 'weather': return '☁️ 날씨 위젯';
|
||||
default: return '🔧 위젯';
|
||||
case "exchange":
|
||||
return "💱 환율 위젯";
|
||||
case "weather":
|
||||
return "☁️ 날씨 위젯";
|
||||
default:
|
||||
return "🔧 위젯";
|
||||
}
|
||||
}
|
||||
return '요소';
|
||||
return "요소";
|
||||
}
|
||||
|
||||
// 요소 내용 생성 헬퍼 함수
|
||||
function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
||||
if (type === 'chart') {
|
||||
if (type === "chart") {
|
||||
switch (subtype) {
|
||||
case 'bar': return '바 차트가 여기에 표시됩니다';
|
||||
case 'pie': return '원형 차트가 여기에 표시됩니다';
|
||||
case 'line': return '꺾은선 차트가 여기에 표시됩니다';
|
||||
default: return '차트가 여기에 표시됩니다';
|
||||
case "bar":
|
||||
return "바 차트가 여기에 표시됩니다";
|
||||
case "pie":
|
||||
return "원형 차트가 여기에 표시됩니다";
|
||||
case "line":
|
||||
return "꺾은선 차트가 여기에 표시됩니다";
|
||||
default:
|
||||
return "차트가 여기에 표시됩니다";
|
||||
}
|
||||
} else if (type === 'widget') {
|
||||
} else if (type === "widget") {
|
||||
switch (subtype) {
|
||||
case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450';
|
||||
case 'weather': return '서울\n23°C\n구름 많음';
|
||||
default: return '위젯 내용이 여기에 표시됩니다';
|
||||
case "exchange":
|
||||
return "USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450";
|
||||
case "weather":
|
||||
return "서울\n23°C\n구름 많음";
|
||||
default:
|
||||
return "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
}
|
||||
return '내용이 여기에 표시됩니다';
|
||||
return "내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { DragData, ElementType, ElementSubtype } from './types';
|
||||
import React from "react";
|
||||
import { DragData, ElementType, ElementSubtype } from "./types";
|
||||
|
||||
/**
|
||||
* 대시보드 사이드바 컴포넌트
|
||||
|
|
@ -12,17 +12,15 @@ export function DashboardSidebar() {
|
|||
// 드래그 시작 처리
|
||||
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
|
||||
const dragData: DragData = { type, subtype };
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto p-5">
|
||||
<div className="w-[370px] overflow-y-auto border-l border-gray-200 bg-white p-6">
|
||||
{/* 차트 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
||||
📊 차트 종류
|
||||
</h3>
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📊 차트 종류</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
|
|
@ -31,7 +29,7 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-primary"
|
||||
className="border-primary border-l-4"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📚"
|
||||
|
|
@ -86,9 +84,7 @@ export function DashboardSidebar() {
|
|||
|
||||
{/* 위젯 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
||||
🔧 위젯 종류
|
||||
</h3>
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">🔧 위젯 종류</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
|
|
@ -125,20 +121,14 @@ interface DraggableItemProps {
|
|||
/**
|
||||
* 드래그 가능한 아이템 컴포넌트
|
||||
*/
|
||||
function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) {
|
||||
function DraggableItem({ icon, title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`
|
||||
p-4 bg-white border-2 border-gray-200 rounded-lg
|
||||
cursor-move transition-all duration-200
|
||||
hover:bg-gray-50 hover:border-green-500 hover:translate-x-1
|
||||
text-center text-sm font-medium
|
||||
${className}
|
||||
`}
|
||||
className={`cursor-move rounded-lg border-2 border-gray-200 bg-white p-4 text-center text-sm font-medium transition-all duration-200 hover:translate-x-1 hover:border-green-500 hover:bg-gray-50 ${className} `}
|
||||
onDragStart={(e) => onDragStart(e, type, subtype)}
|
||||
>
|
||||
<span className="text-lg mr-2">{icon}</span>
|
||||
<span className="mr-2 text-lg">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
# 대시보드 그리드 시스템
|
||||
|
||||
## 개요
|
||||
|
||||
대시보드 캔버스는 **12 컬럼 그리드 시스템**을 사용하여 요소를 정렬하고 배치합니다.
|
||||
모든 요소는 드래그 또는 리사이즈 종료 시 자동으로 그리드에 스냅됩니다.
|
||||
|
||||
## 그리드 설정
|
||||
|
||||
### 기본 설정 (`gridUtils.ts`)
|
||||
|
||||
```typescript
|
||||
GRID_CONFIG = {
|
||||
COLUMNS: 12, // 12 컬럼
|
||||
CELL_SIZE: 60, // 60px x 60px 정사각형 셀
|
||||
GAP: 8, // 셀 간격 8px
|
||||
SNAP_THRESHOLD: 15, // 스냅 임계값 15px
|
||||
};
|
||||
```
|
||||
|
||||
### 실제 그리드 크기
|
||||
|
||||
- **셀 크기 (gap 포함)**: 68px (60px + 8px)
|
||||
- **전체 캔버스 너비**: 808px (12 \* 68px - 8px)
|
||||
- **셀 비율**: 1:1 (정사각형)
|
||||
|
||||
## 스냅 기능
|
||||
|
||||
### 1. 위치 스냅
|
||||
|
||||
요소를 드래그하여 이동할 때:
|
||||
|
||||
- **드래그 중**: 자유롭게 이동 (그리드 무시)
|
||||
- **드래그 종료**: 가장 가까운 그리드 포인트에 자동 스냅
|
||||
- **스냅 계산**: `Math.round(value / 68) * 68`
|
||||
|
||||
### 2. 크기 스냅
|
||||
|
||||
요소의 크기를 조절할 때:
|
||||
|
||||
- **리사이즈 중**: 자유롭게 크기 조절
|
||||
- **리사이즈 종료**: 그리드 단위로 스냅
|
||||
- **최소 크기**: 2셀 x 2셀 (136px x 136px)
|
||||
|
||||
### 3. 드롭 스냅
|
||||
|
||||
사이드바에서 새 요소를 드래그 앤 드롭할 때:
|
||||
|
||||
- 드롭 위치가 자동으로 가장 가까운 그리드 포인트에 스냅
|
||||
- 기본 크기:
|
||||
- 차트: 4 x 3 셀 (264px x 196px)
|
||||
- 위젯: 2 x 2 셀 (136px x 136px)
|
||||
|
||||
## 시각적 피드백
|
||||
|
||||
### 그리드 배경
|
||||
|
||||
캔버스 배경에 그리드 라인이 표시됩니다:
|
||||
|
||||
```typescript
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '68px 68px'
|
||||
```
|
||||
|
||||
### 요소 테두리
|
||||
|
||||
- **선택 안 됨**: 회색 테두리
|
||||
- **선택됨**: 파란색 테두리 + 리사이즈 핸들 표시
|
||||
- **드래그/리사이즈 중**: 트랜지션 비활성화 (부드러운 움직임)
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 기본 사용
|
||||
|
||||
```typescript
|
||||
import { snapToGrid, snapSizeToGrid } from "./gridUtils";
|
||||
|
||||
// 위치 스냅
|
||||
const snappedX = snapToGrid(123); // 136 (가장 가까운 그리드)
|
||||
const snappedY = snapToGrid(45); // 68
|
||||
|
||||
// 크기 스냅
|
||||
const snappedWidth = snapSizeToGrid(250); // 264 (4셀)
|
||||
const snappedHeight = snapSizeToGrid(180); // 196 (3셀)
|
||||
```
|
||||
|
||||
### 경계 체크와 함께
|
||||
|
||||
```typescript
|
||||
import { snapBoundsToGrid } from "./gridUtils";
|
||||
|
||||
const snapped = snapBoundsToGrid(
|
||||
{
|
||||
position: { x: 123, y: 45 },
|
||||
size: { width: 250, height: 180 },
|
||||
},
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
);
|
||||
|
||||
// 결과:
|
||||
// {
|
||||
// position: { x: 136, y: 68 },
|
||||
// size: { width: 264, height: 196 }
|
||||
// }
|
||||
```
|
||||
|
||||
## 그리드 인덱스
|
||||
|
||||
### 좌표 → 인덱스
|
||||
|
||||
```typescript
|
||||
import { getGridIndex } from "./gridUtils";
|
||||
|
||||
const colIndex = getGridIndex(150); // 2 (3번째 컬럼)
|
||||
const rowIndex = getGridIndex(100); // 1 (2번째 행)
|
||||
```
|
||||
|
||||
### 인덱스 → 좌표
|
||||
|
||||
```typescript
|
||||
import { gridIndexToCoordinate } from "./gridUtils";
|
||||
|
||||
const x = gridIndexToCoordinate(0); // 0 (1번째 컬럼)
|
||||
const y = gridIndexToCoordinate(1); // 68 (2번째 행)
|
||||
const z = gridIndexToCoordinate(11); // 748 (12번째 컬럼)
|
||||
```
|
||||
|
||||
## 레이아웃 권장사항
|
||||
|
||||
### 일반 차트
|
||||
|
||||
- **권장 크기**: 4 x 3 셀 (264px x 196px)
|
||||
- **최소 크기**: 2 x 2 셀 (136px x 136px)
|
||||
- **최대 크기**: 12 x 8 셀 (808px x 536px)
|
||||
|
||||
### 작은 위젯
|
||||
|
||||
- **권장 크기**: 2 x 2 셀 (136px x 136px)
|
||||
- **최소 크기**: 2 x 2 셀
|
||||
- **최대 크기**: 4 x 4 셀 (264px x 264px)
|
||||
|
||||
### 큰 차트/대시보드
|
||||
|
||||
- **권장 크기**: 6 x 4 셀 (400px x 264px)
|
||||
- **풀 너비**: 12 셀 (808px)
|
||||
|
||||
## 커스터마이징
|
||||
|
||||
### 그리드 크기 변경
|
||||
|
||||
`gridUtils.ts`의 `GRID_CONFIG`를 수정:
|
||||
|
||||
```typescript
|
||||
export const GRID_CONFIG = {
|
||||
COLUMNS: 12,
|
||||
CELL_SIZE: 80, // 60 → 80 (셀 크기 증가)
|
||||
GAP: 16, // 8 → 16 (간격 증가)
|
||||
SNAP_THRESHOLD: 20, // 15 → 20 (스냅 범위 증가)
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 스냅 비활성화
|
||||
|
||||
특정 요소에서 스냅을 비활성화하려면:
|
||||
|
||||
```typescript
|
||||
// 드래그 종료 시 스냅하지 않고 그냥 업데이트
|
||||
onUpdate(element.id, {
|
||||
position: { x: rawX, y: rawY }, // snapToGrid 호출 안 함
|
||||
});
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 트랜지션 제어
|
||||
|
||||
드래그/리사이즈 중에는 CSS 트랜지션을 비활성화:
|
||||
|
||||
```typescript
|
||||
className={`
|
||||
${(isDragging || isResizing) ? 'transition-none' : 'transition-all duration-150'}
|
||||
`}
|
||||
```
|
||||
|
||||
### 임시 상태 사용
|
||||
|
||||
마우스 이동 중에는 임시 위치/크기만 업데이트하고,
|
||||
마우스 업 시에만 실제 스냅된 값으로 업데이트:
|
||||
|
||||
```typescript
|
||||
// 드래그 중
|
||||
setTempPosition({ x: rawX, y: rawY });
|
||||
|
||||
// 드래그 종료
|
||||
const snapped = snapToGrid(tempPosition.x);
|
||||
onUpdate(element.id, { position: { x: snapped, y: snapped } });
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 요소가 스냅되지 않는 경우
|
||||
|
||||
1. `snapToGrid` 함수가 호출되는지 확인
|
||||
2. `SNAP_THRESHOLD` 값 확인 (너무 작으면 스냅 안 됨)
|
||||
3. 임시 상태가 제대로 초기화되는지 확인
|
||||
|
||||
### 그리드가 보이지 않는 경우
|
||||
|
||||
1. 캔버스의 `backgroundImage` 스타일 확인
|
||||
2. `getCellWithGap()` 반환값 확인
|
||||
3. 브라우저 개발자 도구에서 배경 스타일 검사
|
||||
|
||||
### 성능 문제
|
||||
|
||||
1. 트랜지션이 비활성화되었는지 확인
|
||||
2. 불필요한 리렌더링 방지 (React.memo 사용)
|
||||
3. 마우스 이벤트 리스너가 제대로 제거되는지 확인
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **그리드 유틸리티**: `gridUtils.ts`
|
||||
- **캔버스 컴포넌트**: `DashboardCanvas.tsx`
|
||||
- **요소 컴포넌트**: `CanvasElement.tsx`
|
||||
- **디자이너**: `DashboardDesigner.tsx`
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* 대시보드 그리드 시스템 유틸리티
|
||||
* - 12 컬럼 그리드 시스템
|
||||
* - 정사각형 셀 (가로 = 세로)
|
||||
* - 스냅 기능
|
||||
*/
|
||||
|
||||
// 그리드 설정 (고정 크기)
|
||||
export const GRID_CONFIG = {
|
||||
COLUMNS: 12,
|
||||
CELL_SIZE: 132, // 고정 셀 크기
|
||||
GAP: 8, // 셀 간격
|
||||
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
|
||||
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
|
||||
CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값)
|
||||
// 계산식: (132 + 8) × 12 - 8 = 1672px (그리드)
|
||||
// 추가 여백 10px 포함 = 1682px
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 실제 그리드 셀 크기 계산 (gap 포함)
|
||||
*/
|
||||
export const getCellWithGap = () => {
|
||||
return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 캔버스 너비 계산
|
||||
*/
|
||||
export const getCanvasWidth = () => {
|
||||
const cellWithGap = getCellWithGap();
|
||||
return GRID_CONFIG.COLUMNS * cellWithGap - GRID_CONFIG.GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
* 좌표를 가장 가까운 그리드 포인트로 스냅 (여백 포함)
|
||||
* @param value - 스냅할 좌표값
|
||||
* @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE)
|
||||
* @returns 스냅된 좌표값 (여백 포함)
|
||||
*/
|
||||
export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const gridIndex = Math.round(value / cellWithGap);
|
||||
return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING;
|
||||
};
|
||||
|
||||
/**
|
||||
* 좌표를 그리드에 스냅 (임계값 적용)
|
||||
* @param value - 현재 좌표값
|
||||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값)
|
||||
*/
|
||||
export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||
const snapped = snapToGrid(value, cellSize);
|
||||
const distance = Math.abs(value - snapped);
|
||||
|
||||
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 크기를 그리드 단위로 스냅
|
||||
* @param size - 스냅할 크기
|
||||
* @param minCells - 최소 셀 개수 (기본값: 2)
|
||||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 크기
|
||||
*/
|
||||
export const snapSizeToGrid = (
|
||||
size: number,
|
||||
minCells: number = 2,
|
||||
cellSize: number = GRID_CONFIG.CELL_SIZE,
|
||||
): number => {
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const cells = Math.max(minCells, Math.round(size / cellWithGap));
|
||||
return cells * cellWithGap - GRID_CONFIG.GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치와 크기를 모두 그리드에 스냅
|
||||
*/
|
||||
export interface GridPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface GridSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GridBounds {
|
||||
position: GridPosition;
|
||||
size: GridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소의 위치와 크기를 그리드에 맞춰 조정
|
||||
* @param bounds - 현재 위치와 크기
|
||||
* @param canvasWidth - 캔버스 너비 (경계 체크용)
|
||||
* @param canvasHeight - 캔버스 높이 (경계 체크용)
|
||||
* @returns 그리드에 스냅된 위치와 크기
|
||||
*/
|
||||
export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canvasHeight?: number): GridBounds => {
|
||||
// 위치 스냅
|
||||
let snappedX = snapToGrid(bounds.position.x);
|
||||
let snappedY = snapToGrid(bounds.position.y);
|
||||
|
||||
// 크기 스냅
|
||||
const snappedWidth = snapSizeToGrid(bounds.size.width);
|
||||
const snappedHeight = snapSizeToGrid(bounds.size.height);
|
||||
|
||||
// 캔버스 경계 체크
|
||||
if (canvasWidth) {
|
||||
snappedX = Math.min(snappedX, canvasWidth - snappedWidth);
|
||||
}
|
||||
if (canvasHeight) {
|
||||
snappedY = Math.min(snappedY, canvasHeight - snappedHeight);
|
||||
}
|
||||
|
||||
// 음수 방지
|
||||
snappedX = Math.max(0, snappedX);
|
||||
snappedY = Math.max(0, snappedY);
|
||||
|
||||
return {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
size: { width: snappedWidth, height: snappedHeight },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 좌표가 어느 그리드 셀에 속하는지 계산
|
||||
* @param value - 좌표값
|
||||
* @returns 그리드 인덱스 (0부터 시작)
|
||||
*/
|
||||
export const getGridIndex = (value: number): number => {
|
||||
const cellWithGap = getCellWithGap();
|
||||
return Math.floor(value / cellWithGap);
|
||||
};
|
||||
|
||||
/**
|
||||
* 그리드 인덱스를 좌표로 변환
|
||||
* @param index - 그리드 인덱스
|
||||
* @returns 좌표값
|
||||
*/
|
||||
export const gridIndexToCoordinate = (index: number): number => {
|
||||
const cellWithGap = getCellWithGap();
|
||||
return index * cellWithGap;
|
||||
};
|
||||
|
||||
/**
|
||||
* 스냅 가이드라인 표시용 좌표 계산
|
||||
* @param value - 현재 좌표
|
||||
* @returns 가장 가까운 그리드 라인들의 좌표 배열
|
||||
*/
|
||||
export const getNearbyGridLines = (value: number): number[] => {
|
||||
const snapped = snapToGrid(value);
|
||||
const cellWithGap = getCellWithGap();
|
||||
|
||||
return [snapped - cellWithGap, snapped, snapped + cellWithGap].filter((line) => line >= 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치가 스냅 임계값 내에 있는지 확인
|
||||
* @param value - 현재 값
|
||||
* @param snapValue - 스냅할 값
|
||||
* @returns 임계값 내에 있으면 true
|
||||
*/
|
||||
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
|
||||
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
|
||||
};
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* 환율 위젯 컴포넌트
|
||||
* - 실시간 환율 정보를 표시
|
||||
* - 한국은행(BOK) API 연동
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getExchangeRate, ExchangeRateData } from '@/lib/api/openApi';
|
||||
import { TrendingUp, TrendingDown, RefreshCw, ArrowRightLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
interface ExchangeWidgetProps {
|
||||
baseCurrency?: string;
|
||||
targetCurrency?: string;
|
||||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||||
}
|
||||
|
||||
export default function ExchangeWidget({
|
||||
baseCurrency = 'KRW',
|
||||
targetCurrency = 'USD',
|
||||
refreshInterval = 600000,
|
||||
}: ExchangeWidgetProps) {
|
||||
const [base, setBase] = useState(baseCurrency);
|
||||
const [target, setTarget] = useState(targetCurrency);
|
||||
const [exchangeRate, setExchangeRate] = useState<ExchangeRateData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// 지원 통화 목록
|
||||
const currencies = [
|
||||
{ value: 'KRW', label: '🇰🇷 KRW (원)', symbol: '₩' },
|
||||
{ value: 'USD', label: '🇺🇸 USD (달러)', symbol: '$' },
|
||||
{ value: 'EUR', label: '🇪🇺 EUR (유로)', symbol: '€' },
|
||||
{ value: 'JPY', label: '🇯🇵 JPY (엔)', symbol: '¥' },
|
||||
{ value: 'CNY', label: '🇨🇳 CNY (위안)', symbol: '¥' },
|
||||
{ value: 'GBP', label: '🇬🇧 GBP (파운드)', symbol: '£' },
|
||||
];
|
||||
|
||||
// 환율 조회
|
||||
const fetchExchangeRate = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
const data = await getExchangeRate(base, target);
|
||||
setExchangeRate(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err: any) {
|
||||
console.error('환율 조회 실패:', err);
|
||||
|
||||
let errorMessage = '환율 정보를 가져오는 중 오류가 발생했습니다.';
|
||||
|
||||
if (err.response?.status === 503) {
|
||||
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||||
} else if (err.response?.status === 401) {
|
||||
errorMessage = 'API 키가 유효하지 않습니다.';
|
||||
} else if (err.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로딩 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
fetchExchangeRate();
|
||||
const interval = setInterval(fetchExchangeRate, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [base, target, refreshInterval]);
|
||||
|
||||
// 통화 스왑
|
||||
const handleSwap = () => {
|
||||
setBase(target);
|
||||
setTarget(base);
|
||||
};
|
||||
|
||||
// 통화 기호 가져오기
|
||||
const getCurrencySymbol = (currency: string) => {
|
||||
return currencies.find((c) => c.value === currency)?.symbol || currency;
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (loading && !exchangeRate) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-green-500" />
|
||||
<p className="text-sm text-gray-600">환율 정보 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !exchangeRate) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
|
||||
<TrendingDown className="h-12 w-12 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600 text-center mb-3">{error || '환율 정보를 불러올 수 없습니다.'}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchExchangeRate}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-green-50 to-emerald-50 rounded-lg border p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-1">💱 환율</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchExchangeRate}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 통화 선택 */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Select value={base} onValueChange={setBase}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleSwap}
|
||||
className="h-10 w-10 p-0 rounded-full hover:bg-white"
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Select value={target} onValueChange={setTarget}>
|
||||
<SelectTrigger className="flex-1 bg-white">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{currencies.map((currency) => (
|
||||
<SelectItem key={currency.value} value={currency.value}>
|
||||
{currency.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 환율 표시 */}
|
||||
<div className="bg-white rounded-lg border p-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-sm text-gray-600 mb-2">
|
||||
{exchangeRate.base === 'KRW' ? '1,000' : '1'} {getCurrencySymbol(exchangeRate.base)} =
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-gray-900 mb-1">
|
||||
{exchangeRate.base === 'KRW'
|
||||
? (exchangeRate.rate * 1000).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})
|
||||
: exchangeRate.rate.toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4,
|
||||
})}
|
||||
</div>
|
||||
<div className="text-sm text-gray-600">{getCurrencySymbol(exchangeRate.target)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 계산 예시 */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">10,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(10000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border p-3">
|
||||
<div className="text-xs text-gray-500 mb-1">100,000 {base}</div>
|
||||
<div className="text-lg font-semibold text-gray-900">
|
||||
{(100000 * (base === 'KRW' ? exchangeRate.rate : 1 / exchangeRate.rate)).toLocaleString('ko-KR', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 2,
|
||||
})}{' '}
|
||||
{target}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 데이터 출처 */}
|
||||
<div className="mt-4 pt-3 border-t text-center">
|
||||
<p className="text-xs text-gray-400">출처: {exchangeRate.source}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,405 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* 날씨 위젯 컴포넌트
|
||||
* - 실시간 날씨 정보를 표시
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { getWeather, WeatherData } from '@/lib/api/openApi';
|
||||
import {
|
||||
Cloud,
|
||||
CloudRain,
|
||||
Sun,
|
||||
CloudSnow,
|
||||
Wind,
|
||||
Droplets,
|
||||
Gauge,
|
||||
RefreshCw,
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WeatherWidgetProps {
|
||||
city?: string;
|
||||
refreshInterval?: number; // 새로고침 간격 (ms), 기본값: 600000 (10분)
|
||||
}
|
||||
|
||||
export default function WeatherWidget({
|
||||
city = '서울',
|
||||
refreshInterval = 600000,
|
||||
}: WeatherWidgetProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedCity, setSelectedCity] = useState(city);
|
||||
const [weather, setWeather] = useState<WeatherData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
|
||||
// 도시 목록 (전국 시/군/구 단위)
|
||||
const cities = [
|
||||
// 서울특별시 (25개 구)
|
||||
{ value: '서울', label: '서울' },
|
||||
{ value: '종로구', label: '서울 종로구' },
|
||||
{ value: '중구', label: '서울 중구' },
|
||||
{ value: '용산구', label: '서울 용산구' },
|
||||
{ value: '성동구', label: '서울 성동구' },
|
||||
{ value: '광진구', label: '서울 광진구' },
|
||||
{ value: '동대문구', label: '서울 동대문구' },
|
||||
{ value: '중랑구', label: '서울 중랑구' },
|
||||
{ value: '성북구', label: '서울 성북구' },
|
||||
{ value: '강북구', label: '서울 강북구' },
|
||||
{ value: '도봉구', label: '서울 도봉구' },
|
||||
{ value: '노원구', label: '서울 노원구' },
|
||||
{ value: '은평구', label: '서울 은평구' },
|
||||
{ value: '서대문구', label: '서울 서대문구' },
|
||||
{ value: '마포구', label: '서울 마포구' },
|
||||
{ value: '양천구', label: '서울 양천구' },
|
||||
{ value: '강서구', label: '서울 강서구' },
|
||||
{ value: '구로구', label: '서울 구로구' },
|
||||
{ value: '금천구', label: '서울 금천구' },
|
||||
{ value: '영등포구', label: '서울 영등포구' },
|
||||
{ value: '동작구', label: '서울 동작구' },
|
||||
{ value: '관악구', label: '서울 관악구' },
|
||||
{ value: '서초구', label: '서울 서초구' },
|
||||
{ value: '강남구', label: '서울 강남구' },
|
||||
{ value: '송파구', label: '서울 송파구' },
|
||||
{ value: '강동구', label: '서울 강동구' },
|
||||
|
||||
// 부산광역시
|
||||
{ value: '부산', label: '부산' },
|
||||
{ value: '해운대구', label: '부산 해운대구' },
|
||||
{ value: '부산진구', label: '부산 부산진구' },
|
||||
{ value: '동래구', label: '부산 동래구' },
|
||||
{ value: '사하구', label: '부산 사하구' },
|
||||
{ value: '금정구', label: '부산 금정구' },
|
||||
{ value: '사상구', label: '부산 사상구' },
|
||||
|
||||
// 인천광역시
|
||||
{ value: '인천', label: '인천' },
|
||||
{ value: '부평구', label: '인천 부평구' },
|
||||
{ value: '계양구', label: '인천 계양구' },
|
||||
{ value: '남동구', label: '인천 남동구' },
|
||||
|
||||
// 대구광역시
|
||||
{ value: '대구', label: '대구' },
|
||||
{ value: '수성구', label: '대구 수성구' },
|
||||
{ value: '달서구', label: '대구 달서구' },
|
||||
|
||||
// 광주광역시
|
||||
{ value: '광주', label: '광주' },
|
||||
{ value: '광산구', label: '광주 광산구' },
|
||||
|
||||
// 대전광역시
|
||||
{ value: '대전', label: '대전' },
|
||||
{ value: '유성구', label: '대전 유성구' },
|
||||
|
||||
// 울산광역시
|
||||
{ value: '울산', label: '울산' },
|
||||
|
||||
// 세종특별자치시
|
||||
{ value: '세종', label: '세종' },
|
||||
|
||||
// 경기도 (주요 도시)
|
||||
{ value: '수원', label: '수원' },
|
||||
{ value: '성남', label: '성남' },
|
||||
{ value: '고양', label: '고양' },
|
||||
{ value: '용인', label: '용인' },
|
||||
{ value: '부천', label: '부천' },
|
||||
{ value: '안산', label: '안산' },
|
||||
{ value: '안양', label: '안양' },
|
||||
{ value: '남양주', label: '남양주' },
|
||||
{ value: '화성', label: '화성' },
|
||||
{ value: '평택', label: '평택' },
|
||||
{ value: '의정부', label: '의정부' },
|
||||
{ value: '시흥', label: '시흥' },
|
||||
{ value: '파주', label: '파주' },
|
||||
{ value: '김포', label: '김포' },
|
||||
{ value: '광명', label: '광명' },
|
||||
|
||||
// 강원도
|
||||
{ value: '춘천', label: '춘천' },
|
||||
{ value: '원주', label: '원주' },
|
||||
{ value: '강릉', label: '강릉' },
|
||||
{ value: '속초', label: '속초' },
|
||||
{ value: '동해', label: '동해' },
|
||||
{ value: '태백', label: '태백' },
|
||||
{ value: '삼척', label: '삼척' },
|
||||
|
||||
// 충청북도
|
||||
{ value: '청주', label: '청주' },
|
||||
{ value: '충주', label: '충주' },
|
||||
{ value: '제천', label: '제천' },
|
||||
|
||||
// 충청남도
|
||||
{ value: '천안', label: '천안' },
|
||||
{ value: '공주', label: '공주' },
|
||||
{ value: '보령', label: '보령' },
|
||||
{ value: '아산', label: '아산' },
|
||||
{ value: '서산', label: '서산' },
|
||||
{ value: '논산', label: '논산' },
|
||||
{ value: '당진', label: '당진' },
|
||||
|
||||
// 전라북도
|
||||
{ value: '전주', label: '전주' },
|
||||
{ value: '군산', label: '군산' },
|
||||
{ value: '익산', label: '익산' },
|
||||
{ value: '정읍', label: '정읍' },
|
||||
{ value: '남원', label: '남원' },
|
||||
{ value: '김제', label: '김제' },
|
||||
|
||||
// 전라남도
|
||||
{ value: '목포', label: '목포' },
|
||||
{ value: '여수', label: '여수' },
|
||||
{ value: '순천', label: '순천' },
|
||||
{ value: '나주', label: '나주' },
|
||||
{ value: '광양', label: '광양' },
|
||||
|
||||
// 경상북도
|
||||
{ value: '포항', label: '포항' },
|
||||
{ value: '경주', label: '경주' },
|
||||
{ value: '김천', label: '김천' },
|
||||
{ value: '안동', label: '안동' },
|
||||
{ value: '구미', label: '구미' },
|
||||
{ value: '영주', label: '영주' },
|
||||
{ value: '영천', label: '영천' },
|
||||
{ value: '상주', label: '상주' },
|
||||
{ value: '문경', label: '문경' },
|
||||
{ value: '경산', label: '경산' },
|
||||
{ value: '울릉도', label: '울릉도' },
|
||||
|
||||
// 경상남도
|
||||
{ value: '창원', label: '창원' },
|
||||
{ value: '진주', label: '진주' },
|
||||
{ value: '통영', label: '통영' },
|
||||
{ value: '사천', label: '사천' },
|
||||
{ value: '김해', label: '김해' },
|
||||
{ value: '밀양', label: '밀양' },
|
||||
{ value: '거제', label: '거제' },
|
||||
{ value: '양산', label: '양산' },
|
||||
|
||||
// 제주특별자치도
|
||||
{ value: '제주', label: '제주' },
|
||||
{ value: '서귀포', label: '서귀포' },
|
||||
];
|
||||
|
||||
// 날씨 정보 가져오기
|
||||
const fetchWeather = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await getWeather(selectedCity, 'metric', 'kr');
|
||||
setWeather(data);
|
||||
setLastUpdated(new Date());
|
||||
} catch (err: any) {
|
||||
console.error('날씨 조회 실패:', err);
|
||||
|
||||
// 에러 메시지 추출
|
||||
let errorMessage = '날씨 정보를 가져오는 중 오류가 발생했습니다.';
|
||||
|
||||
if (err.response?.status === 503) {
|
||||
errorMessage = 'API 키가 설정되지 않았습니다. 관리자에게 문의하세요.';
|
||||
} else if (err.response?.status === 401) {
|
||||
errorMessage = 'API 키가 유효하지 않습니다.';
|
||||
} else if (err.response?.status === 404) {
|
||||
errorMessage = `도시를 찾을 수 없습니다: ${city}`;
|
||||
} else if (err.response?.data?.message) {
|
||||
errorMessage = err.response.data.message;
|
||||
}
|
||||
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 초기 로딩 및 자동 새로고침
|
||||
useEffect(() => {
|
||||
fetchWeather();
|
||||
const interval = setInterval(fetchWeather, refreshInterval);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedCity, refreshInterval]);
|
||||
|
||||
// 도시 변경 핸들러
|
||||
const handleCityChange = (newCity: string) => {
|
||||
setSelectedCity(newCity);
|
||||
};
|
||||
|
||||
// 날씨 아이콘 선택
|
||||
const getWeatherIcon = (weatherMain: string) => {
|
||||
switch (weatherMain.toLowerCase()) {
|
||||
case 'clear':
|
||||
return <Sun className="h-12 w-12 text-yellow-500" />;
|
||||
case 'clouds':
|
||||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||||
case 'rain':
|
||||
case 'drizzle':
|
||||
return <CloudRain className="h-12 w-12 text-blue-500" />;
|
||||
case 'snow':
|
||||
return <CloudSnow className="h-12 w-12 text-blue-300" />;
|
||||
default:
|
||||
return <Cloud className="h-12 w-12 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 로딩 상태
|
||||
if (loading && !weather) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-blue-500" />
|
||||
<p className="text-sm text-gray-600">날씨 정보 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !weather) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 rounded-lg border p-6">
|
||||
<Cloud className="h-12 w-12 text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-600 text-center mb-3">{error || '날씨 정보를 불러올 수 없습니다.'}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchWeather}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full bg-gradient-to-br from-blue-50 to-indigo-50 rounded-lg border p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="justify-between text-lg font-semibold text-gray-900 hover:bg-white/50 h-auto py-1 px-2"
|
||||
>
|
||||
{cities.find((city) => city.value === selectedCity)?.label || '도시 선택'}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="도시 검색..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>도시를 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{cities.map((city) => (
|
||||
<CommandItem
|
||||
key={city.value}
|
||||
value={city.value}
|
||||
onSelect={(currentValue) => {
|
||||
handleCityChange(currentValue === selectedCity ? selectedCity : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
selectedCity === city.value ? 'opacity-100' : 'opacity-0'
|
||||
)}
|
||||
/>
|
||||
{city.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 pl-2">
|
||||
{lastUpdated
|
||||
? `업데이트: ${lastUpdated.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={fetchWeather}
|
||||
disabled={loading}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 날씨 아이콘 및 온도 */}
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
{getWeatherIcon(weather.weatherMain)}
|
||||
<div>
|
||||
<div className="text-5xl font-bold text-gray-900">
|
||||
{weather.temperature}°C
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 capitalize">
|
||||
{weather.weatherDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">체감 온도</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.feelsLike}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Droplets className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">습도</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.humidity}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Wind className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">풍속</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.windSpeed} m/s
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-white/50 rounded-lg p-3">
|
||||
<Gauge className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">기압</p>
|
||||
<p className="text-sm font-semibold text-gray-900">
|
||||
{weather.pressure} hPa
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -68,7 +68,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
tables.push(relationships.toTable.tableName);
|
||||
}
|
||||
|
||||
// 관계 수 계산 (actionGroups 기준)
|
||||
// 제어 수 계산 (actionGroups 기준)
|
||||
const actionGroups = relationships.actionGroups || [];
|
||||
const relationshipCount = actionGroups.reduce((count: number, group: any) => {
|
||||
return count + (group.actions?.length || 0);
|
||||
|
|
@ -79,7 +79,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
||||
diagramName: diagram.diagram_name,
|
||||
connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용
|
||||
relationshipType: "multi-relationship", // 다중 관계 타입
|
||||
relationshipType: "multi-relationship", // 다중 제어 타입
|
||||
relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정
|
||||
tableCount: tables.length,
|
||||
tables: tables,
|
||||
|
|
@ -96,14 +96,14 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
setTotal(response.pagination.total || 0);
|
||||
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
|
||||
} catch (error) {
|
||||
console.error("관계 목록 조회 실패", error);
|
||||
toast.error("관계 목록을 불러오는데 실패했습니다.");
|
||||
console.error("제어 목록 조회 실패", error);
|
||||
toast.error("제어 목록을 불러오는데 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, searchTerm, companyCode]);
|
||||
|
||||
// 관계 목록 로드
|
||||
// 제어 목록 로드
|
||||
useEffect(() => {
|
||||
loadDiagrams();
|
||||
}, [loadDiagrams]);
|
||||
|
|
@ -130,13 +130,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
undefined,
|
||||
user?.userId || "SYSTEM",
|
||||
);
|
||||
toast.success(`관계가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
|
||||
toast.success(`제어가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
} catch (error) {
|
||||
console.error("관계 복사 실패:", error);
|
||||
toast.error("관계 복사에 실패했습니다.");
|
||||
console.error("제어 복사 실패:", error);
|
||||
toast.error("제어 복사에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowCopyModal(false);
|
||||
|
|
@ -151,13 +151,13 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
try {
|
||||
setLoading(true);
|
||||
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
|
||||
toast.success(`관계가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
|
||||
toast.success(`제어가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
|
||||
|
||||
// 목록 새로고침
|
||||
await loadDiagrams();
|
||||
} catch (error) {
|
||||
console.error("관계 삭제 실패:", error);
|
||||
toast.error("관계 삭제에 실패했습니다.");
|
||||
console.error("제어 삭제 실패:", error);
|
||||
toast.error("제어 삭제에 실패했습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setShowDeleteModal(false);
|
||||
|
|
@ -181,7 +181,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<div className="relative">
|
||||
<Search className="absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform text-gray-400" />
|
||||
<Input
|
||||
placeholder="관계명, 테이블명으로 검색..."
|
||||
placeholder="제어명, 테이블명으로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-80 pl-10"
|
||||
|
|
@ -189,17 +189,17 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
</div>
|
||||
</div>
|
||||
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => onDesignDiagram(null)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 관계 생성
|
||||
<Plus className="mr-2 h-4 w-4" />새 제어 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 관계 목록 테이블 */}
|
||||
{/* 제어 목록 테이블 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center">
|
||||
<Network className="mr-2 h-5 w-5" />
|
||||
데이터 흐름 관계 ({total})
|
||||
데이터 흐름 제어 ({total})
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -207,10 +207,10 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>관계명</TableHead>
|
||||
<TableHead>제어명</TableHead>
|
||||
<TableHead>회사 코드</TableHead>
|
||||
<TableHead>테이블 수</TableHead>
|
||||
<TableHead>관계 수</TableHead>
|
||||
<TableHead>액션 수</TableHead>
|
||||
<TableHead>최근 수정</TableHead>
|
||||
<TableHead>작업</TableHead>
|
||||
</TableRow>
|
||||
|
|
@ -244,7 +244,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center text-sm">
|
||||
<Calendar className="mr-1 h-3 w-3 text-gray-400" />
|
||||
{new Date(diagram.updatedAt).toLocaleDateString()}
|
||||
</div>
|
||||
|
|
@ -284,8 +284,8 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
{diagrams.length === 0 && (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<Network className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<div className="mb-2 text-lg font-medium">관계가 없습니다</div>
|
||||
<div className="text-sm">새 관계를 생성하여 테이블 간 데이터 관계를 설정해보세요.</div>
|
||||
<div className="mb-2 text-lg font-medium">제어가 없습니다</div>
|
||||
<div className="text-sm">새 제어를 생성하여 테이블 간 데이터 제어를 설정해보세요.</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
@ -302,7 +302,7 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{currentPage} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
|
|
@ -320,11 +320,11 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<Dialog open={showCopyModal} onOpenChange={setShowCopyModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>관계 복사</DialogTitle>
|
||||
<DialogTitle>제어 복사</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 관계를 복사하시겠습니까?
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 복사하시겠습니까?
|
||||
<br />
|
||||
새로운 관계는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다.
|
||||
새로운 제어는 원본 이름 뒤에 (1), (2), (3)... 형태로 생성됩니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
|
|
@ -342,12 +342,12 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
|||
<Dialog open={showDeleteModal} onOpenChange={setShowDeleteModal}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-destructive">관계 삭제</DialogTitle>
|
||||
<DialogTitle className="text-red-600">제어 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
“{selectedDiagramForAction?.diagramName}” 관계를 완전히 삭제하시겠습니까?
|
||||
“{selectedDiagramForAction?.diagramName}” 제어를 완전히 삭제하시겠습니까?
|
||||
<br />
|
||||
<span className="font-medium text-destructive">
|
||||
이 작업은 되돌릴 수 없으며, 모든 관계 정보가 영구적으로 삭제됩니다.
|
||||
<span className="font-medium text-red-600">
|
||||
이 작업은 되돌릴 수 없으며, 모든 제어 정보가 영구적으로 삭제됩니다.
|
||||
</span>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { ArrowLeft, Settings, CheckCircle } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { ArrowLeft, Settings, CheckCircle, Eye } from "lucide-react";
|
||||
|
||||
// 타입 import
|
||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||
|
|
@ -14,6 +15,7 @@ import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
|||
|
||||
// 컴포넌트 import
|
||||
import ActionConditionBuilder from "./ActionConfig/ActionConditionBuilder";
|
||||
import { DataflowVisualization } from "./DataflowVisualization";
|
||||
|
||||
interface ActionConfigStepProps {
|
||||
state: DataConnectionState;
|
||||
|
|
@ -78,7 +80,8 @@ const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
|
|||
|
||||
const canComplete =
|
||||
actionType &&
|
||||
(actionType === "insert" || (actionConditions.length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
|
||||
(actionType === "insert" ||
|
||||
((actionConditions || []).length > 0 && (actionType === "delete" || fieldMappings.length > 0)));
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -89,106 +92,137 @@ const ActionConfigStep: React.FC<ActionConfigStepProps> = ({
|
|||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||
{/* 액션 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">액션 타입</h3>
|
||||
<Select value={actionType} onValueChange={actions.setActionType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="액션 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actionTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{type.label}</span>
|
||||
<p className="text-muted-foreground text-xs">{type.description}</p>
|
||||
<CardContent className="flex h-full flex-col overflow-hidden p-0">
|
||||
<Tabs defaultValue="config" className="flex h-full flex-col">
|
||||
<div className="flex-shrink-0 border-b px-4 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="config" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
액션 설정
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="visualization" className="flex items-center gap-2">
|
||||
<Eye className="h-4 w-4" />
|
||||
흐름 미리보기
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* 액션 설정 탭 */}
|
||||
<TabsContent value="config" className="mt-0 flex-1 overflow-y-auto p-4">
|
||||
<div className="space-y-6">
|
||||
{/* 액션 타입 선택 */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold">액션 타입</h3>
|
||||
<Select value={actionType} onValueChange={actions.setActionType}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="액션 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{actionTypes.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{type.label}</span>
|
||||
<p className="text-muted-foreground text-xs">{type.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{actionType && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
{actionTypes.find((t) => t.value === actionType)?.label}
|
||||
</Badge>
|
||||
<span className="text-sm">{actionTypes.find((t) => t.value === actionType)?.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{actionType && (
|
||||
<div className="bg-primary/5 border-primary/20 rounded-lg border p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="outline" className="text-primary">
|
||||
{actionTypes.find((t) => t.value === actionType)?.label}
|
||||
</Badge>
|
||||
<span className="text-sm">{actionTypes.find((t) => t.value === actionType)?.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 상세 조건 설정 */}
|
||||
{actionType && !isLoading && fromColumns.length > 0 && toColumns.length > 0 && (
|
||||
<ActionConditionBuilder
|
||||
actionType={actionType}
|
||||
fromColumns={fromColumns}
|
||||
toColumns={toColumns}
|
||||
conditions={actionConditions}
|
||||
fieldMappings={fieldMappings}
|
||||
onConditionsChange={(conditions) => {
|
||||
// 액션 조건 배열 전체 업데이트
|
||||
actions.setActionConditions(conditions);
|
||||
}}
|
||||
onFieldMappingsChange={setFieldMappings}
|
||||
/>
|
||||
)}
|
||||
{/* 상세 조건 설정 */}
|
||||
{actionType && !isLoading && fromColumns.length > 0 && toColumns.length > 0 && (
|
||||
<ActionConditionBuilder
|
||||
actionType={actionType}
|
||||
fromColumns={fromColumns}
|
||||
toColumns={toColumns}
|
||||
conditions={actionConditions || []}
|
||||
fieldMappings={fieldMappings}
|
||||
onConditionsChange={(conditions) => {
|
||||
// 액션 조건 배열 전체 업데이트
|
||||
actions.setActionConditions(conditions);
|
||||
}}
|
||||
onFieldMappingsChange={setFieldMappings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 로딩 상태 */}
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* INSERT 액션 안내 */}
|
||||
{actionType === "insert" && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-green-800">INSERT 액션</h4>
|
||||
<p className="text-sm text-green-700">
|
||||
INSERT 액션은 별도의 실행 조건이 필요하지 않습니다. 매핑된 모든 데이터가 새로운 레코드로 삽입됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{/* INSERT 액션 안내 */}
|
||||
{actionType === "insert" && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 p-4">
|
||||
<h4 className="mb-2 text-sm font-medium text-green-800">INSERT 액션</h4>
|
||||
<p className="text-sm text-green-700">
|
||||
INSERT 액션은 별도의 실행 조건이 필요하지 않습니다. 매핑된 모든 데이터가 새로운 레코드로 삽입됩니다.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 액션 요약 */}
|
||||
{actionType && (
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="mb-3 text-sm font-medium">설정 요약</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>액션 타입:</span>
|
||||
<Badge variant="outline">{actionType.toUpperCase()}</Badge>
|
||||
</div>
|
||||
{actionType !== "insert" && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>실행 조건:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
|
||||
</span>
|
||||
</div>
|
||||
{actionType !== "delete" && (
|
||||
{/* 액션 요약 */}
|
||||
{actionType && (
|
||||
<div className="bg-muted/50 rounded-lg p-4">
|
||||
<h4 className="mb-3 text-sm font-medium">설정 요약</h4>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>필드 매핑:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
|
||||
</span>
|
||||
<span>액션 타입:</span>
|
||||
<Badge variant="outline">{actionType.toUpperCase()}</Badge>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
{actionType !== "insert" && (
|
||||
<>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>실행 조건:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{actionConditions.length > 0 ? `${actionConditions.length}개 조건` : "조건 없음"}
|
||||
</span>
|
||||
</div>
|
||||
{actionType !== "delete" && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>필드 매핑:</span>
|
||||
<span className="text-muted-foreground">
|
||||
{fieldMappings.length > 0 ? `${fieldMappings.length}개 필드` : "필드 없음"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 흐름 미리보기 탭 */}
|
||||
<TabsContent value="visualization" className="mt-0 flex-1 overflow-y-auto">
|
||||
<DataflowVisualization
|
||||
state={state}
|
||||
onEdit={(step) => {
|
||||
// 편집 버튼 클릭 시 해당 단계로 이동하는 로직 추가 가능
|
||||
console.log(`편집 요청: ${step}`);
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
<div className="border-t pt-4">
|
||||
<div className="flex-shrink-0 border-t bg-white p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="outline" onClick={onBack} className="flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,321 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Database, Filter, Zap, CheckCircle, XCircle, Edit } from "lucide-react";
|
||||
import { DataConnectionState } from "../types/redesigned";
|
||||
|
||||
interface DataflowVisualizationProps {
|
||||
state: Partial<DataConnectionState> & {
|
||||
dataflowActions?: Array<{
|
||||
actionType: string;
|
||||
targetTable?: string;
|
||||
name?: string;
|
||||
fieldMappings?: any[];
|
||||
}>;
|
||||
};
|
||||
onEdit: (step: "source" | "conditions" | "actions") => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎯 Sankey 다이어그램 스타일 데이터 흐름 시각화
|
||||
*/
|
||||
export const DataflowVisualization: React.FC<DataflowVisualizationProps> = ({ state, onEdit }) => {
|
||||
const { fromTable, toTable, controlConditions = [], dataflowActions = [], fromColumns = [], toColumns = [] } = state;
|
||||
|
||||
// 상태 계산
|
||||
const hasSource = !!fromTable;
|
||||
const hasConditions = controlConditions.length > 0;
|
||||
const hasActions = dataflowActions.length > 0;
|
||||
const isComplete = hasSource && hasActions;
|
||||
|
||||
// 필드명을 라벨명으로 변환하는 함수
|
||||
const getFieldLabel = (fieldName: string) => {
|
||||
// fromColumns와 toColumns에서 해당 필드 찾기
|
||||
const allColumns = [...fromColumns, ...toColumns];
|
||||
const column = allColumns.find((col) => col.columnName === fieldName);
|
||||
return column?.displayName || column?.labelKo || fieldName;
|
||||
};
|
||||
|
||||
// 테이블명을 라벨명으로 변환하는 함수
|
||||
const getTableLabel = (tableName: string) => {
|
||||
// fromTable 또는 toTable의 라벨 반환
|
||||
if (fromTable?.tableName === tableName) {
|
||||
return fromTable?.tableLabel || fromTable?.displayName || tableName;
|
||||
}
|
||||
if (toTable?.tableName === tableName) {
|
||||
return toTable?.tableLabel || toTable?.displayName || tableName;
|
||||
}
|
||||
return tableName;
|
||||
};
|
||||
|
||||
// 액션 그룹별로 대표 액션 1개씩만 표시
|
||||
const actionGroups = dataflowActions.reduce(
|
||||
(acc, action, index) => {
|
||||
// 각 액션을 개별 그룹으로 처리 (실제로는 actionGroups에서 온 것)
|
||||
const groupKey = `group_${index}`;
|
||||
acc[groupKey] = [action];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof dataflowActions>,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600">전체 데이터 흐름을 한눈에 확인</p>
|
||||
<Badge variant={isComplete ? "default" : "secondary"} className="text-sm">
|
||||
{isComplete ? "✅ 설정 완료" : "⚠️ 설정 필요"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Sankey 다이어그램 */}
|
||||
<div className="relative flex items-center justify-center py-12">
|
||||
{/* 연결선 레이어 */}
|
||||
<svg className="absolute inset-0 h-full w-full" style={{ zIndex: 0 }}>
|
||||
{/* 소스 → 조건 선 */}
|
||||
{hasSource && <line x1="25%" y1="50%" x2="50%" y2="50%" stroke="#60a5fa" strokeWidth="3" />}
|
||||
|
||||
{/* 조건 → 액션들 선 (여러 개) */}
|
||||
{hasConditions &&
|
||||
hasActions &&
|
||||
Object.keys(actionGroups).map((groupKey, index) => {
|
||||
const totalActions = Object.keys(actionGroups).length;
|
||||
const startY = 50; // 조건 노드 중앙
|
||||
// 액션이 여러 개면 위에서 아래로 분산
|
||||
const endY =
|
||||
totalActions > 1
|
||||
? 30 + (index * 40) / (totalActions - 1) // 30%~70% 사이에 분산
|
||||
: 50; // 액션이 1개면 중앙
|
||||
|
||||
return (
|
||||
<line
|
||||
key={groupKey}
|
||||
x1="50%"
|
||||
y1={`${startY}%`}
|
||||
x2="75%"
|
||||
y2={`${endY}%`}
|
||||
stroke="#34d399"
|
||||
strokeWidth="2"
|
||||
opacity="0.7"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<div className="relative flex w-full items-center justify-around" style={{ zIndex: 1 }}>
|
||||
{/* 1. 소스 노드 */}
|
||||
<div className="flex flex-col items-center" style={{ width: "28%" }}>
|
||||
<Card
|
||||
className={`w-full cursor-pointer border-2 transition-all hover:shadow-lg ${
|
||||
hasSource ? "border-blue-400 bg-blue-50" : "border-gray-300 bg-gray-50"
|
||||
}`}
|
||||
onClick={() => onEdit("source")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Database className="h-5 w-5 text-blue-600" />
|
||||
<span className="text-sm font-semibold text-gray-900">데이터 소스</span>
|
||||
</div>
|
||||
{hasSource ? (
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{fromTable.tableLabel || fromTable.displayName || fromTable.tableName}
|
||||
</p>
|
||||
{(fromTable.tableLabel || fromTable.displayName) && (
|
||||
<p className="text-xs text-gray-500">({fromTable.tableName})</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">미설정</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 2. 조건 노드 (중앙) */}
|
||||
<div className="flex flex-col items-center" style={{ width: "28%" }}>
|
||||
<Card
|
||||
className={`w-full cursor-pointer border-2 transition-all hover:shadow-lg ${
|
||||
hasConditions ? "border-yellow-400 bg-yellow-50" : "border-gray-300 bg-gray-50"
|
||||
}`}
|
||||
onClick={() => onEdit("conditions")}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Filter className="h-5 w-5 text-yellow-600" />
|
||||
<span className="text-sm font-semibold text-gray-900">조건 검증</span>
|
||||
</div>
|
||||
{hasConditions ? (
|
||||
<div className="space-y-2">
|
||||
{/* 실제 조건들 표시 */}
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto">
|
||||
{controlConditions.slice(0, 3).map((condition, index) => (
|
||||
<div key={index} className="rounded bg-white/50 px-2 py-1">
|
||||
<p className="text-xs font-medium text-gray-800">
|
||||
{index > 0 && (
|
||||
<span className="mr-1 font-bold text-blue-600">
|
||||
{condition.logicalOperator || "AND"}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-blue-800">{getFieldLabel(condition.field)}</span>{" "}
|
||||
<span className="text-gray-600">{condition.operator}</span>{" "}
|
||||
<span className="text-green-700">{condition.value}</span>
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{controlConditions.length > 3 && (
|
||||
<p className="px-2 text-xs text-gray-500">외 {controlConditions.length - 3}개...</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between border-t pt-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<CheckCircle className="h-3 w-3 text-green-600" />
|
||||
<span className="text-xs text-gray-600">충족 → 실행</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<XCircle className="h-3 w-3 text-red-600" />
|
||||
<span className="text-xs text-gray-600">불만족 → 중단</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-gray-500">조건 없음 (항상 실행)</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 3. 액션 노드들 (우측) */}
|
||||
<div className="flex flex-col items-center gap-3" style={{ width: "28%" }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit("actions")}
|
||||
className="mb-2 flex items-center gap-2 self-end"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
<span className="text-xs">전체 편집</span>
|
||||
</Button>
|
||||
|
||||
{hasActions ? (
|
||||
<div className="w-full space-y-3">
|
||||
{Object.entries(actionGroups).map(([groupKey, actions]) => (
|
||||
<ActionFlowCard
|
||||
key={groupKey}
|
||||
type={actions[0].actionType}
|
||||
actions={actions}
|
||||
getTableLabel={getTableLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="w-full border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<CardContent className="p-4 text-center">
|
||||
<Zap className="mx-auto mb-2 h-6 w-6 text-gray-400" />
|
||||
<p className="text-xs text-gray-500">액션 미설정</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건 불만족 시 중단 표시 (하단) */}
|
||||
{hasConditions && (
|
||||
<div
|
||||
className="absolute bottom-0 flex items-center gap-2 rounded-lg border-2 border-red-300 bg-red-50 px-3 py-2"
|
||||
style={{ left: "50%", transform: "translateX(-50%)" }}
|
||||
>
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
<span className="text-xs font-medium text-red-900">조건 불만족 → 실행 중단</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 통계 요약 */}
|
||||
<Card className="border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-around text-center">
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">소스</p>
|
||||
<p className="text-lg font-bold text-blue-600">{hasSource ? 1 : 0}</p>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-gray-300"></div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">조건</p>
|
||||
<p className="text-lg font-bold text-yellow-600">{controlConditions.length}</p>
|
||||
</div>
|
||||
<div className="h-8 w-px bg-gray-300"></div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-600">액션</p>
|
||||
<p className="text-lg font-bold text-green-600">{dataflowActions.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 액션 플로우 카드 컴포넌트
|
||||
interface ActionFlowCardProps {
|
||||
type: string;
|
||||
actions: Array<{
|
||||
actionType: string;
|
||||
targetTable?: string;
|
||||
name?: string;
|
||||
fieldMappings?: any[];
|
||||
}>;
|
||||
getTableLabel: (tableName: string) => string;
|
||||
}
|
||||
|
||||
const ActionFlowCard: React.FC<ActionFlowCardProps> = ({ type, actions, getTableLabel }) => {
|
||||
const actionColors = {
|
||||
insert: { bg: "bg-blue-50", border: "border-blue-300", text: "text-blue-900", icon: "text-blue-600" },
|
||||
update: { bg: "bg-green-50", border: "border-green-300", text: "text-green-900", icon: "text-green-600" },
|
||||
delete: { bg: "bg-red-50", border: "border-red-300", text: "text-red-900", icon: "text-red-600" },
|
||||
upsert: { bg: "bg-purple-50", border: "border-purple-300", text: "text-purple-900", icon: "text-purple-600" },
|
||||
};
|
||||
|
||||
const colors = actionColors[type as keyof typeof actionColors] || actionColors.insert;
|
||||
const action = actions[0]; // 그룹당 1개만 표시
|
||||
const displayName = action.targetTable ? getTableLabel(action.targetTable) : action.name || "액션";
|
||||
const isTableLabel = action.targetTable && getTableLabel(action.targetTable) !== action.targetTable;
|
||||
|
||||
return (
|
||||
<Card className={`border-2 ${colors.border} ${colors.bg}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Zap className={`h-4 w-4 ${colors.icon}`} />
|
||||
<span className={`text-sm font-semibold ${colors.text}`}>{type.toUpperCase()}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<Database className="h-3 w-3 text-gray-500" />
|
||||
<span className="truncate font-medium text-gray-900">{displayName}</span>
|
||||
</div>
|
||||
{isTableLabel && action.targetTable && (
|
||||
<span className="ml-5 truncate text-xs text-gray-500">({action.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,6 +21,7 @@ import {
|
|||
Save,
|
||||
Play,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -28,6 +29,9 @@ import { toast } from "sonner";
|
|||
|
||||
// 타입 import
|
||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||
|
||||
// 컴포넌트 import
|
||||
import { DataflowVisualization } from "./DataflowVisualization";
|
||||
import { ActionGroup, SingleAction, FieldMapping } from "../types/redesigned";
|
||||
|
||||
// 컴포넌트 import
|
||||
|
|
@ -104,7 +108,7 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
onLoadColumns,
|
||||
}) => {
|
||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
|
||||
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
|
||||
const [activeTab, setActiveTab] = useState<"control" | "actions" | "visualization">("control"); // 현재 활성 탭
|
||||
|
||||
// 컬럼 로딩 상태 확인
|
||||
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
|
||||
|
|
@ -163,10 +167,11 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
|
||||
);
|
||||
|
||||
// 탭 정보 (컬럼 매핑 탭 제거)
|
||||
// 탭 정보 (흐름 미리보기 추가)
|
||||
const tabs = [
|
||||
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
|
||||
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
|
||||
{ id: "control" as const, label: "제어 조건", description: "전체 제어 실행 조건" },
|
||||
{ id: "actions" as const, label: "액션 설정", description: "액션 그룹 및 실행 조건" },
|
||||
{ id: "visualization" as const, label: "흐름 미리보기", description: "전체 데이터 흐름을 한눈에 확인" },
|
||||
];
|
||||
|
||||
return (
|
||||
|
|
@ -192,27 +197,16 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
<span>{tab.icon}</span>
|
||||
<span>{tab.label}</span>
|
||||
{tab.id === "actions" && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
{actionGroups.filter((g) => g.isEnabled).length}
|
||||
</Badge>
|
||||
)}
|
||||
{tab.id === "mapping" && hasInsertActions && (
|
||||
<Badge variant="outline" className="ml-1 text-xs">
|
||||
{fieldMappings.length}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 탭 설명 */}
|
||||
<div className="bg-muted/30 mb-4 rounded-md p-3">
|
||||
<p className="text-muted-foreground text-sm">{tabs.find((tab) => tab.id === activeTab)?.description}</p>
|
||||
</div>
|
||||
|
||||
{/* 탭별 컨텐츠 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
{activeTab === "control" && (
|
||||
|
|
@ -671,6 +665,36 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "visualization" && (
|
||||
<DataflowVisualization
|
||||
state={{
|
||||
fromTable,
|
||||
toTable,
|
||||
fromConnection,
|
||||
toConnection,
|
||||
fromColumns,
|
||||
toColumns,
|
||||
controlConditions,
|
||||
dataflowActions: actionGroups.flatMap((group) =>
|
||||
group.actions
|
||||
.filter((action) => action.isEnabled)
|
||||
.map((action) => ({
|
||||
...action,
|
||||
targetTable: toTable?.tableName || "",
|
||||
})),
|
||||
),
|
||||
}}
|
||||
onEdit={(step) => {
|
||||
// 편집 버튼 클릭 시 해당 탭으로 이동
|
||||
if (step === "conditions") {
|
||||
setActiveTab("control");
|
||||
} else if (step === "actions") {
|
||||
setActiveTab("actions");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 하단 네비게이션 */}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,247 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 기반 플로우 에디터 메인 컴포넌트
|
||||
*/
|
||||
|
||||
import { useCallback, useRef } from "react";
|
||||
import ReactFlow, { Background, Controls, MiniMap, Panel, ReactFlowProvider, useReactFlow } from "reactflow";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { NodePalette } from "./sidebar/NodePalette";
|
||||
import { PropertiesPanel } from "./panels/PropertiesPanel";
|
||||
import { FlowToolbar } from "./FlowToolbar";
|
||||
import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
|
||||
import { ConditionNode } from "./nodes/ConditionNode";
|
||||
import { InsertActionNode } from "./nodes/InsertActionNode";
|
||||
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
||||
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||
import { UpsertActionNode } from "./nodes/UpsertActionNode";
|
||||
import { DataTransformNode } from "./nodes/DataTransformNode";
|
||||
import { RestAPISourceNode } from "./nodes/RestAPISourceNode";
|
||||
import { CommentNode } from "./nodes/CommentNode";
|
||||
import { LogNode } from "./nodes/LogNode";
|
||||
|
||||
// 노드 타입들
|
||||
const nodeTypes = {
|
||||
// 데이터 소스
|
||||
tableSource: TableSourceNode,
|
||||
externalDBSource: ExternalDBSourceNode,
|
||||
restAPISource: RestAPISourceNode,
|
||||
referenceLookup: ReferenceLookupNode,
|
||||
// 변환/조건
|
||||
condition: ConditionNode,
|
||||
dataTransform: DataTransformNode,
|
||||
// 액션
|
||||
insertAction: InsertActionNode,
|
||||
updateAction: UpdateActionNode,
|
||||
deleteAction: DeleteActionNode,
|
||||
upsertAction: UpsertActionNode,
|
||||
// 유틸리티
|
||||
comment: CommentNode,
|
||||
log: LogNode,
|
||||
};
|
||||
|
||||
/**
|
||||
* FlowEditor 내부 컴포넌트
|
||||
*/
|
||||
function FlowEditorInner() {
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
onNodesChange,
|
||||
onEdgesChange,
|
||||
onConnect,
|
||||
onNodeDragStart,
|
||||
addNode,
|
||||
showPropertiesPanel,
|
||||
selectNodes,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
undo,
|
||||
redo,
|
||||
} = useFlowEditorStore();
|
||||
|
||||
/**
|
||||
* 노드 선택 변경 핸들러
|
||||
*/
|
||||
const onSelectionChange = useCallback(
|
||||
({ nodes: selectedNodes }: { nodes: any[] }) => {
|
||||
const selectedIds = selectedNodes.map((node) => node.id);
|
||||
selectNodes(selectedIds);
|
||||
console.log("🔍 선택된 노드:", selectedIds);
|
||||
},
|
||||
[selectNodes],
|
||||
);
|
||||
|
||||
/**
|
||||
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo)
|
||||
*/
|
||||
const onKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
console.log("⏪ Undo");
|
||||
undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
|
||||
if (
|
||||
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
||||
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
||||
) {
|
||||
event.preventDefault();
|
||||
console.log("⏩ Redo");
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete: Delete/Backspace 키로 노드 삭제
|
||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
||||
event.preventDefault();
|
||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
||||
removeNodes(selectedNodes);
|
||||
}
|
||||
},
|
||||
[selectedNodes, removeNodes, undo, redo],
|
||||
);
|
||||
|
||||
/**
|
||||
* 드래그 앤 드롭 핸들러
|
||||
*/
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
|
||||
const position = screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
// 🔥 노드 타입별 기본 데이터 설정
|
||||
const defaultData: any = {
|
||||
displayName: `새 ${type} 노드`,
|
||||
};
|
||||
|
||||
// REST API 소스 노드의 경우
|
||||
if (type === "restAPISource") {
|
||||
defaultData.method = "GET";
|
||||
defaultData.url = "";
|
||||
defaultData.headers = {};
|
||||
defaultData.timeout = 30000;
|
||||
defaultData.responseFields = []; // 빈 배열로 초기화
|
||||
defaultData.responseMapping = "";
|
||||
}
|
||||
|
||||
// 액션 노드의 경우 targetType 기본값 설정
|
||||
if (["insertAction", "updateAction", "deleteAction", "upsertAction"].includes(type)) {
|
||||
defaultData.targetType = "internal"; // 기본값: 내부 DB
|
||||
defaultData.fieldMappings = [];
|
||||
defaultData.options = {};
|
||||
|
||||
if (type === "updateAction" || type === "deleteAction") {
|
||||
defaultData.whereConditions = [];
|
||||
}
|
||||
|
||||
if (type === "upsertAction") {
|
||||
defaultData.conflictKeys = [];
|
||||
}
|
||||
}
|
||||
|
||||
const newNode: any = {
|
||||
id: `node_${Date.now()}`,
|
||||
type,
|
||||
position,
|
||||
data: defaultData,
|
||||
};
|
||||
|
||||
addNode(newNode);
|
||||
},
|
||||
[screenToFlowPosition, addNode],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full">
|
||||
{/* 좌측 노드 팔레트 */}
|
||||
<div className="w-[250px] border-r bg-white">
|
||||
<NodePalette />
|
||||
</div>
|
||||
|
||||
{/* 중앙 캔버스 */}
|
||||
<div className="relative flex-1" ref={reactFlowWrapper} onKeyDown={onKeyDown} tabIndex={0}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeDragStart={onNodeDragStart}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
className="bg-gray-50"
|
||||
deleteKeyCode={["Delete", "Backspace"]}
|
||||
>
|
||||
{/* 배경 그리드 */}
|
||||
<Background gap={16} size={1} color="#E5E7EB" />
|
||||
|
||||
{/* 컨트롤 버튼 */}
|
||||
<Controls className="bg-white shadow-md" />
|
||||
|
||||
{/* 미니맵 */}
|
||||
<MiniMap
|
||||
className="bg-white shadow-md"
|
||||
nodeColor={(node) => {
|
||||
// 노드 타입별 색상 (추후 구현)
|
||||
return "#3B82F6";
|
||||
}}
|
||||
maskColor="rgba(0, 0, 0, 0.1)"
|
||||
/>
|
||||
|
||||
{/* 상단 툴바 */}
|
||||
<Panel position="top-center" className="pointer-events-auto">
|
||||
<FlowToolbar />
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* 우측 속성 패널 */}
|
||||
{showPropertiesPanel && (
|
||||
<div className="w-[350px] border-l bg-white">
|
||||
<PropertiesPanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* FlowEditor 메인 컴포넌트 (Provider로 감싸기)
|
||||
*/
|
||||
export function FlowEditor() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-200px)] min-h-[700px] w-full">
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner />
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,191 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 에디터 상단 툴바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { Play, Save, FileCheck, Undo2, Redo2, ZoomIn, ZoomOut, FolderOpen, Download, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { useReactFlow } from "reactflow";
|
||||
import { LoadFlowDialog } from "./dialogs/LoadFlowDialog";
|
||||
import { getNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
export function FlowToolbar() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const {
|
||||
flowName,
|
||||
setFlowName,
|
||||
validateFlow,
|
||||
saveFlow,
|
||||
exportFlow,
|
||||
isExecuting,
|
||||
isSaving,
|
||||
selectedNodes,
|
||||
removeNodes,
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
} = useFlowEditorStore();
|
||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||
|
||||
const handleValidate = () => {
|
||||
const result = validateFlow();
|
||||
if (result.valid) {
|
||||
alert("✅ 검증 성공! 오류가 없습니다.");
|
||||
} else {
|
||||
alert(`❌ 검증 실패\n\n${result.errors.map((e) => `- ${e.message}`).join("\n")}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveFlow();
|
||||
if (result.success) {
|
||||
alert(`✅ ${result.message}\nFlow ID: ${result.flowId}`);
|
||||
} else {
|
||||
alert(`❌ 저장 실패\n\n${result.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportFlow();
|
||||
const blob = new Blob([json], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `${flowName || "flow"}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
alert("✅ JSON 파일로 내보내기 완료!");
|
||||
};
|
||||
|
||||
const handleLoad = async (flowId: number) => {
|
||||
try {
|
||||
const flow = await getNodeFlow(flowId);
|
||||
|
||||
// flowData가 이미 객체인지 문자열인지 확인
|
||||
const parsedData = typeof flow.flowData === "string" ? JSON.parse(flow.flowData) : flow.flowData;
|
||||
|
||||
// Zustand 스토어의 loadFlow 함수 호출
|
||||
useFlowEditorStore
|
||||
.getState()
|
||||
.loadFlow(flow.flowId, flow.flowName, flow.flowDescription, parsedData.nodes, parsedData.edges);
|
||||
alert(`✅ "${flow.flowName}" 플로우를 불러왔습니다!`);
|
||||
} catch (error) {
|
||||
console.error("플로우 불러오기 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 불러올 수 없습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecute = () => {
|
||||
// TODO: 실행 로직 구현
|
||||
alert("실행 기능 구현 예정");
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (selectedNodes.length === 0) {
|
||||
alert("삭제할 노드를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (confirm(`선택된 ${selectedNodes.length}개 노드를 삭제하시겠습니까?`)) {
|
||||
removeNodes(selectedNodes);
|
||||
alert(`✅ ${selectedNodes.length}개 노드가 삭제되었습니다.`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadFlowDialog open={showLoadDialog} onOpenChange={setShowLoadDialog} onLoad={handleLoad} />
|
||||
<div className="flex items-center gap-2 rounded-lg border bg-white p-2 shadow-md">
|
||||
{/* 플로우 이름 */}
|
||||
<Input
|
||||
value={flowName}
|
||||
onChange={(e) => setFlowName(e.target.value)}
|
||||
className="h-8 w-[200px] text-sm"
|
||||
placeholder="플로우 이름"
|
||||
/>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 실행 취소/다시 실행 */}
|
||||
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||
<Undo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||
<Redo2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleDelete}
|
||||
disabled={selectedNodes.length === 0}
|
||||
title={selectedNodes.length > 0 ? `${selectedNodes.length}개 노드 삭제` : "삭제할 노드를 선택하세요"}
|
||||
className="gap-1 text-red-600 hover:bg-red-50 hover:text-red-700 disabled:opacity-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{selectedNodes.length > 0 && <span className="text-xs">({selectedNodes.length})</span>}
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 줌 컨트롤 */}
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomIn()} title="확대">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => zoomOut()} title="축소">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => fitView()} title="전체 보기">
|
||||
<span className="text-xs">전체</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 불러오기 */}
|
||||
<Button variant="outline" size="sm" onClick={() => setShowLoadDialog(true)} className="gap-1">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span className="text-xs">불러오기</span>
|
||||
</Button>
|
||||
|
||||
{/* 저장 */}
|
||||
<Button variant="outline" size="sm" onClick={handleSave} disabled={isSaving} className="gap-1">
|
||||
<Save className="h-4 w-4" />
|
||||
<span className="text-xs">{isSaving ? "저장 중..." : "저장"}</span>
|
||||
</Button>
|
||||
|
||||
{/* 내보내기 */}
|
||||
<Button variant="outline" size="sm" onClick={handleExport} className="gap-1">
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="text-xs">JSON</span>
|
||||
</Button>
|
||||
|
||||
<div className="h-6 w-px bg-gray-200" />
|
||||
|
||||
{/* 검증 */}
|
||||
<Button variant="outline" size="sm" onClick={handleValidate} className="gap-1">
|
||||
<FileCheck className="h-4 w-4" />
|
||||
<span className="text-xs">검증</span>
|
||||
</Button>
|
||||
|
||||
{/* 테스트 실행 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting}
|
||||
className="gap-1 bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
<span className="text-xs">{isExecuting ? "실행 중..." : "테스트 실행"}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,174 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 플로우 불러오기 다이얼로그
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Loader2, FileJson, Calendar, Trash2 } from "lucide-react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getNodeFlows, deleteNodeFlow } from "@/lib/api/nodeFlows";
|
||||
|
||||
interface Flow {
|
||||
flowId: number;
|
||||
flowName: string;
|
||||
flowDescription: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface LoadFlowDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onLoad: (flowId: number) => void;
|
||||
}
|
||||
|
||||
export function LoadFlowDialog({ open, onOpenChange, onLoad }: LoadFlowDialogProps) {
|
||||
const [flows, setFlows] = useState<Flow[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [selectedFlowId, setSelectedFlowId] = useState<number | null>(null);
|
||||
const [deleting, setDeleting] = useState<number | null>(null);
|
||||
|
||||
// 플로우 목록 조회
|
||||
const fetchFlows = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const flows = await getNodeFlows();
|
||||
setFlows(flows);
|
||||
} catch (error) {
|
||||
console.error("플로우 목록 조회 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 플로우 삭제
|
||||
const handleDelete = async (flowId: number, flowName: string) => {
|
||||
if (!confirm(`"${flowName}" 플로우를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleting(flowId);
|
||||
try {
|
||||
await deleteNodeFlow(flowId);
|
||||
alert("✅ 플로우가 삭제되었습니다.");
|
||||
fetchFlows(); // 목록 새로고침
|
||||
} catch (error) {
|
||||
console.error("플로우 삭제 오류:", error);
|
||||
alert(error instanceof Error ? error.message : "플로우를 삭제할 수 없습니다.");
|
||||
} finally {
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 플로우 불러오기
|
||||
const handleLoad = () => {
|
||||
if (selectedFlowId === null) {
|
||||
alert("불러올 플로우를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
onLoad(selectedFlowId);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
// 다이얼로그 열릴 때 목록 조회
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchFlows();
|
||||
setSelectedFlowId(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 날짜 포맷팅
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>플로우 불러오기</DialogTitle>
|
||||
<DialogDescription>저장된 플로우를 선택하여 불러옵니다.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<div className="py-12 text-center">
|
||||
<FileJson className="mx-auto mb-4 h-12 w-12 text-gray-300" />
|
||||
<p className="text-sm text-gray-500">저장된 플로우가 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[400px]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{flows.map((flow) => (
|
||||
<div
|
||||
key={flow.flowId}
|
||||
className={`cursor-pointer rounded-lg border-2 p-4 transition-all hover:border-blue-300 hover:bg-blue-50 ${
|
||||
selectedFlowId === flow.flowId ? "border-blue-500 bg-blue-50" : "border-gray-200 bg-white"
|
||||
}`}
|
||||
onClick={() => setSelectedFlowId(flow.flowId)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-gray-900">{flow.flowName}</h3>
|
||||
<span className="text-xs text-gray-400">#{flow.flowId}</span>
|
||||
</div>
|
||||
{flow.flowDescription && <p className="mt-1 text-sm text-gray-600">{flow.flowDescription}</p>}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>수정: {formatDate(flow.updatedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(flow.flowId, flow.flowName);
|
||||
}}
|
||||
disabled={deleting === flow.flowId}
|
||||
className="text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
>
|
||||
{deleting === flow.flowId ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleLoad} disabled={selectedFlowId === null || loading}>
|
||||
불러오기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 주석 노드 - 플로우 설명용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { NodeProps } from "reactflow";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import type { CommentNodeData } from "@/types/node-editor";
|
||||
|
||||
export const CommentNode = memo(({ data, selected }: NodeProps<CommentNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`max-w-[350px] min-w-[200px] rounded-lg border-2 border-dashed bg-yellow-50 shadow-sm transition-all ${
|
||||
selected ? "border-yellow-500 shadow-md" : "border-yellow-300"
|
||||
}`}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="text-xs font-semibold text-yellow-800">메모</span>
|
||||
</div>
|
||||
<div className="text-sm whitespace-pre-wrap text-gray-700">{data.content || "메모를 입력하세요..."}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
CommentNode.displayName = "CommentNode";
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Zap, Check, X } from "lucide-react";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
const OPERATOR_LABELS: Record<string, string> = {
|
||||
EQUALS: "=",
|
||||
NOT_EQUALS: "≠",
|
||||
GREATER_THAN: ">",
|
||||
LESS_THAN: "<",
|
||||
GREATER_THAN_OR_EQUAL: "≥",
|
||||
LESS_THAN_OR_EQUAL: "≤",
|
||||
LIKE: "포함",
|
||||
NOT_LIKE: "미포함",
|
||||
IN: "IN",
|
||||
NOT_IN: "NOT IN",
|
||||
IS_NULL: "NULL",
|
||||
IS_NOT_NULL: "NOT NULL",
|
||||
};
|
||||
|
||||
export const ConditionNode = memo(({ data, selected }: NodeProps<ConditionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[280px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-yellow-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-yellow-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-yellow-500 px-3 py-2 text-white">
|
||||
<Zap className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">조건 검사</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || "조건 분기"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.conditions && data.conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-gray-700">조건식: ({data.conditions.length}개)</div>
|
||||
<div className="max-h-[150px] space-y-1.5 overflow-y-auto">
|
||||
{data.conditions.slice(0, 4).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-yellow-50 px-2 py-1.5 text-xs">
|
||||
{idx > 0 && (
|
||||
<div className="mb-1 text-center text-xs font-semibold text-yellow-600">{data.logic}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||
<span className="rounded bg-yellow-200 px-1 py-0.5 text-yellow-800">
|
||||
{OPERATOR_LABELS[condition.operator] || condition.operator}
|
||||
</span>
|
||||
{condition.value !== null && condition.value !== undefined && (
|
||||
<span className="text-gray-600">
|
||||
{typeof condition.value === "string" ? `"${condition.value}"` : String(condition.value)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{data.conditions.length > 4 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.conditions.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-xs text-gray-400">조건 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 분기 출력 핸들 */}
|
||||
<div className="relative border-t">
|
||||
{/* TRUE 출력 - 오른쪽 위 */}
|
||||
<div className="relative border-b p-2">
|
||||
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||
<Check className="h-3 w-3 text-green-600" />
|
||||
<span className="font-medium text-green-600">TRUE</span>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="true"
|
||||
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-green-500 !bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* FALSE 출력 - 오른쪽 아래 */}
|
||||
<div className="relative p-2">
|
||||
<div className="flex items-center justify-end gap-1 pr-6 text-xs">
|
||||
<X className="h-3 w-3 text-red-600" />
|
||||
<span className="font-medium text-red-600">FALSE</span>
|
||||
</div>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id="false"
|
||||
className="!top-1/2 !-right-1.5 !h-3 !w-3 !-translate-y-1/2 !border-2 !border-red-500 !bg-white"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ConditionNode.displayName = "ConditionNode";
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Wand2, ArrowRight } from "lucide-react";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DataTransformNode = memo(({ data, selected }: NodeProps<DataTransformNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-indigo-600 px-3 py-2 text-white">
|
||||
<Wand2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "데이터 변환"}</div>
|
||||
<div className="text-xs opacity-80">{data.transformations?.length || 0}개 변환</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.transformations && data.transformations.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{data.transformations.slice(0, 3).map((transform, idx) => {
|
||||
const sourceLabel = transform.sourceFieldLabel || transform.sourceField || "소스";
|
||||
const targetField = transform.targetField || transform.sourceField;
|
||||
const targetLabel = transform.targetFieldLabel || targetField;
|
||||
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
|
||||
|
||||
return (
|
||||
<div key={idx} className="rounded bg-indigo-50 p-2">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs">
|
||||
<span className="font-medium text-indigo-700">{transform.type}</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{sourceLabel}
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
{isInPlace ? (
|
||||
<span className="font-medium text-indigo-600">(자기자신)</span>
|
||||
) : (
|
||||
<span>{targetLabel}</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 타입별 추가 정보 */}
|
||||
{transform.type === "EXPLODE" && transform.delimiter && (
|
||||
<div className="mt-1 text-xs text-gray-500">구분자: {transform.delimiter}</div>
|
||||
)}
|
||||
{transform.type === "CONCAT" && transform.separator && (
|
||||
<div className="mt-1 text-xs text-gray-500">구분자: {transform.separator}</div>
|
||||
)}
|
||||
{transform.type === "REPLACE" && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
"{transform.searchValue}" → "{transform.replaceValue}"
|
||||
</div>
|
||||
)}
|
||||
{transform.expression && (
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
<code className="rounded bg-white px-1 py-0.5">{transform.expression}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.transformations.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.transformations.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4 text-center text-xs text-gray-400">변환 규칙 없음</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-indigo-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-indigo-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DataTransformNode.displayName = "DataTransformNode";
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Trash2, AlertTriangle } from "lucide-react";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const DeleteActionNode = memo(({ data, selected }: NodeProps<DeleteActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-red-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-red-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-red-500 px-3 py-2 text-white">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">DELETE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">타겟: {data.targetTable}</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">WHERE 조건:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-red-50 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-gray-700">{condition.field}</span>
|
||||
<span className="mx-1 text-red-600">{condition.operator}</span>
|
||||
<span className="text-gray-600">{condition.sourceField || condition.staticValue || "?"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded bg-yellow-50 p-2 text-xs text-yellow-700">⚠️ 조건 없음 - 모든 데이터 삭제 주의!</div>
|
||||
)}
|
||||
|
||||
{/* 경고 메시지 */}
|
||||
<div className="mt-3 flex items-start gap-2 rounded border border-red-200 bg-red-50 p-2">
|
||||
<AlertTriangle className="h-3 w-3 flex-shrink-0 text-red-600" />
|
||||
<div className="text-xs text-red-700">
|
||||
<div className="font-medium">주의</div>
|
||||
<div className="mt-0.5">삭제된 데이터는 복구할 수 없습니다</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options?.requireConfirmation && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-red-100 px-1.5 py-0.5 text-xs text-red-700">실행 전 확인 필요</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-red-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DeleteActionNode.displayName = "DeleteActionNode";
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plug } from "lucide-react";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const DB_TYPE_COLORS: Record<string, string> = {
|
||||
PostgreSQL: "#336791",
|
||||
MySQL: "#4479A1",
|
||||
Oracle: "#F80000",
|
||||
MSSQL: "#CC2927",
|
||||
MariaDB: "#003545",
|
||||
};
|
||||
|
||||
const DB_TYPE_ICONS: Record<string, string> = {
|
||||
PostgreSQL: "🐘",
|
||||
MySQL: "🐬",
|
||||
Oracle: "🔴",
|
||||
MSSQL: "🟦",
|
||||
MariaDB: "🦭",
|
||||
};
|
||||
|
||||
export const ExternalDBSourceNode = memo(({ data, selected }: NodeProps<ExternalDBSourceNodeData>) => {
|
||||
const dbColor = (data.dbType && DB_TYPE_COLORS[data.dbType]) || "#F59E0B";
|
||||
const dbIcon = (data.dbType && DB_TYPE_ICONS[data.dbType]) || "🔌";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg px-3 py-2 text-white" style={{ backgroundColor: dbColor }}>
|
||||
<Plug className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.connectionName}</div>
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
</div>
|
||||
<span className="text-lg">{dbIcon}</span>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs">
|
||||
<div className="rounded bg-orange-100 px-2 py-0.5 font-medium text-orange-700">{data.dbType || "DB"}</div>
|
||||
<div className="flex-1 text-gray-500">외부 DB</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: dbColor }} />
|
||||
<span className="font-mono">{field.name}</span>
|
||||
<span className="text-gray-400">({field.type})</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !bg-white"
|
||||
style={{ borderColor: dbColor }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ExternalDBSourceNode.displayName = "ExternalDBSourceNode";
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* INSERT 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { InsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const InsertActionNode = memo(({ data, selected }: NodeProps<InsertActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-green-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-green-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-green-500 px-3 py-2 text-white">
|
||||
<Plus className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">INSERT</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">삽입 필드:</div>
|
||||
<div className="max-h-[120px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 4).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 4 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 4}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{data.options.ignoreDuplicates && (
|
||||
<span className="rounded bg-green-100 px-1.5 py-0.5 text-xs text-green-700">중복 무시</span>
|
||||
)}
|
||||
{data.options.batchSize && (
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-green-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
InsertActionNode.displayName = "InsertActionNode";
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 로그 노드 - 디버깅 및 모니터링용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { FileText, AlertCircle, Info, AlertTriangle } from "lucide-react";
|
||||
import type { LogNodeData } from "@/types/node-editor";
|
||||
|
||||
const LOG_LEVEL_CONFIG = {
|
||||
debug: { icon: Info, color: "text-blue-600", bg: "bg-blue-50", border: "border-blue-200" },
|
||||
info: { icon: Info, color: "text-green-600", bg: "bg-green-50", border: "border-green-200" },
|
||||
warn: { icon: AlertTriangle, color: "text-yellow-600", bg: "bg-yellow-50", border: "border-yellow-200" },
|
||||
error: { icon: AlertCircle, color: "text-red-600", bg: "bg-red-50", border: "border-red-200" },
|
||||
};
|
||||
|
||||
export const LogNode = memo(({ data, selected }: NodeProps<LogNodeData>) => {
|
||||
const config = LOG_LEVEL_CONFIG[data.level] || LOG_LEVEL_CONFIG.info;
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[200px] rounded-lg border-2 bg-white shadow-sm transition-all ${
|
||||
selected ? `${config.border} shadow-md` : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className={`flex items-center gap-2 rounded-t-lg ${config.bg} px-3 py-2`}>
|
||||
<FileText className={`h-4 w-4 ${config.color}`} />
|
||||
<div className="flex-1">
|
||||
<div className={`text-sm font-semibold ${config.color}`}>로그</div>
|
||||
<div className="text-xs text-gray-600">{data.level.toUpperCase()}</div>
|
||||
</div>
|
||||
<Icon className={`h-4 w-4 ${config.color}`} />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{data.message ? (
|
||||
<div className="text-sm text-gray-700">{data.message}</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-400">로그 메시지 없음</div>
|
||||
)}
|
||||
|
||||
{data.includeData && (
|
||||
<div className="mt-2 rounded bg-gray-50 px-2 py-1 text-xs text-gray-600">✓ 데이터 포함</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-gray-400" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-gray-400" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LogNode.displayName = "LogNode";
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 참조 테이블 조회 노드 (내부 DB 전용)
|
||||
* 다른 테이블에서 데이터를 조회하여 조건 비교나 필드 매핑에 사용
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Link2, Database } from "lucide-react";
|
||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
||||
|
||||
export const ReferenceLookupNode = memo(({ data, selected }: NodeProps<ReferenceLookupNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
|
||||
<Link2 className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "참조 조회"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 flex items-center gap-1 text-xs font-medium text-gray-500">
|
||||
<Database className="h-3 w-3" />
|
||||
내부 DB 참조
|
||||
</div>
|
||||
|
||||
{/* 참조 테이블 */}
|
||||
{data.referenceTable && (
|
||||
<div className="mb-3 rounded bg-purple-50 p-2">
|
||||
<div className="text-xs font-medium text-purple-700">📋 참조 테이블</div>
|
||||
<div className="mt-1 font-mono text-xs text-purple-900">
|
||||
{data.referenceTableLabel || data.referenceTable}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 조인 조건 */}
|
||||
{data.joinConditions && data.joinConditions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-medium text-gray-700">🔗 조인 조건:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{data.joinConditions.map((join, idx) => (
|
||||
<div key={idx} className="text-xs text-gray-600">
|
||||
<span className="font-medium">{join.sourceFieldLabel || join.sourceField}</span>
|
||||
<span className="mx-1 text-purple-500">→</span>
|
||||
<span className="font-medium">{join.referenceFieldLabel || join.referenceField}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-xs font-medium text-gray-700">⚡ WHERE 조건:</div>
|
||||
<div className="mt-1 text-xs text-gray-600">{data.whereConditions.length}개 조건</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 출력 필드 */}
|
||||
{data.outputFields && data.outputFields.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-gray-700">📤 출력 필드:</div>
|
||||
<div className="mt-1 max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{data.outputFields.slice(0, 3).map((field, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-purple-400" />
|
||||
<span className="font-medium">{field.alias}</span>
|
||||
<span className="text-gray-400">← {field.fieldLabel || field.fieldName}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.outputFields.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.outputFields.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입력 핸들 (왼쪽) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
|
||||
/>
|
||||
|
||||
{/* 출력 핸들 (오른쪽) */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
ReferenceLookupNode.displayName = "ReferenceLookupNode";
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* REST API 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Globe, Lock } from "lucide-react";
|
||||
import type { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
|
||||
const METHOD_COLORS: Record<string, string> = {
|
||||
GET: "bg-green-100 text-green-700",
|
||||
POST: "bg-blue-100 text-blue-700",
|
||||
PUT: "bg-yellow-100 text-yellow-700",
|
||||
DELETE: "bg-red-100 text-red-700",
|
||||
PATCH: "bg-purple-100 text-purple-700",
|
||||
};
|
||||
|
||||
export const RestAPISourceNode = memo(({ data, selected }: NodeProps<RestAPISourceNodeData>) => {
|
||||
const methodColor = METHOD_COLORS[data.method] || "bg-gray-100 text-gray-700";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-teal-600 px-3 py-2 text-white">
|
||||
<Globe className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "REST API"}</div>
|
||||
<div className="text-xs opacity-80">{data.url || "URL 미설정"}</div>
|
||||
</div>
|
||||
{data.authentication && <Lock className="h-4 w-4 opacity-70" />}
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
{/* HTTP 메서드 */}
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className={`rounded px-2 py-1 text-xs font-semibold ${methodColor}`}>{data.method}</span>
|
||||
{data.timeout && <span className="text-xs text-gray-500">{data.timeout}ms</span>}
|
||||
</div>
|
||||
|
||||
{/* 헤더 */}
|
||||
{data.headers && Object.keys(data.headers).length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">헤더:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{Object.entries(data.headers)
|
||||
.slice(0, 2)
|
||||
.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<span className="font-mono">{key}:</span>
|
||||
<span className="truncate text-gray-500">{value}</span>
|
||||
</div>
|
||||
))}
|
||||
{Object.keys(data.headers).length > 2 && (
|
||||
<div className="text-xs text-gray-400">... 외 {Object.keys(data.headers).length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 응답 매핑 */}
|
||||
{data.responseMapping && (
|
||||
<div className="rounded bg-teal-50 px-2 py-1 text-xs text-teal-700">
|
||||
응답 경로: <code className="font-mono">{data.responseMapping}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-teal-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
RestAPISourceNode.displayName = "RestAPISourceNode";
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database } from "lucide-react";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
export const TableSourceNode = memo(({ data, selected }: NodeProps<TableSourceNodeData>) => {
|
||||
// 디버깅: 필드 데이터 확인
|
||||
if (data.fields && data.fields.length > 0) {
|
||||
console.log("🔍 TableSource 필드 데이터:", data.fields);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-blue-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-blue-500 px-3 py-2 text-white">
|
||||
<Database className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || data.tableName || "테이블 소스"}</div>
|
||||
{data.tableName && data.displayName !== data.tableName && (
|
||||
<div className="text-xs opacity-80">{data.tableName}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">📍 내부 데이터베이스</div>
|
||||
|
||||
{/* 필드 목록 */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">출력 필드:</div>
|
||||
<div className="max-h-[150px] overflow-y-auto">
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
data.fields.slice(0, 5).map((field) => (
|
||||
<div key={field.name} className="flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-blue-400" />
|
||||
<span className="font-medium">{field.label || field.displayName || field.name}</span>
|
||||
{(field.label || field.displayName) && field.label !== field.name && (
|
||||
<span className="font-mono text-gray-400">({field.name})</span>
|
||||
)}
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">필드 없음</div>
|
||||
)}
|
||||
{data.fields && data.fields.length > 5 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fields.length - 5}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TableSourceNode.displayName = "TableSourceNode";
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPDATE 액션 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Edit } from "lucide-react";
|
||||
import type { UpdateActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpdateActionNode = memo(({ data, selected }: NodeProps<UpdateActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-blue-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 입력 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-blue-500 px-3 py-2 text-white">
|
||||
<Edit className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">UPDATE</div>
|
||||
<div className="text-xs opacity-80">{data.displayName || data.targetTable}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
{data.whereConditions && data.whereConditions.length > 0 && (
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">WHERE 조건:</div>
|
||||
<div className="max-h-[80px] space-y-1 overflow-y-auto">
|
||||
{data.whereConditions.slice(0, 2).map((condition, idx) => (
|
||||
<div key={idx} className="rounded bg-blue-50 px-2 py-1 text-xs">
|
||||
<span className="font-mono text-gray-700">{condition.fieldLabel || condition.field}</span>
|
||||
<span className="mx-1 text-blue-600">{condition.operator}</span>
|
||||
<span className="text-gray-600">
|
||||
{condition.sourceFieldLabel || condition.sourceField || condition.staticValue || "?"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{data.whereConditions.length > 2 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.whereConditions.length - 2}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<div className="text-xs font-medium text-gray-700">업데이트 필드:</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
{data.options && data.options.batchSize && (
|
||||
<div className="mt-2">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
배치 {data.options.batchSize}건
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 핸들 */}
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-blue-500 !bg-white" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UpdateActionNode.displayName = "UpdateActionNode";
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* UPSERT 액션 노드
|
||||
* INSERT와 UPDATE를 결합한 노드
|
||||
*/
|
||||
|
||||
import { memo } from "react";
|
||||
import { Handle, Position, NodeProps } from "reactflow";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import type { UpsertActionNodeData } from "@/types/node-editor";
|
||||
|
||||
export const UpsertActionNode = memo(({ data, selected }: NodeProps<UpsertActionNodeData>) => {
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
||||
selected ? "border-orange-500 shadow-lg" : "border-gray-200"
|
||||
}`}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-600 px-3 py-2 text-white">
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{data.displayName || "UPSERT 액션"}</div>
|
||||
<div className="text-xs opacity-80">{data.targetTable}</div>
|
||||
</div>
|
||||
<Database className="h-4 w-4 opacity-70" />
|
||||
</div>
|
||||
|
||||
{/* 본문 */}
|
||||
<div className="p-3">
|
||||
<div className="mb-2 text-xs font-medium text-gray-500">
|
||||
타겟: {data.displayName || data.targetTable}
|
||||
{data.targetTable && data.displayName && data.displayName !== data.targetTable && (
|
||||
<span className="ml-1 font-mono text-gray-400">({data.targetTable})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 충돌 키 */}
|
||||
{data.conflictKeys && data.conflictKeys.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">충돌 키:</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{data.conflictKeys.map((key, idx) => (
|
||||
<span key={idx} className="rounded bg-purple-100 px-2 py-0.5 text-xs text-purple-700">
|
||||
{data.conflictKeyLabels?.[idx] || key}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 필드 매핑 */}
|
||||
{data.fieldMappings && data.fieldMappings.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<div className="text-xs font-medium text-gray-700">필드 매핑:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{data.fieldMappings.slice(0, 3).map((mapping, idx) => (
|
||||
<div key={idx} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
||||
<span className="text-gray-600">
|
||||
{mapping.sourceFieldLabel || mapping.sourceField || mapping.staticValue || "?"}
|
||||
</span>
|
||||
<span className="mx-1 text-gray-400">→</span>
|
||||
<span className="font-mono text-gray-700">{mapping.targetFieldLabel || mapping.targetField}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.fieldMappings.length > 3 && (
|
||||
<div className="text-xs text-gray-400">... 외 {data.fieldMappings.length - 3}개</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 옵션 */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{data.options?.updateOnConflict && (
|
||||
<span className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700">충돌 시 업데이트</span>
|
||||
)}
|
||||
{data.options?.batchSize && (
|
||||
<span className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
|
||||
배치: {data.options.batchSize}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 핸들 */}
|
||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !bg-purple-500" />
|
||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !bg-purple-500" />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
UpsertActionNode.displayName = "UpsertActionNode";
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 속성 편집 패널
|
||||
*/
|
||||
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
|
||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||
import { ConditionProperties } from "./properties/ConditionProperties";
|
||||
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
|
||||
import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
||||
import { ExternalDBSourceProperties } from "./properties/ExternalDBSourceProperties";
|
||||
import { UpsertActionProperties } from "./properties/UpsertActionProperties";
|
||||
import { DataTransformProperties } from "./properties/DataTransformProperties";
|
||||
import { RestAPISourceProperties } from "./properties/RestAPISourceProperties";
|
||||
import { CommentProperties } from "./properties/CommentProperties";
|
||||
import { LogProperties } from "./properties/LogProperties";
|
||||
import type { NodeType } from "@/types/node-editor";
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { nodes, selectedNodes, setShowPropertiesPanel } = useFlowEditorStore();
|
||||
|
||||
// 선택된 노드가 하나일 경우 해당 노드 데이터 가져오기
|
||||
const selectedNode = selectedNodes.length === 1 ? nodes.find((n) => n.id === selectedNodes[0]) : null;
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between border-b p-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900">속성</h3>
|
||||
{selectedNode && (
|
||||
<p className="mt-0.5 text-xs text-gray-500">{getNodeTypeLabel(selectedNode.type as NodeType)}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowPropertiesPanel(false)} className="h-6 w-6 p-0">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedNodes.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="mb-2 text-2xl">📝</div>
|
||||
<p>노드를 선택하여</p>
|
||||
<p>속성을 편집하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedNodes.length === 1 && selectedNode ? (
|
||||
<NodePropertiesRenderer node={selectedNode} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center p-4">
|
||||
<div className="text-center text-sm text-gray-500">
|
||||
<div className="mb-2 text-2xl">📋</div>
|
||||
<p>{selectedNodes.length}개의 노드가</p>
|
||||
<p>선택되었습니다</p>
|
||||
<p className="mt-2 text-xs">한 번에 하나의 노드만 편집할 수 있습니다</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 타입별 속성 렌더러
|
||||
*/
|
||||
function NodePropertiesRenderer({ node }: { node: any }) {
|
||||
switch (node.type) {
|
||||
case "tableSource":
|
||||
return <TableSourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "referenceLookup":
|
||||
return <ReferenceLookupProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "insertAction":
|
||||
return <InsertActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "condition":
|
||||
return <ConditionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "updateAction":
|
||||
return <UpdateActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "deleteAction":
|
||||
return <DeleteActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "externalDBSource":
|
||||
return <ExternalDBSourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "upsertAction":
|
||||
return <UpsertActionProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "dataTransform":
|
||||
return <DataTransformProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "restAPISource":
|
||||
return <RestAPISourceProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "comment":
|
||||
return <CommentProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
case "log":
|
||||
return <LogProperties nodeId={node.id} data={node.data} />;
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="rounded border border-yellow-200 bg-yellow-50 p-4 text-sm">
|
||||
<p className="font-medium text-yellow-800">🚧 속성 편집 준비 중</p>
|
||||
<p className="mt-2 text-xs text-yellow-700">
|
||||
{getNodeTypeLabel(node.type as NodeType)} 노드의 속성 편집 UI는 곧 구현될 예정입니다.
|
||||
</p>
|
||||
<div className="mt-3 rounded bg-white p-2 text-xs">
|
||||
<p className="font-medium text-gray-700">노드 ID:</p>
|
||||
<p className="font-mono text-gray-600">{node.id}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 타입 라벨 가져오기
|
||||
*/
|
||||
function getNodeTypeLabel(type: NodeType): string {
|
||||
const labels: Record<NodeType, string> = {
|
||||
tableSource: "테이블 소스",
|
||||
externalDBSource: "외부 DB 소스",
|
||||
restAPISource: "REST API 소스",
|
||||
condition: "조건 분기",
|
||||
fieldMapping: "필드 매핑",
|
||||
dataTransform: "데이터 변환",
|
||||
insertAction: "INSERT 액션",
|
||||
updateAction: "UPDATE 액션",
|
||||
deleteAction: "DELETE 액션",
|
||||
upsertAction: "UPSERT 액션",
|
||||
comment: "주석",
|
||||
log: "로그",
|
||||
};
|
||||
return labels[type] || type;
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { CommentNodeData } from "@/types/node-editor";
|
||||
import { MessageSquare } from "lucide-react";
|
||||
|
||||
interface CommentPropertiesProps {
|
||||
nodeId: string;
|
||||
data: CommentNodeData;
|
||||
}
|
||||
|
||||
export function CommentProperties({ nodeId, data }: CommentPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [content, setContent] = useState(data.content || "");
|
||||
|
||||
useEffect(() => {
|
||||
setContent(data.content || "");
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
updateNode(nodeId, {
|
||||
content,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-yellow-50 p-2">
|
||||
<MessageSquare className="h-4 w-4 text-yellow-600" />
|
||||
<span className="font-semibold text-yellow-600">주석</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="content" className="text-xs">
|
||||
메모 내용
|
||||
</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
placeholder="플로우 설명이나 메모를 입력하세요..."
|
||||
className="mt-1 text-sm"
|
||||
rows={8}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">이 노드는 플로우 실행에 영향을 주지 않습니다.</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,418 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 조건 분기 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ConditionNodeData } from "@/types/node-editor";
|
||||
|
||||
// 필드 정의
|
||||
interface FieldDefinition {
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface ConditionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: ConditionNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "같음 (=)" },
|
||||
{ value: "NOT_EQUALS", label: "같지 않음 (≠)" },
|
||||
{ value: "GREATER_THAN", label: "보다 큼 (>)" },
|
||||
{ value: "LESS_THAN", label: "보다 작음 (<)" },
|
||||
{ value: "GREATER_THAN_OR_EQUAL", label: "크거나 같음 (≥)" },
|
||||
{ value: "LESS_THAN_OR_EQUAL", label: "작거나 같음 (≤)" },
|
||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
||||
{ value: "NOT_LIKE", label: "미포함 (NOT LIKE)" },
|
||||
{ value: "IN", label: "IN" },
|
||||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
{ value: "IS_NULL", label: "NULL" },
|
||||
{ value: "IS_NOT_NULL", label: "NOT NULL" },
|
||||
] as const;
|
||||
|
||||
export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "조건 분기");
|
||||
const [conditions, setConditions] = useState(data.conditions || []);
|
||||
const [logic, setLogic] = useState<"AND" | "OR">(data.logic || "AND");
|
||||
const [availableFields, setAvailableFields] = useState<FieldDefinition[]>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "조건 분기");
|
||||
setConditions(data.conditions || []);
|
||||
setLogic(data.logic || "AND");
|
||||
}, [data]);
|
||||
|
||||
// 🔥 연결된 소스 노드의 필드를 재귀적으로 수집
|
||||
useEffect(() => {
|
||||
const getAllSourceFields = (currentNodeId: string, visited: Set<string> = new Set()): FieldDefinition[] => {
|
||||
if (visited.has(currentNodeId)) return [];
|
||||
visited.add(currentNodeId);
|
||||
|
||||
const fields: FieldDefinition[] = [];
|
||||
|
||||
// 현재 노드로 들어오는 엣지 찾기
|
||||
const incomingEdges = edges.filter((e) => e.target === currentNodeId);
|
||||
|
||||
for (const edge of incomingEdges) {
|
||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
const sourceData = sourceNode.data as any;
|
||||
|
||||
// 소스 노드 타입별 필드 수집
|
||||
if (sourceNode.type === "tableSource") {
|
||||
// Table Source: fields 사용
|
||||
if (sourceData.fields && Array.isArray(sourceData.fields)) {
|
||||
console.log("🔍 [ConditionProperties] Table Source 필드:", sourceData.fields);
|
||||
fields.push(...sourceData.fields);
|
||||
} else {
|
||||
console.log("⚠️ [ConditionProperties] Table Source에 필드 없음:", sourceData);
|
||||
}
|
||||
} else if (sourceNode.type === "externalDBSource") {
|
||||
// External DB Source: outputFields 사용
|
||||
if (sourceData.outputFields && Array.isArray(sourceData.outputFields)) {
|
||||
console.log("🔍 [ConditionProperties] External DB 필드:", sourceData.outputFields);
|
||||
fields.push(...sourceData.outputFields);
|
||||
} else {
|
||||
console.log("⚠️ [ConditionProperties] External DB에 필드 없음:", sourceData);
|
||||
}
|
||||
} else if (sourceNode.type === "dataTransform") {
|
||||
// Data Transform: 재귀적으로 상위 노드 필드 수집
|
||||
const upperFields = getAllSourceFields(sourceNode.id, visited);
|
||||
|
||||
// Data Transform의 변환 결과 추가
|
||||
if (sourceData.transformations && Array.isArray(sourceData.transformations)) {
|
||||
const inPlaceFields = new Set<string>();
|
||||
|
||||
for (const transform of sourceData.transformations) {
|
||||
const { sourceField, targetField } = transform;
|
||||
|
||||
// In-place 변환인지 확인
|
||||
if (!targetField || targetField === sourceField) {
|
||||
inPlaceFields.add(sourceField);
|
||||
} else {
|
||||
// 새로운 필드 생성
|
||||
fields.push({ name: targetField, label: targetField });
|
||||
}
|
||||
}
|
||||
|
||||
// 원본 필드 중 in-place 변환되지 않은 것들 추가
|
||||
for (const field of upperFields) {
|
||||
if (!inPlaceFields.has(field.name)) {
|
||||
fields.push(field);
|
||||
} else {
|
||||
// In-place 변환된 필드는 원본 이름으로 유지
|
||||
fields.push(field);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fields.push(...upperFields);
|
||||
}
|
||||
} else if (sourceNode.type === "restAPISource") {
|
||||
// REST API Source: responseFields 사용
|
||||
if (sourceData.responseFields && Array.isArray(sourceData.responseFields)) {
|
||||
console.log("🔍 [ConditionProperties] REST API 필드:", sourceData.responseFields);
|
||||
fields.push(
|
||||
...sourceData.responseFields.map((f: any) => ({
|
||||
name: f.name || f.fieldName,
|
||||
label: f.label || f.displayName || f.name,
|
||||
type: f.dataType || f.type,
|
||||
})),
|
||||
);
|
||||
} else {
|
||||
console.log("⚠️ [ConditionProperties] REST API에 필드 없음:", sourceData);
|
||||
}
|
||||
} else if (sourceNode.type === "condition") {
|
||||
// 조건 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드)
|
||||
console.log("✅ [ConditionProperties] 조건 노드 통과 → 상위 탐색");
|
||||
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
||||
} else if (
|
||||
sourceNode.type === "insertAction" ||
|
||||
sourceNode.type === "updateAction" ||
|
||||
sourceNode.type === "deleteAction" ||
|
||||
sourceNode.type === "upsertAction"
|
||||
) {
|
||||
// Action 노드: 재귀적으로 상위 노드 필드 수집
|
||||
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
||||
} else {
|
||||
// 기타 모든 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드로 처리)
|
||||
console.log(`✅ [ConditionProperties] 통과 노드 (${sourceNode.type}) → 상위 탐색`);
|
||||
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
||||
}
|
||||
}
|
||||
|
||||
// 중복 제거
|
||||
const uniqueFields = Array.from(new Map(fields.map((f) => [f.name, f])).values());
|
||||
return uniqueFields;
|
||||
};
|
||||
|
||||
const fields = getAllSourceFields(nodeId);
|
||||
console.log("✅ [ConditionProperties] 최종 수집된 필드:", fields);
|
||||
console.log("🔍 [ConditionProperties] 현재 노드 ID:", nodeId);
|
||||
console.log(
|
||||
"🔍 [ConditionProperties] 연결된 엣지:",
|
||||
edges.filter((e) => e.target === nodeId),
|
||||
);
|
||||
setAvailableFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setConditions([
|
||||
...conditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
valueType: "static", // "static" (고정값) 또는 "field" (필드 참조)
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setConditions(conditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...conditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setConditions(newConditions);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
conditions,
|
||||
logic,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="logic" className="text-xs">
|
||||
조건 로직
|
||||
</Label>
|
||||
<Select value={logic} onValueChange={(value: "AND" | "OR") => setLogic(value)}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="AND">AND (모두 충족)</SelectItem>
|
||||
<SelectItem value="OR">OR (하나라도 충족)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조건식 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">조건식</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{conditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{conditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-yellow-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium text-yellow-700">조건 #{index + 1}</span>
|
||||
{index > 0 && (
|
||||
<span className="rounded bg-yellow-200 px-1.5 py-0.5 text-xs font-semibold text-yellow-800">
|
||||
{logic}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드명</Label>
|
||||
{availableFields.length > 0 ? (
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => handleConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
소스 노드를 연결하세요
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">비교 값 타입</Label>
|
||||
<Select
|
||||
value={(condition as any).valueType || "static"}
|
||||
onValueChange={(value) => handleConditionChange(index, "valueType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">필드 참조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{(condition as any).valueType === "field" ? "비교 필드" : "비교 값"}
|
||||
</Label>
|
||||
{(condition as any).valueType === "field" ? (
|
||||
// 필드 참조: 드롭다운으로 선택
|
||||
availableFields.length > 0 ? (
|
||||
<Select
|
||||
value={condition.value as string}
|
||||
onValueChange={(value) => handleConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="비교할 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
{field.type && <span className="ml-2 text-xs text-gray-400">({field.type})</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="mt-1 rounded border border-dashed bg-gray-50 p-2 text-center text-xs text-gray-400">
|
||||
소스 노드를 연결하세요
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
// 고정값: 직접 입력
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교할 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
조건식이 없습니다. "추가" 버튼을 클릭하세요.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
🔌 <strong>소스 노드 연결</strong>: 테이블/외부DB 노드를 연결하면 자동으로 필드 목록이 표시됩니다.
|
||||
</div>
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">
|
||||
🔄 <strong>비교 값 타입</strong>:<br />• <strong>고정값</strong>: 직접 입력한 값과 비교 (예: age > 30)
|
||||
<br />• <strong>필드 참조</strong>: 다른 필드의 값과 비교 (예: 주문수량 > 재고수량)
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
💡 <strong>AND</strong>: 모든 조건이 참이어야 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
💡 <strong>OR</strong>: 하나라도 참이면 TRUE 출력
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
⚡ TRUE 출력은 오른쪽 위, FALSE 출력은 오른쪽 아래입니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 데이터 변환 노드 속성 편집 (개선 버전)
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, Wand2, ArrowRight } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { DataTransformNodeData } from "@/types/node-editor";
|
||||
|
||||
interface DataTransformPropertiesProps {
|
||||
nodeId: string;
|
||||
data: DataTransformNodeData;
|
||||
}
|
||||
|
||||
const TRANSFORM_TYPES = [
|
||||
{ value: "UPPERCASE", label: "대문자 변환", category: "기본" },
|
||||
{ value: "LOWERCASE", label: "소문자 변환", category: "기본" },
|
||||
{ value: "TRIM", label: "공백 제거", category: "기본" },
|
||||
{ value: "CONCAT", label: "문자열 결합", category: "기본" },
|
||||
{ value: "SPLIT", label: "문자열 분리", category: "기본" },
|
||||
{ value: "REPLACE", label: "문자열 치환", category: "기본" },
|
||||
{ value: "EXPLODE", label: "행 확장 (1→N)", category: "고급" },
|
||||
{ value: "CAST", label: "타입 변환", category: "고급" },
|
||||
{ value: "FORMAT", label: "형식화", category: "고급" },
|
||||
{ value: "CALCULATE", label: "계산식", category: "고급" },
|
||||
{ value: "JSON_EXTRACT", label: "JSON 추출", category: "고급" },
|
||||
{ value: "CUSTOM", label: "사용자 정의", category: "고급" },
|
||||
] as const;
|
||||
|
||||
export function DataTransformProperties({ nodeId, data }: DataTransformPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "데이터 변환");
|
||||
const [transformations, setTransformations] = useState(data.transformations || []);
|
||||
|
||||
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
|
||||
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "데이터 변환");
|
||||
setTransformations(data.transformations || []);
|
||||
}, [data]);
|
||||
|
||||
// 연결된 소스 노드에서 필드 가져오기
|
||||
useEffect(() => {
|
||||
const inputEdges = edges.filter((edge) => edge.target === nodeId);
|
||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||
|
||||
const fields: Array<{ name: string; label?: string }> = [];
|
||||
sourceNodes.forEach((node) => {
|
||||
if (node.type === "tableSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
} else if (node.type === "externalDBSource" && node.data.fields) {
|
||||
node.data.fields.forEach((field: any) => {
|
||||
fields.push({
|
||||
name: field.name,
|
||||
label: field.label || field.displayName,
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
const handleAddTransformation = () => {
|
||||
setTransformations([
|
||||
...transformations,
|
||||
{
|
||||
type: "UPPERCASE" as const,
|
||||
sourceField: "",
|
||||
targetField: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveTransformation = (index: number) => {
|
||||
const newTransformations = transformations.filter((_, i) => i !== index);
|
||||
setTransformations(newTransformations);
|
||||
|
||||
// 즉시 반영
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
transformations: newTransformations,
|
||||
});
|
||||
};
|
||||
|
||||
const handleTransformationChange = (index: number, field: string, value: any) => {
|
||||
const newTransformations = [...transformations];
|
||||
|
||||
// 필드 변경 시 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newTransformations[index] = {
|
||||
...newTransformations[index],
|
||||
sourceField: value,
|
||||
sourceFieldLabel: sourceField?.label,
|
||||
};
|
||||
} else if (field === "targetField") {
|
||||
// 타겟 필드는 새로 생성하는 필드이므로 라벨은 사용자가 직접 입력
|
||||
newTransformations[index] = {
|
||||
...newTransformations[index],
|
||||
targetField: value,
|
||||
};
|
||||
} else {
|
||||
newTransformations[index] = { ...newTransformations[index], [field]: value };
|
||||
}
|
||||
|
||||
setTransformations(newTransformations);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
transformations,
|
||||
});
|
||||
};
|
||||
|
||||
const renderTransformationFields = (transform: any, index: number) => {
|
||||
const commonFields = (
|
||||
<>
|
||||
{/* 소스 필드 */}
|
||||
{transform.type !== "CONCAT" && (
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={transform.sourceField || ""}
|
||||
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-400">연결된 소스 노드가 없습니다</div>
|
||||
) : (
|
||||
sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{field.label || field.name}</span>
|
||||
{field.label && field.label !== field.name && (
|
||||
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 타겟 필드 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">타겟 필드명 (선택)</Label>
|
||||
<Input
|
||||
value={transform.targetField || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "targetField", e.target.value)}
|
||||
placeholder="비어있으면 소스 필드에 적용"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{transform.targetField ? (
|
||||
transform.targetField === transform.sourceField ? (
|
||||
<span className="text-indigo-600">✓ 소스 필드를 덮어씁니다</span>
|
||||
) : (
|
||||
<span className="text-green-600">✓ 새 필드를 생성합니다</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-indigo-600">비어있음: 소스 필드를 덮어씁니다</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// 타입별 추가 필드
|
||||
switch (transform.type) {
|
||||
case "EXPLODE":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.delimiter || ","}
|
||||
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
||||
placeholder="예: , 또는 ; 또는 |"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">이 문자로 분리하여 여러 행으로 확장합니다</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CONCAT":
|
||||
return (
|
||||
<>
|
||||
{/* CONCAT은 다중 소스 필드를 지원 - 간소화 버전 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">첫 번째 필드</Label>
|
||||
<Select
|
||||
value={transform.sourceField || ""}
|
||||
onValueChange={(value) => handleTransformationChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="첫 번째 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name} className="text-xs">
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.separator || " "}
|
||||
onChange={(e) => handleTransformationChange(index, "separator", e.target.value)}
|
||||
placeholder="예: 공백 또는 , 또는 -"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
{commonFields}
|
||||
</>
|
||||
);
|
||||
|
||||
case "SPLIT":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">구분자</Label>
|
||||
<Input
|
||||
value={transform.delimiter || ","}
|
||||
onChange={(e) => handleTransformationChange(index, "delimiter", e.target.value)}
|
||||
placeholder="예: , 또는 ; 또는 |"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">인덱스 (0부터 시작)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={transform.splitIndex !== undefined ? transform.splitIndex : 0}
|
||||
onChange={(e) => handleTransformationChange(index, "splitIndex", parseInt(e.target.value))}
|
||||
placeholder="0"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">분리된 값 중 몇 번째를 가져올지 지정 (0=첫번째)</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "REPLACE":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">찾을 문자열</Label>
|
||||
<Input
|
||||
value={transform.searchValue || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "searchValue", e.target.value)}
|
||||
placeholder="예: old"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">바꿀 문자열</Label>
|
||||
<Input
|
||||
value={transform.replaceValue || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "replaceValue", e.target.value)}
|
||||
placeholder="예: new"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CAST":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환할 타입</Label>
|
||||
<Select
|
||||
value={transform.castType || "string"}
|
||||
onValueChange={(value) => handleTransformationChange(index, "castType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string" className="text-xs">
|
||||
문자열 (String)
|
||||
</SelectItem>
|
||||
<SelectItem value="number" className="text-xs">
|
||||
숫자 (Number)
|
||||
</SelectItem>
|
||||
<SelectItem value="boolean" className="text-xs">
|
||||
불린 (Boolean)
|
||||
</SelectItem>
|
||||
<SelectItem value="date" className="text-xs">
|
||||
날짜 (Date)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
case "CALCULATE":
|
||||
case "FORMAT":
|
||||
case "JSON_EXTRACT":
|
||||
case "CUSTOM":
|
||||
return (
|
||||
<>
|
||||
{commonFields}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">표현식</Label>
|
||||
<Input
|
||||
value={transform.expression || ""}
|
||||
onChange={(e) => handleTransformationChange(index, "expression", e.target.value)}
|
||||
placeholder="예: field1 + field2"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400">
|
||||
{transform.type === "CALCULATE" && "계산 수식을 입력하세요 (예: field1 + field2)"}
|
||||
{transform.type === "FORMAT" && "형식 문자열을 입력하세요 (예: {0}-{1})"}
|
||||
{transform.type === "JSON_EXTRACT" && "JSON 경로를 입력하세요 (예: $.data.name)"}
|
||||
{transform.type === "CUSTOM" && "JavaScript 표현식을 입력하세요"}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
default:
|
||||
// UPPERCASE, LOWERCASE, TRIM 등
|
||||
return commonFields;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-indigo-50 p-2">
|
||||
<Wand2 className="h-4 w-4 text-indigo-600" />
|
||||
<span className="font-semibold text-indigo-600">데이터 변환</span>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 변환 규칙 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">변환 규칙</h3>
|
||||
<p className="text-xs text-gray-500">데이터를 변환할 규칙을 추가하세요</p>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={handleAddTransformation} className="h-7 px-2 text-xs">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
규칙 추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{transformations.length === 0 ? (
|
||||
<div className="rounded border border-dashed bg-gray-50 p-4 text-center text-xs text-gray-500">
|
||||
변환 규칙을 추가하세요
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{transformations.map((transform, index) => (
|
||||
<div key={index} className="rounded border bg-indigo-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-indigo-700">변환 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveTransformation(index)}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* 변환 타입 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">변환 타입</Label>
|
||||
<Select
|
||||
value={transform.type}
|
||||
onValueChange={(value) => handleTransformationChange(index, "type", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<div className="px-2 py-1 text-xs font-semibold text-gray-500">기본 변환</div>
|
||||
{TRANSFORM_TYPES.filter((t) => t.category === "기본").map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
<div className="px-2 py-1 text-xs font-semibold text-gray-500">고급 변환</div>
|
||||
{TRANSFORM_TYPES.filter((t) => t.category === "고급").map((type) => (
|
||||
<SelectItem key={type.value} value={type.value} className="text-xs">
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 타입별 필드 렌더링 */}
|
||||
{renderTransformationFields(transform, index)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 적용 버튼 */}
|
||||
<div className="sticky bottom-0 border-t bg-white pt-3">
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
<p className="mt-2 text-center text-xs text-gray-500">✅ 변경 사항이 즉시 노드에 반영됩니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,722 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* DELETE 액션 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Plus, Trash2, AlertTriangle, Database, Globe, Link2, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
|
||||
import type { DeleteActionNodeData } from "@/types/node-editor";
|
||||
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
|
||||
|
||||
interface DeleteActionPropertiesProps {
|
||||
nodeId: string;
|
||||
data: DeleteActionNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "EQUALS", label: "=" },
|
||||
{ value: "NOT_EQUALS", label: "≠" },
|
||||
{ value: "GREATER_THAN", label: ">" },
|
||||
{ value: "LESS_THAN", label: "<" },
|
||||
{ value: "IN", label: "IN" },
|
||||
{ value: "NOT_IN", label: "NOT IN" },
|
||||
] as const;
|
||||
|
||||
export function DeleteActionProperties({ nodeId, data }: DeleteActionPropertiesProps) {
|
||||
const { updateNode, getExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
// 🔥 타겟 타입 상태
|
||||
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || `${data.targetTable} 삭제`);
|
||||
const [targetTable, setTargetTable] = useState(data.targetTable);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
|
||||
// 🔥 외부 DB 관련 상태
|
||||
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
|
||||
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
|
||||
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
|
||||
data.externalConnectionId,
|
||||
);
|
||||
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
|
||||
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
|
||||
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
|
||||
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
|
||||
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
|
||||
|
||||
// 🔥 REST API 관련 상태 (DELETE는 요청 바디 없음)
|
||||
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
|
||||
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
|
||||
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
|
||||
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
|
||||
|
||||
// 🔥 내부 DB 테이블 관련 상태
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [selectedTableLabel, setSelectedTableLabel] = useState(data.targetTable);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || `${data.targetTable} 삭제`);
|
||||
setTargetTable(data.targetTable);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
}, [data]);
|
||||
|
||||
// 🔥 내부 DB 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "internal") {
|
||||
loadTables();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 커넥션 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external") {
|
||||
loadExternalConnections();
|
||||
}
|
||||
}, [targetType]);
|
||||
|
||||
// 🔥 외부 테이블 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId) {
|
||||
loadExternalTables(selectedExternalConnectionId);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId]);
|
||||
|
||||
// 🔥 외부 컬럼 로딩
|
||||
useEffect(() => {
|
||||
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
|
||||
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
|
||||
}
|
||||
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
|
||||
|
||||
const loadExternalConnections = async () => {
|
||||
try {
|
||||
setExternalConnectionsLoading(true);
|
||||
|
||||
const cached = getExternalConnectionsCache();
|
||||
if (cached) {
|
||||
setExternalConnections(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await getTestedExternalConnections();
|
||||
setExternalConnections(data);
|
||||
} catch (error) {
|
||||
console.error("외부 커넥션 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalConnectionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalTables = async (connectionId: number) => {
|
||||
try {
|
||||
setExternalTablesLoading(true);
|
||||
const data = await getExternalTables(connectionId);
|
||||
setExternalTables(data);
|
||||
} catch (error) {
|
||||
console.error("외부 테이블 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadExternalColumns = async (connectionId: number, tableName: string) => {
|
||||
try {
|
||||
setExternalColumnsLoading(true);
|
||||
const data = await getExternalColumns(connectionId, tableName);
|
||||
setExternalColumns(data);
|
||||
} catch (error) {
|
||||
console.error("외부 컬럼 로딩 실패:", error);
|
||||
} finally {
|
||||
setExternalColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
|
||||
setTargetType(newType);
|
||||
updateNode(nodeId, {
|
||||
targetType: newType,
|
||||
targetTable: newType === "internal" ? targetTable : undefined,
|
||||
externalConnectionId: newType === "external" ? selectedExternalConnectionId : undefined,
|
||||
externalTargetTable: newType === "external" ? externalTargetTable : undefined,
|
||||
apiEndpoint: newType === "api" ? apiEndpoint : undefined,
|
||||
apiAuthType: newType === "api" ? apiAuthType : undefined,
|
||||
apiAuthConfig: newType === "api" ? apiAuthConfig : undefined,
|
||||
apiHeaders: newType === "api" ? apiHeaders : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
// 🔥 테이블 목록 로딩
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setTablesLoading(true);
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
setTables(tableList);
|
||||
} catch (error) {
|
||||
console.error("테이블 목록 로딩 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
const selectedTable = tables.find((t: any) => t.tableName === tableName);
|
||||
const label = (selectedTable as any)?.tableLabel || selectedTable?.displayName || tableName;
|
||||
|
||||
setTargetTable(tableName);
|
||||
setSelectedTableLabel(label);
|
||||
setTablesOpen(false);
|
||||
|
||||
updateNode(nodeId, {
|
||||
targetTable: tableName,
|
||||
displayName: label,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "EQUALS",
|
||||
value: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...whereConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
setWhereConditions(newConditions);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
targetTable,
|
||||
whereConditions,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 경고 */}
|
||||
<div className="rounded-lg border-2 border-red-200 bg-red-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="h-5 w-5 flex-shrink-0 text-red-600" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-red-800">위험한 작업입니다!</p>
|
||||
<p className="mt-1 text-xs text-red-700">
|
||||
DELETE 작업은 되돌릴 수 없습니다. WHERE 조건을 반드시 설정하세요.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 🔥 타겟 타입 선택 */}
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium">타겟 선택</Label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("internal")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
|
||||
>
|
||||
내부 DB
|
||||
</span>
|
||||
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("external")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "external"
|
||||
? "border-green-500 bg-green-50"
|
||||
: "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs font-medium",
|
||||
targetType === "external" ? "text-green-700" : "text-gray-600",
|
||||
)}
|
||||
>
|
||||
외부 DB
|
||||
</span>
|
||||
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTargetTypeChange("api")}
|
||||
className={cn(
|
||||
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
|
||||
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
|
||||
)}
|
||||
>
|
||||
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
|
||||
<span
|
||||
className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}
|
||||
>
|
||||
REST API
|
||||
</span>
|
||||
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 내부 DB: 타겟 테이블 Combobox */}
|
||||
{targetType === "internal" && (
|
||||
<div>
|
||||
<Label className="text-xs">타겟 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : targetTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." />
|
||||
<CommandEmpty>테이블을 찾을 수 없습니다.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{tables.map((table: any) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.tableLabel || table.displayName} ${table.tableName}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
targetTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.tableLabel || table.displayName}</span>
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 🔥 외부 DB 설정 */}
|
||||
{targetType === "external" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">외부 데이터베이스 커넥션</Label>
|
||||
<Select
|
||||
value={selectedExternalConnectionId?.toString()}
|
||||
onValueChange={(value) => {
|
||||
const connectionId = parseInt(value);
|
||||
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
|
||||
setSelectedExternalConnectionId(connectionId);
|
||||
setExternalTargetTable("");
|
||||
setExternalColumns([]);
|
||||
updateNode(nodeId, {
|
||||
externalConnectionId: connectionId,
|
||||
externalConnectionName: selectedConnection?.name,
|
||||
externalDbType: selectedConnection?.db_type,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="커넥션을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalConnectionsLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalConnections.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">사용 가능한 커넥션이 없습니다</div>
|
||||
) : (
|
||||
externalConnections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id!.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{conn.db_type}</span>
|
||||
<span className="text-gray-500">-</span>
|
||||
<span>{conn.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedExternalConnectionId && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">테이블</Label>
|
||||
<Select
|
||||
value={externalTargetTable}
|
||||
onValueChange={(value) => {
|
||||
const selectedTable = externalTables.find((t) => t.table_name === value);
|
||||
setExternalTargetTable(value);
|
||||
updateNode(nodeId, {
|
||||
externalTargetTable: value,
|
||||
externalTargetSchema: selectedTable?.schema,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="테이블을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{externalTablesLoading ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">로딩 중...</div>
|
||||
) : externalTables.length === 0 ? (
|
||||
<div className="p-2 text-center text-xs text-gray-500">테이블이 없습니다</div>
|
||||
) : (
|
||||
externalTables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{externalTargetTable && externalColumns.length > 0 && (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">컬럼 목록</Label>
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
|
||||
{externalColumns.map((col) => (
|
||||
<div key={col.column_name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 🔥 REST API 설정 (DELETE는 간단함) */}
|
||||
{targetType === "api" && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">API 엔드포인트</Label>
|
||||
<Input
|
||||
placeholder="https://api.example.com/v1/users/{id}"
|
||||
value={apiEndpoint}
|
||||
onChange={(e) => {
|
||||
setApiEndpoint(e.target.value);
|
||||
updateNode(nodeId, { apiEndpoint: e.target.value });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">인증 방식</Label>
|
||||
<Select
|
||||
value={apiAuthType}
|
||||
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
|
||||
setApiAuthType(value);
|
||||
updateNode(nodeId, { apiAuthType: value });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">인증 없음</SelectItem>
|
||||
<SelectItem value="bearer">Bearer Token</SelectItem>
|
||||
<SelectItem value="basic">Basic Auth</SelectItem>
|
||||
<SelectItem value="apikey">API Key</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{apiAuthType !== "none" && (
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
<Label className="block text-xs font-medium">인증 정보</Label>
|
||||
|
||||
{apiAuthType === "bearer" && (
|
||||
<Input
|
||||
placeholder="Bearer Token"
|
||||
value={(apiAuthConfig as any)?.token || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { token: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
|
||||
{apiAuthType === "basic" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="사용자명"
|
||||
value={(apiAuthConfig as any)?.username || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="비밀번호"
|
||||
value={(apiAuthConfig as any)?.password || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiAuthType === "apikey" && (
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
placeholder="헤더 이름 (예: X-API-Key)"
|
||||
value={(apiAuthConfig as any)?.headerName || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="API Key"
|
||||
value={(apiAuthConfig as any)?.apiKey || ""}
|
||||
onChange={(e) => {
|
||||
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
|
||||
setApiAuthConfig(newConfig);
|
||||
updateNode(nodeId, { apiAuthConfig: newConfig });
|
||||
}}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium">커스텀 헤더 (선택사항)</Label>
|
||||
<div className="space-y-2 rounded border bg-gray-50 p-3">
|
||||
{Object.entries(apiHeaders).map(([key, value], index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="헤더 이름"
|
||||
value={key}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
newHeaders[e.target.value] = value;
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Input
|
||||
placeholder="헤더 값"
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
const newHeaders = { ...apiHeaders, [key]: e.target.value };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 flex-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders };
|
||||
delete newHeaders[key];
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
const newHeaders = { ...apiHeaders, "": "" };
|
||||
setApiHeaders(newHeaders);
|
||||
updateNode(nodeId, { apiHeaders: newHeaders });
|
||||
}}
|
||||
className="h-7 w-full text-xs"
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
헤더 추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">WHERE 조건 (필수)</h3>
|
||||
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7">
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{whereConditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border-2 border-red-200 bg-red-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-red-700">조건 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Input
|
||||
value={condition.field}
|
||||
onChange={(e) => handleConditionChange(index, "field", e.target.value)}
|
||||
placeholder="조건 필드명"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값</Label>
|
||||
<Input
|
||||
value={condition.value as string}
|
||||
onChange={(e) => handleConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border-2 border-dashed border-red-300 bg-red-50 p-4 text-center text-xs text-red-600">
|
||||
⚠️ WHERE 조건이 없습니다! 모든 데이터가 삭제됩니다!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleSave} variant="destructive" className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">
|
||||
🚨 WHERE 조건 없이 삭제하면 테이블의 모든 데이터가 영구 삭제됩니다!
|
||||
</div>
|
||||
<div className="rounded bg-red-50 p-3 text-xs text-red-700">💡 실행 전 WHERE 조건을 꼭 확인하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,375 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 외부 DB 소스 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Database, RefreshCw } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import {
|
||||
getTestedExternalConnections,
|
||||
getExternalTables,
|
||||
getExternalColumns,
|
||||
type ExternalConnection,
|
||||
type ExternalTable,
|
||||
type ExternalColumn,
|
||||
} from "@/lib/api/nodeExternalConnections";
|
||||
import { toast } from "sonner";
|
||||
import type { ExternalDBSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
interface ExternalDBSourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: ExternalDBSourceNodeData;
|
||||
}
|
||||
|
||||
const DB_TYPE_INFO: Record<string, { label: string; color: string; icon: string }> = {
|
||||
postgresql: { label: "PostgreSQL", color: "#336791", icon: "🐘" },
|
||||
mysql: { label: "MySQL", color: "#4479A1", icon: "🐬" },
|
||||
oracle: { label: "Oracle", color: "#F80000", icon: "🔴" },
|
||||
mssql: { label: "MS SQL Server", color: "#CC2927", icon: "🏢" },
|
||||
mariadb: { label: "MariaDB", color: "#003545", icon: "🌊" },
|
||||
};
|
||||
|
||||
export function ExternalDBSourceProperties({ nodeId, data }: ExternalDBSourcePropertiesProps) {
|
||||
const { updateNode, getExternalConnectionsCache, setExternalConnectionsCache } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.connectionName);
|
||||
const [selectedConnectionId, setSelectedConnectionId] = useState<number | undefined>(data.connectionId);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
const [schema, setSchema] = useState(data.schema || "");
|
||||
|
||||
const [connections, setConnections] = useState<ExternalConnection[]>([]);
|
||||
const [tables, setTables] = useState<ExternalTable[]>([]);
|
||||
const [columns, setColumns] = useState<ExternalColumn[]>([]);
|
||||
const [loadingConnections, setLoadingConnections] = useState(false);
|
||||
const [loadingTables, setLoadingTables] = useState(false);
|
||||
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0); // 🔥 마지막 새로고침 시간
|
||||
const [remainingCooldown, setRemainingCooldown] = useState<number>(0); // 🔥 남은 쿨다운 시간
|
||||
|
||||
const selectedConnection = connections.find((conn) => conn.id === selectedConnectionId);
|
||||
const dbInfo =
|
||||
selectedConnection && DB_TYPE_INFO[selectedConnection.db_type]
|
||||
? DB_TYPE_INFO[selectedConnection.db_type]
|
||||
: {
|
||||
label: selectedConnection ? selectedConnection.db_type.toUpperCase() : "알 수 없음",
|
||||
color: "#666",
|
||||
icon: "💾",
|
||||
};
|
||||
|
||||
// 🔥 첫 로드 시에만 커넥션 목록 로드 (전역 캐싱)
|
||||
useEffect(() => {
|
||||
const cachedData = getExternalConnectionsCache();
|
||||
if (cachedData) {
|
||||
console.log("✅ 캐시된 커넥션 사용:", cachedData.length);
|
||||
setConnections(cachedData);
|
||||
} else {
|
||||
console.log("🔄 API 호출하여 커넥션 로드");
|
||||
loadConnections();
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 커넥션 변경 시 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedConnectionId) {
|
||||
loadTables();
|
||||
}
|
||||
}, [selectedConnectionId]);
|
||||
|
||||
// 테이블 변경 시 컬럼 목록 로드
|
||||
useEffect(() => {
|
||||
if (selectedConnectionId && tableName) {
|
||||
loadColumns();
|
||||
}
|
||||
}, [selectedConnectionId, tableName]);
|
||||
|
||||
// 🔥 쿨다운 타이머 (1초마다 업데이트)
|
||||
useEffect(() => {
|
||||
const THROTTLE_DURATION = 10000; // 10초
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (lastRefreshTime > 0) {
|
||||
const elapsed = Date.now() - lastRefreshTime;
|
||||
const remaining = Math.max(0, THROTTLE_DURATION - elapsed);
|
||||
setRemainingCooldown(Math.ceil(remaining / 1000));
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [lastRefreshTime]);
|
||||
|
||||
const loadConnections = async () => {
|
||||
// 🔥 쓰로틀링: 10초 이내 재요청 차단
|
||||
const THROTTLE_DURATION = 10000; // 10초
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastRefreshTime < THROTTLE_DURATION) {
|
||||
const remainingSeconds = Math.ceil((THROTTLE_DURATION - (now - lastRefreshTime)) / 1000);
|
||||
toast.warning(`잠시 후 다시 시도해주세요 (${remainingSeconds}초 후)`);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingConnections(true);
|
||||
setLastRefreshTime(now); // 🔥 마지막 실행 시간 기록
|
||||
|
||||
try {
|
||||
const data = await getTestedExternalConnections();
|
||||
setConnections(data);
|
||||
setExternalConnectionsCache(data); // 🔥 전역 캐시에 저장
|
||||
console.log("✅ 테스트 성공한 커넥션 로드 및 캐싱:", data.length);
|
||||
toast.success(`${data.length}개의 커넥션을 불러왔습니다.`);
|
||||
} catch (error) {
|
||||
console.error("❌ 커넥션 로드 실패:", error);
|
||||
toast.error("외부 DB 연결 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingConnections(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTables = async () => {
|
||||
if (!selectedConnectionId) return;
|
||||
|
||||
setLoadingTables(true);
|
||||
try {
|
||||
const data = await getExternalTables(selectedConnectionId);
|
||||
setTables(data);
|
||||
console.log("✅ 테이블 목록 로드:", data.length);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 로드 실패:", error);
|
||||
toast.error("테이블 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingTables(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadColumns = async () => {
|
||||
if (!selectedConnectionId || !tableName) return;
|
||||
|
||||
setLoadingColumns(true);
|
||||
try {
|
||||
const data = await getExternalColumns(selectedConnectionId, tableName);
|
||||
setColumns(data);
|
||||
console.log("✅ 컬럼 목록 로드:", data.length);
|
||||
|
||||
// 노드에 outputFields 업데이트
|
||||
updateNode(nodeId, {
|
||||
outputFields: data.map((col) => ({
|
||||
name: col.column_name,
|
||||
type: col.data_type,
|
||||
label: col.column_name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 로드 실패:", error);
|
||||
toast.error("컬럼 목록을 불러올 수 없습니다.");
|
||||
} finally {
|
||||
setLoadingColumns(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnectionChange = (connectionId: string) => {
|
||||
const id = parseInt(connectionId);
|
||||
setSelectedConnectionId(id);
|
||||
setTableName("");
|
||||
setTables([]);
|
||||
setColumns([]);
|
||||
|
||||
const connection = connections.find((conn) => conn.id === id);
|
||||
if (connection) {
|
||||
updateNode(nodeId, {
|
||||
connectionId: id,
|
||||
connectionName: connection.connection_name,
|
||||
dbType: connection.db_type,
|
||||
displayName: connection.connection_name,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleTableChange = (newTableName: string) => {
|
||||
setTableName(newTableName);
|
||||
setColumns([]);
|
||||
|
||||
updateNode(nodeId, {
|
||||
tableName: newTableName,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
connectionId: selectedConnectionId,
|
||||
connectionName: selectedConnection?.connection_name || "",
|
||||
tableName,
|
||||
schema,
|
||||
dbType: selectedConnection?.db_type,
|
||||
});
|
||||
toast.success("설정이 저장되었습니다.");
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* DB 타입 정보 */}
|
||||
<div
|
||||
className="rounded-lg border-2 p-4"
|
||||
style={{
|
||||
borderColor: dbInfo.color,
|
||||
backgroundColor: `${dbInfo.color}10`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-12 w-12 items-center justify-center rounded-lg"
|
||||
style={{ backgroundColor: dbInfo.color }}
|
||||
>
|
||||
<span className="text-2xl">{dbInfo.icon}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold" style={{ color: dbInfo.color }}>
|
||||
{dbInfo.label}
|
||||
</p>
|
||||
<p className="text-xs text-gray-600">외부 데이터베이스</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 연결 선택 */}
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">외부 DB 연결</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={loadConnections}
|
||||
disabled={loadingConnections || remainingCooldown > 0}
|
||||
className="relative h-7 px-2"
|
||||
title={
|
||||
loadingConnections
|
||||
? "테스트 진행 중..."
|
||||
: remainingCooldown > 0
|
||||
? `${remainingCooldown}초 후 재시도 가능`
|
||||
: "연결 테스트 재실행 (10초 간격 제한)"
|
||||
}
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${loadingConnections ? "animate-spin" : ""}`} />
|
||||
{remainingCooldown > 0 && !loadingConnections && (
|
||||
<span className="absolute -top-1 -right-1 flex h-4 w-4 items-center justify-center rounded-full bg-orange-500 text-[9px] text-white">
|
||||
{remainingCooldown}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">연결 선택 (테스트 성공한 커넥션만 표시)</Label>
|
||||
<Select
|
||||
value={selectedConnectionId?.toString()}
|
||||
onValueChange={handleConnectionChange}
|
||||
disabled={loadingConnections}
|
||||
>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="외부 DB 연결 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections.map((conn) => (
|
||||
<SelectItem key={conn.id} value={conn.id.toString()}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{DB_TYPE_INFO[conn.db_type]?.icon || "💾"}</span>
|
||||
<span>{conn.connection_name}</span>
|
||||
<span className="text-xs text-gray-500">({conn.db_type})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingConnections && <p className="mt-1 text-xs text-gray-500">테스트 중... ⏳</p>}
|
||||
{connections.length === 0 && !loadingConnections && (
|
||||
<p className="mt-1 text-xs text-orange-600">⚠️ 테스트에 성공한 커넥션이 없습니다.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 */}
|
||||
{selectedConnectionId && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">테이블 선택</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs">테이블명</Label>
|
||||
<Select value={tableName} onValueChange={handleTableChange} disabled={loadingTables}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue placeholder="테이블 선택..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{tables.map((table) => (
|
||||
<SelectItem key={table.table_name} value={table.table_name}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>📋</span>
|
||||
<span>{table.table_name}</span>
|
||||
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{loadingTables && <p className="mt-1 text-xs text-gray-500">테이블 목록 로딩 중... ⏳</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컬럼 정보 */}
|
||||
{columns.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드 ({columns.length}개)</h3>
|
||||
{loadingColumns ? (
|
||||
<p className="text-xs text-gray-500">컬럼 목록 로딩 중... ⏳</p>
|
||||
) : (
|
||||
<div className="max-h-[200px] space-y-1 overflow-y-auto">
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between rounded border bg-gray-50 px-3 py-2 text-xs"
|
||||
>
|
||||
<span className="font-medium">{col.column_name}</span>
|
||||
<span className="font-mono text-gray-500">{col.data_type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button onClick={handleSave} className="w-full" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
|
||||
<div className="rounded p-3 text-xs" style={{ backgroundColor: `${dbInfo.color}15`, color: dbInfo.color }}>
|
||||
💡 외부 DB 연결은 "외부 DB 연결 관리" 메뉴에서 미리 설정해야 합니다.
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,113 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { LogNodeData } from "@/types/node-editor";
|
||||
import { FileText, Info, AlertTriangle, AlertCircle } from "lucide-react";
|
||||
|
||||
interface LogPropertiesProps {
|
||||
nodeId: string;
|
||||
data: LogNodeData;
|
||||
}
|
||||
|
||||
const LOG_LEVELS = [
|
||||
{ value: "debug", label: "Debug", icon: Info, color: "text-blue-600" },
|
||||
{ value: "info", label: "Info", icon: Info, color: "text-green-600" },
|
||||
{ value: "warn", label: "Warning", icon: AlertTriangle, color: "text-yellow-600" },
|
||||
{ value: "error", label: "Error", icon: AlertCircle, color: "text-red-600" },
|
||||
];
|
||||
|
||||
export function LogProperties({ nodeId, data }: LogPropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [level, setLevel] = useState(data.level || "info");
|
||||
const [message, setMessage] = useState(data.message || "");
|
||||
const [includeData, setIncludeData] = useState(data.includeData ?? false);
|
||||
|
||||
useEffect(() => {
|
||||
setLevel(data.level || "info");
|
||||
setMessage(data.message || "");
|
||||
setIncludeData(data.includeData ?? false);
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
updateNode(nodeId, {
|
||||
level: level as any,
|
||||
message,
|
||||
includeData,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedLevel = LOG_LEVELS.find((l) => l.value === level);
|
||||
const LevelIcon = selectedLevel?.icon || Info;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-gray-50 p-2">
|
||||
<FileText className="h-4 w-4 text-gray-600" />
|
||||
<span className="font-semibold text-gray-600">로그</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">로그 레벨</Label>
|
||||
<Select value={level} onValueChange={setLevel}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LOG_LEVELS.map((lvl) => {
|
||||
const Icon = lvl.icon;
|
||||
return (
|
||||
<SelectItem key={lvl.value} value={lvl.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-4 w-4 ${lvl.color}`} />
|
||||
<span>{lvl.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-xs">
|
||||
로그 메시지
|
||||
</Label>
|
||||
<Input
|
||||
id="message"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="로그 메시지를 입력하세요"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label className="text-xs">데이터 포함</Label>
|
||||
<p className="text-xs text-gray-500">플로우 데이터를 로그에 포함합니다</p>
|
||||
</div>
|
||||
<Switch checked={includeData} onCheckedChange={setIncludeData} />
|
||||
</div>
|
||||
|
||||
<div className={`rounded-md border p-3 ${selectedLevel?.color || "text-gray-600"}`}>
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<LevelIcon className="h-4 w-4" />
|
||||
<span className="text-xs font-semibold uppercase">{level}</span>
|
||||
</div>
|
||||
<div className="text-sm">{message || "메시지가 없습니다"}</div>
|
||||
{includeData && <div className="mt-1 text-xs opacity-70">+ 플로우 데이터</div>}
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,643 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 참조 테이블 조회 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Plus, Trash2, Search } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import type { ReferenceLookupNodeData } from "@/types/node-editor";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
|
||||
// 필드 정의
|
||||
interface FieldDefinition {
|
||||
name: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
interface ReferenceLookupPropertiesProps {
|
||||
nodeId: string;
|
||||
data: ReferenceLookupNodeData;
|
||||
}
|
||||
|
||||
const OPERATORS = [
|
||||
{ value: "=", label: "같음 (=)" },
|
||||
{ value: "!=", label: "같지 않음 (≠)" },
|
||||
{ value: ">", label: "보다 큼 (>)" },
|
||||
{ value: "<", label: "보다 작음 (<)" },
|
||||
{ value: ">=", label: "크거나 같음 (≥)" },
|
||||
{ value: "<=", label: "작거나 같음 (≤)" },
|
||||
{ value: "LIKE", label: "포함 (LIKE)" },
|
||||
{ value: "IN", label: "IN" },
|
||||
] as const;
|
||||
|
||||
export function ReferenceLookupProperties({ nodeId, data }: ReferenceLookupPropertiesProps) {
|
||||
const { updateNode, nodes, edges } = useFlowEditorStore();
|
||||
|
||||
// 상태
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "참조 조회");
|
||||
const [referenceTable, setReferenceTable] = useState(data.referenceTable || "");
|
||||
const [referenceTableLabel, setReferenceTableLabel] = useState(data.referenceTableLabel || "");
|
||||
const [joinConditions, setJoinConditions] = useState(data.joinConditions || []);
|
||||
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
|
||||
const [outputFields, setOutputFields] = useState(data.outputFields || []);
|
||||
|
||||
// 소스 필드 수집
|
||||
const [sourceFields, setSourceFields] = useState<FieldDefinition[]>([]);
|
||||
|
||||
// 참조 테이블 관련
|
||||
const [tables, setTables] = useState<any[]>([]);
|
||||
const [tablesLoading, setTablesLoading] = useState(false);
|
||||
const [tablesOpen, setTablesOpen] = useState(false);
|
||||
const [referenceColumns, setReferenceColumns] = useState<FieldDefinition[]>([]);
|
||||
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 동기화
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "참조 조회");
|
||||
setReferenceTable(data.referenceTable || "");
|
||||
setReferenceTableLabel(data.referenceTableLabel || "");
|
||||
setJoinConditions(data.joinConditions || []);
|
||||
setWhereConditions(data.whereConditions || []);
|
||||
setOutputFields(data.outputFields || []);
|
||||
}, [data]);
|
||||
|
||||
// 🔍 소스 필드 수집 (업스트림 노드에서)
|
||||
useEffect(() => {
|
||||
const incomingEdges = edges.filter((e) => e.target === nodeId);
|
||||
const fields: FieldDefinition[] = [];
|
||||
|
||||
for (const edge of incomingEdges) {
|
||||
const sourceNode = nodes.find((n) => n.id === edge.source);
|
||||
if (!sourceNode) continue;
|
||||
|
||||
const sourceData = sourceNode.data as any;
|
||||
|
||||
if (sourceNode.type === "tableSource" && sourceData.fields) {
|
||||
fields.push(...sourceData.fields);
|
||||
} else if (sourceNode.type === "externalDBSource" && sourceData.outputFields) {
|
||||
fields.push(...sourceData.outputFields);
|
||||
}
|
||||
}
|
||||
|
||||
setSourceFields(fields);
|
||||
}, [nodeId, nodes, edges]);
|
||||
|
||||
// 📊 테이블 목록 로드
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
const loadTables = async () => {
|
||||
setTablesLoading(true);
|
||||
try {
|
||||
const data = await tableTypeApi.getTables();
|
||||
setTables(data);
|
||||
} catch (error) {
|
||||
console.error("테이블 로드 실패:", error);
|
||||
} finally {
|
||||
setTablesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 📋 참조 테이블 컬럼 로드
|
||||
useEffect(() => {
|
||||
if (referenceTable) {
|
||||
loadReferenceColumns();
|
||||
} else {
|
||||
setReferenceColumns([]);
|
||||
}
|
||||
}, [referenceTable]);
|
||||
|
||||
const loadReferenceColumns = async () => {
|
||||
if (!referenceTable) return;
|
||||
|
||||
setColumnsLoading(true);
|
||||
try {
|
||||
const cols = await tableTypeApi.getColumns(referenceTable);
|
||||
const formatted = cols.map((col: any) => ({
|
||||
name: col.columnName,
|
||||
type: col.dataType,
|
||||
label: col.displayName || col.columnName,
|
||||
}));
|
||||
setReferenceColumns(formatted);
|
||||
} catch (error) {
|
||||
console.error("컬럼 로드 실패:", error);
|
||||
setReferenceColumns([]);
|
||||
} finally {
|
||||
setColumnsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 선택 핸들러
|
||||
const handleTableSelect = (tableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === tableName);
|
||||
if (selectedTable) {
|
||||
setReferenceTable(tableName);
|
||||
setReferenceTableLabel(selectedTable.label);
|
||||
setTablesOpen(false);
|
||||
|
||||
// 기존 설정 초기화
|
||||
setJoinConditions([]);
|
||||
setWhereConditions([]);
|
||||
setOutputFields([]);
|
||||
}
|
||||
};
|
||||
|
||||
// 조인 조건 추가
|
||||
const handleAddJoinCondition = () => {
|
||||
setJoinConditions([
|
||||
...joinConditions,
|
||||
{
|
||||
sourceField: "",
|
||||
referenceField: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveJoinCondition = (index: number) => {
|
||||
setJoinConditions(joinConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleJoinConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...joinConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
|
||||
// 라벨도 함께 저장
|
||||
if (field === "sourceField") {
|
||||
const sourceField = sourceFields.find((f) => f.name === value);
|
||||
newConditions[index].sourceFieldLabel = sourceField?.label || value;
|
||||
} else if (field === "referenceField") {
|
||||
const refField = referenceColumns.find((f) => f.name === value);
|
||||
newConditions[index].referenceFieldLabel = refField?.label || value;
|
||||
}
|
||||
|
||||
setJoinConditions(newConditions);
|
||||
};
|
||||
|
||||
// WHERE 조건 추가
|
||||
const handleAddWhereCondition = () => {
|
||||
setWhereConditions([
|
||||
...whereConditions,
|
||||
{
|
||||
field: "",
|
||||
operator: "=",
|
||||
value: "",
|
||||
valueType: "static",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveWhereCondition = (index: number) => {
|
||||
setWhereConditions(whereConditions.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleWhereConditionChange = (index: number, field: string, value: any) => {
|
||||
const newConditions = [...whereConditions];
|
||||
newConditions[index] = { ...newConditions[index], [field]: value };
|
||||
|
||||
// 라벨도 함께 저장
|
||||
if (field === "field") {
|
||||
const refField = referenceColumns.find((f) => f.name === value);
|
||||
newConditions[index].fieldLabel = refField?.label || value;
|
||||
}
|
||||
|
||||
setWhereConditions(newConditions);
|
||||
};
|
||||
|
||||
// 출력 필드 추가
|
||||
const handleAddOutputField = () => {
|
||||
setOutputFields([
|
||||
...outputFields,
|
||||
{
|
||||
fieldName: "",
|
||||
alias: "",
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleRemoveOutputField = (index: number) => {
|
||||
setOutputFields(outputFields.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleOutputFieldChange = (index: number, field: string, value: any) => {
|
||||
const newFields = [...outputFields];
|
||||
newFields[index] = { ...newFields[index], [field]: value };
|
||||
|
||||
// 라벨도 함께 저장
|
||||
if (field === "fieldName") {
|
||||
const refField = referenceColumns.find((f) => f.name === value);
|
||||
newFields[index].fieldLabel = refField?.label || value;
|
||||
// alias 자동 설정
|
||||
if (!newFields[index].alias) {
|
||||
newFields[index].alias = `ref_${value}`;
|
||||
}
|
||||
}
|
||||
|
||||
setOutputFields(newFields);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
referenceTable,
|
||||
referenceTableLabel,
|
||||
joinConditions,
|
||||
whereConditions,
|
||||
outputFields,
|
||||
});
|
||||
};
|
||||
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === referenceTable)?.label || referenceTable;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 참조 테이블 선택 */}
|
||||
<div>
|
||||
<Label className="text-xs">참조 테이블</Label>
|
||||
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tablesOpen}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={tablesLoading}
|
||||
>
|
||||
{tablesLoading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : referenceTable ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블 선택...</span>
|
||||
)}
|
||||
<Search className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.tableName} ${table.description}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
referenceTable === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 조인 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">조인 조건 (FK 매핑)</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddJoinCondition}
|
||||
className="h-7"
|
||||
disabled={!referenceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{joinConditions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{joinConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-purple-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-purple-700">조인 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveJoinCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
||||
<Select
|
||||
value={condition.sourceField}
|
||||
onValueChange={(value) => handleJoinConditionChange(index, "sourceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">참조 필드</Label>
|
||||
<Select
|
||||
value={condition.referenceField}
|
||||
onValueChange={(value) => handleJoinConditionChange(index, "referenceField", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="참조 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
조인 조건을 추가하세요 (필수)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* WHERE 조건 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">WHERE 조건 (선택사항)</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddWhereCondition}
|
||||
className="h-7"
|
||||
disabled={!referenceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{whereConditions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{whereConditions.map((condition, index) => (
|
||||
<div key={index} className="rounded border bg-yellow-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-yellow-700">WHERE #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveWhereCondition(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">필드</Label>
|
||||
<Select
|
||||
value={condition.field}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "field", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">연산자</Label>
|
||||
<Select
|
||||
value={condition.operator}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "operator", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{OPERATORS.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{op.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">값 타입</Label>
|
||||
<Select
|
||||
value={condition.valueType || "static"}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "valueType", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="static">고정값</SelectItem>
|
||||
<SelectItem value="field">소스 필드 참조</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">
|
||||
{condition.valueType === "field" ? "소스 필드" : "값"}
|
||||
</Label>
|
||||
{condition.valueType === "field" ? (
|
||||
<Select
|
||||
value={condition.value}
|
||||
onValueChange={(value) => handleWhereConditionChange(index, "value", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="소스 필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sourceFields.map((field) => (
|
||||
<SelectItem key={field.name} value={field.name}>
|
||||
{field.label || field.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={condition.value}
|
||||
onChange={(e) => handleWhereConditionChange(index, "value", e.target.value)}
|
||||
placeholder="비교할 값"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 출력 필드 */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">출력 필드</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleAddOutputField}
|
||||
className="h-7"
|
||||
disabled={!referenceTable}
|
||||
>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{outputFields.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{outputFields.map((field, index) => (
|
||||
<div key={index} className="rounded border bg-blue-50 p-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-blue-700">필드 #{index + 1}</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleRemoveOutputField(index)}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">참조 테이블 필드</Label>
|
||||
<Select
|
||||
value={field.fieldName}
|
||||
onValueChange={(value) => handleOutputFieldChange(index, "fieldName", value)}
|
||||
>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder="필드 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{referenceColumns.map((col) => (
|
||||
<SelectItem key={col.name} value={col.name}>
|
||||
{col.label || col.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs text-gray-600">별칭 (Alias)</Label>
|
||||
<Input
|
||||
value={field.alias}
|
||||
onChange={(e) => handleOutputFieldChange(index, "alias", e.target.value)}
|
||||
placeholder="ref_field_name"
|
||||
className="mt-1 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
||||
출력 필드를 추가하세요 (필수)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="space-y-2">
|
||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
||||
🔗 <strong>조인 조건</strong>: 소스 데이터와 참조 테이블을 연결하는 키 (예: customer_id → id)
|
||||
</div>
|
||||
<div className="rounded bg-yellow-50 p-3 text-xs text-yellow-700">
|
||||
⚡ <strong>WHERE 조건</strong>: 참조 테이블에서 특정 조건의 데이터만 가져오기 (예: grade = 'VIP')
|
||||
</div>
|
||||
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
|
||||
📤 <strong>출력 필드</strong>: 참조 테이블에서 가져올 필드 선택 (별칭으로 결과에 추가됨)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { RestAPISourceNodeData } from "@/types/node-editor";
|
||||
import { Globe, Plus, Trash2 } from "lucide-react";
|
||||
|
||||
interface RestAPISourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: RestAPISourceNodeData;
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
||||
const AUTH_TYPES = [
|
||||
{ value: "none", label: "인증 없음" },
|
||||
{ value: "bearer", label: "Bearer Token" },
|
||||
{ value: "basic", label: "Basic Auth" },
|
||||
{ value: "apikey", label: "API Key" },
|
||||
];
|
||||
|
||||
export function RestAPISourceProperties({ nodeId, data }: RestAPISourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || "");
|
||||
const [url, setUrl] = useState(data.url || "");
|
||||
const [method, setMethod] = useState(data.method || "GET");
|
||||
const [headers, setHeaders] = useState(data.headers || {});
|
||||
const [newHeaderKey, setNewHeaderKey] = useState("");
|
||||
const [newHeaderValue, setNewHeaderValue] = useState("");
|
||||
const [body, setBody] = useState(JSON.stringify(data.body || {}, null, 2));
|
||||
const [authType, setAuthType] = useState(data.authentication?.type || "none");
|
||||
const [authToken, setAuthToken] = useState(data.authentication?.token || "");
|
||||
const [timeout, setTimeout] = useState(data.timeout?.toString() || "30000");
|
||||
const [responseMapping, setResponseMapping] = useState(data.responseMapping || "");
|
||||
const [responseFields, setResponseFields] = useState(data.responseFields || []);
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || "");
|
||||
setUrl(data.url || "");
|
||||
setMethod(data.method || "GET");
|
||||
setHeaders(data.headers || {});
|
||||
setBody(JSON.stringify(data.body || {}, null, 2));
|
||||
setAuthType(data.authentication?.type || "none");
|
||||
setAuthToken(data.authentication?.token || "");
|
||||
setTimeout(data.timeout?.toString() || "30000");
|
||||
setResponseMapping(data.responseMapping || "");
|
||||
setResponseFields(data.responseFields || []);
|
||||
}, [data]);
|
||||
|
||||
const handleApply = () => {
|
||||
let parsedBody = {};
|
||||
try {
|
||||
parsedBody = body.trim() ? JSON.parse(body) : {};
|
||||
} catch (e) {
|
||||
alert("Body JSON 형식이 올바르지 않습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("🔧 REST API 노드 업데이트 중...");
|
||||
console.log("📦 responseFields:", responseFields);
|
||||
console.log("📊 responseFields 개수:", responseFields.length);
|
||||
|
||||
updateNode(nodeId, {
|
||||
displayName,
|
||||
url,
|
||||
method: method as any,
|
||||
headers,
|
||||
body: parsedBody,
|
||||
authentication: {
|
||||
type: authType as any,
|
||||
token: authToken || undefined,
|
||||
},
|
||||
timeout: parseInt(timeout) || 30000,
|
||||
responseMapping,
|
||||
responseFields,
|
||||
});
|
||||
|
||||
console.log("✅ REST API 노드 업데이트 완료");
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
if (newHeaderKey.trim() && newHeaderValue.trim()) {
|
||||
setHeaders({ ...headers, [newHeaderKey.trim()]: newHeaderValue.trim() });
|
||||
setNewHeaderKey("");
|
||||
setNewHeaderValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const removeHeader = (key: string) => {
|
||||
const newHeaders = { ...headers };
|
||||
delete newHeaders[key];
|
||||
setHeaders(newHeaders);
|
||||
};
|
||||
|
||||
const addResponseField = () => {
|
||||
const newFields = [
|
||||
...responseFields,
|
||||
{
|
||||
name: "",
|
||||
label: "",
|
||||
dataType: "TEXT",
|
||||
},
|
||||
];
|
||||
console.log("➕ 응답 필드 추가:", newFields);
|
||||
setResponseFields(newFields);
|
||||
};
|
||||
|
||||
const updateResponseField = (index: number, field: string, value: string) => {
|
||||
const updated = [...responseFields];
|
||||
updated[index] = { ...updated[index], [field]: value };
|
||||
console.log(`✏️ 응답 필드 ${index} 업데이트 (${field}=${value}):`, updated);
|
||||
setResponseFields(updated);
|
||||
};
|
||||
|
||||
const removeResponseField = (index: number) => {
|
||||
const newFields = responseFields.filter((_, i) => i !== index);
|
||||
console.log("🗑️ 응답 필드 삭제:", newFields);
|
||||
setResponseFields(newFields);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 rounded-md bg-teal-50 p-2">
|
||||
<Globe className="h-4 w-4 text-teal-600" />
|
||||
<span className="font-semibold text-teal-600">REST API 소스</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="노드에 표시될 이름"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="url" className="text-xs">
|
||||
API URL
|
||||
</Label>
|
||||
<Input
|
||||
id="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/data"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">HTTP 메서드</Label>
|
||||
<Select value={method} onValueChange={setMethod}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HTTP_METHODS.map((m) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">헤더</Label>
|
||||
<div className="mt-1 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newHeaderKey}
|
||||
onChange={(e) => setNewHeaderKey(e.target.value)}
|
||||
placeholder="Key"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={newHeaderValue}
|
||||
onChange={(e) => setNewHeaderValue(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && addHeader()}
|
||||
placeholder="Value"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button size="sm" onClick={addHeader}>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[100px] space-y-1 overflow-y-auto">
|
||||
{Object.entries(headers).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between rounded bg-teal-50 px-2 py-1">
|
||||
<span className="text-xs">
|
||||
<span className="font-medium">{key}:</span> {value}
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeHeader(key)}>
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(method === "POST" || method === "PUT" || method === "PATCH") && (
|
||||
<div>
|
||||
<Label htmlFor="body" className="text-xs">
|
||||
Body (JSON)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="body"
|
||||
value={body}
|
||||
onChange={(e) => setBody(e.target.value)}
|
||||
placeholder='{"key": "value"}'
|
||||
className="mt-1 font-mono text-sm"
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label className="text-xs">인증</Label>
|
||||
<Select value={authType} onValueChange={setAuthType}>
|
||||
<SelectTrigger className="mt-1">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{AUTH_TYPES.map((type) => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{authType !== "none" && (
|
||||
<Input
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
placeholder="토큰/키 입력"
|
||||
className="mt-2 text-sm"
|
||||
type="password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="timeout" className="text-xs">
|
||||
타임아웃 (ms)
|
||||
</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={timeout}
|
||||
onChange={(e) => setTimeout(e.target.value)}
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="responseMapping" className="text-xs">
|
||||
응답 매핑 (JSON 경로)
|
||||
</Label>
|
||||
<Input
|
||||
id="responseMapping"
|
||||
value={responseMapping}
|
||||
onChange={(e) => setResponseMapping(e.target.value)}
|
||||
placeholder="예: data.items"
|
||||
className="mt-1 text-sm"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">배열 데이터의 경로를 지정하세요 (예: data.items, result.users)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<Label className="text-xs">응답 필드 정의</Label>
|
||||
<Button size="sm" variant="outline" onClick={addResponseField}>
|
||||
<Plus className="mr-1 h-3 w-3" />
|
||||
필드 추가
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[300px] space-y-2 overflow-y-auto">
|
||||
{responseFields.length === 0 ? (
|
||||
<div className="rounded border border-dashed p-3 text-center text-xs text-gray-400">
|
||||
응답 필드를 추가하여 데이터 구조를 정의하세요
|
||||
</div>
|
||||
) : (
|
||||
responseFields.map((field, index) => (
|
||||
<div key={index} className="rounded border bg-gray-50 p-2">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">필드 {index + 1}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => removeResponseField(index)}>
|
||||
<Trash2 className="h-3 w-3 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
value={field.name}
|
||||
onChange={(e) => updateResponseField(index, "name", e.target.value)}
|
||||
placeholder="필드명 (예: userId)"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Input
|
||||
value={field.label || ""}
|
||||
onChange={(e) => updateResponseField(index, "label", e.target.value)}
|
||||
placeholder="표시명 (예: 사용자 ID)"
|
||||
className="text-xs"
|
||||
/>
|
||||
<Select
|
||||
value={field.dataType || "TEXT"}
|
||||
onValueChange={(value) => updateResponseField(index, "dataType", value)}
|
||||
>
|
||||
<SelectTrigger className="text-xs">
|
||||
<SelectValue placeholder="데이터 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="TEXT">텍스트</SelectItem>
|
||||
<SelectItem value="NUMBER">숫자</SelectItem>
|
||||
<SelectItem value="DATE">날짜</SelectItem>
|
||||
<SelectItem value="BOOLEAN">참/거짓</SelectItem>
|
||||
<SelectItem value="JSON">JSON</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleApply} className="w-full">
|
||||
적용
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,262 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 테이블 소스 노드 속성 편집
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Check, ChevronsUpDown } from "lucide-react";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||
import { tableTypeApi } from "@/lib/api/screen";
|
||||
import type { TableSourceNodeData } from "@/types/node-editor";
|
||||
|
||||
interface TableSourcePropertiesProps {
|
||||
nodeId: string;
|
||||
data: TableSourceNodeData;
|
||||
}
|
||||
|
||||
interface TableOption {
|
||||
tableName: string;
|
||||
displayName: string;
|
||||
description: string;
|
||||
label: string; // 표시용 (라벨 또는 테이블명)
|
||||
}
|
||||
|
||||
export function TableSourceProperties({ nodeId, data }: TableSourcePropertiesProps) {
|
||||
const { updateNode } = useFlowEditorStore();
|
||||
|
||||
const [displayName, setDisplayName] = useState(data.displayName || data.tableName);
|
||||
const [tableName, setTableName] = useState(data.tableName);
|
||||
|
||||
// 테이블 선택 관련 상태
|
||||
const [tables, setTables] = useState<TableOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// 데이터 변경 시 로컬 상태 업데이트
|
||||
useEffect(() => {
|
||||
setDisplayName(data.displayName || data.tableName);
|
||||
setTableName(data.tableName);
|
||||
}, [data.displayName, data.tableName]);
|
||||
|
||||
// 테이블 목록 로딩
|
||||
useEffect(() => {
|
||||
loadTables();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 테이블 목록 로드
|
||||
*/
|
||||
const loadTables = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
console.log("🔍 테이블 목록 로딩 중...");
|
||||
|
||||
const tableList = await tableTypeApi.getTables();
|
||||
|
||||
// 테이블 목록 변환 (라벨 또는 displayName 우선 표시)
|
||||
const options: TableOption[] = tableList.map((table) => {
|
||||
// tableLabel이 있으면 우선 사용, 없으면 displayName, 그것도 없으면 tableName
|
||||
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
|
||||
|
||||
return {
|
||||
tableName: table.tableName,
|
||||
displayName: table.displayName || table.tableName,
|
||||
description: table.description || "",
|
||||
label,
|
||||
};
|
||||
});
|
||||
|
||||
setTables(options);
|
||||
console.log(`✅ 테이블 ${options.length}개 로딩 완료`);
|
||||
} catch (error) {
|
||||
console.error("❌ 테이블 목록 로딩 실패:", error);
|
||||
setTables([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 테이블 선택 핸들러 (즉시 노드 업데이트 + 컬럼 로드)
|
||||
*/
|
||||
const handleTableSelect = async (selectedTableName: string) => {
|
||||
const selectedTable = tables.find((t) => t.tableName === selectedTableName);
|
||||
if (selectedTable) {
|
||||
const newTableName = selectedTable.tableName;
|
||||
const newDisplayName = selectedTable.label;
|
||||
|
||||
setTableName(newTableName);
|
||||
setDisplayName(newDisplayName);
|
||||
setOpen(false);
|
||||
|
||||
// 컬럼 정보 로드
|
||||
console.log(`🔍 테이블 "${newTableName}" 컬럼 로드 중...`);
|
||||
try {
|
||||
const columns = await tableTypeApi.getColumns(newTableName);
|
||||
console.log("🔍 API에서 받은 컬럼 데이터:", columns);
|
||||
|
||||
const fields = columns.map((col: any) => ({
|
||||
name: col.column_name || col.columnName,
|
||||
type: col.data_type || col.dataType || "unknown",
|
||||
nullable: col.is_nullable === "YES" || col.isNullable === true,
|
||||
// displayName이 라벨입니다!
|
||||
label: col.displayName || col.label_ko || col.columnLabel || col.column_label,
|
||||
}));
|
||||
|
||||
console.log(`✅ ${fields.length}개 컬럼 로드 완료:`, fields);
|
||||
|
||||
// 필드 정보와 함께 노드 업데이트
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName: newTableName,
|
||||
fields,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("❌ 컬럼 로드 실패:", error);
|
||||
|
||||
// 실패해도 테이블 정보는 업데이트
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName: newTableName,
|
||||
fields: [],
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`✅ 테이블 선택: ${newTableName} (${newDisplayName})`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 표시 이름 변경 핸들러
|
||||
*/
|
||||
const handleDisplayNameChange = (newDisplayName: string) => {
|
||||
setDisplayName(newDisplayName);
|
||||
updateNode(nodeId, {
|
||||
displayName: newDisplayName,
|
||||
tableName,
|
||||
});
|
||||
};
|
||||
|
||||
// 현재 선택된 테이블의 라벨 찾기
|
||||
const selectedTableLabel = tables.find((t) => t.tableName === tableName)?.label || tableName;
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 p-4">
|
||||
{/* 기본 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label htmlFor="displayName" className="text-xs">
|
||||
표시 이름
|
||||
</Label>
|
||||
<Input
|
||||
id="displayName"
|
||||
value={displayName}
|
||||
onChange={(e) => handleDisplayNameChange(e.target.value)}
|
||||
className="mt-1"
|
||||
placeholder="노드 표시 이름"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 테이블 선택 Combobox */}
|
||||
<div>
|
||||
<Label className="text-xs">테이블 선택</Label>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="mt-1 w-full justify-between"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<span className="text-muted-foreground">로딩 중...</span>
|
||||
) : tableName ? (
|
||||
<span className="truncate">{selectedTableLabel}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">테이블을 선택하세요</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[320px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder="테이블 검색..." className="h-9" />
|
||||
<CommandList>
|
||||
<CommandEmpty>검색 결과가 없습니다.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<ScrollArea className="h-[300px]">
|
||||
{tables.map((table) => (
|
||||
<CommandItem
|
||||
key={table.tableName}
|
||||
value={`${table.label} ${table.tableName} ${table.description}`}
|
||||
onSelect={() => handleTableSelect(table.tableName)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
tableName === table.tableName ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{table.label}</span>
|
||||
{table.label !== table.tableName && (
|
||||
<span className="text-muted-foreground text-xs">{table.tableName}</span>
|
||||
)}
|
||||
{table.description && (
|
||||
<span className="text-muted-foreground text-xs">{table.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{tableName && selectedTableLabel !== tableName && (
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
실제 테이블명: <code className="rounded bg-gray-100 px-1 py-0.5">{tableName}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 필드 정보 */}
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">출력 필드</h3>
|
||||
{data.fields && data.fields.length > 0 ? (
|
||||
<div className="space-y-1 rounded border p-2">
|
||||
{data.fields.map((field) => (
|
||||
<div key={field.name} className="flex items-center justify-between text-xs">
|
||||
<span className="font-mono text-gray-700">{field.name}</span>
|
||||
<span className="text-gray-400">{field.type}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded border p-4 text-center text-xs text-gray-400">필드 정보가 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 안내 */}
|
||||
<div className="rounded bg-green-50 p-3 text-xs text-green-700">✅ 변경 사항이 즉시 노드에 반영됩니다.</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,114 @@
|
|||
"use client";
|
||||
|
||||
/**
|
||||
* 노드 팔레트 사이드바
|
||||
*/
|
||||
|
||||
import { useState } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { NODE_CATEGORIES, getNodesByCategory } from "./nodePaletteConfig";
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export function NodePalette() {
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(["source", "transform", "action"]));
|
||||
|
||||
const toggleCategory = (categoryId: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(categoryId)) {
|
||||
next.delete(categoryId);
|
||||
} else {
|
||||
next.add(categoryId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* 헤더 */}
|
||||
<div className="border-b bg-gray-50 p-4">
|
||||
<h3 className="text-sm font-semibold text-gray-900">노드 라이브러리</h3>
|
||||
<p className="mt-1 text-xs text-gray-500">캔버스로 드래그하여 추가</p>
|
||||
</div>
|
||||
|
||||
{/* 노드 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-2">
|
||||
{NODE_CATEGORIES.map((category) => {
|
||||
const isExpanded = expandedCategories.has(category.id);
|
||||
const nodes = getNodesByCategory(category.id);
|
||||
|
||||
return (
|
||||
<div key={category.id} className="mb-2">
|
||||
{/* 카테고리 헤더 */}
|
||||
<button
|
||||
onClick={() => toggleCategory(category.id)}
|
||||
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{isExpanded ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
<span>{category.icon}</span>
|
||||
<span>{category.label}</span>
|
||||
<span className="ml-auto text-xs text-gray-400">{nodes.length}</span>
|
||||
</button>
|
||||
|
||||
{/* 노드 아이템들 */}
|
||||
{isExpanded && (
|
||||
<div className="mt-1 ml-2 space-y-1">
|
||||
{nodes.map((node) => (
|
||||
<NodePaletteItemComponent key={node.type} node={node} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 푸터 도움말 */}
|
||||
<div className="border-t bg-gray-50 p-3">
|
||||
<p className="text-xs text-gray-500">💡 노드를 드래그하여 캔버스에 배치하고 연결선으로 연결하세요</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 노드 팔레트 아이템 컴포넌트
|
||||
*/
|
||||
function NodePaletteItemComponent({ node }: { node: NodePaletteItem }) {
|
||||
const onDragStart = (event: React.DragEvent) => {
|
||||
event.dataTransfer.setData("application/reactflow", node.type);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group cursor-move rounded-lg border border-gray-200 bg-white p-3 shadow-sm transition-all hover:border-gray-300 hover:shadow-md"
|
||||
draggable
|
||||
onDragStart={onDragStart}
|
||||
title={node.description}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{/* 아이콘 */}
|
||||
<div
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded text-lg"
|
||||
style={{ backgroundColor: `${node.color}20` }}
|
||||
>
|
||||
{node.icon}
|
||||
</div>
|
||||
|
||||
{/* 라벨 및 설명 */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">{node.label}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-gray-500">{node.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 색상 인디케이터 */}
|
||||
<div
|
||||
className="mt-2 h-1 w-full rounded-full opacity-0 transition-opacity group-hover:opacity-100"
|
||||
style={{ backgroundColor: node.color }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* 노드 팔레트 설정
|
||||
*/
|
||||
|
||||
import type { NodePaletteItem } from "@/types/node-editor";
|
||||
|
||||
export const NODE_PALETTE: NodePaletteItem[] = [
|
||||
// ========================================================================
|
||||
// 데이터 소스
|
||||
// ========================================================================
|
||||
{
|
||||
type: "tableSource",
|
||||
label: "테이블",
|
||||
icon: "📊",
|
||||
description: "내부 데이터베이스 테이블에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#3B82F6", // 파란색
|
||||
},
|
||||
{
|
||||
type: "externalDBSource",
|
||||
label: "외부 DB",
|
||||
icon: "🔌",
|
||||
description: "외부 데이터베이스에서 데이터를 읽어옵니다",
|
||||
category: "source",
|
||||
color: "#F59E0B", // 주황색
|
||||
},
|
||||
{
|
||||
type: "restAPISource",
|
||||
label: "REST API",
|
||||
icon: "📁",
|
||||
description: "REST API를 호출하여 데이터를 가져옵니다",
|
||||
category: "source",
|
||||
color: "#10B981", // 초록색
|
||||
},
|
||||
{
|
||||
type: "referenceLookup",
|
||||
label: "참조 조회",
|
||||
icon: "🔗",
|
||||
description: "다른 테이블에서 데이터를 조회합니다 (내부 DB 전용)",
|
||||
category: "source",
|
||||
color: "#A855F7", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 변환/조건
|
||||
// ========================================================================
|
||||
{
|
||||
type: "condition",
|
||||
label: "조건 분기",
|
||||
icon: "⚡",
|
||||
description: "조건에 따라 데이터 흐름을 분기합니다",
|
||||
category: "transform",
|
||||
color: "#EAB308", // 노란색
|
||||
},
|
||||
{
|
||||
type: "dataTransform",
|
||||
label: "데이터 변환",
|
||||
icon: "🔧",
|
||||
description: "데이터를 변환하거나 가공합니다",
|
||||
category: "transform",
|
||||
color: "#06B6D4", // 청록색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 액션
|
||||
// ========================================================================
|
||||
{
|
||||
type: "insertAction",
|
||||
label: "INSERT",
|
||||
icon: "➕",
|
||||
description: "데이터를 삽입합니다",
|
||||
category: "action",
|
||||
color: "#22C55E", // 초록색
|
||||
},
|
||||
{
|
||||
type: "updateAction",
|
||||
label: "UPDATE",
|
||||
icon: "✏️",
|
||||
description: "데이터를 수정합니다",
|
||||
category: "action",
|
||||
color: "#3B82F6", // 파란색
|
||||
},
|
||||
{
|
||||
type: "deleteAction",
|
||||
label: "DELETE",
|
||||
icon: "❌",
|
||||
description: "데이터를 삭제합니다",
|
||||
category: "action",
|
||||
color: "#EF4444", // 빨간색
|
||||
},
|
||||
{
|
||||
type: "upsertAction",
|
||||
label: "UPSERT",
|
||||
icon: "🔄",
|
||||
description: "데이터를 삽입하거나 수정합니다",
|
||||
category: "action",
|
||||
color: "#8B5CF6", // 보라색
|
||||
},
|
||||
|
||||
// ========================================================================
|
||||
// 유틸리티
|
||||
// ========================================================================
|
||||
{
|
||||
type: "comment",
|
||||
label: "주석",
|
||||
icon: "💬",
|
||||
description: "주석을 추가합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
{
|
||||
type: "log",
|
||||
label: "로그",
|
||||
icon: "🔍",
|
||||
description: "로그를 출력합니다",
|
||||
category: "utility",
|
||||
color: "#6B7280", // 회색
|
||||
},
|
||||
];
|
||||
|
||||
export const NODE_CATEGORIES = [
|
||||
{
|
||||
id: "source",
|
||||
label: "데이터 소스",
|
||||
icon: "📂",
|
||||
},
|
||||
{
|
||||
id: "transform",
|
||||
label: "변환/조건",
|
||||
icon: "🔀",
|
||||
},
|
||||
{
|
||||
id: "action",
|
||||
label: "액션",
|
||||
icon: "⚡",
|
||||
},
|
||||
{
|
||||
id: "utility",
|
||||
label: "유틸리티",
|
||||
icon: "🛠️",
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 노드 타입별 팔레트 아이템 조회
|
||||
*/
|
||||
export function getNodePaletteItem(type: string): NodePaletteItem | undefined {
|
||||
return NODE_PALETTE.find((item) => item.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리별 노드 목록 조회
|
||||
*/
|
||||
export function getNodesByCategory(category: string): NodePaletteItem[] {
|
||||
return NODE_PALETTE.filter((item) => item.category === category);
|
||||
}
|
||||
|
|
@ -0,0 +1,228 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { CreateReportRequest, ReportTemplate } from "@/types/report";
|
||||
|
||||
interface ReportCreateModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export function ReportCreateModal({ isOpen, onClose, onSuccess }: ReportCreateModalProps) {
|
||||
const [formData, setFormData] = useState<CreateReportRequest>({
|
||||
reportNameKor: "",
|
||||
reportNameEng: "",
|
||||
templateId: undefined,
|
||||
reportType: "BASIC",
|
||||
description: "",
|
||||
});
|
||||
const [templates, setTemplates] = useState<ReportTemplate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingTemplates, setIsLoadingTemplates] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
// 템플릿 목록 불러오기
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchTemplates();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
setIsLoadingTemplates(true);
|
||||
try {
|
||||
const response = await reportApi.getTemplates();
|
||||
if (response.success && response.data) {
|
||||
setTemplates([...response.data.system, ...response.data.custom]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: "템플릿 목록을 불러오는데 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingTemplates(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// 유효성 검증
|
||||
if (!formData.reportNameKor.trim()) {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "리포트명(한글)을 입력해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reportType) {
|
||||
toast({
|
||||
title: "입력 오류",
|
||||
description: "리포트 타입을 선택해주세요.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await reportApi.createReport(formData);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 생성되었습니다.",
|
||||
});
|
||||
handleClose();
|
||||
onSuccess();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 생성에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setFormData({
|
||||
reportNameKor: "",
|
||||
reportNameEng: "",
|
||||
templateId: undefined,
|
||||
reportType: "BASIC",
|
||||
description: "",
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 리포트 생성</DialogTitle>
|
||||
<DialogDescription>새로운 리포트를 생성합니다. 필수 항목을 입력해주세요.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 리포트명 (한글) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportNameKor">
|
||||
리포트명 (한글) <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="reportNameKor"
|
||||
placeholder="예: 발주서"
|
||||
value={formData.reportNameKor}
|
||||
onChange={(e) => setFormData({ ...formData, reportNameKor: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 리포트명 (영문) */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportNameEng">리포트명 (영문)</Label>
|
||||
<Input
|
||||
id="reportNameEng"
|
||||
placeholder="예: Purchase Order"
|
||||
value={formData.reportNameEng}
|
||||
onChange={(e) => setFormData({ ...formData, reportNameEng: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 템플릿 선택 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="templateId">템플릿</Label>
|
||||
<Select
|
||||
value={formData.templateId || "none"}
|
||||
onValueChange={(value) => setFormData({ ...formData, templateId: value === "none" ? undefined : value })}
|
||||
disabled={isLoadingTemplates}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="템플릿 선택 (선택사항)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">템플릿 없음</SelectItem>
|
||||
{templates.map((template) => (
|
||||
<SelectItem key={template.template_id} value={template.template_id}>
|
||||
{template.template_name_kor}
|
||||
{template.is_system === "Y" && " (시스템)"}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 리포트 타입 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reportType">
|
||||
리포트 타입 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.reportType}
|
||||
onValueChange={(value) => setFormData({ ...formData, reportType: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="타입 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ORDER">발주서</SelectItem>
|
||||
<SelectItem value="INVOICE">청구서</SelectItem>
|
||||
<SelectItem value="STATEMENT">거래명세서</SelectItem>
|
||||
<SelectItem value="RECEIPT">영수증</SelectItem>
|
||||
<SelectItem value="BASIC">기본</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 설명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
placeholder="리포트에 대한 설명을 입력하세요"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
생성 중...
|
||||
</>
|
||||
) : (
|
||||
"생성"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ReportMaster } from "@/types/report";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Pencil, Copy, Trash2, Loader2 } from "lucide-react";
|
||||
import { reportApi } from "@/lib/api/reportApi";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
|
||||
interface ReportListTableProps {
|
||||
reports: ReportMaster[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
isLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function ReportListTable({
|
||||
reports,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
isLoading,
|
||||
onPageChange,
|
||||
onRefresh,
|
||||
}: ReportListTableProps) {
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isCopying, setIsCopying] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
|
||||
// 수정
|
||||
const handleEdit = (reportId: string) => {
|
||||
router.push(`/admin/report/designer/${reportId}`);
|
||||
};
|
||||
|
||||
// 복사
|
||||
const handleCopy = async (reportId: string) => {
|
||||
setIsCopying(true);
|
||||
try {
|
||||
const response = await reportApi.copyReport(reportId);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 복사되었습니다.",
|
||||
});
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 복사에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제 확인
|
||||
const handleDeleteClick = (reportId: string) => {
|
||||
setDeleteTarget(reportId);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!deleteTarget) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const response = await reportApi.deleteReport(deleteTarget);
|
||||
if (response.success) {
|
||||
toast({
|
||||
title: "성공",
|
||||
description: "리포트가 삭제되었습니다.",
|
||||
});
|
||||
setDeleteTarget(null);
|
||||
onRefresh();
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast({
|
||||
title: "오류",
|
||||
description: error.message || "리포트 삭제에 실패했습니다.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 포맷
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return "-";
|
||||
try {
|
||||
return format(new Date(dateString), "yyyy-MM-dd");
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-muted-foreground h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (reports.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground flex h-64 flex-col items-center justify-center">
|
||||
<p>등록된 리포트가 없습니다.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[80px]">No</TableHead>
|
||||
<TableHead>리포트명</TableHead>
|
||||
<TableHead className="w-[120px]">작성자</TableHead>
|
||||
<TableHead className="w-[120px]">수정일</TableHead>
|
||||
<TableHead className="w-[200px]">액션</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{reports.map((report, index) => {
|
||||
const rowNumber = (page - 1) * limit + index + 1;
|
||||
return (
|
||||
<TableRow key={report.report_id}>
|
||||
<TableCell className="font-medium">{rowNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{report.report_name_kor}</div>
|
||||
{report.report_name_eng && (
|
||||
<div className="text-muted-foreground text-sm">{report.report_name_eng}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{report.created_by || "-"}</TableCell>
|
||||
<TableCell>{formatDate(report.updated_at || report.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(report.report_id)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleCopy(report.report_id)}
|
||||
disabled={isCopying}
|
||||
className="gap-1"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
복사
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteClick(report.report_id)}
|
||||
className="gap-1"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-center gap-2 p-4">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page - 1)} disabled={page === 1}>
|
||||
이전
|
||||
</Button>
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(page + 1)} disabled={page === totalPages}>
|
||||
다음
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteTarget !== null} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>리포트 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 리포트를 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 리포트는 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDeleteConfirm}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
삭제 중...
|
||||
</>
|
||||
) : (
|
||||
"삭제"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue