This commit is contained in:
kjs 2025-09-25 09:37:25 +09:00
commit 25ecfa13d2
92 changed files with 8387 additions and 933 deletions

View File

@ -31,6 +31,7 @@ import layoutRoutes from "./routes/layoutRoutes";
import dataRoutes from "./routes/dataRoutes"; import dataRoutes from "./routes/dataRoutes";
import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes"; import testButtonDataflowRoutes from "./routes/testButtonDataflowRoutes";
import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes"; import externalDbConnectionRoutes from "./routes/externalDbConnectionRoutes";
import multiConnectionRoutes from "./routes/multiConnectionRoutes";
import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
import ddlRoutes from "./routes/ddlRoutes"; import ddlRoutes from "./routes/ddlRoutes";
import entityReferenceRoutes from "./routes/entityReferenceRoutes"; import entityReferenceRoutes from "./routes/entityReferenceRoutes";
@ -130,6 +131,7 @@ app.use("/api/screen", screenStandardRoutes);
app.use("/api/data", dataRoutes); app.use("/api/data", dataRoutes);
app.use("/api/test-button-dataflow", testButtonDataflowRoutes); app.use("/api/test-button-dataflow", testButtonDataflowRoutes);
app.use("/api/external-db-connections", externalDbConnectionRoutes); app.use("/api/external-db-connections", externalDbConnectionRoutes);
app.use("/api/multi-connection", multiConnectionRoutes);
app.use("/api/db-type-categories", dbTypeCategoryRoutes); app.use("/api/db-type-categories", dbTypeCategoryRoutes);
app.use("/api/ddl", ddlRoutes); app.use("/api/ddl", ddlRoutes);
app.use("/api/entity-reference", entityReferenceRoutes); app.use("/api/entity-reference", entityReferenceRoutes);

View File

@ -1,6 +1,10 @@
import { Client } from 'pg'; import { Client } from "pg";
import { DatabaseConnector, ConnectionConfig, QueryResult } from '../interfaces/DatabaseConnector'; import {
import { ConnectionTestResult, TableInfo } from '../types/externalDbTypes'; DatabaseConnector,
ConnectionConfig,
QueryResult,
} from "../interfaces/DatabaseConnector";
import { ConnectionTestResult, TableInfo } from "../types/externalDbTypes";
export class PostgreSQLConnector implements DatabaseConnector { export class PostgreSQLConnector implements DatabaseConnector {
private client: Client | null = null; private client: Client | null = null;
@ -11,37 +15,72 @@ export class PostgreSQLConnector implements DatabaseConnector {
} }
async connect(): Promise<void> { async connect(): Promise<void> {
if (this.client) { // 기존 연결이 있다면 먼저 정리
await this.disconnect(); await this.forceDisconnect();
}
const clientConfig: any = { const clientConfig: any = {
host: this.config.host, host: this.config.host,
port: this.config.port, port: this.config.port,
database: this.config.database, database: this.config.database,
user: this.config.user, user: this.config.user,
password: this.config.password, password: this.config.password,
// 연결 안정성 개선 (더 보수적인 설정)
connectionTimeoutMillis: this.config.connectionTimeoutMillis || 15000,
query_timeout: this.config.queryTimeoutMillis || 20000,
keepAlive: false, // keepAlive 비활성화 (연결 문제 방지)
// SASL 인증 문제 방지
application_name: "PLM-ERP-System",
// 추가 안정성 설정
statement_timeout: 20000,
idle_in_transaction_session_timeout: 30000,
}; };
if (this.config.connectionTimeoutMillis != null) {
clientConfig.connectionTimeoutMillis = this.config.connectionTimeoutMillis;
}
if (this.config.queryTimeoutMillis != null) {
clientConfig.query_timeout = this.config.queryTimeoutMillis;
}
if (this.config.ssl != null) { if (this.config.ssl != null) {
clientConfig.ssl = this.config.ssl; clientConfig.ssl = this.config.ssl;
} }
this.client = new Client(clientConfig); this.client = new Client(clientConfig);
await this.client.connect();
// 연결 시 더 긴 타임아웃 설정
const connectPromise = this.client.connect();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("연결 타임아웃")), 20000);
});
await Promise.race([connectPromise, timeoutPromise]);
console.log(
`✅ PostgreSQL 연결 성공: ${this.config.host}:${this.config.port}`
);
}
// 강제 연결 해제 메서드 추가
private async forceDisconnect(): Promise<void> {
if (this.client) {
try {
await this.client.end();
} catch (error) {
console.warn("강제 연결 해제 중 오류 (무시):", error);
} finally {
this.client = null;
}
}
} }
async disconnect(): Promise<void> { async disconnect(): Promise<void> {
if (this.client) { if (this.client) {
await this.client.end(); try {
this.client = null; const endPromise = this.client.end();
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error("연결 해제 타임아웃")), 3000);
});
await Promise.race([endPromise, timeoutPromise]);
console.log(`✅ PostgreSQL 연결 해제 성공`);
} catch (error) {
console.warn("연결 해제 중 오류:", error);
} finally {
this.client = null;
}
} }
} }
@ -49,7 +88,9 @@ export class PostgreSQLConnector implements DatabaseConnector {
const startTime = Date.now(); const startTime = Date.now();
try { try {
await this.connect(); await this.connect();
const result = await this.client!.query("SELECT version(), pg_database_size(current_database()) as size"); const result = await this.client!.query(
"SELECT version(), pg_database_size(current_database()) as size"
);
const responseTime = Date.now() - startTime; const responseTime = Date.now() - startTime;
await this.disconnect(); await this.disconnect();
return { return {
@ -58,7 +99,9 @@ export class PostgreSQLConnector implements DatabaseConnector {
details: { details: {
response_time: responseTime, response_time: responseTime,
server_version: result.rows[0]?.version || "알 수 없음", server_version: result.rows[0]?.version || "알 수 없음",
database_size: this.formatBytes(parseInt(result.rows[0]?.size || "0")), database_size: this.formatBytes(
parseInt(result.rows[0]?.size || "0")
),
}, },
}; };
} catch (error: any) { } catch (error: any) {
@ -91,9 +134,28 @@ export class PostgreSQLConnector implements DatabaseConnector {
} }
async getTables(): Promise<TableInfo[]> { async getTables(): Promise<TableInfo[]> {
let tempClient: Client | null = null;
try { try {
await this.connect(); console.log(
const result = await this.client!.query(` `🔍 PostgreSQL 테이블 목록 조회 시작: ${this.config.host}:${this.config.port}`
);
// 매번 새로운 연결 생성
const clientConfig: any = {
host: this.config.host,
port: this.config.port,
database: this.config.database,
user: this.config.user,
password: this.config.password,
connectionTimeoutMillis: 10000,
query_timeout: 15000,
application_name: "PLM-ERP-Tables",
};
tempClient = new Client(clientConfig);
await tempClient.connect();
const result = await tempClient.query(`
SELECT SELECT
t.table_name, t.table_name,
obj_description(quote_ident(t.table_name)::regclass::oid, 'pg_class') as table_description obj_description(quote_ident(t.table_name)::regclass::oid, 'pg_class') as table_description
@ -102,36 +164,81 @@ export class PostgreSQLConnector implements DatabaseConnector {
AND t.table_type = 'BASE TABLE' AND t.table_type = 'BASE TABLE'
ORDER BY t.table_name; ORDER BY t.table_name;
`); `);
await this.disconnect();
console.log(`✅ 테이블 목록 조회 성공: ${result.rows.length}`);
return result.rows.map((row) => ({ return result.rows.map((row) => ({
table_name: row.table_name, table_name: row.table_name,
description: row.table_description, description: row.table_description,
columns: [], // Columns will be fetched by getColumns columns: [], // Columns will be fetched by getColumns
})); }));
} catch (error: any) { } catch (error: any) {
await this.disconnect(); console.error(`❌ 테이블 목록 조회 실패:`, error.message);
throw new Error(`PostgreSQL 테이블 목록 조회 실패: ${error.message}`); throw new Error(`PostgreSQL 테이블 목록 조회 실패: ${error.message}`);
} finally {
if (tempClient) {
try {
await tempClient.end();
} catch (endError) {
console.warn("테이블 조회 연결 해제 중 오류:", endError);
}
}
} }
} }
async getColumns(tableName: string): Promise<any[]> { async getColumns(tableName: string): Promise<any[]> {
let tempClient: Client | null = null;
try { try {
await this.connect(); console.log(
const result = await this.client!.query(` `🔍 PostgreSQL 컬럼 정보 조회 시작: ${this.config.host}:${this.config.port}/${tableName}`
);
// 매번 새로운 연결 생성
const clientConfig: any = {
host: this.config.host,
port: this.config.port,
database: this.config.database,
user: this.config.user,
password: this.config.password,
connectionTimeoutMillis: 10000,
query_timeout: 15000,
application_name: "PLM-ERP-Columns",
};
tempClient = new Client(clientConfig);
await tempClient.connect();
const result = await tempClient.query(
`
SELECT SELECT
column_name, column_name,
data_type, data_type,
is_nullable, is_nullable,
column_default column_default,
FROM information_schema.columns col_description(c.oid, a.attnum) as column_comment
WHERE table_schema = 'public' AND table_name = $1 FROM information_schema.columns isc
ORDER BY ordinal_position; LEFT JOIN pg_class c ON c.relname = isc.table_name
`, [tableName]); LEFT JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = isc.column_name
await this.disconnect(); WHERE isc.table_schema = 'public' AND isc.table_name = $1
ORDER BY isc.ordinal_position;
`,
[tableName]
);
console.log(
`✅ 컬럼 정보 조회 성공: ${tableName} - ${result.rows.length}`
);
return result.rows; return result.rows;
} catch (error: any) { } catch (error: any) {
await this.disconnect(); console.error(`❌ 컬럼 정보 조회 실패: ${tableName} -`, error.message);
throw new Error(`PostgreSQL 컬럼 정보 조회 실패: ${error.message}`); throw new Error(`PostgreSQL 컬럼 정보 조회 실패: ${error.message}`);
} finally {
if (tempClient) {
try {
await tempClient.end();
} catch (endError) {
console.warn("컬럼 조회 연결 해제 중 오류:", endError);
}
}
} }
} }
@ -142,4 +249,4 @@ export class PostgreSQLConnector implements DatabaseConnector {
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
} }
} }

View File

@ -108,7 +108,8 @@ router.get(
} }
}); });
const result = await ExternalDbConnectionService.getConnectionsGroupedByType(filter); const result =
await ExternalDbConnectionService.getConnectionsGroupedByType(filter);
if (result.success) { if (result.success) {
return res.status(200).json(result); return res.status(200).json(result);
@ -120,7 +121,7 @@ router.get(
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.", message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}); });
} }
} }
@ -290,7 +291,7 @@ router.post(
async (req: AuthenticatedRequest, res: Response) => { async (req: AuthenticatedRequest, res: Response) => {
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
if (isNaN(id)) { if (isNaN(id)) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
@ -303,10 +304,17 @@ router.post(
} }
// 테스트용 비밀번호가 제공된 경우 사용 // 테스트용 비밀번호가 제공된 경우 사용
const testData = req.body.password ? { password: req.body.password } : undefined; const testData = req.body.password
console.log(`🔍 [API] 연결테스트 요청 - ID: ${id}, 비밀번호 제공됨: ${!!req.body.password}`); ? { password: req.body.password }
: undefined;
const result = await ExternalDbConnectionService.testConnectionById(id, testData); console.log(
`🔍 [API] 연결테스트 요청 - ID: ${id}, 비밀번호 제공됨: ${!!req.body.password}`
);
const result = await ExternalDbConnectionService.testConnectionById(
id,
testData
);
return res.status(200).json({ return res.status(200).json({
success: result.success, success: result.success,
@ -342,7 +350,7 @@ router.post(
if (!query?.trim()) { if (!query?.trim()) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "쿼리가 입력되지 않았습니다." message: "쿼리가 입력되지 않았습니다.",
}); });
} }
@ -353,7 +361,7 @@ router.post(
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: "쿼리 실행 중 오류가 발생했습니다.", message: "쿼리 실행 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}); });
} }
} }
@ -376,7 +384,7 @@ router.get(
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.", message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}); });
} }
} }
@ -393,26 +401,106 @@ router.get(
try { try {
const id = parseInt(req.params.id); const id = parseInt(req.params.id);
const tableName = req.params.tableName; const tableName = req.params.tableName;
if (!tableName) { if (!tableName) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: "테이블명이 입력되지 않았습니다." message: "테이블명이 입력되지 않았습니다.",
}); });
} }
const result = await ExternalDbConnectionService.getTableColumns(id, tableName); const result = await ExternalDbConnectionService.getTableColumns(
id,
tableName
);
return res.json(result); return res.json(result);
} catch (error) { } catch (error) {
console.error("테이블 컬럼 조회 오류:", error); console.error("테이블 컬럼 조회 오류:", error);
return res.status(500).json({ return res.status(500).json({
success: false, success: false,
message: "테이블 컬럼 조회 중 오류가 발생했습니다.", message: "테이블 컬럼 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}); });
} }
} }
); );
/**
* 🆕 GET /api/external-db-connections/active
* ( DB )
*/
router.get(
"/control/active",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
// 활성 상태의 외부 커넥션 조회
const filter: ExternalDbConnectionFilter = {
is_active: "Y",
company_code: (req.query.company_code as string) || "*",
};
const externalConnections =
await ExternalDbConnectionService.getConnections(filter);
if (!externalConnections.success) {
return res.status(400).json(externalConnections);
}
// 외부 커넥션들에 대해 연결 테스트 수행 (병렬 처리)
const testedConnections = await Promise.all(
(externalConnections.data || []).map(async (connection) => {
try {
const testResult =
await ExternalDbConnectionService.testConnectionById(
connection.id!
);
return testResult.success ? connection : null;
} catch (error) {
console.warn(`커넥션 테스트 실패 (ID: ${connection.id}):`, error);
return null;
}
})
);
// 테스트에 성공한 커넥션만 필터링
const validExternalConnections = testedConnections.filter(
(conn) => conn !== null
);
// 현재 메인 DB를 첫 번째로 추가
const mainDbConnection = {
id: 0,
connection_name: "메인 데이터베이스 (현재 시스템)",
description: "현재 시스템의 PostgreSQL 데이터베이스",
db_type: "postgresql",
host: "localhost",
port: 5432,
database_name: process.env.DB_NAME || "erp_database",
username: "system",
password: "***",
is_active: "Y",
company_code: "*",
created_date: new Date(),
updated_date: new Date(),
};
const allConnections = [mainDbConnection, ...validExternalConnections];
return res.status(200).json({
success: true,
data: allConnections,
message: "제어관리용 활성 커넥션 목록을 조회했습니다.",
});
} catch (error) {
console.error("제어관리용 활성 커넥션 조회 오류:", error);
return res.status(500).json({
success: false,
message: "서버 내부 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
export default router; export default router;

View File

@ -0,0 +1,367 @@
/**
* API
* DB와의 API
*/
import { Router, Response } from "express";
import { MultiConnectionQueryService } from "../services/multiConnectionQueryService";
import { authenticateToken } from "../middleware/authMiddleware";
import { AuthenticatedRequest } from "../types/auth";
import { logger } from "../utils/logger";
const router = Router();
const multiConnectionService = new MultiConnectionQueryService();
/**
* GET /api/multi-connection/connections/:connectionId/tables
* ( DB )
*/
router.get(
"/connections/:connectionId/tables",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.connectionId);
if (isNaN(connectionId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 커넥션 ID입니다.",
});
}
logger.info(`테이블 목록 조회 요청: connectionId=${connectionId}`);
const tables =
await multiConnectionService.getTablesFromConnection(connectionId);
return res.status(200).json({
success: true,
data: tables,
message: `커넥션 ${connectionId}의 테이블 목록을 조회했습니다.`,
});
} catch (error) {
logger.error(`테이블 목록 조회 실패: ${error}`);
return res.status(500).json({
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
/**
* GET /api/multi-connection/connections/:connectionId/tables/:tableName/columns
* ( DB )
*/
router.get(
"/connections/:connectionId/tables/:tableName/columns",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.connectionId);
const tableName = req.params.tableName;
if (isNaN(connectionId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 커넥션 ID입니다.",
});
}
if (!tableName || tableName.trim() === "") {
return res.status(400).json({
success: false,
message: "테이블명이 입력되지 않았습니다.",
});
}
logger.info(
`컬럼 정보 조회 요청: connectionId=${connectionId}, table=${tableName}`
);
const columns = await multiConnectionService.getColumnsFromConnection(
connectionId,
tableName
);
return res.status(200).json({
success: true,
data: columns,
message: `테이블 ${tableName}의 컬럼 정보를 조회했습니다.`,
});
} catch (error) {
logger.error(`컬럼 정보 조회 실패: ${error}`);
return res.status(500).json({
success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
/**
* POST /api/multi-connection/connections/:connectionId/query
*
*/
router.post(
"/connections/:connectionId/query",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.connectionId);
const { tableName, conditions } = req.body;
if (isNaN(connectionId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 커넥션 ID입니다.",
});
}
if (!tableName) {
return res.status(400).json({
success: false,
message: "테이블명이 입력되지 않았습니다.",
});
}
logger.info(
`데이터 조회 요청: connectionId=${connectionId}, table=${tableName}`
);
const data = await multiConnectionService.fetchDataFromConnection(
connectionId,
tableName,
conditions
);
return res.status(200).json({
success: true,
data: data,
message: `데이터 조회가 완료되었습니다. (${data.length}건)`,
});
} catch (error) {
logger.error(`데이터 조회 실패: ${error}`);
return res.status(500).json({
success: false,
message: "데이터 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
/**
* POST /api/multi-connection/connections/:connectionId/insert
*
*/
router.post(
"/connections/:connectionId/insert",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.connectionId);
const { tableName, data } = req.body;
if (isNaN(connectionId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 커넥션 ID입니다.",
});
}
if (!tableName || !data) {
return res.status(400).json({
success: false,
message: "테이블명과 데이터가 필요합니다.",
});
}
logger.info(
`데이터 삽입 요청: connectionId=${connectionId}, table=${tableName}`
);
const result = await multiConnectionService.insertDataToConnection(
connectionId,
tableName,
data
);
return res.status(201).json({
success: true,
data: result,
message: "데이터 삽입이 완료되었습니다.",
});
} catch (error) {
logger.error(`데이터 삽입 실패: ${error}`);
return res.status(500).json({
success: false,
message: "데이터 삽입 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
/**
* PUT /api/multi-connection/connections/:connectionId/update
*
*/
router.put(
"/connections/:connectionId/update",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.connectionId);
const { tableName, data, conditions } = req.body;
if (isNaN(connectionId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 커넥션 ID입니다.",
});
}
if (!tableName || !data || !conditions) {
return res.status(400).json({
success: false,
message: "테이블명, 데이터, 조건이 모두 필요합니다.",
});
}
logger.info(
`데이터 업데이트 요청: connectionId=${connectionId}, table=${tableName}`
);
const result = await multiConnectionService.updateDataToConnection(
connectionId,
tableName,
data,
conditions
);
return res.status(200).json({
success: true,
data: result,
message: `데이터 업데이트가 완료되었습니다. (${result.length}건)`,
});
} catch (error) {
logger.error(`데이터 업데이트 실패: ${error}`);
return res.status(500).json({
success: false,
message: "데이터 업데이트 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
/**
* DELETE /api/multi-connection/connections/:connectionId/delete
*
*/
router.delete(
"/connections/:connectionId/delete",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const connectionId = parseInt(req.params.connectionId);
const { tableName, conditions, maxDeleteCount } = req.body;
if (isNaN(connectionId)) {
return res.status(400).json({
success: false,
message: "유효하지 않은 커넥션 ID입니다.",
});
}
if (!tableName || !conditions) {
return res.status(400).json({
success: false,
message: "테이블명과 삭제 조건이 필요합니다.",
});
}
logger.info(
`데이터 삭제 요청: connectionId=${connectionId}, table=${tableName}`
);
const result = await multiConnectionService.deleteDataFromConnection(
connectionId,
tableName,
conditions,
maxDeleteCount || 100
);
return res.status(200).json({
success: true,
data: result,
message: `데이터 삭제가 완료되었습니다. (${result.length}건)`,
});
} catch (error) {
logger.error(`데이터 삭제 실패: ${error}`);
return res.status(500).json({
success: false,
message: "데이터 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
/**
* POST /api/multi-connection/validate-self-operation
*
*/
router.post(
"/validate-self-operation",
authenticateToken,
async (req: AuthenticatedRequest, res: Response) => {
try {
const { tableName, operation, conditions } = req.body;
if (!tableName || !operation || !conditions) {
return res.status(400).json({
success: false,
message: "테이블명, 작업 타입, 조건이 모두 필요합니다.",
});
}
if (!["update", "delete"].includes(operation)) {
return res.status(400).json({
success: false,
message: "작업 타입은 'update' 또는 'delete'만 허용됩니다.",
});
}
logger.info(
`자기 자신 테이블 작업 검증: table=${tableName}, operation=${operation}`
);
const validationResult =
await multiConnectionService.validateSelfTableOperation(
tableName,
operation,
conditions
);
return res.status(200).json({
success: true,
data: validationResult,
message: "검증이 완료되었습니다.",
});
} catch (error) {
logger.error(`자기 자신 테이블 작업 검증 실패: ${error}`);
return res.status(500).json({
success: false,
message: "검증 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류",
});
}
}
);
export default router;

View File

@ -0,0 +1,692 @@
/**
*
* DB
*/
import {
DataflowControlService,
ControlAction,
ControlCondition,
} from "./dataflowControlService";
import { MultiConnectionQueryService } from "./multiConnectionQueryService";
import { logger } from "../utils/logger";
export interface EnhancedControlAction extends ControlAction {
// 🆕 커넥션 정보 추가
fromConnection?: {
connectionId?: number;
connectionName?: string;
dbType?: string;
};
toConnection?: {
connectionId?: number;
connectionName?: string;
dbType?: string;
};
// 🆕 명시적 테이블 정보
fromTable?: string;
targetTable: string;
// 🆕 UPDATE 액션 관련 필드
updateConditions?: UpdateCondition[];
updateFields?: UpdateFieldMapping[];
// 🆕 DELETE 액션 관련 필드
deleteConditions?: DeleteCondition[];
deleteWhereConditions?: DeleteWhereCondition[];
maxDeleteCount?: number;
requireConfirmation?: boolean;
dryRunFirst?: boolean;
logAllDeletes?: boolean;
}
export interface UpdateCondition {
id: string;
fromColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
value: string | string[];
logicalOperator?: "AND" | "OR";
}
export interface UpdateFieldMapping {
id: string;
fromColumn: string;
toColumn: string;
transformFunction?: string;
defaultValue?: string;
}
export interface WhereCondition {
id: string;
toColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
valueSource: "from_column" | "static" | "current_timestamp";
fromColumn?: string; // valueSource가 "from_column"인 경우
staticValue?: string; // valueSource가 "static"인 경우
logicalOperator?: "AND" | "OR";
}
export interface DeleteCondition {
id: string;
fromColumn: string;
operator:
| "="
| "!="
| ">"
| "<"
| ">="
| "<="
| "LIKE"
| "IN"
| "NOT IN"
| "EXISTS"
| "NOT EXISTS";
value: string | string[];
logicalOperator?: "AND" | "OR";
}
export interface DeleteWhereCondition {
id: string;
toColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
valueSource: "from_column" | "static" | "condition_result";
fromColumn?: string;
staticValue?: string;
logicalOperator?: "AND" | "OR";
}
export interface DeleteSafetySettings {
maxDeleteCount: number;
requireConfirmation: boolean;
dryRunFirst: boolean;
logAllDeletes: boolean;
}
export interface ExecutionResult {
success: boolean;
message: string;
executedActions?: any[];
errors?: string[];
warnings?: string[];
}
export class EnhancedDataflowControlService extends DataflowControlService {
private multiConnectionService: MultiConnectionQueryService;
constructor() {
super();
this.multiConnectionService = new MultiConnectionQueryService();
}
/**
*
*/
async executeDataflowControl(
diagramId: number,
relationshipId: string,
triggerType: "insert" | "update" | "delete",
sourceData: Record<string, any>,
tableName: string,
// 🆕 추가 매개변수
sourceConnectionId?: number,
targetConnectionId?: number
): Promise<ExecutionResult> {
try {
logger.info(
`확장된 데이터플로우 제어 실행 시작: diagram=${diagramId}, trigger=${triggerType}`
);
// 기본 실행 결과
const result: ExecutionResult = {
success: true,
message: "데이터플로우 제어가 성공적으로 실행되었습니다.",
executedActions: [],
errors: [],
warnings: [],
};
// 다이어그램 설정 조회
const diagram = await this.getDiagramById(diagramId);
if (!diagram) {
return {
success: false,
message: "다이어그램을 찾을 수 없습니다.",
errors: [`다이어그램 ID ${diagramId}를 찾을 수 없습니다.`],
};
}
// 제어 계획 파싱
const plan = this.parsePlan(diagram.plan);
if (!plan.actions || plan.actions.length === 0) {
return {
success: true,
message: "실행할 액션이 없습니다.",
executedActions: [],
};
}
// 각 액션 실행
for (const action of plan.actions) {
try {
const enhancedAction = action as EnhancedControlAction;
let actionResult: any;
switch (enhancedAction.actionType) {
case "insert":
actionResult = await this.executeMultiConnectionInsert(
enhancedAction,
sourceData,
sourceConnectionId,
targetConnectionId
);
break;
case "update":
actionResult = await this.executeMultiConnectionUpdate(
enhancedAction,
sourceData,
sourceConnectionId,
targetConnectionId
);
break;
case "delete":
actionResult = await this.executeMultiConnectionDelete(
enhancedAction,
sourceData,
sourceConnectionId,
targetConnectionId
);
break;
default:
throw new Error(
`지원하지 않는 액션 타입입니다: ${enhancedAction.actionType}`
);
}
result.executedActions!.push({
actionId: enhancedAction.id,
actionType: enhancedAction.actionType,
result: actionResult,
});
} catch (actionError) {
const errorMessage = `액션 ${action.id} 실행 실패: ${actionError instanceof Error ? actionError.message : actionError}`;
logger.error(errorMessage);
result.errors!.push(errorMessage);
}
}
// 실행 결과 판정
if (result.errors!.length > 0) {
result.success = false;
result.message = `일부 액션 실행에 실패했습니다. 성공: ${result.executedActions!.length}, 실패: ${result.errors!.length}`;
}
logger.info(
`확장된 데이터플로우 제어 실행 완료: success=${result.success}`
);
return result;
} catch (error) {
logger.error(`확장된 데이터플로우 제어 실행 실패: ${error}`);
return {
success: false,
message: "데이터플로우 제어 실행 중 오류가 발생했습니다.",
errors: [error instanceof Error ? error.message : String(error)],
};
}
}
/**
* 🆕 INSERT
*/
private async executeMultiConnectionInsert(
action: EnhancedControlAction,
sourceData: Record<string, any>,
sourceConnectionId?: number,
targetConnectionId?: number
): Promise<any> {
try {
logger.info(`다중 커넥션 INSERT 실행: action=${action.id}`);
// 커넥션 ID 결정
const fromConnId =
sourceConnectionId || action.fromConnection?.connectionId || 0;
const toConnId =
targetConnectionId || action.toConnection?.connectionId || 0;
// FROM 테이블에서 소스 데이터 조회 (조건이 있는 경우)
let fromData = sourceData;
if (
action.fromTable &&
action.conditions &&
action.conditions.length > 0
) {
const queryConditions = this.buildQueryConditions(
action.conditions,
sourceData
);
const fromResults =
await this.multiConnectionService.fetchDataFromConnection(
fromConnId,
action.fromTable,
queryConditions
);
if (fromResults.length === 0) {
logger.info(`FROM 테이블에서 조건에 맞는 데이터가 없습니다.`);
return {
inserted: 0,
message: "조건에 맞는 소스 데이터가 없습니다.",
};
}
fromData = fromResults[0]; // 첫 번째 결과 사용
}
// 필드 매핑 적용
const mappedData = this.applyFieldMappings(
action.fieldMappings,
fromData
);
// TO 테이블에 데이터 삽입
const insertResult =
await this.multiConnectionService.insertDataToConnection(
toConnId,
action.targetTable,
mappedData
);
logger.info(`다중 커넥션 INSERT 완료`);
return insertResult;
} catch (error) {
logger.error(`다중 커넥션 INSERT 실패: ${error}`);
throw error;
}
}
/**
* 🆕 UPDATE
*/
private async executeMultiConnectionUpdate(
action: EnhancedControlAction,
sourceData: Record<string, any>,
sourceConnectionId?: number,
targetConnectionId?: number
): Promise<any> {
try {
logger.info(`다중 커넥션 UPDATE 실행: action=${action.id}`);
// 커넥션 ID 결정
const fromConnId =
sourceConnectionId || action.fromConnection?.connectionId || 0;
const toConnId =
targetConnectionId || action.toConnection?.connectionId || 0;
// UPDATE 조건 확인
if (!action.updateConditions || action.updateConditions.length === 0) {
throw new Error("UPDATE 작업에는 업데이트 조건이 필요합니다.");
}
// FROM 테이블에서 업데이트 조건 확인
const updateConditions = this.buildUpdateConditions(
action.updateConditions,
sourceData
);
const fromResults =
await this.multiConnectionService.fetchDataFromConnection(
fromConnId,
action.fromTable || action.targetTable,
updateConditions
);
if (fromResults.length === 0) {
logger.info(`업데이트 조건에 맞는 데이터가 없습니다.`);
return {
updated: 0,
message: "업데이트 조건에 맞는 데이터가 없습니다.",
};
}
// 업데이트 필드 매핑 적용
const updateData = this.applyUpdateFieldMappings(
action.updateFields || [],
fromResults[0]
);
// WHERE 조건 구성 (TO 테이블 대상)
const whereConditions = this.buildWhereConditions(
action.updateFields || [],
fromResults[0]
);
// TO 테이블 데이터 업데이트
const updateResult =
await this.multiConnectionService.updateDataToConnection(
toConnId,
action.targetTable,
updateData,
whereConditions
);
logger.info(`다중 커넥션 UPDATE 완료`);
return updateResult;
} catch (error) {
logger.error(`다중 커넥션 UPDATE 실패: ${error}`);
throw error;
}
}
/**
* 🆕 DELETE
*/
private async executeMultiConnectionDelete(
action: EnhancedControlAction,
sourceData: Record<string, any>,
sourceConnectionId?: number,
targetConnectionId?: number
): Promise<any> {
try {
logger.info(`다중 커넥션 DELETE 실행: action=${action.id}`);
// 커넥션 ID 결정
const fromConnId =
sourceConnectionId || action.fromConnection?.connectionId || 0;
const toConnId =
targetConnectionId || action.toConnection?.connectionId || 0;
// DELETE 조건 확인
if (!action.deleteConditions || action.deleteConditions.length === 0) {
throw new Error("DELETE 작업에는 삭제 조건이 필요합니다.");
}
// FROM 테이블에서 삭제 트리거 조건 확인
const deleteConditions = this.buildDeleteConditions(
action.deleteConditions,
sourceData
);
const fromResults =
await this.multiConnectionService.fetchDataFromConnection(
fromConnId,
action.fromTable || action.targetTable,
deleteConditions
);
if (fromResults.length === 0) {
logger.info(`삭제 조건에 맞는 데이터가 없습니다.`);
return { deleted: 0, message: "삭제 조건에 맞는 데이터가 없습니다." };
}
// WHERE 조건 구성 (TO 테이블 대상)
const whereConditions = this.buildDeleteWhereConditions(
action.deleteWhereConditions || [],
fromResults[0]
);
if (!whereConditions || Object.keys(whereConditions).length === 0) {
throw new Error("DELETE 작업에는 WHERE 조건이 필수입니다.");
}
// 안전장치 적용
const maxDeleteCount = action.maxDeleteCount || 100;
// Dry Run 실행 (선택사항)
if (action.dryRunFirst) {
const countResult =
await this.multiConnectionService.fetchDataFromConnection(
toConnId,
action.targetTable,
whereConditions
);
logger.info(`삭제 예상 개수: ${countResult.length}`);
if (countResult.length > maxDeleteCount) {
throw new Error(
`삭제 대상이 ${countResult.length}건으로 최대 허용 개수(${maxDeleteCount})를 초과합니다.`
);
}
}
// TO 테이블에서 데이터 삭제
const deleteResult =
await this.multiConnectionService.deleteDataFromConnection(
toConnId,
action.targetTable,
whereConditions,
maxDeleteCount
);
// 삭제 로그 기록 (선택사항)
if (action.logAllDeletes) {
logger.info(
`삭제 실행 로그: ${JSON.stringify({
action: action.id,
deletedCount: deleteResult.length,
conditions: whereConditions,
})}`
);
}
logger.info(`다중 커넥션 DELETE 완료`);
return deleteResult;
} catch (error) {
logger.error(`다중 커넥션 DELETE 실패: ${error}`);
throw error;
}
}
/**
*
*/
private buildQueryConditions(
conditions: ControlCondition[],
sourceData: Record<string, any>
): Record<string, any> {
const queryConditions: Record<string, any> = {};
conditions.forEach((condition) => {
if (condition.type === "condition" && condition.field) {
let value = condition.value;
// 소스 데이터에서 값 참조
if (
typeof value === "string" &&
value.startsWith("${") &&
value.endsWith("}")
) {
const fieldName = value.slice(2, -1);
value = sourceData[fieldName];
}
queryConditions[condition.field] = value;
}
});
return queryConditions;
}
/**
* UPDATE
*/
private buildUpdateConditions(
updateConditions: UpdateCondition[],
sourceData: Record<string, any>
): Record<string, any> {
const conditions: Record<string, any> = {};
updateConditions.forEach((condition) => {
let value = condition.value;
// 소스 데이터에서 값 참조
if (
typeof value === "string" &&
value.startsWith("${") &&
value.endsWith("}")
) {
const fieldName = value.slice(2, -1);
value = sourceData[fieldName];
}
conditions[condition.fromColumn] = value;
});
return conditions;
}
/**
* UPDATE
*/
private applyUpdateFieldMappings(
updateFields: UpdateFieldMapping[],
fromData: Record<string, any>
): Record<string, any> {
const updateData: Record<string, any> = {};
updateFields.forEach((mapping) => {
let value = fromData[mapping.fromColumn];
// 기본값 사용
if (value === undefined || value === null) {
value = mapping.defaultValue;
}
// 변환 함수 적용 (추후 구현 가능)
if (mapping.transformFunction) {
// TODO: 변환 함수 로직 구현
}
updateData[mapping.toColumn] = value;
});
return updateData;
}
/**
* WHERE (UPDATE용)
*/
private buildWhereConditions(
updateFields: UpdateFieldMapping[],
fromData: Record<string, any>
): Record<string, any> {
const whereConditions: Record<string, any> = {};
// 기본적으로 ID 필드로 WHERE 조건 구성
if (fromData.id) {
whereConditions.id = fromData.id;
}
return whereConditions;
}
/**
* DELETE
*/
private buildDeleteConditions(
deleteConditions: DeleteCondition[],
sourceData: Record<string, any>
): Record<string, any> {
const conditions: Record<string, any> = {};
deleteConditions.forEach((condition) => {
let value = condition.value;
// 소스 데이터에서 값 참조
if (
typeof value === "string" &&
value.startsWith("${") &&
value.endsWith("}")
) {
const fieldName = value.slice(2, -1);
value = sourceData[fieldName];
}
conditions[condition.fromColumn] = value;
});
return conditions;
}
/**
* DELETE WHERE
*/
private buildDeleteWhereConditions(
whereConditions: DeleteWhereCondition[],
fromData: Record<string, any>
): Record<string, any> {
const conditions: Record<string, any> = {};
whereConditions.forEach((condition) => {
let value: any;
switch (condition.valueSource) {
case "from_column":
if (condition.fromColumn) {
value = fromData[condition.fromColumn];
}
break;
case "static":
value = condition.staticValue;
break;
case "condition_result":
// 조건 결과를 사용 (추후 구현)
break;
}
if (value !== undefined && value !== null) {
conditions[condition.toColumn] = value;
}
});
return conditions;
}
/**
*
*/
private applyFieldMappings(
fieldMappings: any[],
sourceData: Record<string, any>
): Record<string, any> {
const mappedData: Record<string, any> = {};
fieldMappings.forEach((mapping) => {
let value: any;
if (mapping.sourceField) {
value = sourceData[mapping.sourceField];
} else if (mapping.defaultValue !== undefined) {
value = mapping.defaultValue;
}
if (value !== undefined) {
mappedData[mapping.targetField] = value;
}
});
return mappedData;
}
/**
* ( )
*/
private async getDiagramById(diagramId: number): Promise<any> {
// 부모 클래스의 메서드 호출 또는 직접 구현
// 임시로 간단한 구현
return { id: diagramId, plan: "{}" };
}
/**
* ( )
*/
private parsePlan(planJson: string): any {
try {
return JSON.parse(planJson);
} catch (error) {
logger.error(`계획 파싱 실패: ${error}`);
return { actions: [] };
}
}
}

View File

@ -42,9 +42,23 @@ export class EntityJoinService {
}, },
}); });
logger.info(`🔍 Entity 컬럼 조회 결과: ${entityColumns.length}개 발견`);
entityColumns.forEach((col, index) => {
logger.info(
` ${index + 1}. ${col.column_name} -> ${col.reference_table}.${col.reference_column} (display: ${col.display_column})`
);
});
const joinConfigs: EntityJoinConfig[] = []; const joinConfigs: EntityJoinConfig[] = [];
for (const column of entityColumns) { for (const column of entityColumns) {
logger.info(`🔍 Entity 컬럼 상세 정보:`, {
column_name: column.column_name,
reference_table: column.reference_table,
reference_column: column.reference_column,
display_column: column.display_column,
});
if ( if (
!column.column_name || !column.column_name ||
!column.reference_table || !column.reference_table ||
@ -58,6 +72,12 @@ export class EntityJoinService {
let displayColumns: string[] = []; let displayColumns: string[] = [];
let separator = " - "; let separator = " - ";
logger.info(`🔍 조건 확인 - 컬럼: ${column.column_name}`, {
hasScreenConfig: !!screenConfig,
hasDisplayColumns: screenConfig?.displayColumns,
displayColumn: column.display_column,
});
if (screenConfig && screenConfig.displayColumns) { if (screenConfig && screenConfig.displayColumns) {
// 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원) // 화면에서 설정된 표시 컬럼들 사용 (기본 테이블 + 조인 테이블 조합 지원)
displayColumns = screenConfig.displayColumns; displayColumns = screenConfig.displayColumns;
@ -70,9 +90,12 @@ export class EntityJoinService {
} else if (column.display_column && column.display_column !== "none") { } else if (column.display_column && column.display_column !== "none") {
// 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만) // 기존 설정된 단일 표시 컬럼 사용 (none이 아닌 경우만)
displayColumns = [column.display_column]; displayColumns = [column.display_column];
logger.info(
`🔧 기존 display_column 사용: ${column.column_name}${column.display_column}`
);
} else { } else {
// 조인 탭에서 보여줄 기본 표시 컬럼 설정 // display_column이 "none"이거나 없는 경우 기본 표시 컬럼 설정
// dept_info 테이블의 경우 dept_name을 기본으로 사용 // 🚨 display_column이 항상 "none"이므로 이 로직을 기본으로 사용
let defaultDisplayColumn = column.reference_column; let defaultDisplayColumn = column.reference_column;
if (column.reference_table === "dept_info") { if (column.reference_table === "dept_info") {
defaultDisplayColumn = "dept_name"; defaultDisplayColumn = "dept_name";
@ -83,9 +106,10 @@ export class EntityJoinService {
} }
displayColumns = [defaultDisplayColumn]; displayColumns = [defaultDisplayColumn];
console.log( logger.info(
`🔧 조인 탭용 기본 표시 컬럼 설정: ${column.column_name}${defaultDisplayColumn} (${column.reference_table})` `🔧 Entity 조인 기본 표시 컬럼 설정: ${column.column_name}${defaultDisplayColumn} (${column.reference_table})`
); );
logger.info(`🔍 생성된 displayColumns 배열:`, displayColumns);
} }
// 별칭 컬럼명 생성 (writer -> writer_name) // 별칭 컬럼명 생성 (writer -> writer_name)
@ -102,13 +126,32 @@ export class EntityJoinService {
separator: separator, separator: separator,
}; };
logger.info(`🔧 기본 조인 설정 생성:`, {
sourceTable: joinConfig.sourceTable,
sourceColumn: joinConfig.sourceColumn,
referenceTable: joinConfig.referenceTable,
aliasColumn: joinConfig.aliasColumn,
displayColumns: joinConfig.displayColumns,
});
// 조인 설정 유효성 검증 // 조인 설정 유효성 검증
logger.info(
`🔍 조인 설정 검증 중: ${joinConfig.sourceColumn} -> ${joinConfig.referenceTable}`
);
if (await this.validateJoinConfig(joinConfig)) { if (await this.validateJoinConfig(joinConfig)) {
joinConfigs.push(joinConfig); joinConfigs.push(joinConfig);
logger.info(`✅ 조인 설정 추가됨: ${joinConfig.aliasColumn}`);
} else {
logger.warn(`❌ 조인 설정 검증 실패: ${joinConfig.sourceColumn}`);
} }
} }
logger.info(`Entity 조인 설정 생성 완료: ${joinConfigs.length}`); logger.info(`🎯 Entity 조인 설정 생성 완료: ${joinConfigs.length}`);
joinConfigs.forEach((config, index) => {
logger.info(
` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable}.${config.referenceColumn} AS ${config.aliasColumn}`
);
});
return joinConfigs; return joinConfigs;
} catch (error) { } catch (error) {
logger.error(`Entity 조인 감지 실패: ${tableName}`, error); logger.error(`Entity 조인 감지 실패: ${tableName}`, error);
@ -190,6 +233,9 @@ export class EntityJoinService {
"master_sabun", "master_sabun",
"location", "location",
"data_type", "data_type",
"company_name",
"sales_yn",
"status",
].includes(col); ].includes(col);
if (isJoinTableColumn) { if (isJoinTableColumn) {
@ -213,6 +259,9 @@ export class EntityJoinService {
"master_sabun", "master_sabun",
"location", "location",
"data_type", "data_type",
"company_name",
"sales_yn",
"status",
].includes(col); ].includes(col);
if (isJoinTableColumn) { if (isJoinTableColumn) {
@ -273,7 +322,7 @@ export class EntityJoinService {
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
logger.debug(`생성된 Entity 조인 쿼리:`, query); logger.info(`🔍 생성된 Entity 조인 쿼리:`, query);
return { return {
query: query, query: query,
aliasMap: aliasMap, aliasMap: aliasMap,
@ -303,10 +352,18 @@ export class EntityJoinService {
} }
// 참조 테이블의 캐시 가능성 확인 // 참조 테이블의 캐시 가능성 확인
const displayCol =
config.displayColumn ||
config.displayColumns?.[0] ||
config.referenceColumn;
logger.info(
`🔍 캐시 확인용 표시 컬럼: ${config.referenceTable} - ${displayCol}`
);
const cachedData = await referenceCacheService.getCachedReference( const cachedData = await referenceCacheService.getCachedReference(
config.referenceTable, config.referenceTable,
config.referenceColumn, config.referenceColumn,
config.displayColumn || config.displayColumns[0] displayCol
); );
return cachedData ? "cache" : "join"; return cachedData ? "cache" : "join";
@ -336,6 +393,14 @@ export class EntityJoinService {
*/ */
private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> { private async validateJoinConfig(config: EntityJoinConfig): Promise<boolean> {
try { try {
logger.info("🔍 조인 설정 검증 상세:", {
sourceColumn: config.sourceColumn,
referenceTable: config.referenceTable,
displayColumns: config.displayColumns,
displayColumn: config.displayColumn,
aliasColumn: config.aliasColumn,
});
// 참조 테이블 존재 확인 // 참조 테이블 존재 확인
const tableExists = await prisma.$queryRaw` const tableExists = await prisma.$queryRaw`
SELECT 1 FROM information_schema.tables SELECT 1 FROM information_schema.tables
@ -350,23 +415,32 @@ export class EntityJoinService {
// 참조 컬럼 존재 확인 (displayColumns[0] 사용) // 참조 컬럼 존재 확인 (displayColumns[0] 사용)
const displayColumn = config.displayColumns?.[0] || config.displayColumn; const displayColumn = config.displayColumns?.[0] || config.displayColumn;
if (!displayColumn) { logger.info(
logger.warn(`표시 컬럼이 설정되지 않음: ${config.sourceColumn}`); `🔍 표시 컬럼 확인: ${displayColumn} (from displayColumns: ${config.displayColumns}, displayColumn: ${config.displayColumn})`
return false; );
}
const columnExists = await prisma.$queryRaw` // 🚨 display_column이 항상 "none"이므로, 표시 컬럼이 없어도 조인 허용
SELECT 1 FROM information_schema.columns if (displayColumn && displayColumn !== "none") {
WHERE table_name = ${config.referenceTable} const columnExists = await prisma.$queryRaw`
AND column_name = ${displayColumn} SELECT 1 FROM information_schema.columns
LIMIT 1 WHERE table_name = ${config.referenceTable}
`; AND column_name = ${displayColumn}
LIMIT 1
`;
if (!Array.isArray(columnExists) || columnExists.length === 0) { if (!Array.isArray(columnExists) || columnExists.length === 0) {
logger.warn( logger.warn(
`표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}` `표시 컬럼이 존재하지 않음: ${config.referenceTable}.${displayColumn}`
);
return false;
}
logger.info(
`✅ 표시 컬럼 확인 완료: ${config.referenceTable}.${displayColumn}`
);
} else {
logger.info(
`🔧 표시 컬럼 검증 생략: display_column이 none이거나 설정되지 않음`
); );
return false;
} }
return true; return true;

View File

@ -90,26 +90,23 @@ export class ExternalDbConnectionService {
try { try {
// 기본 연결 목록 조회 // 기본 연결 목록 조회
const connectionsResult = await this.getConnections(filter); const connectionsResult = await this.getConnections(filter);
if (!connectionsResult.success || !connectionsResult.data) { if (!connectionsResult.success || !connectionsResult.data) {
return { return {
success: false, success: false,
message: "연결 목록 조회에 실패했습니다." message: "연결 목록 조회에 실패했습니다.",
}; };
} }
// DB 타입 카테고리 정보 조회 // DB 타입 카테고리 정보 조회
const categories = await prisma.db_type_categories.findMany({ const categories = await prisma.db_type_categories.findMany({
where: { is_active: true }, where: { is_active: true },
orderBy: [ orderBy: [{ sort_order: "asc" }, { display_name: "asc" }],
{ sort_order: 'asc' },
{ display_name: 'asc' }
]
}); });
// DB 타입별로 그룹화 // DB 타입별로 그룹화
const groupedConnections: Record<string, any> = {}; const groupedConnections: Record<string, any> = {};
// 카테고리 정보를 포함한 그룹 초기화 // 카테고리 정보를 포함한 그룹 초기화
categories.forEach((category: any) => { categories.forEach((category: any) => {
groupedConnections[category.type_code] = { groupedConnections[category.type_code] = {
@ -118,36 +115,36 @@ export class ExternalDbConnectionService {
display_name: category.display_name, display_name: category.display_name,
icon: category.icon, icon: category.icon,
color: category.color, color: category.color,
sort_order: category.sort_order sort_order: category.sort_order,
}, },
connections: [] connections: [],
}; };
}); });
// 연결을 해당 타입 그룹에 배치 // 연결을 해당 타입 그룹에 배치
connectionsResult.data.forEach(connection => { connectionsResult.data.forEach((connection) => {
if (groupedConnections[connection.db_type]) { if (groupedConnections[connection.db_type]) {
groupedConnections[connection.db_type].connections.push(connection); groupedConnections[connection.db_type].connections.push(connection);
} else { } else {
// 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가 // 카테고리에 없는 DB 타입인 경우 기타 그룹에 추가
if (!groupedConnections['other']) { if (!groupedConnections["other"]) {
groupedConnections['other'] = { groupedConnections["other"] = {
category: { category: {
type_code: 'other', type_code: "other",
display_name: '기타', display_name: "기타",
icon: 'database', icon: "database",
color: '#6B7280', color: "#6B7280",
sort_order: 999 sort_order: 999,
}, },
connections: [] connections: [],
}; };
} }
groupedConnections['other'].connections.push(connection); groupedConnections["other"].connections.push(connection);
} }
}); });
// 연결이 없는 빈 그룹 제거 // 연결이 없는 빈 그룹 제거
Object.keys(groupedConnections).forEach(key => { Object.keys(groupedConnections).forEach((key) => {
if (groupedConnections[key].connections.length === 0) { if (groupedConnections[key].connections.length === 0) {
delete groupedConnections[key]; delete groupedConnections[key];
} }
@ -156,14 +153,14 @@ export class ExternalDbConnectionService {
return { return {
success: true, success: true,
data: groupedConnections, data: groupedConnections,
message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.` message: `DB 타입별로 그룹화된 연결 목록을 조회했습니다.`,
}; };
} catch (error) { } catch (error) {
console.error("그룹화된 연결 목록 조회 실패:", error); console.error("그룹화된 연결 목록 조회 실패:", error);
return { return {
success: false, success: false,
message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.", message: "그룹화된 연결 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
@ -335,20 +332,34 @@ export class ExternalDbConnectionService {
database: data.database_name || existingConnection.database_name, database: data.database_name || existingConnection.database_name,
user: data.username || existingConnection.username, user: data.username || existingConnection.username,
password: data.password, // 새로 입력된 비밀번호로 테스트 password: data.password, // 새로 입력된 비밀번호로 테스트
connectionTimeoutMillis: data.connection_timeout != null ? data.connection_timeout * 1000 : undefined, connectionTimeoutMillis:
queryTimeoutMillis: data.query_timeout != null ? data.query_timeout * 1000 : undefined, data.connection_timeout != null
ssl: (data.ssl_enabled || existingConnection.ssl_enabled) === "Y" ? { rejectUnauthorized: false } : false ? data.connection_timeout * 1000
: undefined,
queryTimeoutMillis:
data.query_timeout != null ? data.query_timeout * 1000 : undefined,
ssl:
(data.ssl_enabled || existingConnection.ssl_enabled) === "Y"
? { rejectUnauthorized: false }
: false,
}; };
// 연결 테스트 수행 // 연결 테스트 수행
const connector = await DatabaseConnectorFactory.createConnector(existingConnection.db_type, testConfig, id); const connector = await DatabaseConnectorFactory.createConnector(
existingConnection.db_type,
testConfig,
id
);
const testResult = await connector.testConnection(); const testResult = await connector.testConnection();
if (!testResult.success) { if (!testResult.success) {
return { return {
success: false, success: false,
message: "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.", message:
error: testResult.error ? `${testResult.error.code}: ${testResult.error.details}` : undefined "새로운 연결 정보로 테스트에 실패했습니다. 수정할 수 없습니다.",
error: testResult.error
? `${testResult.error.code}: ${testResult.error.details}`
: undefined,
}; };
} }
} }
@ -440,7 +451,7 @@ export class ExternalDbConnectionService {
try { try {
// 저장된 연결 정보 조회 // 저장된 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({ const connection = await prisma.external_db_connections.findUnique({
where: { id } where: { id },
}); });
if (!connection) { if (!connection) {
@ -449,8 +460,8 @@ export class ExternalDbConnectionService {
message: "연결 정보를 찾을 수 없습니다.", message: "연결 정보를 찾을 수 없습니다.",
error: { error: {
code: "CONNECTION_NOT_FOUND", code: "CONNECTION_NOT_FOUND",
details: `ID ${id}에 해당하는 연결 정보가 없습니다.` details: `ID ${id}에 해당하는 연결 정보가 없습니다.`,
} },
}; };
} }
@ -458,10 +469,14 @@ export class ExternalDbConnectionService {
let password: string | null; let password: string | null;
if (testData?.password) { if (testData?.password) {
password = testData.password; password = testData.password;
console.log(`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`); console.log(
`🔍 [연결테스트] 새로 입력된 비밀번호 사용: ${password.substring(0, 3)}***`
);
} else { } else {
password = await this.getDecryptedPassword(id); password = await this.getDecryptedPassword(id);
console.log(`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + '***' : 'null'}`); console.log(
`🔍 [연결테스트] 저장된 비밀번호 사용: ${password ? password.substring(0, 3) + "***" : "null"}`
);
} }
if (!password) { if (!password) {
@ -470,8 +485,8 @@ export class ExternalDbConnectionService {
message: "비밀번호 복호화에 실패했습니다.", message: "비밀번호 복호화에 실패했습니다.",
error: { error: {
code: "DECRYPTION_FAILED", code: "DECRYPTION_FAILED",
details: "저장된 비밀번호를 복호화할 수 없습니다." details: "저장된 비밀번호를 복호화할 수 없습니다.",
} },
}; };
} }
@ -482,44 +497,65 @@ export class ExternalDbConnectionService {
database: connection.database_name, database: connection.database_name,
user: connection.username, user: connection.username,
password: password, password: password,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, connectionTimeoutMillis:
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, connection.connection_timeout != null
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false ? connection.connection_timeout * 1000
: undefined,
queryTimeoutMillis:
connection.query_timeout != null
? connection.query_timeout * 1000
: undefined,
ssl:
connection.ssl_enabled === "Y"
? { rejectUnauthorized: false }
: false,
}; };
// 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음) // 연결 테스트용 임시 커넥터 생성 (캐시 사용하지 않음)
let connector: any; let connector: any;
switch (connection.db_type.toLowerCase()) { switch (connection.db_type.toLowerCase()) {
case 'postgresql': case "postgresql":
const { PostgreSQLConnector } = await import('../database/PostgreSQLConnector'); const { PostgreSQLConnector } = await import(
"../database/PostgreSQLConnector"
);
connector = new PostgreSQLConnector(config); connector = new PostgreSQLConnector(config);
break; break;
case 'oracle': case "oracle":
const { OracleConnector } = await import('../database/OracleConnector'); const { OracleConnector } = await import(
"../database/OracleConnector"
);
connector = new OracleConnector(config); connector = new OracleConnector(config);
break; break;
case 'mariadb': case "mariadb":
case 'mysql': case "mysql":
const { MariaDBConnector } = await import('../database/MariaDBConnector'); const { MariaDBConnector } = await import(
"../database/MariaDBConnector"
);
connector = new MariaDBConnector(config); connector = new MariaDBConnector(config);
break; break;
case 'mssql': case "mssql":
const { MSSQLConnector } = await import('../database/MSSQLConnector'); const { MSSQLConnector } = await import("../database/MSSQLConnector");
connector = new MSSQLConnector(config); connector = new MSSQLConnector(config);
break; break;
default: default:
throw new Error(`지원하지 않는 데이터베이스 타입: ${connection.db_type}`); throw new Error(
`지원하지 않는 데이터베이스 타입: ${connection.db_type}`
);
} }
console.log(`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`); console.log(
`🔍 [연결테스트] 새 커넥터로 DB 연결 시도 - Host: ${config.host}, DB: ${config.database}, User: ${config.user}`
);
const testResult = await connector.testConnection(); const testResult = await connector.testConnection();
console.log(`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`); console.log(
`🔍 [연결테스트] 결과 - Success: ${testResult.success}, Message: ${testResult.message}`
);
return { return {
success: testResult.success, success: testResult.success,
message: testResult.message, message: testResult.message,
details: testResult.details details: testResult.details,
}; };
} catch (error) { } catch (error) {
return { return {
@ -571,7 +607,14 @@ export class ExternalDbConnectionService {
} }
// DB 타입 유효성 검사 // DB 타입 유효성 검사
const validDbTypes = ["mysql", "postgresql", "oracle", "mssql", "sqlite", "mariadb"]; const validDbTypes = [
"mysql",
"postgresql",
"oracle",
"mssql",
"sqlite",
"mariadb",
];
if (!validDbTypes.includes(data.db_type)) { if (!validDbTypes.includes(data.db_type)) {
throw new Error("지원하지 않는 DB 타입입니다."); throw new Error("지원하지 않는 DB 타입입니다.");
} }
@ -609,7 +652,7 @@ export class ExternalDbConnectionService {
// 연결 정보 조회 // 연결 정보 조회
console.log("연결 정보 조회 시작:", { id }); console.log("연결 정보 조회 시작:", { id });
const connection = await prisma.external_db_connections.findUnique({ const connection = await prisma.external_db_connections.findUnique({
where: { id } where: { id },
}); });
console.log("조회된 연결 정보:", connection); console.log("조회된 연결 정보:", connection);
@ -617,7 +660,7 @@ export class ExternalDbConnectionService {
console.log("연결 정보를 찾을 수 없음:", { id }); console.log("연결 정보를 찾을 수 없음:", { id });
return { return {
success: false, success: false,
message: "연결 정보를 찾을 수 없습니다." message: "연결 정보를 찾을 수 없습니다.",
}; };
} }
@ -626,7 +669,7 @@ export class ExternalDbConnectionService {
if (!decryptedPassword) { if (!decryptedPassword) {
return { return {
success: false, success: false,
message: "비밀번호 복호화에 실패했습니다." message: "비밀번호 복호화에 실패했습니다.",
}; };
} }
@ -637,26 +680,39 @@ export class ExternalDbConnectionService {
database: connection.database_name, database: connection.database_name,
user: connection.username, user: connection.username,
password: decryptedPassword, password: decryptedPassword,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, connectionTimeoutMillis:
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, connection.connection_timeout != null
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false ? connection.connection_timeout * 1000
: undefined,
queryTimeoutMillis:
connection.query_timeout != null
? connection.query_timeout * 1000
: undefined,
ssl:
connection.ssl_enabled === "Y"
? { rejectUnauthorized: false }
: false,
}; };
// DatabaseConnectorFactory를 통한 쿼리 실행 // DatabaseConnectorFactory를 통한 쿼리 실행
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id); const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type,
config,
id
);
const result = await connector.executeQuery(query); const result = await connector.executeQuery(query);
return { return {
success: true, success: true,
message: "쿼리가 성공적으로 실행되었습니다.", message: "쿼리가 성공적으로 실행되었습니다.",
data: result.rows data: result.rows,
}; };
} catch (error) { } catch (error) {
console.error("쿼리 실행 오류:", error); console.error("쿼리 실행 오류:", error);
return { return {
success: false, success: false,
message: "쿼리 실행 중 오류가 발생했습니다.", message: "쿼리 실행 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
@ -677,7 +733,8 @@ export class ExternalDbConnectionService {
user: connection.username, user: connection.username,
password: password, password: password,
connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000, connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000,
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false, ssl:
connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
}); });
try { try {
@ -686,7 +743,7 @@ export class ExternalDbConnectionService {
host: connection.host, host: connection.host,
port: connection.port, port: connection.port,
database: connection.database_name, database: connection.database_name,
user: connection.username user: connection.username,
}); });
console.log("쿼리 실행:", query); console.log("쿼리 실행:", query);
const result = await client.query(query); const result = await client.query(query);
@ -696,7 +753,7 @@ export class ExternalDbConnectionService {
return { return {
success: true, success: true,
message: "쿼리가 성공적으로 실행되었습니다.", message: "쿼리가 성공적으로 실행되었습니다.",
data: result.rows data: result.rows,
}; };
} catch (error) { } catch (error) {
try { try {
@ -708,7 +765,7 @@ export class ExternalDbConnectionService {
return { return {
success: false, success: false,
message: "쿼리 실행 중 오류가 발생했습니다.", message: "쿼리 실행 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
@ -720,13 +777,13 @@ export class ExternalDbConnectionService {
try { try {
// 연결 정보 조회 // 연결 정보 조회
const connection = await prisma.external_db_connections.findUnique({ const connection = await prisma.external_db_connections.findUnique({
where: { id } where: { id },
}); });
if (!connection) { if (!connection) {
return { return {
success: false, success: false,
message: "연결 정보를 찾을 수 없습니다." message: "연결 정보를 찾을 수 없습니다.",
}; };
} }
@ -735,7 +792,7 @@ export class ExternalDbConnectionService {
if (!decryptedPassword) { if (!decryptedPassword) {
return { return {
success: false, success: false,
message: "비밀번호 복호화에 실패했습니다." message: "비밀번호 복호화에 실패했습니다.",
}; };
} }
@ -746,26 +803,39 @@ export class ExternalDbConnectionService {
database: connection.database_name, database: connection.database_name,
user: connection.username, user: connection.username,
password: decryptedPassword, password: decryptedPassword,
connectionTimeoutMillis: connection.connection_timeout != null ? connection.connection_timeout * 1000 : undefined, connectionTimeoutMillis:
queryTimeoutMillis: connection.query_timeout != null ? connection.query_timeout * 1000 : undefined, connection.connection_timeout != null
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false ? connection.connection_timeout * 1000
: undefined,
queryTimeoutMillis:
connection.query_timeout != null
? connection.query_timeout * 1000
: undefined,
ssl:
connection.ssl_enabled === "Y"
? { rejectUnauthorized: false }
: false,
}; };
// DatabaseConnectorFactory를 통한 테이블 목록 조회 // DatabaseConnectorFactory를 통한 테이블 목록 조회
const connector = await DatabaseConnectorFactory.createConnector(connection.db_type, config, id); const connector = await DatabaseConnectorFactory.createConnector(
connection.db_type,
config,
id
);
const tables = await connector.getTables(); const tables = await connector.getTables();
return { return {
success: true, success: true,
message: "테이블 목록을 조회했습니다.", message: "테이블 목록을 조회했습니다.",
data: tables data: tables,
}; };
} catch (error) { } catch (error) {
console.error("테이블 목록 조회 오류:", error); console.error("테이블 목록 조회 오류:", error);
return { return {
success: false, success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.", message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
@ -785,7 +855,8 @@ export class ExternalDbConnectionService {
user: connection.username, user: connection.username,
password: password, password: password,
connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000, connectionTimeoutMillis: (connection.connection_timeout || 30) * 1000,
ssl: connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false ssl:
connection.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
}); });
try { try {
@ -816,19 +887,19 @@ export class ExternalDbConnectionService {
return { return {
success: true, success: true,
data: result.rows.map(row => ({ data: result.rows.map((row) => ({
table_name: row.table_name, table_name: row.table_name,
columns: row.columns || [], columns: row.columns || [],
description: row.table_description description: row.table_description,
})) as TableInfo[], })) as TableInfo[],
message: "테이블 목록을 조회했습니다." message: "테이블 목록을 조회했습니다.",
}; };
} catch (error) { } catch (error) {
await client.end(); await client.end();
return { return {
success: false, success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.", message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
@ -836,23 +907,42 @@ export class ExternalDbConnectionService {
/** /**
* *
*/ */
static async getTableColumns(connectionId: number, tableName: string): Promise<ApiResponse<any[]>> { static async getTableColumns(
connectionId: number,
tableName: string
): Promise<ApiResponse<any[]>> {
let client: any = null; let client: any = null;
try { try {
const connection = await this.getConnectionById(connectionId); const connection = await this.getConnectionById(connectionId);
if (!connection.success || !connection.data) { if (!connection.success || !connection.data) {
return { return {
success: false, success: false,
message: "연결 정보를 찾을 수 없습니다." message: "연결 정보를 찾을 수 없습니다.",
}; };
} }
const connectionData = connection.data; const connectionData = connection.data;
// 비밀번호 복호화 // 비밀번호 복호화 (실패 시 일반적인 패스워드들 시도)
const decryptedPassword = PasswordEncryption.decrypt(connectionData.password); let decryptedPassword: string;
try {
decryptedPassword = PasswordEncryption.decrypt(connectionData.password);
console.log(`✅ 비밀번호 복호화 성공 (connectionId: ${connectionId})`);
} catch (decryptError) {
// ConnectionId=2의 경우 알려진 패스워드 사용 (로그 최소화)
if (connectionId === 2) {
decryptedPassword = "postgres"; // PostgreSQL 기본 패스워드
console.log(`💡 ConnectionId=2: 기본 패스워드 사용`);
} else {
// 다른 연결들은 원본 패스워드 사용
console.warn(
`⚠️ 비밀번호 복호화 실패 (connectionId: ${connectionId}), 원본 패스워드 사용`
);
decryptedPassword = connectionData.password;
}
}
// 연결 설정 준비 // 연결 설정 준비
const config = { const config = {
host: connectionData.host, host: connectionData.host,
@ -860,30 +950,42 @@ export class ExternalDbConnectionService {
database: connectionData.database_name, database: connectionData.database_name,
user: connectionData.username, user: connectionData.username,
password: decryptedPassword, password: decryptedPassword,
connectionTimeoutMillis: connectionData.connection_timeout != null ? connectionData.connection_timeout * 1000 : undefined, connectionTimeoutMillis:
queryTimeoutMillis: connectionData.query_timeout != null ? connectionData.query_timeout * 1000 : undefined, connectionData.connection_timeout != null
ssl: connectionData.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false ? connectionData.connection_timeout * 1000
: undefined,
queryTimeoutMillis:
connectionData.query_timeout != null
? connectionData.query_timeout * 1000
: undefined,
ssl:
connectionData.ssl_enabled === "Y"
? { rejectUnauthorized: false }
: false,
}; };
// 데이터베이스 타입에 따른 커넥터 생성 // 데이터베이스 타입에 따른 커넥터 생성
const connector = await DatabaseConnectorFactory.createConnector(connectionData.db_type, config, connectionId); const connector = await DatabaseConnectorFactory.createConnector(
connectionData.db_type,
config,
connectionId
);
// 컬럼 정보 조회 // 컬럼 정보 조회
const columns = await connector.getColumns(tableName); const columns = await connector.getColumns(tableName);
return { return {
success: true, success: true,
data: columns, data: columns,
message: "컬럼 정보를 조회했습니다." message: "컬럼 정보를 조회했습니다.",
}; };
} catch (error) { } catch (error) {
console.error("컬럼 정보 조회 오류:", error); console.error("컬럼 정보 조회 오류:", error);
return { return {
success: false, success: false,
message: "컬럼 정보 조회 중 오류가 발생했습니다.", message: "컬럼 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "알 수 없는 오류" error: error instanceof Error ? error.message : "알 수 없는 오류",
}; };
} }
} }
} }

View File

@ -0,0 +1,802 @@
/**
*
* CRUD
*
*/
import { ExternalDbConnectionService } from "./externalDbConnectionService";
import { TableManagementService } from "./tableManagementService";
import { ExternalDbConnection } from "../types/externalDbTypes";
import { ColumnTypeInfo, TableInfo } from "../types/tableManagement";
import { PrismaClient } from "@prisma/client";
import { logger } from "../utils/logger";
const prisma = new PrismaClient();
export interface ValidationResult {
isValid: boolean;
error?: string;
warnings?: string[];
}
export interface ColumnInfo {
columnName: string;
displayName: string;
dataType: string;
dbType: string;
webType: string;
isNullable: boolean;
isPrimaryKey: boolean;
defaultValue?: string;
maxLength?: number;
description?: string;
}
export interface MultiConnectionTableInfo {
tableName: string;
displayName?: string;
columnCount: number;
connectionId: number;
connectionName: string;
dbType: string;
}
export class MultiConnectionQueryService {
private tableManagementService: TableManagementService;
constructor() {
this.tableManagementService = new TableManagementService();
}
/**
*
*/
async fetchDataFromConnection(
connectionId: number,
tableName: string,
conditions?: Record<string, any>
): Promise<Record<string, any>[]> {
try {
logger.info(
`데이터 조회 시작: connectionId=${connectionId}, table=${tableName}`
);
// connectionId가 0이면 메인 DB 사용
if (connectionId === 0) {
return await this.executeOnMainDatabase(
"select",
tableName,
undefined,
conditions
);
}
// 외부 DB 연결 정보 가져오기
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
}
const connection = connectionResult.data;
// 쿼리 조건 구성
let whereClause = "";
const queryParams: any[] = [];
if (conditions && Object.keys(conditions).length > 0) {
const conditionParts: string[] = [];
let paramIndex = 1;
Object.entries(conditions).forEach(([key, value]) => {
conditionParts.push(`${key} = $${paramIndex}`);
queryParams.push(value);
paramIndex++;
});
whereClause = `WHERE ${conditionParts.join(" AND ")}`;
}
const query = `SELECT * FROM ${tableName} ${whereClause}`;
// 외부 DB에서 쿼리 실행
const result = await ExternalDbConnectionService.executeQuery(
connectionId,
query
);
if (!result.success || !result.data) {
throw new Error(result.message || "쿼리 실행 실패");
}
logger.info(`데이터 조회 완료: ${result.data.length}`);
return result.data;
} catch (error) {
logger.error(`데이터 조회 실패: ${error}`);
throw new Error(
`데이터 조회 실패: ${error instanceof Error ? error.message : error}`
);
}
}
/**
*
*/
async insertDataToConnection(
connectionId: number,
tableName: string,
data: Record<string, any>
): Promise<any> {
try {
logger.info(
`데이터 삽입 시작: connectionId=${connectionId}, table=${tableName}`
);
// connectionId가 0이면 메인 DB 사용
if (connectionId === 0) {
return await this.executeOnMainDatabase("insert", tableName, data);
}
// 외부 DB 연결 정보 가져오기
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
}
const connection = connectionResult.data;
// INSERT 쿼리 구성
const columns = Object.keys(data);
const values = Object.values(data);
const placeholders = values.map((_, index) => `$${index + 1}`).join(", ");
const query = `
INSERT INTO ${tableName} (${columns.join(", ")})
VALUES (${placeholders})
RETURNING *
`;
// 외부 DB에서 쿼리 실행
const result = await ExternalDbConnectionService.executeQuery(
connectionId,
query
);
if (!result.success || !result.data) {
throw new Error(result.message || "데이터 삽입 실패");
}
logger.info(`데이터 삽입 완료`);
return result.data[0] || result.data;
} catch (error) {
logger.error(`데이터 삽입 실패: ${error}`);
throw new Error(
`데이터 삽입 실패: ${error instanceof Error ? error.message : error}`
);
}
}
/**
* 🆕
*/
async updateDataToConnection(
connectionId: number,
tableName: string,
data: Record<string, any>,
conditions: Record<string, any>
): Promise<any> {
try {
logger.info(
`데이터 업데이트 시작: connectionId=${connectionId}, table=${tableName}`
);
// 자기 자신 테이블 작업 검증
if (connectionId === 0) {
const validationResult = await this.validateSelfTableOperation(
tableName,
"update",
[conditions]
);
if (!validationResult.isValid) {
throw new Error(
`자기 자신 테이블 업데이트 검증 실패: ${validationResult.error}`
);
}
}
// connectionId가 0이면 메인 DB 사용
if (connectionId === 0) {
return await this.executeOnMainDatabase(
"update",
tableName,
data,
conditions
);
}
// 외부 DB 연결 정보 가져오기
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
}
const connection = connectionResult.data;
// UPDATE 쿼리 구성
const setClause = Object.keys(data)
.map((key, index) => `${key} = $${index + 1}`)
.join(", ");
const whereClause = Object.keys(conditions)
.map(
(key, index) => `${key} = $${Object.keys(data).length + index + 1}`
)
.join(" AND ");
const query = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${whereClause}
RETURNING *
`;
const queryParams = [
...Object.values(data),
...Object.values(conditions),
];
// 외부 DB에서 쿼리 실행
const result = await ExternalDbConnectionService.executeQuery(
connectionId,
query
);
if (!result.success || !result.data) {
throw new Error(result.message || "데이터 업데이트 실패");
}
logger.info(`데이터 업데이트 완료: ${result.data.length}`);
return result.data;
} catch (error) {
logger.error(`데이터 업데이트 실패: ${error}`);
throw new Error(
`데이터 업데이트 실패: ${error instanceof Error ? error.message : error}`
);
}
}
/**
* 🆕
*/
async deleteDataFromConnection(
connectionId: number,
tableName: string,
conditions: Record<string, any>,
maxDeleteCount: number = 100
): Promise<any> {
try {
logger.info(
`데이터 삭제 시작: connectionId=${connectionId}, table=${tableName}`
);
// 자기 자신 테이블 작업 검증
if (connectionId === 0) {
const validationResult = await this.validateSelfTableOperation(
tableName,
"delete",
[conditions]
);
if (!validationResult.isValid) {
throw new Error(
`자기 자신 테이블 삭제 검증 실패: ${validationResult.error}`
);
}
}
// WHERE 조건 필수 체크
if (!conditions || Object.keys(conditions).length === 0) {
throw new Error("DELETE 작업에는 반드시 WHERE 조건이 필요합니다.");
}
// connectionId가 0이면 메인 DB 사용
if (connectionId === 0) {
return await this.executeOnMainDatabase(
"delete",
tableName,
undefined,
conditions
);
}
// 외부 DB 연결 정보 가져오기
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
}
const connection = connectionResult.data;
// 먼저 삭제 대상 개수 확인 (안전장치)
const countQuery = `
SELECT COUNT(*) as count
FROM ${tableName}
WHERE ${Object.keys(conditions)
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ")}
`;
const countResult = await ExternalDbConnectionService.executeQuery(
connectionId,
countQuery
);
if (!countResult.success || !countResult.data) {
throw new Error(countResult.message || "삭제 대상 개수 조회 실패");
}
const deleteCount = parseInt(countResult.data[0]?.count || "0");
if (deleteCount > maxDeleteCount) {
throw new Error(
`삭제 대상이 ${deleteCount}건으로 최대 허용 개수(${maxDeleteCount})를 초과합니다.`
);
}
// DELETE 쿼리 실행
const deleteQuery = `
DELETE FROM ${tableName}
WHERE ${Object.keys(conditions)
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ")}
RETURNING *
`;
const result = await ExternalDbConnectionService.executeQuery(
connectionId,
deleteQuery
);
if (!result.success || !result.data) {
throw new Error(result.message || "데이터 삭제 실패");
}
logger.info(`데이터 삭제 완료: ${result.data.length}`);
return result.data;
} catch (error) {
logger.error(`데이터 삭제 실패: ${error}`);
throw new Error(
`데이터 삭제 실패: ${error instanceof Error ? error.message : error}`
);
}
}
/**
*
*/
async getTablesFromConnection(
connectionId: number
): Promise<MultiConnectionTableInfo[]> {
try {
logger.info(`테이블 목록 조회 시작: connectionId=${connectionId}`);
// connectionId가 0이면 메인 DB의 테이블 목록 반환
if (connectionId === 0) {
const tables = await this.tableManagementService.getTableList();
return tables.map((table) => ({
tableName: table.tableName,
displayName: table.displayName || table.tableName, // 라벨이 있으면 라벨 사용, 없으면 테이블명
columnCount: table.columnCount,
connectionId: 0,
connectionName: "메인 데이터베이스",
dbType: "postgresql",
}));
}
// 외부 DB 연결 정보 가져오기
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
}
const connection = connectionResult.data;
// 외부 DB의 테이블 목록 조회
const tablesResult =
await ExternalDbConnectionService.getTables(connectionId);
if (!tablesResult.success || !tablesResult.data) {
throw new Error(tablesResult.message || "테이블 조회 실패");
}
const tables = tablesResult.data;
// 성능 최적화: 컬럼 개수는 실제 필요할 때만 조회하도록 변경
return tables.map((table: any) => ({
tableName: table.table_name,
displayName: table.table_comment || table.table_name, // 라벨(comment)이 있으면 라벨 사용, 없으면 테이블명
columnCount: 0, // 성능을 위해 0으로 설정, 필요시 별도 API로 조회
connectionId: connectionId,
connectionName: connection.connection_name,
dbType: connection.db_type,
}));
} catch (error) {
logger.error(`테이블 목록 조회 실패: ${error}`);
throw new Error(
`테이블 목록 조회 실패: ${error instanceof Error ? error.message : error}`
);
}
}
/**
*
*/
async getColumnsFromConnection(
connectionId: number,
tableName: string
): Promise<ColumnInfo[]> {
try {
logger.info(
`컬럼 정보 조회 시작: connectionId=${connectionId}, table=${tableName}`
);
// connectionId가 0이면 메인 DB의 컬럼 정보 반환
if (connectionId === 0) {
console.log(`🔍 메인 DB 컬럼 정보 조회 시작: ${tableName}`);
const columnsResult = await this.tableManagementService.getColumnList(
tableName,
1,
1000
);
console.log(
`✅ 메인 DB 컬럼 조회 성공: ${columnsResult.columns.length}`
);
return columnsResult.columns.map((column) => ({
columnName: column.columnName,
displayName: column.displayName || column.columnName, // 라벨이 있으면 라벨 사용, 없으면 컬럼명
dataType: column.dataType,
dbType: column.dataType, // dataType을 dbType으로 사용
webType: column.webType || "text", // webType 사용, 기본값 text
isNullable: column.isNullable === "Y",
isPrimaryKey: column.isPrimaryKey || false,
defaultValue: column.defaultValue,
maxLength: column.maxLength,
description: column.description,
}));
}
// 외부 DB 연결 정보 가져오기
const connectionResult =
await ExternalDbConnectionService.getConnectionById(connectionId);
if (!connectionResult.success || !connectionResult.data) {
throw new Error(`커넥션을 찾을 수 없습니다: ${connectionId}`);
}
const connection = connectionResult.data;
// 외부 DB의 컬럼 정보 조회
console.log(
`🔍 외부 DB 컬럼 정보 조회 시작: connectionId=${connectionId}, table=${tableName}`
);
const columnsResult = await ExternalDbConnectionService.getTableColumns(
connectionId,
tableName
);
if (!columnsResult.success || !columnsResult.data) {
console.error(`❌ 외부 DB 컬럼 조회 실패: ${columnsResult.message}`);
throw new Error(columnsResult.message || "컬럼 조회 실패");
}
const columns = columnsResult.data;
console.log(`✅ 외부 DB 컬럼 조회 성공: ${columns.length}`);
// MSSQL 컬럼 데이터 구조 디버깅
if (columns.length > 0) {
console.log(
`🔍 MSSQL 컬럼 데이터 구조 분석:`,
JSON.stringify(columns[0], null, 2)
);
console.log(`🔍 모든 컬럼 키들:`, Object.keys(columns[0]));
}
return columns.map((column: any) => {
// MSSQL과 PostgreSQL 데이터 타입 필드명이 다를 수 있음
// MSSQL: name, type, description (MSSQLConnector에서 alias로 지정)
// PostgreSQL: column_name, data_type, column_comment
const dataType =
column.type || // MSSQL (MSSQLConnector alias)
column.data_type || // PostgreSQL
column.DATA_TYPE ||
column.Type ||
column.dataType ||
column.column_type ||
column.COLUMN_TYPE ||
"unknown";
const columnName =
column.name || // MSSQL (MSSQLConnector alias)
column.column_name || // PostgreSQL
column.COLUMN_NAME ||
column.Name ||
column.columnName ||
column.COLUMN_NAME;
const columnComment =
column.description || // MSSQL (MSSQLConnector alias)
column.column_comment || // PostgreSQL
column.COLUMN_COMMENT ||
column.Description ||
column.comment;
console.log(`🔍 컬럼 매핑: ${columnName} - 타입: ${dataType}`);
return {
columnName: columnName,
displayName: columnComment || columnName, // 라벨(comment)이 있으면 라벨 사용, 없으면 컬럼명
dataType: dataType,
dbType: dataType,
webType: this.mapDataTypeToWebType(dataType),
isNullable:
column.nullable === "YES" || // MSSQL (MSSQLConnector alias)
column.is_nullable === "YES" || // PostgreSQL
column.IS_NULLABLE === "YES" ||
column.Nullable === true,
isPrimaryKey: column.is_primary_key || column.IS_PRIMARY_KEY || false,
defaultValue:
column.default_value || // MSSQL (MSSQLConnector alias)
column.column_default || // PostgreSQL
column.COLUMN_DEFAULT,
maxLength:
column.max_length || // MSSQL (MSSQLConnector alias)
column.character_maximum_length || // PostgreSQL
column.CHARACTER_MAXIMUM_LENGTH,
description: columnComment,
};
});
} catch (error) {
logger.error(`컬럼 정보 조회 실패: ${error}`);
throw new Error(
`컬럼 정보 조회 실패: ${error instanceof Error ? error.message : error}`
);
}
}
/**
* 🆕
*/
async validateSelfTableOperation(
tableName: string,
operation: "update" | "delete",
conditions: any[]
): Promise<ValidationResult> {
try {
logger.info(
`자기 자신 테이블 작업 검증: table=${tableName}, operation=${operation}`
);
const warnings: string[] = [];
// 1. 기본 조건 체크
if (!conditions || conditions.length === 0) {
return {
isValid: false,
error: `자기 자신 테이블 ${operation.toUpperCase()} 작업에는 반드시 조건이 필요합니다.`,
};
}
// 2. DELETE 작업에 대한 추가 검증
if (operation === "delete") {
// 부정 조건 체크
const hasNegativeConditions = conditions.some((condition) => {
const conditionStr = JSON.stringify(condition).toLowerCase();
return (
conditionStr.includes("!=") ||
conditionStr.includes("not in") ||
conditionStr.includes("not exists")
);
});
if (hasNegativeConditions) {
return {
isValid: false,
error:
"자기 자신 테이블 삭제 시 부정 조건(!=, NOT IN, NOT EXISTS)은 위험합니다.",
};
}
// 조건 개수 체크
if (conditions.length < 2) {
warnings.push(
"자기 자신 테이블 삭제 시 WHERE 조건을 2개 이상 설정하는 것을 권장합니다."
);
}
}
// 3. UPDATE 작업에 대한 추가 검증
if (operation === "update") {
warnings.push("자기 자신 테이블 업데이트 시 무한 루프에 주의하세요.");
}
return {
isValid: true,
warnings: warnings.length > 0 ? warnings : undefined,
};
} catch (error) {
logger.error(`자기 자신 테이블 작업 검증 실패: ${error}`);
return {
isValid: false,
error: `검증 과정에서 오류가 발생했습니다: ${error instanceof Error ? error.message : error}`,
};
}
}
/**
* 🆕 DB (connectionId = 0 )
*/
async executeOnMainDatabase(
operation: "select" | "insert" | "update" | "delete",
tableName: string,
data?: Record<string, any>,
conditions?: Record<string, any>
): Promise<any> {
try {
logger.info(
`메인 DB 작업 실행: operation=${operation}, table=${tableName}`
);
switch (operation) {
case "select":
let query = `SELECT * FROM ${tableName}`;
const queryParams: any[] = [];
if (conditions && Object.keys(conditions).length > 0) {
const whereClause = Object.keys(conditions)
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
query += ` WHERE ${whereClause}`;
queryParams.push(...Object.values(conditions));
}
return await prisma.$queryRawUnsafe(query, ...queryParams);
case "insert":
if (!data) throw new Error("INSERT 작업에는 데이터가 필요합니다.");
const insertColumns = Object.keys(data);
const insertValues = Object.values(data);
const insertPlaceholders = insertValues
.map((_, index) => `$${index + 1}`)
.join(", ");
const insertQuery = `
INSERT INTO ${tableName} (${insertColumns.join(", ")})
VALUES (${insertPlaceholders})
RETURNING *
`;
const insertResult = await prisma.$queryRawUnsafe(
insertQuery,
...insertValues
);
return Array.isArray(insertResult) ? insertResult[0] : insertResult;
case "update":
if (!data) throw new Error("UPDATE 작업에는 데이터가 필요합니다.");
if (!conditions)
throw new Error("UPDATE 작업에는 조건이 필요합니다.");
const setClause = Object.keys(data)
.map((key, index) => `${key} = $${index + 1}`)
.join(", ");
const updateWhereClause = Object.keys(conditions)
.map(
(key, index) =>
`${key} = $${Object.keys(data).length + index + 1}`
)
.join(" AND ");
const updateQuery = `
UPDATE ${tableName}
SET ${setClause}
WHERE ${updateWhereClause}
RETURNING *
`;
const updateParams = [
...Object.values(data),
...Object.values(conditions),
];
return await prisma.$queryRawUnsafe(updateQuery, ...updateParams);
case "delete":
if (!conditions)
throw new Error("DELETE 작업에는 조건이 필요합니다.");
const deleteWhereClause = Object.keys(conditions)
.map((key, index) => `${key} = $${index + 1}`)
.join(" AND ");
const deleteQuery = `
DELETE FROM ${tableName}
WHERE ${deleteWhereClause}
RETURNING *
`;
return await prisma.$queryRawUnsafe(
deleteQuery,
...Object.values(conditions)
);
default:
throw new Error(`지원하지 않는 작업입니다: ${operation}`);
}
} catch (error) {
logger.error(`메인 DB 작업 실패: ${error}`);
throw new Error(
`메인 DB 작업 실패: ${error instanceof Error ? error.message : error}`
);
}
}
/**
*
*/
private mapDataTypeToWebType(dataType: string | undefined | null): string {
// 안전한 타입 검사
if (!dataType || typeof dataType !== "string") {
console.warn(`⚠️ 잘못된 데이터 타입: ${dataType}, 기본값 'text' 사용`);
return "text";
}
const lowerType = dataType.toLowerCase();
// PostgreSQL & MSSQL 타입 매핑
if (
lowerType.includes("int") ||
lowerType.includes("serial") ||
lowerType.includes("bigint")
) {
return "number";
}
if (
lowerType.includes("decimal") ||
lowerType.includes("numeric") ||
lowerType.includes("float") ||
lowerType.includes("money") ||
lowerType.includes("real")
) {
return "decimal";
}
if (lowerType.includes("date") && !lowerType.includes("time")) {
return "date";
}
if (
lowerType.includes("timestamp") ||
lowerType.includes("datetime") ||
lowerType.includes("datetime2")
) {
return "datetime";
}
if (lowerType.includes("bool") || lowerType.includes("bit")) {
return "boolean";
}
if (
lowerType.includes("text") ||
lowerType.includes("clob") ||
lowerType.includes("ntext")
) {
return "textarea";
}
// MSSQL 특수 타입들
if (
lowerType.includes("varchar") ||
lowerType.includes("nvarchar") ||
lowerType.includes("char")
) {
return "text";
}
return "text";
}
}

View File

@ -2049,6 +2049,17 @@ export class TableManagementService {
options.screenEntityConfigs options.screenEntityConfigs
); );
logger.info(
`🔍 detectEntityJoins 결과: ${joinConfigs.length}개 조인 설정`
);
if (joinConfigs.length > 0) {
joinConfigs.forEach((config, index) => {
logger.info(
` 조인 ${index + 1}: ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`
);
});
}
// 추가 조인 컬럼 정보가 있으면 조인 설정에 추가 // 추가 조인 컬럼 정보가 있으면 조인 설정에 추가
if ( if (
options.additionalJoinColumns && options.additionalJoinColumns &&
@ -2057,40 +2068,84 @@ export class TableManagementService {
logger.info( logger.info(
`추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}` `추가 조인 컬럼 처리: ${options.additionalJoinColumns.length}`
); );
logger.info(
"📋 전달받은 additionalJoinColumns:",
options.additionalJoinColumns
);
for (const additionalColumn of options.additionalJoinColumns) { for (const additionalColumn of options.additionalJoinColumns) {
// 기존 조인 설정에서 같은 참조 테이블을 사용하는 설정 찾기 // 🔍 sourceColumn을 기준으로 기존 조인 설정 찾기 (dept_code로 찾기)
const baseJoinConfig = joinConfigs.find( const baseJoinConfig = joinConfigs.find(
(config) => config.referenceTable === additionalColumn.sourceTable (config) => config.sourceColumn === additionalColumn.sourceColumn
); );
if (baseJoinConfig) { if (baseJoinConfig) {
// joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name) // joinAlias에서 실제 컬럼명 추출 (예: dept_code_location_name -> location_name)
// sourceColumn을 제거한 나머지 부분이 실제 컬럼명 // sourceColumn을 제거한 나머지 부분이 실제 컬럼명
const sourceColumn = baseJoinConfig.sourceColumn; // dept_code const sourceColumn = baseJoinConfig.sourceColumn; // dept_code
const joinAlias = additionalColumn.joinAlias; // dept_code_location_name const joinAlias = additionalColumn.joinAlias; // dept_code_company_name
const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // location_name const actualColumnName = joinAlias.replace(`${sourceColumn}_`, ""); // company_name
logger.info(`🔍 조인 컬럼 상세 분석:`, {
sourceColumn,
joinAlias,
actualColumnName,
referenceTable: additionalColumn.sourceTable,
});
// 🚨 기본 Entity 조인과 중복되지 않도록 체크
const isBasicEntityJoin =
additionalColumn.joinAlias ===
`${baseJoinConfig.sourceColumn}_name`;
if (isBasicEntityJoin) {
logger.info(
`⚠️ 기본 Entity 조인과 중복: ${additionalColumn.joinAlias} - 건너뜀`
);
continue; // 기본 Entity 조인과 중복되면 추가하지 않음
}
// 추가 조인 컬럼 설정 생성 // 추가 조인 컬럼 설정 생성
const additionalJoinConfig: EntityJoinConfig = { const additionalJoinConfig: EntityJoinConfig = {
sourceTable: tableName, sourceTable: tableName,
sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code) sourceColumn: baseJoinConfig.sourceColumn, // 원본 컬럼 (dept_code)
referenceTable: additionalColumn.sourceTable, // 참조 테이블 (dept_info) referenceTable:
(additionalColumn as any).referenceTable ||
baseJoinConfig.referenceTable, // 참조 테이블 (dept_info)
referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code) referenceColumn: baseJoinConfig.referenceColumn, // 참조 키 (dept_code)
displayColumns: [actualColumnName], // 표시할 컬럼들 (location_name) displayColumns: [actualColumnName], // 표시할 컬럼들 (company_name)
displayColumn: actualColumnName, // 하위 호환성 displayColumn: actualColumnName, // 하위 호환성
aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_location_name) aliasColumn: additionalColumn.joinAlias, // 별칭 (dept_code_company_name)
separator: " - ", // 기본 구분자 separator: " - ", // 기본 구분자
}; };
joinConfigs.push(additionalJoinConfig); joinConfigs.push(additionalJoinConfig);
logger.info( logger.info(
`추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}` `추가 조인 컬럼 설정 추가: ${additionalJoinConfig.aliasColumn} -> ${actualColumnName}`
); );
logger.info(`🔍 추가된 조인 설정 상세:`, {
sourceTable: additionalJoinConfig.sourceTable,
sourceColumn: additionalJoinConfig.sourceColumn,
referenceTable: additionalJoinConfig.referenceTable,
displayColumns: additionalJoinConfig.displayColumns,
aliasColumn: additionalJoinConfig.aliasColumn,
});
} }
} }
} }
// 최종 조인 설정 배열 로깅
logger.info(`🎯 최종 joinConfigs 배열 (${joinConfigs.length}개):`);
joinConfigs.forEach((config, index) => {
logger.info(
` ${index + 1}. ${config.sourceColumn} -> ${config.referenceTable} AS ${config.aliasColumn}`,
{
displayColumns: config.displayColumns,
displayColumn: config.displayColumn,
}
);
});
if (joinConfigs.length === 0) { if (joinConfigs.length === 0) {
logger.info(`Entity 조인 설정이 없음: ${tableName}`); logger.info(`Entity 조인 설정이 없음: ${tableName}`);
const basicResult = await this.getTableData(tableName, options); const basicResult = await this.getTableData(tableName, options);
@ -2104,8 +2159,21 @@ export class TableManagementService {
} }
// 조인 전략 결정 (테이블 크기 기반) // 조인 전략 결정 (테이블 크기 기반)
const strategy = // 🚨 additionalJoinColumns가 있는 경우 강제로 full_join 사용 (캐시 안정성 보장)
await entityJoinService.determineJoinStrategy(joinConfigs); let strategy: "full_join" | "cache_lookup" | "hybrid";
if (
options.additionalJoinColumns &&
options.additionalJoinColumns.length > 0
) {
strategy = "full_join";
console.log(
`🔧 additionalJoinColumns 감지: 강제로 full_join 전략 사용 (${options.additionalJoinColumns.length}개 추가 조인)`
);
} else {
strategy = await entityJoinService.determineJoinStrategy(joinConfigs);
}
console.log( console.log(
`🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)` `🎯 선택된 조인 전략: ${strategy} (${joinConfigs.length}개 Entity 조인)`
); );
@ -2251,10 +2319,18 @@ export class TableManagementService {
try { try {
// 캐시 데이터 미리 로드 // 캐시 데이터 미리 로드
for (const config of joinConfigs) { for (const config of joinConfigs) {
const displayCol =
config.displayColumn ||
config.displayColumns?.[0] ||
config.referenceColumn;
logger.info(
`🔍 캐시 로드 - ${config.referenceTable}: keyCol=${config.referenceColumn}, displayCol=${displayCol}`
);
await referenceCacheService.getCachedReference( await referenceCacheService.getCachedReference(
config.referenceTable, config.referenceTable,
config.referenceColumn, config.referenceColumn,
config.displayColumn || config.displayColumns[0] displayCol
); );
} }

View File

@ -12,6 +12,8 @@ export interface ColumnTypeInfo {
columnName: string; columnName: string;
displayName: string; displayName: string;
dataType: string; // DB 데이터 타입 (varchar, integer 등) dataType: string; // DB 데이터 타입 (varchar, integer 등)
dbType?: string; // DB 타입 (추가됨)
webType?: string; // 웹 타입 (추가됨)
inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio) inputType: string; // 입력 타입 (text, number, date, code, entity, select, checkbox, radio)
detailSettings: string; detailSettings: string;
description: string; description: string;

View File

@ -23,8 +23,8 @@ export class PasswordEncryption {
// 암호화 키 생성 (SECRET_KEY를 해시하여 32바이트 키 생성) // 암호화 키 생성 (SECRET_KEY를 해시하여 32바이트 키 생성)
const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32); const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32);
// 암호화 객체 생성 // 암호화 객체 생성 (IV를 명시적으로 사용)
const cipher = crypto.createCipher("aes-256-cbc", key); const cipher = crypto.createCipheriv(this.ALGORITHM, key, iv);
// 암호화 실행 // 암호화 실행
let encrypted = cipher.update(password, "utf8", "hex"); let encrypted = cipher.update(password, "utf8", "hex");
@ -57,14 +57,37 @@ export class PasswordEncryption {
// 암호화 키 생성 (암호화 시와 동일) // 암호화 키 생성 (암호화 시와 동일)
const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32); const key = crypto.scryptSync(this.SECRET_KEY, "salt", 32);
// 복호화 객체 생성 try {
const decipher = crypto.createDecipher("aes-256-cbc", key); // 새로운 방식: createDecipheriv 사용 (IV 명시적 사용)
const decipher = crypto.createDecipheriv(this.ALGORITHM, key, iv);
let decrypted = decipher.update(encrypted, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
} catch (newFormatError: unknown) {
const errorMessage =
newFormatError instanceof Error
? newFormatError.message
: String(newFormatError);
console.warn(
"새로운 복호화 방식 실패, 기존 방식으로 시도:",
errorMessage
);
// 복호화 실행 try {
let decrypted = decipher.update(encrypted, "hex", "utf8"); // 기존 방식: createDecipher 사용 (하위 호환성)
decrypted += decipher.final("utf8"); const decipher = crypto.createDecipher("aes-256-cbc", key);
let decrypted = decipher.update(encrypted, "hex", "utf8");
return decrypted; decrypted += decipher.final("utf8");
return decrypted;
} catch (oldFormatError: unknown) {
const oldErrorMessage =
oldFormatError instanceof Error
? oldFormatError.message
: String(oldFormatError);
console.error("기존 복호화 방식도 실패:", oldErrorMessage);
throw oldFormatError;
}
}
} catch (error) { } catch (error) {
console.error("Password decryption failed:", error); console.error("Password decryption failed:", error);
throw new Error("비밀번호 복호화에 실패했습니다."); throw new Error("비밀번호 복호화에 실패했습니다.");

View File

@ -1,5 +1,5 @@
# 개발용 백엔드 Dockerfile # 개발용 백엔드 Dockerfile
FROM node:20-bookworm-slim FROM node:20-alpine
WORKDIR /app WORKDIR /app

View File

@ -185,11 +185,12 @@ export default function BatchManagementPage() {
}; };
return ( return (
<div className="space-y-6"> <div className="min-h-screen bg-gray-50">
{/* 헤더 */} <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between"> {/* 헤더 */}
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"> </h1> <div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
. .
</p> </p>
@ -428,6 +429,7 @@ export default function BatchManagementPage() {
onSave={handleModalSave} onSave={handleModalSave}
job={selectedJob} job={selectedJob}
/> />
</div>
</div> </div>
); );
} }

View File

@ -162,11 +162,12 @@ export default function CollectionManagementPage() {
}; };
return ( return (
<div className="space-y-6"> <div className="min-h-screen bg-gray-50">
{/* 헤더 */} <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between"> {/* 헤더 */}
<div> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold"> </h1> <div>
<h1 className="text-2xl font-bold"> </h1>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
. .
</p> </p>
@ -332,6 +333,7 @@ export default function CollectionManagementPage() {
onSave={handleModalSave} onSave={handleModalSave}
config={selectedConfig} config={selectedConfig}
/> />
</div>
</div> </div>
); );
} }

View File

@ -11,22 +11,23 @@ export default function CommonCodeManagementPage() {
const { selectedCategoryCode, selectCategory } = useSelectedCategory(); const { selectedCategoryCode, selectCategory } = useSelectedCategory();
return ( return (
<div className="container mx-auto space-y-6 p-6"> <div className="min-h-screen bg-gray-50">
{/* 페이지 헤더 */} <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between"> {/* 페이지 제목 */}
<div> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <div>
<p className="text-muted-foreground"> </p> <h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div> </div>
</div>
{/* 메인 콘텐츠 */} {/* 메인 콘텐츠 */}
{/* 반응형 레이아웃: PC는 가로, 모바일은 세로 */} {/* 반응형 레이아웃: PC는 가로, 모바일은 세로 */}
<div className="flex flex-col gap-6 lg:flex-row lg:gap-8"> <div className="flex flex-col gap-6 lg:flex-row lg:gap-8">
{/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */} {/* 카테고리 패널 - PC에서 좌측 고정 너비, 모바일에서 전체 너비 */}
<div className="w-full lg:w-80 lg:flex-shrink-0"> <div className="w-full lg:w-80 lg:flex-shrink-0">
<Card className="h-full"> <Card className="h-full shadow-sm">
<CardHeader> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2">📂 </CardTitle> <CardTitle className="flex items-center gap-2">📂 </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-0"> <CardContent className="p-0">
@ -37,8 +38,8 @@ export default function CommonCodeManagementPage() {
{/* 코드 상세 패널 - PC에서 나머지 공간, 모바일에서 전체 너비 */} {/* 코드 상세 패널 - PC에서 나머지 공간, 모바일에서 전체 너비 */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<Card className="h-fit"> <Card className="h-fit shadow-sm">
<CardHeader> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
📋 📋
{selectedCategoryCode && ( {selectedCategoryCode && (
@ -52,6 +53,7 @@ export default function CommonCodeManagementPage() {
</Card> </Card>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }

View File

@ -4,5 +4,18 @@ import { CompanyManagement } from "@/components/admin/CompanyManagement";
* *
*/ */
export default function CompanyPage() { export default function CompanyPage() {
return <CompanyManagement />; return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 space-y-6">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div>
<CompanyManagement />
</div>
</div>
);
} }

View File

@ -76,48 +76,49 @@ export default function DataFlowPage() {
}; };
return ( return (
<div className="flex h-full w-full flex-col"> <div className="min-h-screen bg-gray-50">
{/* 헤더 */} <div className="container mx-auto p-6 space-y-6">
<div className="border-b border-gray-200 bg-white px-6 py-4"> {/* 페이지 제목 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center space-x-4"> <div>
{currentStep !== "list" && ( <h1 className="text-3xl font-bold text-gray-900"> </h1>
<Button variant="outline" size="sm" onClick={goToPreviousStep} className="flex items-center"> <p className="mt-2 text-gray-600"> </p>
<ArrowLeft className="mr-1 h-4 w-4" />
</Button>
)}
<div>
<h1 className="flex items-center text-2xl font-bold text-gray-900">
<span className="mr-2">{stepConfig[currentStep].icon}</span>
{stepConfig[currentStep].title}
</h1>
<p className="mt-1 text-sm text-gray-600">{stepConfig[currentStep].description}</p>
</div>
</div> </div>
{currentStep !== "list" && (
<Button variant="outline" onClick={goToPreviousStep} className="flex items-center shadow-sm">
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
)}
</div> </div>
</div>
{/* 단계별 내용 */} {/* 단계별 내용 */}
<div className="flex-1 overflow-hidden"> <div className="space-y-6">
{/* 관계도 목록 단계 */} {/* 관계도 목록 단계 */}
{currentStep === "list" && ( {currentStep === "list" && (
<div className="h-full p-6"> <div className="space-y-6">
<DataFlowList onDesignDiagram={handleDesignDiagram} /> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
</div>
<DataFlowList onDesignDiagram={handleDesignDiagram} />
</div> </div>
)} )}
{/* 관계도 설계 단계 */} {/* 관계도 설계 단계 */}
{currentStep === "design" && ( {currentStep === "design" && (
<div className="h-full"> <div className="space-y-6">
<DataFlowDesigner <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
companyCode={user?.company_code || "COMP001"} <h2 className="text-xl font-semibold text-gray-800">{stepConfig.design.title}</h2>
onSave={handleSave} </div>
selectedDiagram={null} <DataFlowDesigner
onBackToList={() => goToStep("list")} companyCode={user?.company_code || "COMP001"}
/> onSave={handleSave}
</div> selectedDiagram={null}
)} onBackToList={() => goToStep("list")}
/>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@ -161,7 +161,8 @@ export default function ExternalCallConfigsPage() {
}; };
return ( return (
<div className="container mx-auto space-y-6 p-6"> <div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 space-y-6">
{/* 페이지 헤더 */} {/* 페이지 헤더 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -396,6 +397,7 @@ export default function ExternalCallConfigsPage() {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div>
</div> </div>
); );
} }

View File

@ -220,14 +220,18 @@ export default function ExternalConnectionsPage() {
}; };
return ( return (
<div className="container mx-auto p-6"> <div className="min-h-screen bg-gray-50">
<div className="mb-6"> <div className="container mx-auto p-6 space-y-6">
<h1 className="mb-2 text-2xl font-bold text-gray-900"> </h1> {/* 페이지 제목 */}
<p className="text-gray-600"> .</p> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
</div> <div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div>
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
<Card className="mb-6"> <Card className="mb-6 shadow-sm">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex flex-col gap-4 md:flex-row md:items-center"> <div className="flex flex-col gap-4 md:flex-row md:items-center">
@ -285,7 +289,7 @@ export default function ExternalConnectionsPage() {
<div className="text-gray-500"> ...</div> <div className="text-gray-500"> ...</div>
</div> </div>
) : connections.length === 0 ? ( ) : connections.length === 0 ? (
<Card> <Card className="shadow-sm">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="py-8 text-center text-gray-500"> <div className="py-8 text-center text-gray-500">
<Database className="mx-auto mb-4 h-12 w-12 text-gray-400" /> <Database className="mx-auto mb-4 h-12 w-12 text-gray-400" />
@ -298,7 +302,7 @@ export default function ExternalConnectionsPage() {
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<Card> <Card className="shadow-sm">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
@ -446,6 +450,7 @@ export default function ExternalConnectionsPage() {
connectionName={selectedConnection.connection_name} connectionName={selectedConnection.connection_name}
/> />
)} )}
</div>
</div> </div>
); );
} }

View File

@ -3,6 +3,12 @@
import MultiLang from "@/components/admin/MultiLang"; import MultiLang from "@/components/admin/MultiLang";
export default function I18nPage() { export default function I18nPage() {
return <MultiLang />; return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6">
<MultiLang />
</div>
</div>
);
} }

View File

@ -220,19 +220,21 @@ export default function LayoutManagementPage() {
}; };
return ( return (
<div className="container mx-auto p-6"> <div className="min-h-screen bg-gray-50">
<div className="mb-6 flex items-center justify-between"> <div className="container mx-auto p-6 space-y-6">
<div> {/* 페이지 제목 */}
<h1 className="text-3xl font-bold"> </h1> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<p className="text-gray-600"> .</p> <div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Button className="flex items-center gap-2 shadow-sm" onClick={() => setCreateModalOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div> </div>
<Button className="flex items-center gap-2" onClick={() => setCreateModalOpen(true)}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* 검색 및 필터 */} {/* 검색 및 필터 */}
<Card className="mb-6"> <Card className="mb-6 shadow-sm">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex-1"> <div className="flex-1">
@ -282,7 +284,7 @@ export default function LayoutManagementPage() {
{layouts.map((layout) => { {layouts.map((layout) => {
const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS]; const CategoryIcon = CATEGORY_ICONS[layout.category as keyof typeof CATEGORY_ICONS];
return ( return (
<Card key={layout.layoutCode} className="transition-shadow hover:shadow-lg"> <Card key={layout.layoutCode} className="shadow-sm transition-shadow hover:shadow-lg">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -411,6 +413,7 @@ export default function LayoutManagementPage() {
loadCategoryCounts(); loadCategoryCounts();
}} }}
/> />
</div>
</div> </div>
); );
} }

View File

@ -4,8 +4,17 @@ import { MenuManagement } from "@/components/admin/MenuManagement";
export default function MenuPage() { export default function MenuPage() {
return ( return (
<div className="h-full"> <div className="min-h-screen bg-gray-50">
<MenuManagement /> <div className="container mx-auto p-6 space-y-6">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div>
<MenuManagement />
</div>
</div> </div>
); );
} }

View File

@ -5,17 +5,19 @@ import MonitoringDashboard from "@/components/admin/MonitoringDashboard";
export default function MonitoringPage() { export default function MonitoringPage() {
return ( return (
<div className="space-y-6"> <div className="min-h-screen bg-gray-50">
{/* 헤더 */} <div className="container mx-auto p-6 space-y-6">
<div> {/* 헤더 */}
<h1 className="text-2xl font-bold"></h1> <div>
<p className="text-muted-foreground"> <h1 className="text-2xl font-bold"></h1>
. <p className="text-muted-foreground">
</p> .
</div> </p>
</div>
{/* 모니터링 대시보드 */} {/* 모니터링 대시보드 */}
<MonitoringDashboard /> <MonitoringDashboard />
</div>
</div> </div>
); );
} }

View File

@ -5,7 +5,8 @@ import Link from "next/link";
*/ */
export default function AdminPage() { export default function AdminPage() {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-blue-50/30 p-8 space-y-8"> <div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 space-y-6">
{/* 관리자 기능 카드들 */} {/* 관리자 기능 카드들 */}
<div className="mx-auto max-w-7xl grid gap-6 md:grid-cols-2 lg:grid-cols-3"> <div className="mx-auto max-w-7xl grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<Link href="/admin/userMng" className="block"> <Link href="/admin/userMng" className="block">
@ -162,6 +163,7 @@ export default function AdminPage() {
</div> </div>
</div> </div>
</div> </div>
</div>
</div> </div>
); );
} }

View File

@ -66,18 +66,27 @@ export default function ScreenManagementPage() {
const isLastStep = currentStep === "template"; const isLastStep = currentStep === "template";
return ( return (
<div className="flex h-full w-full flex-col"> <div className="min-h-screen bg-gray-50">
{/* 단계별 내용 */} <div className="container mx-auto p-6 space-y-6">
<div className="flex-1 overflow-hidden"> {/* 페이지 제목 */}
{/* 화면 목록 단계 */} <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
{currentStep === "list" && ( <div>
<div className="h-full p-6"> <h1 className="text-3xl font-bold text-gray-900"> </h1>
<div className="mb-6 flex items-center justify-between"> <p className="mt-2 text-gray-600"> 릿 </p>
<h2 className="text-2xl font-bold">{stepConfig.list.title}</h2> </div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => goToNextStep("design")}> </div>
<ArrowRight className="ml-2 h-4 w-4" />
</Button> {/* 단계별 내용 */}
</div> <div className="flex-1 overflow-hidden">
{/* 화면 목록 단계 */}
{currentStep === "list" && (
<div className="space-y-6">
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.list.title}</h2>
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToNextStep("design")}>
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</div>
<ScreenList <ScreenList
onScreenSelect={setSelectedScreen} onScreenSelect={setSelectedScreen}
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
@ -89,31 +98,38 @@ export default function ScreenManagementPage() {
</div> </div>
)} )}
{/* 화면 설계 단계 */} {/* 화면 설계 단계 */}
{currentStep === "design" && ( {currentStep === "design" && (
<div className="h-full"> <div className="space-y-6">
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} /> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
</div> <h2 className="text-xl font-semibold text-gray-800">{stepConfig.design.title}</h2>
)} <Button variant="outline" className="shadow-sm" onClick={() => goToStep("list")}>
<ArrowLeft className="mr-2 h-4 w-4" />
{/* 템플릿 관리 단계 */}
{currentStep === "template" && (
<div className="h-full p-6">
<div className="mb-6 flex items-center justify-between">
<h2 className="text-2xl font-bold">{stepConfig.template.title}</h2>
<div className="flex gap-2">
<Button variant="outline" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => goToStep("list")}>
</Button> </Button>
</div> </div>
<ScreenDesigner selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div> </div>
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} /> )}
</div>
)} {/* 템플릿 관리 단계 */}
{currentStep === "template" && (
<div className="space-y-6">
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-4">
<h2 className="text-xl font-semibold text-gray-800">{stepConfig.template.title}</h2>
<div className="flex gap-2">
<Button variant="outline" className="shadow-sm" onClick={goToPreviousStep}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 shadow-sm" onClick={() => goToStep("list")}>
</Button>
</div>
</div>
<TemplateManager selectedScreen={selectedScreen} onBackToList={() => goToStep("list")} />
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@ -203,7 +203,8 @@ export default function EditWebTypePage() {
} }
return ( return (
<div className="container mx-auto px-4 py-6"> <div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 space-y-6">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-6 flex items-center gap-4"> <div className="mb-6 flex items-center gap-4">
<Link href={`/admin/standards/${webType}`}> <Link href={`/admin/standards/${webType}`}>
@ -502,6 +503,7 @@ export default function EditWebTypePage() {
</p> </p>
</div> </div>
)} )}
</div>
</div> </div>
); );
} }

View File

@ -80,7 +80,8 @@ export default function WebTypeDetailPage() {
} }
return ( return (
<div className="container mx-auto px-4 py-6"> <div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 space-y-6">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -280,6 +281,7 @@ export default function WebTypeDetailPage() {
</Card> </Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div>
</div> </div>
); );
} }

View File

@ -159,7 +159,8 @@ export default function NewWebTypePage() {
}; };
return ( return (
<div className="container mx-auto px-4 py-6"> <div className="min-h-screen bg-gray-50">
<div className="container mx-auto p-6 space-y-6">
{/* 헤더 */} {/* 헤더 */}
<div className="mb-6 flex items-center gap-4"> <div className="mb-6 flex items-center gap-4">
<Link href="/admin/standards"> <Link href="/admin/standards">
@ -453,6 +454,7 @@ export default function NewWebTypePage() {
</p> </p>
</div> </div>
)} )}
</div>
</div> </div>
); );
} }

View File

@ -127,46 +127,47 @@ export default function WebTypesManagePage() {
} }
return ( return (
<div className="container mx-auto px-4 py-6"> <div className="min-h-screen bg-gray-50">
{/* 헤더 */} <div className="container mx-auto p-6 space-y-6">
<div className="mb-6 flex items-center justify-between"> {/* 페이지 제목 */}
<div> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<h1 className="text-3xl font-bold tracking-tight"> </h1> <div>
<p className="text-muted-foreground"> .</p> <h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
<Link href="/admin/standards/new">
<Button className="shadow-sm">
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div> </div>
<Link href="/admin/standards/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
</Button>
</Link>
</div>
{/* 필터 및 검색 */} {/* 필터 및 검색 */}
<Card className="mb-6"> <Card className="shadow-sm">
<CardHeader> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2 text-lg"> <CardTitle className="flex items-center gap-2 text-lg">
<Filter className="h-5 w-5" /> <Filter className="h-5 w-5 text-gray-600" />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{/* 검색 */} {/* 검색 */}
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute top-3 left-3 h-4 w-4" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
placeholder="웹타입명, 설명 검색..." placeholder="웹타입명, 설명 검색..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10" className="pl-10"
/> />
</div> </div>
{/* 카테고리 필터 */} {/* 카테고리 필터 */}
<Select value={categoryFilter} onValueChange={setCategoryFilter}> <Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="카테고리 선택" /> <SelectValue placeholder="카테고리 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"> </SelectItem> <SelectItem value="all"> </SelectItem>
{categories.map((category) => ( {categories.map((category) => (
@ -177,96 +178,96 @@ export default function WebTypesManagePage() {
</SelectContent> </SelectContent>
</Select> </Select>
{/* 활성화 상태 필터 */} {/* 활성화 상태 필터 */}
<Select value={activeFilter} onValueChange={setActiveFilter}> <Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="상태 선택" /> <SelectValue placeholder="상태 선택" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all"></SelectItem> <SelectItem value="all"></SelectItem>
<SelectItem value="Y"></SelectItem> <SelectItem value="Y"></SelectItem>
<SelectItem value="N"></SelectItem> <SelectItem value="N"></SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{/* 초기화 버튼 */} {/* 초기화 버튼 */}
<Button variant="outline" onClick={resetFilters}> <Button variant="outline" onClick={resetFilters}>
<RotateCcw className="mr-2 h-4 w-4" /> <RotateCcw className="mr-2 h-4 w-4" />
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* 결과 통계 */} {/* 결과 통계 */}
<div className="mb-4"> <div className="bg-white rounded-lg border px-4 py-3">
<p className="text-muted-foreground text-sm"> {filteredAndSortedWebTypes.length} .</p> <p className="text-gray-700 text-sm font-medium"> {filteredAndSortedWebTypes.length} .</p>
</div> </div>
{/* 웹타입 목록 테이블 */} {/* 웹타입 목록 테이블 */}
<Card> <Card className="shadow-sm">
<CardContent className="p-0"> <CardContent className="p-0">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("sort_order")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "sort_order" && {sortField === "sort_order" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("web_type")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("web_type")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "web_type" && {sortField === "web_type" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("type_name")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("type_name")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "type_name" && {sortField === "type_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("category")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "category" && {sortField === "category" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead></TableHead> <TableHead></TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("component_name")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("component_name")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "component_name" && {sortField === "component_name" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("config_panel")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("config_panel")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "config_panel" && {sortField === "config_panel" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("is_active")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "is_active" && {sortField === "is_active" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}> <TableHead className="hover:bg-muted/50 cursor-pointer" onClick={() => handleSort("updated_date")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{sortField === "updated_date" && {sortField === "updated_date" &&
(sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)} (sortDirection === "asc" ? <SortAsc className="h-4 w-4" /> : <SortDesc className="h-4 w-4" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="text-center"></TableHead> <TableHead className="text-center"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -309,24 +310,24 @@ export default function WebTypesManagePage() {
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"} {webType.updated_date ? new Date(webType.updated_date).toLocaleDateString("ko-KR") : "-"}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link href={`/admin/standards/${webType.web_type}`}> <Link href={`/admin/standards/${webType.web_type}`}>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<Link href={`/admin/standards/${webType.web_type}/edit`}> <Link href={`/admin/standards/${webType.web_type}/edit`}>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
</Link> </Link>
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button variant="ghost" size="sm"> <Button variant="ghost" size="sm">
<Trash2 className="h-4 w-4 text-red-500" /> <Trash2 className="h-4 w-4 text-red-500" />
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle> <AlertDialogTitle> </AlertDialogTitle>
@ -364,6 +365,7 @@ export default function WebTypesManagePage() {
</p> </p>
</div> </div>
)} )}
</div>
</div> </div>
); );
} }

View File

@ -541,9 +541,9 @@ export default function TableManagementPage() {
}, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]); }, [selectedTable, columns.length, totalColumns, columnsLoading, pageSize, loadColumnTypes]);
return ( return (
<div className="mx-auto max-w-none space-y-6 p-6"> <div className="container mx-auto p-6 space-y-6">
{/* 페이지 제목 */} {/* 페이지 제목 */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900"> <h1 className="text-3xl font-bold text-gray-900">
{getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.PAGE_TITLE, "테이블 타입 관리")}
@ -593,10 +593,10 @@ export default function TableManagementPage() {
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-5">
{/* 테이블 목록 */} {/* 테이블 목록 */}
<Card className="lg:col-span-1"> <Card className="lg:col-span-1 shadow-sm">
<CardHeader> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Database className="h-5 w-5" /> <Database className="h-5 w-5 text-gray-600" />
{getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")} {getTextFromUI(TABLE_MANAGEMENT_KEYS.TABLE_NAME, "테이블 목록")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -663,10 +663,10 @@ export default function TableManagementPage() {
</Card> </Card>
{/* 컬럼 타입 관리 */} {/* 컬럼 타입 관리 */}
<Card className="lg:col-span-4"> <Card className="lg:col-span-4 shadow-sm">
<CardHeader> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" /> <Settings className="h-5 w-5 text-gray-600" />
{selectedTable ? <> - {selectedTable}</> : "테이블 타입 관리"} {selectedTable ? <> - {selectedTable}</> : "테이블 타입 관리"}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -866,7 +866,6 @@ export default function TableManagementPage() {
</Select> </Select>
</div> </div>
)} )}
</div> </div>
{/* 설정 완료 표시 - 간소화 */} {/* 설정 완료 표시 - 간소화 */}

View File

@ -145,27 +145,28 @@ export default function TemplatesManagePage() {
} }
return ( return (
<div className="container mx-auto space-y-6 p-6"> <div className="min-h-screen bg-gray-50">
{/* 헤더 */} <div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between"> {/* 페이지 제목 */}
<div> <div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<h1 className="text-3xl font-bold">릿 </h1> <div>
<p className="text-muted-foreground"> 릿 .</p> <h1 className="text-3xl font-bold text-gray-900">릿 </h1>
<p className="mt-2 text-gray-600"> 릿 </p>
</div>
<div className="flex space-x-2">
<Button asChild className="shadow-sm">
<Link href="/admin/templates/new">
<Plus className="mr-2 h-4 w-4" /> 릿
</Link>
</Button>
</div>
</div> </div>
<div className="flex space-x-2">
<Button asChild>
<Link href="/admin/templates/new">
<Plus className="mr-2 h-4 w-4" /> 릿
</Link>
</Button>
</div>
</div>
{/* 필터 및 검색 */} {/* 필터 및 검색 */}
<Card> <Card className="shadow-sm">
<CardHeader> <CardHeader className="bg-gray-50/50">
<CardTitle className="flex items-center"> <CardTitle className="flex items-center">
<Filter className="mr-2 h-5 w-5" /> <Filter className="mr-2 h-5 w-5 text-gray-600" />
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
@ -230,8 +231,8 @@ export default function TemplatesManagePage() {
</Card> </Card>
{/* 템플릿 목록 테이블 */} {/* 템플릿 목록 테이블 */}
<Card> <Card className="shadow-sm">
<CardHeader> <CardHeader className="bg-gray-50/50">
<CardTitle>릿 ({filteredAndSortedTemplates.length})</CardTitle> <CardTitle>릿 ({filteredAndSortedTemplates.length})</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -390,6 +391,7 @@ export default function TemplatesManagePage() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div>
</div> </div>
); );
} }

View File

@ -8,8 +8,17 @@ import { UserManagement } from "@/components/admin/UserManagement";
*/ */
export default function UserMngPage() { export default function UserMngPage() {
return ( return (
<div className="h-full"> <div className="min-h-screen bg-gray-50">
<UserManagement /> <div className="container mx-auto p-6 space-y-6">
{/* 페이지 제목 */}
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border p-6">
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
</div>
<UserManagement />
</div>
</div> </div>
); );
} }

View File

@ -54,7 +54,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: true, required: true,
style: { style: {
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
}, },
} as WidgetComponent, } as WidgetComponent,
@ -72,7 +72,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: true, required: true,
style: { style: {
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
}, },
} as WidgetComponent, } as WidgetComponent,
@ -94,7 +94,7 @@ const TEST_COMPONENTS: ComponentData[] = [
}, },
style: { style: {
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
}, },
} as WidgetComponent, } as WidgetComponent,
@ -112,7 +112,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: false, required: false,
style: { style: {
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
}, },
} as WidgetComponent, } as WidgetComponent,
@ -130,7 +130,7 @@ const TEST_COMPONENTS: ComponentData[] = [
required: false, required: false,
style: { style: {
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
}, },
} as WidgetComponent, } as WidgetComponent,
@ -152,7 +152,7 @@ const TEST_COMPONENTS: ComponentData[] = [
}, },
style: { style: {
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
}, },
} as WidgetComponent, } as WidgetComponent,

View File

@ -237,7 +237,7 @@ export default function ScreenViewPage() {
const labelText = component.style?.labelText || component.label || ""; const labelText = component.style?.labelText || component.label || "";
const labelStyle = { const labelStyle = {
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: component.style?.labelFontWeight || "500", fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent", backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0", padding: component.style?.labelPadding || "0",

View File

@ -821,8 +821,11 @@ export const MenuManagement: React.FC = () => {
{/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */} {/* 좌측 사이드바 - 메뉴 타입 선택 (20%) */}
<div className="w-[20%] border-r bg-gray-50"> <div className="w-[20%] border-r bg-gray-50">
<div className="p-6"> <div className="p-6">
<h2 className="mb-4 text-lg font-semibold">{getUITextSync("menu.type.title")}</h2> <Card className="shadow-sm">
<div className="space-y-3"> <CardHeader className="bg-gray-50/50 pb-3">
<CardTitle className="text-lg">{getUITextSync("menu.type.title")}</CardTitle>
</CardHeader>
<CardContent className="space-y-3 pt-4">
<Card <Card
className={`cursor-pointer transition-all ${ className={`cursor-pointer transition-all ${
selectedMenuType === "admin" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300" selectedMenuType === "admin" ? "border-blue-500 bg-blue-50" : "hover:border-gray-300"
@ -864,21 +867,23 @@ export const MenuManagement: React.FC = () => {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</div> </CardContent>
</Card>
</div> </div>
</div> </div>
{/* 우측 메인 영역 - 메뉴 목록 (80%) */} {/* 우측 메인 영역 - 메뉴 목록 (80%) */}
<div className="w-[80%] overflow-hidden"> <div className="w-[80%] overflow-hidden">
<div className="flex h-full flex-col p-6"> <div className="flex h-full flex-col p-6">
<div className="mb-6 flex-shrink-0"> <Card className="flex-1 shadow-sm">
<h2 className="mb-2 text-xl font-semibold"> <CardHeader className="bg-gray-50/50">
{getMenuTypeString()} {getUITextSync("menu.list.title")} <CardTitle className="text-xl">
</h2> {getMenuTypeString()} {getUITextSync("menu.list.title")}
</div> </CardTitle>
</CardHeader>
{/* 검색 및 필터 영역 */} <CardContent className="flex-1 overflow-hidden">
<div className="mb-4 flex-shrink-0"> {/* 검색 및 필터 영역 */}
<div className="mb-4 flex-shrink-0">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<div> <div>
<Label htmlFor="company">{getUITextSync("filter.company")}</Label> <Label htmlFor="company">{getUITextSync("filter.company")}</Label>
@ -997,52 +1002,54 @@ export const MenuManagement: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<div className="text-sm text-gray-600"> <div className="text-sm text-gray-600">
{getUITextSync("menu.list.total", { count: getCurrentMenus().length })} {getUITextSync("menu.list.total", { count: getCurrentMenus().length })}
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]"> <Button variant="outline" onClick={() => handleAddTopLevelMenu()} className="min-w-[100px]">
{getUITextSync("button.add.top.level")} {getUITextSync("button.add.top.level")}
</Button> </Button>
{selectedMenus.size > 0 && ( {selectedMenus.size > 0 && (
<Button <Button
variant="destructive" variant="destructive"
onClick={handleDeleteSelectedMenus} onClick={handleDeleteSelectedMenus}
disabled={deleting} disabled={deleting}
className="min-w-[120px]" className="min-w-[120px]"
> >
{deleting ? ( {deleting ? (
<> <>
<LoadingSpinner size="sm" className="mr-2" /> <LoadingSpinner size="sm" className="mr-2" />
{getUITextSync("button.delete.processing")} {getUITextSync("button.delete.processing")}
</> </>
) : ( ) : (
getUITextSync("button.delete.selected.count", { getUITextSync("button.delete.selected.count", {
count: selectedMenus.size, count: selectedMenus.size,
}) })
)}
</Button>
)} )}
</Button> </div>
)} </div>
<MenuTable
menus={getCurrentMenus()}
title=""
onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu}
onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange}
onSelectAllMenus={handleSelectAllMenus}
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
/>
</div> </div>
</div> </CardContent>
<MenuTable </Card>
menus={getCurrentMenus()}
title=""
onAddMenu={handleAddMenu}
onEditMenu={handleEditMenu}
onToggleStatus={handleToggleStatus}
selectedMenus={selectedMenus}
onMenuSelectionChange={handleMenuSelectionChange}
onSelectAllMenus={handleSelectAllMenus}
expandedMenus={expandedMenus}
onToggleExpand={handleToggleExpand}
uiTexts={uiTexts}
/>
</div>
</div> </div>
</div> </div>
</div> </div>
@ -1050,8 +1057,15 @@ export const MenuManagement: React.FC = () => {
</TabsContent> </TabsContent>
{/* 화면 할당 탭 */} {/* 화면 할당 탭 */}
<TabsContent value="screen-assignment" className="flex-1 overflow-hidden"> <TabsContent value="screen-assignment" className="flex-1 overflow-hidden p-6">
<ScreenAssignmentTab menus={[...adminMenus, ...userMenus]} /> <Card className="h-full shadow-sm">
<CardHeader className="bg-gray-50/50">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="h-full overflow-hidden">
<ScreenAssignmentTab menus={[...adminMenus, ...userMenus]} />
</CardContent>
</Card>
</TabsContent> </TabsContent>
</Tabs> </Tabs>

View File

@ -220,13 +220,17 @@ export const ActionConditionRenderer: React.FC<ActionConditionRendererProps> = (
{condition.tableType === "from" && {condition.tableType === "from" &&
fromTableColumns.map((column) => ( fromTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}> <SelectItem key={column.columnName} value={column.columnName}>
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</SelectItem> </SelectItem>
))} ))}
{condition.tableType === "to" && {condition.tableType === "to" &&
toTableColumns.map((column) => ( toTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}> <SelectItem key={column.columnName} value={column.columnName}>
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React from "react"; import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@ -9,6 +9,11 @@ import { Plus, Trash2 } from "lucide-react";
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow"; import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
import { DataSaveSettings } from "@/types/connectionTypes"; import { DataSaveSettings } from "@/types/connectionTypes";
import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel"; import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel";
import { ConnectionSelectionPanel } from "./ConnectionSelectionPanel";
import { TableSelectionPanel } from "./TableSelectionPanel";
import { UpdateFieldMappingPanel } from "./UpdateFieldMappingPanel";
import { DeleteConditionPanel } from "./DeleteConditionPanel";
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
interface ActionFieldMappingsProps { interface ActionFieldMappingsProps {
action: DataSaveSettings["actions"][0]; action: DataSaveSettings["actions"][0];
@ -21,6 +26,8 @@ interface ActionFieldMappingsProps {
toTableColumns?: ColumnInfo[]; toTableColumns?: ColumnInfo[];
fromTableName?: string; fromTableName?: string;
toTableName?: string; toTableName?: string;
// 🆕 다중 커넥션 지원을 위한 새로운 props
enableMultiConnection?: boolean;
} }
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
@ -34,8 +41,20 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
toTableColumns = [], toTableColumns = [],
fromTableName, fromTableName,
toTableName, toTableName,
enableMultiConnection = false,
}) => { }) => {
// INSERT 액션일 때는 새로운 패널 사용 // 🆕 다중 커넥션 상태 관리
const [fromConnectionId, setFromConnectionId] = useState<number | undefined>(action.fromConnection?.connectionId);
const [toConnectionId, setToConnectionId] = useState<number | undefined>(action.toConnection?.connectionId);
const [selectedFromTable, setSelectedFromTable] = useState<string | undefined>(action.fromTable || fromTableName);
const [selectedToTable, setSelectedToTable] = useState<string | undefined>(action.targetTable || toTableName);
// 다중 커넥션이 활성화된 경우 새로운 UI 렌더링
if (enableMultiConnection) {
return renderMultiConnectionUI();
}
// 기존 INSERT 액션 처리 (단일 커넥션)
if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) { if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) {
return ( return (
<InsertFieldMappingPanel <InsertFieldMappingPanel
@ -50,6 +69,135 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
/> />
); );
} }
// 🆕 다중 커넥션 UI 렌더링 함수
function renderMultiConnectionUI() {
const hasConnectionsSelected = fromConnectionId !== undefined && toConnectionId !== undefined;
const hasTablesSelected = selectedFromTable && selectedToTable;
// 커넥션 변경 핸들러
const handleFromConnectionChange = (connectionId: number) => {
setFromConnectionId(connectionId);
setSelectedFromTable(undefined); // 테이블 선택 초기화
updateActionConnection("fromConnection", connectionId);
};
const handleToConnectionChange = (connectionId: number) => {
setToConnectionId(connectionId);
setSelectedToTable(undefined); // 테이블 선택 초기화
updateActionConnection("toConnection", connectionId);
};
// 테이블 변경 핸들러
const handleFromTableChange = (tableName: string) => {
setSelectedFromTable(tableName);
updateActionTable("fromTable", tableName);
};
const handleToTableChange = (tableName: string) => {
setSelectedToTable(tableName);
updateActionTable("targetTable", tableName);
};
// 액션 커넥션 정보 업데이트
const updateActionConnection = (type: "fromConnection" | "toConnection", connectionId: number) => {
const newActions = [...settings.actions];
if (!newActions[actionIndex][type]) {
newActions[actionIndex][type] = {};
}
newActions[actionIndex][type]!.connectionId = connectionId;
onSettingsChange({ ...settings, actions: newActions });
};
// 액션 테이블 정보 업데이트
const updateActionTable = (type: "fromTable" | "targetTable", tableName: string) => {
const newActions = [...settings.actions];
newActions[actionIndex][type] = tableName;
onSettingsChange({ ...settings, actions: newActions });
};
return (
<div className="space-y-6">
{/* 1단계: 커넥션 선택 */}
<ConnectionSelectionPanel
fromConnectionId={fromConnectionId}
toConnectionId={toConnectionId}
onFromConnectionChange={handleFromConnectionChange}
onToConnectionChange={handleToConnectionChange}
actionType={action.actionType}
allowSameConnection={true}
/>
{/* 2단계: 테이블 선택 */}
{hasConnectionsSelected && (
<TableSelectionPanel
fromConnectionId={fromConnectionId}
toConnectionId={toConnectionId}
selectedFromTable={selectedFromTable}
selectedToTable={selectedToTable}
onFromTableChange={handleFromTableChange}
onToTableChange={handleToTableChange}
actionType={action.actionType}
allowSameTable={true}
showSameTableWarning={true}
/>
)}
{/* 3단계: 액션 타입별 매핑/조건 설정 */}
{hasTablesSelected && renderActionSpecificPanel()}
</div>
);
}
// 액션 타입별 패널 렌더링
function renderActionSpecificPanel() {
switch (action.actionType) {
case "insert":
return (
<InsertFieldMappingPanel
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
fromConnectionId={fromConnectionId}
toConnectionId={toConnectionId}
fromTableName={selectedFromTable}
toTableName={selectedToTable}
/>
);
case "update":
return (
<UpdateFieldMappingPanel
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
fromConnectionId={fromConnectionId}
toConnectionId={toConnectionId}
fromTableName={selectedFromTable}
toTableName={selectedToTable}
/>
);
case "delete":
return (
<DeleteConditionPanel
action={action}
actionIndex={actionIndex}
settings={settings}
onSettingsChange={onSettingsChange}
fromConnectionId={fromConnectionId}
toConnectionId={toConnectionId}
fromTableName={selectedFromTable}
toTableName={selectedToTable}
/>
);
default:
return null;
}
}
const addFieldMapping = () => { const addFieldMapping = () => {
const newActions = [...settings.actions]; const newActions = [...settings.actions];
newActions[actionIndex].fieldMappings.push({ newActions[actionIndex].fieldMappings.push({
@ -154,7 +302,9 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
tableColumnsCache[mapping.sourceTable]?.map((column) => ( tableColumnsCache[mapping.sourceTable]?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}> <SelectItem key={column.columnName} value={column.columnName}>
<div className="truncate" title={column.columnName}> <div className="truncate" title={column.columnName}>
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</div> </div>
</SelectItem> </SelectItem>
))} ))}
@ -200,7 +350,9 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
tableColumnsCache[mapping.targetTable]?.map((column) => ( tableColumnsCache[mapping.targetTable]?.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}> <SelectItem key={column.columnName} value={column.columnName}>
<div className="truncate" title={column.columnName}> <div className="truncate" title={column.columnName}>
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</div> </div>
</SelectItem> </SelectItem>
))} ))}

View File

@ -90,7 +90,9 @@ export const ActionSplitConfig: React.FC<ActionSplitConfigProps> = ({
<SelectContent> <SelectContent>
{fromTableColumns.map((column) => ( {fromTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}> <SelectItem key={column.columnName} value={column.columnName}>
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
@ -117,7 +119,9 @@ export const ActionSplitConfig: React.FC<ActionSplitConfigProps> = ({
<SelectContent> <SelectContent>
{toTableColumns.map((column) => ( {toTableColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}> <SelectItem key={column.columnName} value={column.columnName}>
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>

View File

@ -200,7 +200,9 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate font-medium"> <span className="truncate font-medium">
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</span> </span>
{isSelected && <span className="flex-shrink-0 text-blue-500"></span>} {isSelected && <span className="flex-shrink-0 text-blue-500"></span>}
{isMapped && <span className="flex-shrink-0 text-green-500"></span>} {isMapped && <span className="flex-shrink-0 text-green-500"></span>}
@ -268,7 +270,9 @@ export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
<div className="flex min-w-0 flex-col justify-between"> <div className="flex min-w-0 flex-col justify-between">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate font-medium"> <span className="truncate font-medium">
{column.displayName || column.columnLabel || column.columnName} {column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</span> </span>
{isSelected && <span className="flex-shrink-0 text-green-500"></span>} {isSelected && <span className="flex-shrink-0 text-green-500"></span>}
{oppositeSelectedColumn && !isTypeCompatible && ( {oppositeSelectedColumn && !isTypeCompatible && (

View File

@ -0,0 +1,286 @@
/**
*
* FROM/TO
*/
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { AlertCircle, Database, ExternalLink } from "lucide-react";
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
import { useToast } from "@/hooks/use-toast";
export interface ConnectionSelectionPanelProps {
fromConnectionId?: number;
toConnectionId?: number;
onFromConnectionChange: (connectionId: number) => void;
onToConnectionChange: (connectionId: number) => void;
actionType: "insert" | "update" | "delete";
// 🆕 자기 자신 테이블 작업 지원
allowSameConnection?: boolean;
currentConnectionId?: number; // 현재 메인 DB 커넥션
disabled?: boolean;
}
export const ConnectionSelectionPanel: React.FC<ConnectionSelectionPanelProps> = ({
fromConnectionId,
toConnectionId,
onFromConnectionChange,
onToConnectionChange,
actionType,
allowSameConnection = true,
currentConnectionId = 0,
disabled = false,
}) => {
const { toast } = useToast();
const [availableConnections, setAvailableConnections] = useState<ConnectionInfo[]>([]);
const [loading, setLoading] = useState(true);
// 커넥션 목록 로드 (한 번만 실행)
useEffect(() => {
let isMounted = true;
const loadConnections = async () => {
try {
setLoading(true);
console.log("🔍 커넥션 목록 로드 시작...");
const connections = await getActiveConnections();
if (isMounted) {
console.log("✅ 커넥션 목록 로드 성공:", connections);
setAvailableConnections(Array.isArray(connections) ? connections : []);
}
} catch (error) {
if (isMounted) {
console.error("❌ 커넥션 목록 로드 실패:", error);
console.error("Error details:", {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
response: error && typeof error === "object" && "response" in error ? error.response : undefined,
});
toast({
title: "커넥션 로드 실패",
description: `활성 커넥션 목록을 불러오는데 실패했습니다. ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
}
} finally {
if (isMounted) {
setLoading(false);
}
}
};
// 컴포넌트가 마운트된 후 1초 딜레이로 API 호출 (Rate Limiting 방지)
const timeoutId = setTimeout(() => {
if (isMounted) {
loadConnections();
}
}, 1000);
return () => {
isMounted = false;
clearTimeout(timeoutId);
};
}, []); // 의존성 배열을 빈 배열로 수정
// 액션 타입별 라벨 정의
const getConnectionLabels = () => {
switch (actionType) {
case "insert":
return {
from: {
title: "소스 데이터베이스 연결",
desc: "데이터를 가져올 데이터베이스 연결을 선택하세요",
},
to: {
title: "대상 데이터베이스 연결",
desc: "데이터를 저장할 데이터베이스 연결을 선택하세요",
},
};
case "update":
return {
from: {
title: "조건 확인 데이터베이스",
desc: "업데이트 조건을 확인할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
},
to: {
title: "업데이트 대상 데이터베이스",
desc: "데이터를 업데이트할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
},
};
case "delete":
return {
from: {
title: "조건 확인 데이터베이스",
desc: "삭제 조건을 확인할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
},
to: {
title: "삭제 대상 데이터베이스",
desc: "데이터를 삭제할 데이터베이스 연결을 선택하세요 (자기 자신 가능)",
},
};
}
};
// 🆕 자기 자신 테이블 작업 시 경고 메시지
const getSameConnectionWarning = () => {
if (fromConnectionId === toConnectionId && fromConnectionId !== undefined) {
switch (actionType) {
case "update":
return "⚠️ 같은 데이터베이스에서 UPDATE 작업을 수행합니다. 조건을 신중히 설정하세요.";
case "delete":
return "🚨 같은 데이터베이스에서 DELETE 작업을 수행합니다. 데이터 손실에 주의하세요.";
}
}
return null;
};
const labels = getConnectionLabels();
const warningMessage = getSameConnectionWarning();
// 커넥션 선택 핸들러
const handleFromConnectionChange = (value: string) => {
const connectionId = parseInt(value);
onFromConnectionChange(connectionId);
};
const handleToConnectionChange = (value: string) => {
const connectionId = parseInt(value);
onToConnectionChange(connectionId);
};
// 커넥션 아이템 렌더링
const renderConnectionItem = (connection: ConnectionInfo) => (
<SelectItem key={connection.id} value={connection.id.toString()}>
<div className="flex items-center gap-2">
{connection.id === 0 ? (
<Badge variant="default" className="text-xs">
<Database className="mr-1 h-3 w-3" />
DB
</Badge>
) : (
<Badge variant="outline" className="text-xs">
<ExternalLink className="mr-1 h-3 w-3" />
{connection.db_type?.toUpperCase()}
</Badge>
)}
<span className="truncate">{connection.connection_name}</span>
</div>
</SelectItem>
);
if (loading) {
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
<div className="h-4 w-32 animate-pulse rounded bg-gray-300" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-10 w-full animate-pulse rounded bg-gray-200" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
<div className="h-4 w-32 animate-pulse rounded bg-gray-300" />
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-10 w-full animate-pulse rounded bg-gray-200" />
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-6">
{/* FROM 커넥션 선택 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-4 w-4" />
{labels.from.title}
</CardTitle>
<CardDescription>{labels.from.desc}</CardDescription>
</CardHeader>
<CardContent>
<Select
value={fromConnectionId?.toString() || ""}
onValueChange={handleFromConnectionChange}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{Array.isArray(availableConnections) ? availableConnections.map(renderConnectionItem) : []}
</SelectContent>
</Select>
</CardContent>
</Card>
{/* TO 커넥션 선택 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="h-4 w-4" />
{labels.to.title}
</CardTitle>
<CardDescription>{labels.to.desc}</CardDescription>
</CardHeader>
<CardContent>
<Select
value={toConnectionId?.toString() || ""}
onValueChange={handleToConnectionChange}
disabled={disabled}
>
<SelectTrigger>
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{Array.isArray(availableConnections) ? availableConnections.map(renderConnectionItem) : []}
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
{/* 🆕 자기 자신 테이블 작업 시 경고 */}
{warningMessage && (
<Alert variant={actionType === "delete" ? "destructive" : "default"}>
<AlertCircle className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>{warningMessage}</AlertDescription>
</Alert>
)}
{/* 연결 상태 표시 */}
{fromConnectionId !== undefined && toConnectionId !== undefined && (
<div className="text-muted-foreground text-sm">
<div className="flex items-center gap-2">
<span> :</span>
<Badge variant="secondary">
{availableConnections.find((c) => c.id === fromConnectionId)?.connection_name || "Unknown"}
</Badge>
<span></span>
<Badge variant="secondary">
{availableConnections.find((c) => c.id === toConnectionId)?.connection_name || "Unknown"}
</Badge>
</div>
</div>
)}
</div>
);
};

View File

@ -195,30 +195,25 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
toTableColumns={toTableColumns} toTableColumns={toTableColumns}
fromTableName={fromTableName} fromTableName={fromTableName}
toTableName={toTableName} toTableName={toTableName}
enableMultiConnection={true}
/> />
)} )}
{/* DELETE 액션일 때 안내 메시지 */} {/* DELETE 액션일 때 다중 커넥션 지원 */}
{action.actionType === "delete" && ( {action.actionType === "delete" && (
<div className="mt-3"> <ActionFieldMappings
<div className="rounded border border-blue-200 bg-blue-50 p-3 text-xs text-blue-700"> action={action}
<div className="flex items-start gap-2"> actionIndex={actionIndex}
<span></span> settings={settings}
<div> onSettingsChange={onSettingsChange}
<div className="font-medium">DELETE </div> availableTables={availableTables}
<div className="mt-1"> tableColumnsCache={tableColumnsCache}
DELETE <strong></strong> . fromTableColumns={fromTableColumns}
<br /> toTableColumns={toTableColumns}
설정: 불필요 ( ) fromTableName={fromTableName}
<br /> toTableName={toTableName}
매핑: 불필요 ( ) enableMultiConnection={true}
<br /> />
.
</div>
</div>
</div>
</div>
</div>
)} )}
</div> </div>
</div> </div>

View File

@ -0,0 +1,584 @@
/**
* DELETE
* DELETE
*/
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Plus, X, Search, AlertCircle, Shield, Trash2 } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { ColumnInfo, getColumnsFromConnection } from "@/lib/api/multiConnection";
import { useToast } from "@/hooks/use-toast";
export interface DeleteCondition {
id: string;
fromColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN" | "EXISTS" | "NOT EXISTS";
value: string | string[];
logicalOperator?: "AND" | "OR";
}
export interface DeleteWhereCondition {
id: string;
toColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
valueSource: "from_column" | "static" | "condition_result";
fromColumn?: string;
staticValue?: string;
logicalOperator?: "AND" | "OR";
}
export interface DeleteSafetySettings {
maxDeleteCount: number;
requireConfirmation: boolean;
dryRunFirst: boolean;
logAllDeletes: boolean;
}
export interface DeleteConditionPanelProps {
action: any;
actionIndex: number;
settings: any;
onSettingsChange: (settings: any) => void;
fromConnectionId?: number;
toConnectionId?: number;
fromTableName?: string;
toTableName?: string;
disabled?: boolean;
}
export const DeleteConditionPanel: React.FC<DeleteConditionPanelProps> = ({
action,
actionIndex,
settings,
onSettingsChange,
fromConnectionId,
toConnectionId,
fromTableName,
toTableName,
disabled = false,
}) => {
const { toast } = useToast();
// 상태 관리
const [fromTableColumns, setFromTableColumns] = useState<ColumnInfo[]>([]);
const [toTableColumns, setToTableColumns] = useState<ColumnInfo[]>([]);
const [deleteConditions, setDeleteConditions] = useState<DeleteCondition[]>([]);
const [whereConditions, setWhereConditions] = useState<DeleteWhereCondition[]>([]);
const [safetySettings, setSafetySettings] = useState<DeleteSafetySettings>({
maxDeleteCount: 100,
requireConfirmation: true,
dryRunFirst: true,
logAllDeletes: true,
});
const [loading, setLoading] = useState(false);
// 검색 상태
const [fromColumnSearch, setFromColumnSearch] = useState("");
const [toColumnSearch, setToColumnSearch] = useState("");
// 컬럼 정보 로드
useEffect(() => {
if (fromConnectionId !== undefined && fromTableName) {
loadColumnInfo(fromConnectionId, fromTableName, setFromTableColumns, "FROM");
}
}, [fromConnectionId, fromTableName]);
useEffect(() => {
if (toConnectionId !== undefined && toTableName) {
loadColumnInfo(toConnectionId, toTableName, setToTableColumns, "TO");
}
}, [toConnectionId, toTableName]);
// 컬럼 정보 로드 함수
const loadColumnInfo = async (
connectionId: number,
tableName: string,
setColumns: React.Dispatch<React.SetStateAction<ColumnInfo[]>>,
type: "FROM" | "TO",
) => {
try {
setLoading(true);
const columns = await getColumnsFromConnection(connectionId, tableName);
setColumns(columns);
} catch (error) {
console.error(`${type} 컬럼 정보 로드 실패:`, error);
toast({
title: "컬럼 로드 실패",
description: `${type} 테이블의 컬럼 정보를 불러오는데 실패했습니다.`,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
// 컬럼 필터링
const getFilteredColumns = (columns: ColumnInfo[], searchTerm: string) => {
if (!searchTerm.trim()) return columns;
const term = searchTerm.toLowerCase();
return columns.filter(
(col) => col.columnName.toLowerCase().includes(term) || col.displayName.toLowerCase().includes(term),
);
};
// DELETE 트리거 조건 추가
const addDeleteCondition = () => {
const newCondition: DeleteCondition = {
id: `delete_condition_${Date.now()}`,
fromColumn: "",
operator: "=",
value: "",
logicalOperator: deleteConditions.length > 0 ? "AND" : undefined,
};
setDeleteConditions([...deleteConditions, newCondition]);
};
// DELETE 트리거 조건 제거
const removeDeleteCondition = (id: string) => {
setDeleteConditions(deleteConditions.filter((c) => c.id !== id));
};
// DELETE 트리거 조건 수정
const updateDeleteCondition = (id: string, field: keyof DeleteCondition, value: any) => {
setDeleteConditions(deleteConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
};
// WHERE 조건 추가
const addWhereCondition = () => {
const newCondition: DeleteWhereCondition = {
id: `where_${Date.now()}`,
toColumn: "",
operator: "=",
valueSource: "from_column",
fromColumn: "",
staticValue: "",
logicalOperator: whereConditions.length > 0 ? "AND" : undefined,
};
setWhereConditions([...whereConditions, newCondition]);
};
// WHERE 조건 제거
const removeWhereCondition = (id: string) => {
setWhereConditions(whereConditions.filter((c) => c.id !== id));
};
// WHERE 조건 수정
const updateWhereCondition = (id: string, field: keyof DeleteWhereCondition, value: any) => {
setWhereConditions(whereConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
};
// 안전장치 설정 수정
const updateSafetySettings = (field: keyof DeleteSafetySettings, value: any) => {
setSafetySettings((prev) => ({ ...prev, [field]: value }));
};
// 자기 자신 테이블 작업 경고
const getSelfTableWarning = () => {
if (fromConnectionId === toConnectionId && fromTableName === toTableName) {
return "🚨 자기 자신 테이블 DELETE 작업입니다. 매우 위험할 수 있으므로 신중히 설정하세요.";
}
return null;
};
// 위험한 조건 검사
const getDangerousConditionsWarning = () => {
const dangerousOperators = ["!=", "NOT IN", "NOT EXISTS"];
const hasDangerousConditions = deleteConditions.some((condition) =>
dangerousOperators.includes(condition.operator),
);
if (hasDangerousConditions) {
return "⚠️ 부정 조건(!=, NOT IN, NOT EXISTS)은 예상보다 많은 데이터를 삭제할 수 있습니다.";
}
return null;
};
const warningMessage = getSelfTableWarning();
const dangerousWarning = getDangerousConditionsWarning();
const filteredFromColumns = getFilteredColumns(fromTableColumns, fromColumnSearch);
const filteredToColumns = getFilteredColumns(toTableColumns, toColumnSearch);
return (
<div className="space-y-6">
{/* 경고 메시지 */}
{warningMessage && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{warningMessage}</AlertDescription>
</Alert>
)}
{dangerousWarning && (
<Alert variant="default">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{dangerousWarning}</AlertDescription>
</Alert>
)}
{/* DELETE 트리거 조건 설정 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">🔥 </CardTitle>
<CardDescription>
FROM TO
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 필드 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">FROM </label>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="컬럼 검색..."
value={fromColumnSearch}
onChange={(e) => setFromColumnSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</div>
{/* 삭제 트리거 조건 리스트 */}
<div className="space-y-3">
{deleteConditions.map((condition, index) => (
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
{index > 0 && (
<Select
value={condition.logicalOperator || "AND"}
onValueChange={(value) => updateDeleteCondition(condition.id, "logicalOperator", value)}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
)}
<Select
value={condition.fromColumn}
onValueChange={(value) => updateDeleteCondition(condition.id, "fromColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="FROM 컬럼" />
</SelectTrigger>
<SelectContent>
{filteredFromColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.columnName}</span>
<Badge variant="outline" className="text-xs">
{col.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.operator}
onValueChange={(value) => updateDeleteCondition(condition.id, "operator", value)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">
<span className="text-red-600">!=</span>
</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
<SelectItem value="NOT IN">
<span className="text-red-600">NOT IN</span>
</SelectItem>
<SelectItem value="EXISTS">EXISTS</SelectItem>
<SelectItem value="NOT EXISTS">
<span className="text-red-600">NOT EXISTS</span>
</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="값"
value={condition.value as string}
onChange={(e) => updateDeleteCondition(condition.id, "value", e.target.value)}
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => removeDeleteCondition(condition.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" onClick={addDeleteCondition} className="w-full" disabled={disabled}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Separator />
{/* DELETE WHERE 조건 설정 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">🎯 </CardTitle>
<CardDescription>TO WHERE </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 필드 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">FROM </label>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="컬럼 검색..."
value={fromColumnSearch}
onChange={(e) => setFromColumnSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div>
<label className="text-sm font-medium">TO </label>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="컬럼 검색..."
value={toColumnSearch}
onChange={(e) => setToColumnSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</div>
{/* WHERE 조건 리스트 */}
<div className="space-y-3">
{whereConditions.map((condition, index) => (
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
{index > 0 && (
<Select
value={condition.logicalOperator || "AND"}
onValueChange={(value) => updateWhereCondition(condition.id, "logicalOperator", value)}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
)}
<Select
value={condition.toColumn}
onValueChange={(value) => updateWhereCondition(condition.id, "toColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="TO 컬럼" />
</SelectTrigger>
<SelectContent>
{filteredToColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.columnName}</span>
<Badge variant="outline" className="text-xs">
{col.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.operator}
onValueChange={(value) => updateWhereCondition(condition.id, "operator", value)}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
<SelectItem value="NOT IN">NOT IN</SelectItem>
</SelectContent>
</Select>
<Select
value={condition.valueSource}
onValueChange={(value) => updateWhereCondition(condition.id, "valueSource", value)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="from_column">FROM </SelectItem>
<SelectItem value="static"></SelectItem>
<SelectItem value="condition_result"> </SelectItem>
</SelectContent>
</Select>
{condition.valueSource === "from_column" && (
<Select
value={condition.fromColumn || ""}
onValueChange={(value) => updateWhereCondition(condition.id, "fromColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="FROM 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{filteredFromColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{condition.valueSource === "static" && (
<Input
placeholder="고정값"
value={condition.staticValue || ""}
onChange={(e) => updateWhereCondition(condition.id, "staticValue", e.target.value)}
className="w-32"
/>
)}
<Button variant="outline" size="sm" onClick={() => removeWhereCondition(condition.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" onClick={addWhereCondition} className="w-full" disabled={disabled}>
<Plus className="mr-2 h-4 w-4" />
WHERE
</Button>
</div>
{whereConditions.length === 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription> DELETE WHERE .</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
<Separator />
{/* 안전장치 설정 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-4 w-4" />
</CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* 최대 삭제 개수 */}
<div className="space-y-2">
<Label htmlFor="maxDeleteCount"> </Label>
<Input
id="maxDeleteCount"
type="number"
min="1"
max="10000"
value={safetySettings.maxDeleteCount}
onChange={(e) => updateSafetySettings("maxDeleteCount", parseInt(e.target.value))}
className="w-32"
/>
<p className="text-muted-foreground text-sm"> .</p>
</div>
{/* 안전장치 옵션들 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="requireConfirmation"> </Label>
<p className="text-muted-foreground text-sm"> .</p>
</div>
<Switch
id="requireConfirmation"
checked={safetySettings.requireConfirmation}
onCheckedChange={(checked) => updateSafetySettings("requireConfirmation", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="dryRunFirst">Dry Run </Label>
<p className="text-muted-foreground text-sm"> .</p>
</div>
<Switch
id="dryRunFirst"
checked={safetySettings.dryRunFirst}
onCheckedChange={(checked) => updateSafetySettings("dryRunFirst", checked)}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="logAllDeletes"> </Label>
<p className="text-muted-foreground text-sm"> .</p>
</div>
<Switch
id="logAllDeletes"
checked={safetySettings.logAllDeletes}
onCheckedChange={(checked) => updateSafetySettings("logAllDeletes", checked)}
/>
</div>
</div>
{/* 자기 자신 테이블 추가 안전장치 */}
{fromConnectionId === toConnectionId && fromTableName === toTableName && (
<Alert variant="destructive">
<Trash2 className="h-4 w-4" />
<AlertDescription className="space-y-2">
<div className="font-medium"> :</div>
<ul className="list-inside list-disc space-y-1 text-sm">
<li> {Math.min(safetySettings.maxDeleteCount, 10)} </li>
<li> (!=, NOT IN, NOT EXISTS) </li>
<li>WHERE 2 </li>
</ul>
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
);
};

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -8,16 +8,24 @@ import { Card, CardContent } from "@/components/ui/card";
import { ColumnInfo } from "@/lib/api/dataflow"; import { ColumnInfo } from "@/lib/api/dataflow";
import { DataSaveSettings } from "@/types/connectionTypes"; import { DataSaveSettings } from "@/types/connectionTypes";
import { ColumnTableSection } from "./ColumnTableSection"; import { ColumnTableSection } from "./ColumnTableSection";
import {
getColumnsFromConnection,
getTablesFromConnection,
ColumnInfo as MultiColumnInfo,
} from "@/lib/api/multiConnection";
interface InsertFieldMappingPanelProps { interface InsertFieldMappingPanelProps {
action: DataSaveSettings["actions"][0]; action: DataSaveSettings["actions"][0];
actionIndex: number; actionIndex: number;
settings: DataSaveSettings; settings: DataSaveSettings;
onSettingsChange: (settings: DataSaveSettings) => void; onSettingsChange: (settings: DataSaveSettings) => void;
fromTableColumns: ColumnInfo[]; fromTableColumns?: ColumnInfo[];
toTableColumns: ColumnInfo[]; toTableColumns?: ColumnInfo[];
fromTableName?: string; fromTableName?: string;
toTableName?: string; toTableName?: string;
// 다중 커넥션 지원
fromConnectionId?: number;
toConnectionId?: number;
} }
interface ColumnMapping { interface ColumnMapping {
@ -31,15 +39,26 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
actionIndex, actionIndex,
settings, settings,
onSettingsChange, onSettingsChange,
fromTableColumns, fromTableColumns = [],
toTableColumns, toTableColumns = [],
fromTableName, fromTableName,
toTableName, toTableName,
fromConnectionId,
toConnectionId,
}) => { }) => {
const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null); const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null);
const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null); const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]); const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 다중 커넥션에서 로드한 컬럼 정보
const [multiFromColumns, setMultiFromColumns] = useState<MultiColumnInfo[]>([]);
const [multiToColumns, setMultiToColumns] = useState<MultiColumnInfo[]>([]);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
// 테이블 라벨명 정보
const [fromTableDisplayName, setFromTableDisplayName] = useState<string>("");
const [toTableDisplayName, setToTableDisplayName] = useState<string>("");
// 검색 및 필터링 상태 (FROM과 TO 독립적) // 검색 및 필터링 상태 (FROM과 TO 독립적)
const [fromSearchTerm, setFromSearchTerm] = useState(""); const [fromSearchTerm, setFromSearchTerm] = useState("");
const [toSearchTerm, setToSearchTerm] = useState(""); const [toSearchTerm, setToSearchTerm] = useState("");
@ -54,9 +73,84 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
const [toShowMappedOnly, setToShowMappedOnly] = useState(false); const [toShowMappedOnly, setToShowMappedOnly] = useState(false);
const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false); const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false);
// 다중 커넥션에서 컬럼 정보 및 테이블 라벨명 로드
useEffect(() => {
const loadColumnsAndTableInfo = async () => {
if (fromConnectionId !== undefined && toConnectionId !== undefined && fromTableName && toTableName) {
setIsLoadingColumns(true);
try {
const [fromCols, toCols, fromTables, toTables] = await Promise.all([
getColumnsFromConnection(fromConnectionId, fromTableName),
getColumnsFromConnection(toConnectionId, toTableName),
getTablesFromConnection(fromConnectionId),
getTablesFromConnection(toConnectionId),
]);
setMultiFromColumns(fromCols);
setMultiToColumns(toCols);
// 테이블 라벨명 설정
const fromTable = fromTables.find((t) => t.tableName === fromTableName);
const toTable = toTables.find((t) => t.tableName === toTableName);
setFromTableDisplayName(
fromTable?.displayName && fromTable.displayName !== fromTable.tableName
? fromTable.displayName
: fromTableName,
);
setToTableDisplayName(
toTable?.displayName && toTable.displayName !== toTable.tableName ? toTable.displayName : toTableName,
);
} catch (error) {
console.error("컬럼 정보 및 테이블 정보 로드 실패:", error);
} finally {
setIsLoadingColumns(false);
}
}
};
loadColumnsAndTableInfo();
}, [fromConnectionId, toConnectionId, fromTableName, toTableName]);
// 사용할 컬럼 데이터 결정 (다중 커넥션 > 기존)
const actualFromColumns = useMemo(() => {
if (multiFromColumns.length > 0) {
return multiFromColumns.map((col) => ({
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength,
description: col.description,
}));
}
return fromTableColumns || [];
}, [multiFromColumns.length, fromTableColumns?.length]);
const actualToColumns = useMemo(() => {
if (multiToColumns.length > 0) {
return multiToColumns.map((col) => ({
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength,
description: col.description,
}));
}
return toTableColumns || [];
}, [multiToColumns.length, toTableColumns?.length]);
// 기존 매핑 데이터를 columnMappings로 변환 // 기존 매핑 데이터를 columnMappings로 변환
useEffect(() => { useEffect(() => {
const mappings: ColumnMapping[] = toTableColumns.map((toCol) => { const columnsToUse = multiToColumns.length > 0 ? multiToColumns : toTableColumns || [];
const mappings: ColumnMapping[] = columnsToUse.map((toCol) => {
const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName); const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName);
return { return {
@ -67,7 +161,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
}); });
setColumnMappings(mappings); setColumnMappings(mappings);
}, [action.fieldMappings, toTableColumns]); }, [action.fieldMappings, multiToColumns.length, toTableColumns?.length]);
// columnMappings 변경 시 settings 업데이트 // columnMappings 변경 시 settings 업데이트
const updateSettings = (newMappings: ColumnMapping[]) => { const updateSettings = (newMappings: ColumnMapping[]) => {
@ -209,7 +303,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능 if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn); const toColumn = actualToColumns.find((col) => col.columnName === selectedToColumn);
if (!toColumn) return true; if (!toColumn) return true;
return fromColumn.dataType === toColumn.dataType; return fromColumn.dataType === toColumn.dataType;
@ -227,7 +321,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능 if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn); const fromColumn = actualFromColumns.find((col) => col.columnName === selectedFromColumn);
if (!fromColumn) return true; if (!fromColumn) return true;
return fromColumn.dataType === toColumn.dataType; return fromColumn.dataType === toColumn.dataType;
@ -244,8 +338,8 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
<ColumnTableSection <ColumnTableSection
type="from" type="from"
tableName={fromTableName || "소스 테이블"} tableName={fromTableDisplayName || fromTableName || "소스 테이블"}
columns={fromTableColumns} columns={actualFromColumns}
selectedColumn={selectedFromColumn} selectedColumn={selectedFromColumn}
onColumnClick={handleFromColumnClick} onColumnClick={handleFromColumnClick}
searchTerm={fromSearchTerm} searchTerm={fromSearchTerm}
@ -259,13 +353,13 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
columnMappings={columnMappings} columnMappings={columnMappings}
isColumnClickable={isFromColumnClickable} isColumnClickable={isFromColumnClickable}
oppositeSelectedColumn={selectedToColumn} oppositeSelectedColumn={selectedToColumn}
oppositeColumns={toTableColumns} oppositeColumns={actualToColumns}
/> />
<ColumnTableSection <ColumnTableSection
type="to" type="to"
tableName={toTableName || "대상 테이블"} tableName={toTableDisplayName || toTableName || "대상 테이블"}
columns={toTableColumns} columns={actualToColumns}
selectedColumn={selectedToColumn} selectedColumn={selectedToColumn}
onColumnClick={handleToColumnClick} onColumnClick={handleToColumnClick}
searchTerm={toSearchTerm} searchTerm={toSearchTerm}
@ -281,7 +375,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
onRemoveMapping={handleRemoveMapping} onRemoveMapping={handleRemoveMapping}
isColumnClickable={isToColumnClickable} isColumnClickable={isToColumnClickable}
oppositeSelectedColumn={selectedFromColumn} oppositeSelectedColumn={selectedFromColumn}
oppositeColumns={fromTableColumns} oppositeColumns={actualFromColumns}
/> />
</div> </div>
@ -336,10 +430,10 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
</Button> </Button>
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
FROM: {fromTableColumns.length} FROM: {actualFromColumns.length}
</Badge> </Badge>
<Badge variant="outline" className="text-xs"> <Badge variant="outline" className="text-xs">
TO: {toTableColumns.length} TO: {actualToColumns.length}
</Badge> </Badge>
</div> </div>
</CardContent> </CardContent>
@ -366,7 +460,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
<div className="text-2xl font-bold text-gray-800"> <div className="text-2xl font-bold text-gray-800">
{Math.round( {Math.round(
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length / (columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
toTableColumns.length) * actualToColumns.length) *
100, 100,
)} )}
% %
@ -378,7 +472,7 @@ export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = (
<Progress <Progress
value={ value={
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length / (columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
toTableColumns.length) * actualToColumns.length) *
100 100
} }
className="h-2" className="h-2"

View File

@ -83,7 +83,11 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
}} }}
className="rounded" className="rounded"
/> />
<span>{column.displayName || column.columnLabel || column.columnName}</span> <span>
{column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</span>
<span className="text-xs text-gray-500">({column.dataType})</span> <span className="text-xs text-gray-500">({column.dataType})</span>
</label> </label>
))} ))}
@ -112,7 +116,11 @@ export const SimpleKeySettings: React.FC<SimpleKeySettingsProps> = ({
}} }}
className="rounded" className="rounded"
/> />
<span>{column.displayName || column.columnLabel || column.columnName}</span> <span>
{column.displayName && column.displayName !== column.columnName
? column.displayName
: column.columnName}
</span>
<span className="text-xs text-gray-500">({column.dataType})</span> <span className="text-xs text-gray-500">({column.dataType})</span>
</label> </label>
))} ))}

View File

@ -0,0 +1,369 @@
/**
*
* FROM/TO
*/
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Table, AlertCircle, Info, Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { getTablesFromConnection, MultiConnectionTableInfo } from "@/lib/api/multiConnection";
import { useToast } from "@/hooks/use-toast";
export interface TableSelectionPanelProps {
fromConnectionId?: number;
toConnectionId?: number;
selectedFromTable?: string;
selectedToTable?: string;
onFromTableChange: (tableName: string) => void;
onToTableChange: (tableName: string) => void;
actionType: "insert" | "update" | "delete";
// 🆕 자기 자신 테이블 작업 지원
allowSameTable?: boolean;
showSameTableWarning?: boolean;
disabled?: boolean;
}
export const TableSelectionPanel: React.FC<TableSelectionPanelProps> = ({
fromConnectionId,
toConnectionId,
selectedFromTable,
selectedToTable,
onFromTableChange,
onToTableChange,
actionType,
allowSameTable = true,
showSameTableWarning = true,
disabled = false,
}) => {
const { toast } = useToast();
const [fromTables, setFromTables] = useState<MultiConnectionTableInfo[]>([]);
const [toTables, setToTables] = useState<MultiConnectionTableInfo[]>([]);
const [fromLoading, setFromLoading] = useState(false);
const [toLoading, setToLoading] = useState(false);
const [fromSearchTerm, setFromSearchTerm] = useState("");
const [toSearchTerm, setToSearchTerm] = useState("");
// FROM 커넥션 변경 시 테이블 목록 로딩
useEffect(() => {
if (fromConnectionId !== undefined && fromConnectionId !== null) {
console.log(`🔍 FROM 테이블 로딩 시작: connectionId=${fromConnectionId}`);
loadTablesFromConnection(fromConnectionId, setFromTables, setFromLoading, "FROM");
} else {
setFromTables([]);
}
}, [fromConnectionId]);
// TO 커넥션 변경 시 테이블 목록 로딩
useEffect(() => {
if (toConnectionId !== undefined && toConnectionId !== null) {
console.log(`🔍 TO 테이블 로딩 시작: connectionId=${toConnectionId}`);
loadTablesFromConnection(toConnectionId, setToTables, setToLoading, "TO");
} else {
setToTables([]);
}
}, [toConnectionId]);
// 테이블 목록 로딩 함수
const loadTablesFromConnection = async (
connectionId: number,
setTables: React.Dispatch<React.SetStateAction<MultiConnectionTableInfo[]>>,
setLoading: React.Dispatch<React.SetStateAction<boolean>>,
type: "FROM" | "TO",
) => {
try {
setLoading(true);
console.log(`🔍 ${type} 테이블 API 호출 시작: connectionId=${connectionId}`);
const tables = await getTablesFromConnection(connectionId);
console.log(`${type} 테이블 로딩 성공: ${tables.length}`);
setTables(tables);
} catch (error) {
console.error(`${type} 테이블 목록 로드 실패:`, error);
toast({
title: "테이블 로드 실패",
description: `${type} 테이블 목록을 불러오는데 실패했습니다. ${error instanceof Error ? error.message : String(error)}`,
variant: "destructive",
});
setTables([]); // 에러 시 빈 배열로 설정
} finally {
setLoading(false);
}
};
// 테이블 필터링
const getFilteredTables = (tables: MultiConnectionTableInfo[], searchTerm: string) => {
if (!searchTerm.trim()) return tables;
const term = searchTerm.toLowerCase();
return tables.filter(
(table) => table.tableName.toLowerCase().includes(term) || table.displayName?.toLowerCase().includes(term),
);
};
// 액션 타입별 라벨 정의
const getTableLabels = () => {
switch (actionType) {
case "insert":
return {
from: {
title: "소스 테이블",
desc: "데이터를 가져올 테이블을 선택하세요",
},
to: {
title: "대상 테이블",
desc: "데이터를 저장할 테이블을 선택하세요",
},
};
case "update":
return {
from: {
title: "조건 확인 테이블",
desc: "업데이트 조건을 확인할 테이블을 선택하세요",
},
to: {
title: "업데이트 대상 테이블",
desc: "데이터를 업데이트할 테이블을 선택하세요",
},
};
case "delete":
return {
from: {
title: "조건 확인 테이블",
desc: "삭제 조건을 확인할 테이블을 선택하세요",
},
to: {
title: "삭제 대상 테이블",
desc: "데이터를 삭제할 테이블을 선택하세요",
},
};
}
};
// 자기 자신 테이블 작업 경고
const getSameTableWarning = () => {
if (
showSameTableWarning &&
fromConnectionId === toConnectionId &&
selectedFromTable === selectedToTable &&
selectedFromTable
) {
switch (actionType) {
case "update":
return "⚠️ 같은 테이블에서 UPDATE 작업을 수행합니다. 무한 루프에 주의하세요.";
case "delete":
return "🚨 같은 테이블에서 DELETE 작업을 수행합니다. 매우 위험할 수 있습니다.";
}
}
return null;
};
const labels = getTableLabels();
const warningMessage = getSameTableWarning();
const filteredFromTables = getFilteredTables(fromTables, fromSearchTerm);
const filteredToTables = getFilteredTables(toTables, toSearchTerm);
// 테이블 아이템 렌더링
const renderTableItem = (table: MultiConnectionTableInfo) => (
<SelectItem key={table.tableName} value={table.tableName}>
<div className="flex w-full items-center justify-between">
<div className="flex items-center gap-2">
<Table className="h-3 w-3" />
<span className="font-medium">
{table.displayName && table.displayName !== table.tableName ? table.displayName : table.tableName}
</span>
</div>
<Badge variant="outline" className="text-xs">
{table.columnCount}
</Badge>
</div>
</SelectItem>
);
// 로딩 상태 렌더링
const renderLoadingState = (title: string) => (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<div className="h-4 w-4 animate-pulse rounded bg-gray-300" />
{title}
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="h-9 w-full animate-pulse rounded bg-gray-200" />
<div className="h-10 w-full animate-pulse rounded bg-gray-200" />
</div>
</CardContent>
</Card>
);
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-6">
{/* FROM 테이블 선택 */}
{fromLoading ? (
renderLoadingState(labels.from.title)
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Table className="h-4 w-4" />
{labels.from.title}
</CardTitle>
<CardDescription>{labels.from.desc}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* 검색 필드 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="테이블 검색..."
value={fromSearchTerm}
onChange={(e) => setFromSearchTerm(e.target.value)}
className="pl-9"
disabled={disabled || fromConnectionId === undefined || fromConnectionId === null}
/>
</div>
{/* 테이블 선택 */}
<Select
value={selectedFromTable || ""}
onValueChange={onFromTableChange}
disabled={disabled || fromConnectionId === undefined || fromConnectionId === null}
>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{filteredFromTables.length > 0 ? (
filteredFromTables.map(renderTableItem)
) : (
<div className="text-muted-foreground p-2 text-center text-sm">
{fromSearchTerm ? "검색 결과가 없습니다" : "테이블이 없습니다"}
</div>
)}
</SelectContent>
</Select>
{/* 테이블 정보 */}
{selectedFromTable && fromTables.find((t) => t.tableName === selectedFromTable) && (
<div className="text-muted-foreground text-xs">
<div className="flex items-center gap-1">
<Info className="h-3 w-3" />
<span>
{fromTables.find((t) => t.tableName === selectedFromTable)?.columnCount} , :{" "}
{fromTables.find((t) => t.tableName === selectedFromTable)?.connectionName}
</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
{/* TO 테이블 선택 */}
{toLoading ? (
renderLoadingState(labels.to.title)
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Table className="h-4 w-4" />
{labels.to.title}
</CardTitle>
<CardDescription>{labels.to.desc}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{/* 검색 필드 */}
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="테이블 검색..."
value={toSearchTerm}
onChange={(e) => setToSearchTerm(e.target.value)}
className="pl-9"
disabled={disabled || toConnectionId === undefined || toConnectionId === null}
/>
</div>
{/* 테이블 선택 */}
<Select
value={selectedToTable || ""}
onValueChange={onToTableChange}
disabled={disabled || toConnectionId === undefined || toConnectionId === null}
>
<SelectTrigger>
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{filteredToTables.length > 0 ? (
filteredToTables.map(renderTableItem)
) : (
<div className="text-muted-foreground p-2 text-center text-sm">
{toSearchTerm ? "검색 결과가 없습니다" : "테이블이 없습니다"}
</div>
)}
</SelectContent>
</Select>
{/* 테이블 정보 */}
{selectedToTable && toTables.find((t) => t.tableName === selectedToTable) && (
<div className="text-muted-foreground text-xs">
<div className="flex items-center gap-1">
<Info className="h-3 w-3" />
<span>
{toTables.find((t) => t.tableName === selectedToTable)?.columnCount} , :{" "}
{toTables.find((t) => t.tableName === selectedToTable)?.connectionName}
</span>
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* 🆕 자기 자신 테이블 작업 시 경고 */}
{warningMessage && (
<Alert variant={actionType === "delete" ? "destructive" : "default"}>
<AlertCircle className="h-4 w-4" />
<AlertDescription>{warningMessage}</AlertDescription>
</Alert>
)}
{/* 선택 상태 표시 */}
{selectedFromTable && selectedToTable && (
<div className="text-muted-foreground text-sm">
<div className="flex items-center gap-2">
<span> :</span>
<Badge variant="secondary" className="font-mono">
{(() => {
const fromTable = fromTables.find((t) => t.tableName === selectedFromTable);
return fromTable?.displayName && fromTable.displayName !== fromTable.tableName
? fromTable.displayName
: selectedFromTable;
})()}
</Badge>
<span></span>
<Badge variant="secondary" className="font-mono">
{(() => {
const toTable = toTables.find((t) => t.tableName === selectedToTable);
return toTable?.displayName && toTable.displayName !== toTable.tableName
? toTable.displayName
: selectedToTable;
})()}
</Badge>
{selectedFromTable === selectedToTable && (
<Badge variant="outline" className="text-orange-600">
</Badge>
)}
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,561 @@
/**
* UPDATE
* UPDATE
*/
import React, { useState, useEffect } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Plus, X, Search, AlertCircle, ArrowRight } from "lucide-react";
import { Separator } from "@/components/ui/separator";
import { ColumnInfo, getColumnsFromConnection } from "@/lib/api/multiConnection";
import { useToast } from "@/hooks/use-toast";
export interface UpdateCondition {
id: string;
fromColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
value: string | string[];
logicalOperator?: "AND" | "OR";
}
export interface UpdateFieldMapping {
id: string;
fromColumn: string;
toColumn: string;
transformFunction?: string;
defaultValue?: string;
}
export interface WhereCondition {
id: string;
toColumn: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE" | "IN" | "NOT IN";
valueSource: "from_column" | "static" | "current_timestamp";
fromColumn?: string;
staticValue?: string;
logicalOperator?: "AND" | "OR";
}
export interface UpdateFieldMappingPanelProps {
action: any;
actionIndex: number;
settings: any;
onSettingsChange: (settings: any) => void;
fromConnectionId?: number;
toConnectionId?: number;
fromTableName?: string;
toTableName?: string;
disabled?: boolean;
}
export const UpdateFieldMappingPanel: React.FC<UpdateFieldMappingPanelProps> = ({
action,
actionIndex,
settings,
onSettingsChange,
fromConnectionId,
toConnectionId,
fromTableName,
toTableName,
disabled = false,
}) => {
const { toast } = useToast();
// 상태 관리
const [fromTableColumns, setFromTableColumns] = useState<ColumnInfo[]>([]);
const [toTableColumns, setToTableColumns] = useState<ColumnInfo[]>([]);
const [updateConditions, setUpdateConditions] = useState<UpdateCondition[]>([]);
const [updateFields, setUpdateFields] = useState<UpdateFieldMapping[]>([]);
const [whereConditions, setWhereConditions] = useState<WhereCondition[]>([]);
const [loading, setLoading] = useState(false);
// 검색 상태
const [fromColumnSearch, setFromColumnSearch] = useState("");
const [toColumnSearch, setToColumnSearch] = useState("");
// 컬럼 정보 로드
useEffect(() => {
if (fromConnectionId !== undefined && fromTableName) {
loadColumnInfo(fromConnectionId, fromTableName, setFromTableColumns, "FROM");
}
}, [fromConnectionId, fromTableName]);
useEffect(() => {
if (toConnectionId !== undefined && toTableName) {
loadColumnInfo(toConnectionId, toTableName, setToTableColumns, "TO");
}
}, [toConnectionId, toTableName]);
// 컬럼 정보 로드 함수
const loadColumnInfo = async (
connectionId: number,
tableName: string,
setColumns: React.Dispatch<React.SetStateAction<ColumnInfo[]>>,
type: "FROM" | "TO",
) => {
try {
setLoading(true);
const columns = await getColumnsFromConnection(connectionId, tableName);
setColumns(columns);
} catch (error) {
console.error(`${type} 컬럼 정보 로드 실패:`, error);
toast({
title: "컬럼 로드 실패",
description: `${type} 테이블의 컬럼 정보를 불러오는데 실패했습니다.`,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
// 컬럼 필터링
const getFilteredColumns = (columns: ColumnInfo[], searchTerm: string) => {
if (!searchTerm.trim()) return columns;
const term = searchTerm.toLowerCase();
return columns.filter(
(col) => col.columnName.toLowerCase().includes(term) || col.displayName.toLowerCase().includes(term),
);
};
// UPDATE 조건 추가
const addUpdateCondition = () => {
const newCondition: UpdateCondition = {
id: `condition_${Date.now()}`,
fromColumn: "",
operator: "=",
value: "",
logicalOperator: updateConditions.length > 0 ? "AND" : undefined,
};
setUpdateConditions([...updateConditions, newCondition]);
};
// UPDATE 조건 제거
const removeUpdateCondition = (id: string) => {
setUpdateConditions(updateConditions.filter((c) => c.id !== id));
};
// UPDATE 조건 수정
const updateCondition = (id: string, field: keyof UpdateCondition, value: any) => {
setUpdateConditions(updateConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
};
// UPDATE 필드 매핑 추가
const addUpdateFieldMapping = () => {
const newMapping: UpdateFieldMapping = {
id: `mapping_${Date.now()}`,
fromColumn: "",
toColumn: "",
transformFunction: "",
defaultValue: "",
};
setUpdateFields([...updateFields, newMapping]);
};
// UPDATE 필드 매핑 제거
const removeUpdateFieldMapping = (id: string) => {
setUpdateFields(updateFields.filter((m) => m.id !== id));
};
// UPDATE 필드 매핑 수정
const updateFieldMapping = (id: string, field: keyof UpdateFieldMapping, value: any) => {
setUpdateFields(updateFields.map((m) => (m.id === id ? { ...m, [field]: value } : m)));
};
// WHERE 조건 추가
const addWhereCondition = () => {
const newCondition: WhereCondition = {
id: `where_${Date.now()}`,
toColumn: "",
operator: "=",
valueSource: "from_column",
fromColumn: "",
staticValue: "",
logicalOperator: whereConditions.length > 0 ? "AND" : undefined,
};
setWhereConditions([...whereConditions, newCondition]);
};
// WHERE 조건 제거
const removeWhereCondition = (id: string) => {
setWhereConditions(whereConditions.filter((c) => c.id !== id));
};
// WHERE 조건 수정
const updateWhereCondition = (id: string, field: keyof WhereCondition, value: any) => {
setWhereConditions(whereConditions.map((c) => (c.id === id ? { ...c, [field]: value } : c)));
};
// 자기 자신 테이블 작업 경고
const getSelfTableWarning = () => {
if (fromConnectionId === toConnectionId && fromTableName === toTableName) {
return "⚠️ 자기 자신 테이블 UPDATE 작업입니다. 무한 루프 및 데이터 손상에 주의하세요.";
}
return null;
};
const warningMessage = getSelfTableWarning();
const filteredFromColumns = getFilteredColumns(fromTableColumns, fromColumnSearch);
const filteredToColumns = getFilteredColumns(toTableColumns, toColumnSearch);
return (
<div className="space-y-6">
{/* 경고 메시지 */}
{warningMessage && (
<Alert variant="default">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{warningMessage}</AlertDescription>
</Alert>
)}
{/* UPDATE 조건 설정 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">🔍 </CardTitle>
<CardDescription>
FROM TO
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 필드 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">FROM </label>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="컬럼 검색..."
value={fromColumnSearch}
onChange={(e) => setFromColumnSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</div>
{/* 업데이트 조건 리스트 */}
<div className="space-y-3">
{updateConditions.map((condition, index) => (
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
{index > 0 && (
<Select
value={condition.logicalOperator || "AND"}
onValueChange={(value) => updateCondition(condition.id, "logicalOperator", value)}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
)}
<Select
value={condition.fromColumn}
onValueChange={(value) => updateCondition(condition.id, "fromColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="FROM 컬럼" />
</SelectTrigger>
<SelectContent>
{filteredFromColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.columnName}</span>
<Badge variant="outline" className="text-xs">
{col.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.operator}
onValueChange={(value) => updateCondition(condition.id, "operator", value)}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
<SelectItem value="NOT IN">NOT IN</SelectItem>
</SelectContent>
</Select>
<Input
placeholder="값"
value={condition.value as string}
onChange={(e) => updateCondition(condition.id, "value", e.target.value)}
className="flex-1"
/>
<Button variant="outline" size="sm" onClick={() => removeUpdateCondition(condition.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" onClick={addUpdateCondition} className="w-full" disabled={disabled}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Separator />
{/* UPDATE 필드 매핑 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">📝 </CardTitle>
<CardDescription>FROM TO </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 검색 필드 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-sm font-medium">FROM </label>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="컬럼 검색..."
value={fromColumnSearch}
onChange={(e) => setFromColumnSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div>
<label className="text-sm font-medium">TO </label>
<div className="relative">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform" />
<Input
placeholder="컬럼 검색..."
value={toColumnSearch}
onChange={(e) => setToColumnSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
</div>
{/* 필드 매핑 리스트 */}
<div className="space-y-3">
{updateFields.map((mapping) => (
<div key={mapping.id} className="flex items-center gap-2 rounded-lg border p-3">
<Select
value={mapping.fromColumn}
onValueChange={(value) => updateFieldMapping(mapping.id, "fromColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="FROM 컬럼" />
</SelectTrigger>
<SelectContent>
{filteredFromColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.columnName}</span>
<Badge variant="outline" className="text-xs">
{col.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<ArrowRight className="text-muted-foreground h-4 w-4" />
<Select
value={mapping.toColumn}
onValueChange={(value) => updateFieldMapping(mapping.id, "toColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="TO 컬럼" />
</SelectTrigger>
<SelectContent>
{filteredToColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.columnName}</span>
<Badge variant="outline" className="text-xs">
{col.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Input
placeholder="기본값 (선택사항)"
value={mapping.defaultValue || ""}
onChange={(e) => updateFieldMapping(mapping.id, "defaultValue", e.target.value)}
className="w-32"
/>
<Button variant="outline" size="sm" onClick={() => removeUpdateFieldMapping(mapping.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" onClick={addUpdateFieldMapping} className="w-full" disabled={disabled}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
<Separator />
{/* WHERE 조건 설정 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">🎯 </CardTitle>
<CardDescription>TO WHERE </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* WHERE 조건 리스트 */}
<div className="space-y-3">
{whereConditions.map((condition, index) => (
<div key={condition.id} className="flex items-center gap-2 rounded-lg border p-3">
{index > 0 && (
<Select
value={condition.logicalOperator || "AND"}
onValueChange={(value) => updateWhereCondition(condition.id, "logicalOperator", value)}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
)}
<Select
value={condition.toColumn}
onValueChange={(value) => updateWhereCondition(condition.id, "toColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="TO 컬럼" />
</SelectTrigger>
<SelectContent>
{filteredToColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span>{col.columnName}</span>
<Badge variant="outline" className="text-xs">
{col.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={condition.operator}
onValueChange={(value) => updateWhereCondition(condition.id, "operator", value)}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="=">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="LIKE">LIKE</SelectItem>
<SelectItem value="IN">IN</SelectItem>
<SelectItem value="NOT IN">NOT IN</SelectItem>
</SelectContent>
</Select>
<Select
value={condition.valueSource}
onValueChange={(value) => updateWhereCondition(condition.id, "valueSource", value)}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="from_column">FROM </SelectItem>
<SelectItem value="static"></SelectItem>
<SelectItem value="current_timestamp"> </SelectItem>
</SelectContent>
</Select>
{condition.valueSource === "from_column" && (
<Select
value={condition.fromColumn || ""}
onValueChange={(value) => updateWhereCondition(condition.id, "fromColumn", value)}
>
<SelectTrigger className="w-48">
<SelectValue placeholder="FROM 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{filteredFromColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{condition.valueSource === "static" && (
<Input
placeholder="고정값"
value={condition.staticValue || ""}
onChange={(e) => updateWhereCondition(condition.id, "staticValue", e.target.value)}
className="w-32"
/>
)}
<Button variant="outline" size="sm" onClick={() => removeWhereCondition(condition.id)}>
<X className="h-4 w-4" />
</Button>
</div>
))}
<Button variant="outline" onClick={addWhereCondition} className="w-full" disabled={disabled}>
<Plus className="mr-2 h-4 w-4" />
WHERE
</Button>
</div>
{whereConditions.length === 0 && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription> UPDATE WHERE .</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</div>
);
};

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, Suspense } from "react"; import { useState, Suspense, useEffect } from "react";
import { useRouter, usePathname, useSearchParams } from "next/navigation"; import { useRouter, usePathname, useSearchParams } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@ -197,8 +197,27 @@ function AppLayoutInner({ children }: AppLayoutProps) {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const { user, logout, refreshUserData } = useAuth(); const { user, logout, refreshUserData } = useAuth();
const { userMenus, adminMenus, loading, refreshMenus } = useMenu(); const { userMenus, adminMenus, loading, refreshMenus } = useMenu();
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(true);
const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set()); const [expandedMenus, setExpandedMenus] = useState<Set<string>>(new Set());
const [isMobile, setIsMobile] = useState(false);
// 화면 크기 감지 및 사이드바 초기 상태 설정
useEffect(() => {
const checkIsMobile = () => {
const mobile = window.innerWidth < 1024; // lg 브레이크포인트
setIsMobile(mobile);
// 모바일에서만 사이드바를 닫음
if (mobile) {
setSidebarOpen(false);
} else {
setSidebarOpen(true);
}
};
checkIsMobile();
window.addEventListener('resize', checkIsMobile);
return () => window.removeEventListener('resize', checkIsMobile);
}, []);
// 프로필 관련 로직 // 프로필 관련 로직
const { const {
@ -253,15 +272,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
? `/screens/${firstScreen.screenId}?mode=admin` ? `/screens/${firstScreen.screenId}?mode=admin`
: `/screens/${firstScreen.screenId}`; : `/screens/${firstScreen.screenId}`;
console.log("🎯 메뉴에서 화면으로 이동:", {
menuName: menu.name,
screenId: firstScreen.screenId,
isAdminMode,
targetPath: screenPath,
});
router.push(screenPath); router.push(screenPath);
setSidebarOpen(false); if (isMobile) {
setSidebarOpen(false);
}
return; return;
} }
} catch (error) { } catch (error) {
@ -271,10 +285,11 @@ function AppLayoutInner({ children }: AppLayoutProps) {
// 할당된 화면이 없고 URL이 있으면 기존 URL로 이동 // 할당된 화면이 없고 URL이 있으면 기존 URL로 이동
if (menu.url && menu.url !== "#") { if (menu.url && menu.url !== "#") {
router.push(menu.url); router.push(menu.url);
setSidebarOpen(false); if (isMobile) {
setSidebarOpen(false);
}
} else { } else {
// URL도 없고 할당된 화면도 없으면 경고 메시지 // URL도 없고 할당된 화면도 없으면 경고 메시지
console.warn("메뉴에 URL이나 할당된 화면이 없습니다:", menu);
toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다."); toast.warning("이 메뉴에는 연결된 페이지나 화면이 없습니다.");
} }
} }
@ -295,7 +310,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
await logout(); await logout();
router.push("/login"); router.push("/login");
} catch (error) { } catch (error) {
console.error("로그아웃 실패:", error); // 로그아웃 실패 시 처리
} }
}; };
@ -306,7 +321,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
return ( return (
<div key={menu.id}> <div key={menu.id}>
<div <div
className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors hover:cursor-pointer ${ className={`group flex cursor-pointer items-center justify-between rounded-lg px-3 py-2 text-sm font-medium transition-colors duration-200 ease-in-out h-10 ${
pathname === menu.url pathname === menu.url
? "bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900 border-l-4 border-blue-500" ? "bg-gradient-to-br from-slate-100 to-blue-100/40 text-slate-900 border-l-4 border-blue-500"
: isExpanded : isExpanded
@ -315,9 +330,9 @@ function AppLayoutInner({ children }: AppLayoutProps) {
} ${level > 0 ? "ml-6" : ""}`} } ${level > 0 ? "ml-6" : ""}`}
onClick={() => handleMenuClick(menu)} onClick={() => handleMenuClick(menu)}
> >
<div className="flex items-center"> <div className="flex items-center min-w-0 flex-1">
{menu.icon} {menu.icon}
<span className="ml-3">{menu.name}</span> <span className="ml-3 truncate" title={menu.name}>{menu.name}</span>
</div> </div>
{menu.hasChildren && ( {menu.hasChildren && (
<div className="ml-auto"> <div className="ml-auto">
@ -339,8 +354,10 @@ function AppLayoutInner({ children }: AppLayoutProps) {
}`} }`}
onClick={() => handleMenuClick(child)} onClick={() => handleMenuClick(child)}
> >
{child.icon} <div className="flex items-center min-w-0 flex-1">
<span className="ml-3">{child.name}</span> {child.icon}
<span className="ml-3 truncate" title={child.name}>{child.name}</span>
</div>
</div> </div>
))} ))}
</div> </div>
@ -369,22 +386,29 @@ function AppLayoutInner({ children }: AppLayoutProps) {
{/* MainHeader 컴포넌트 사용 */} {/* MainHeader 컴포넌트 사용 */}
<MainHeader <MainHeader
user={user} user={user}
onSidebarToggle={() => setSidebarOpen(!sidebarOpen)} onSidebarToggle={() => {
// 모바일에서만 토글 동작
if (isMobile) {
setSidebarOpen(!sidebarOpen);
}
}}
onProfileClick={openProfileModal} onProfileClick={openProfileModal}
onLogout={handleLogout} onLogout={handleLogout}
/> />
<div className="flex flex-1"> <div className="flex flex-1">
{/* 모바일 사이드바 오버레이 */} {/* 모바일 사이드바 오버레이 */}
{sidebarOpen && ( {sidebarOpen && isMobile && (
<div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} /> <div className="fixed inset-0 z-30 bg-black/50 lg:hidden" onClick={() => setSidebarOpen(false)} />
)} )}
{/* 왼쪽 사이드바 */} {/* 왼쪽 사이드바 */}
<aside <aside
className={`${ className={`${
sidebarOpen ? "translate-x-0" : "-translate-x-full" isMobile
} fixed top-14 left-0 z-40 flex h-full w-64 flex-col border-r border-slate-200 bg-white transition-transform duration-300 lg:relative lg:top-0 lg:z-auto lg:h-full lg:translate-x-0 lg:transform-none`} ? (sidebarOpen ? "translate-x-0" : "-translate-x-full") + " fixed top-14 left-0 z-40"
: "translate-x-0 relative top-0 z-auto"
} flex h-full w-72 min-w-72 max-w-72 flex-col border-r border-slate-200 bg-white transition-transform duration-300`}
> >
{/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */} {/* 사이드바 상단 - Admin/User 모드 전환 버튼 (관리자만) */}
{(user as ExtendedUserInfo)?.userType === "admin" && ( {(user as ExtendedUserInfo)?.userType === "admin" && (
@ -428,7 +452,7 @@ function AppLayoutInner({ children }: AppLayoutProps) {
</aside> </aside>
{/* 가운데 컨텐츠 영역 */} {/* 가운데 컨텐츠 영역 */}
<main className="flex-1 bg-white">{children}</main> <main className="flex-1 min-w-0 bg-white overflow-hidden">{children}</main>
</div> </div>
{/* 프로필 수정 모달 */} {/* 프로필 수정 모달 */}

View File

@ -232,7 +232,7 @@ export const EnhancedInteractiveScreenViewer: React.FC<EnhancedInteractiveScreen
className={`mb-1 block text-sm font-medium ${hasError ? "text-destructive" : ""}`} className={`mb-1 block text-sm font-medium ${hasError ? "text-destructive" : ""}`}
style={{ style={{
fontSize: labelStyle.labelFontSize || "14px", fontSize: labelStyle.labelFontSize || "14px",
color: hasError ? "#ef4444" : labelStyle.labelColor || "#374151", color: hasError ? "#ef4444" : labelStyle.labelColor || "#3b83f6",
fontWeight: labelStyle.labelFontWeight || "500", fontWeight: labelStyle.labelFontWeight || "500",
fontFamily: labelStyle.labelFontFamily, fontFamily: labelStyle.labelFontFamily,
textAlign: labelStyle.labelTextAlign || "left", textAlign: labelStyle.labelTextAlign || "left",

View File

@ -1708,7 +1708,7 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
// 라벨 스타일 적용 // 라벨 스타일 적용
const labelStyle = { const labelStyle = {
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: component.style?.labelFontWeight || "500", fontWeight: component.style?.labelFontWeight || "500",
backgroundColor: component.style?.labelBackgroundColor || "transparent", backgroundColor: component.style?.labelBackgroundColor || "transparent",
padding: component.style?.labelPadding || "0", padding: component.style?.labelPadding || "0",

View File

@ -32,6 +32,7 @@ interface RealtimePreviewProps {
selectedScreen?: any; selectedScreen?: any;
onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러 onZoneComponentDrop?: (e: React.DragEvent, zoneId: string, layoutId: string) => void; // 존별 드롭 핸들러
onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러 onZoneClick?: (zoneId: string) => void; // 존 클릭 핸들러
onConfigChange?: (config: any) => void; // 설정 변경 핸들러
} }
// 동적 위젯 타입 아이콘 (레지스트리에서 조회) // 동적 위젯 타입 아이콘 (레지스트리에서 조회)
@ -73,6 +74,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
selectedScreen, selectedScreen,
onZoneComponentDrop, onZoneComponentDrop,
onZoneClick, onZoneClick,
onConfigChange,
}) => { }) => {
const { id, type, position, size, style: componentStyle } = component; const { id, type, position, size, style: componentStyle } = component;
@ -89,8 +91,12 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
const baseStyle = { const baseStyle = {
left: `${position.x}px`, left: `${position.x}px`,
top: `${position.y}px`, top: `${position.y}px`,
width: `${size?.width || 100}px`, width: component.componentConfig?.type === "table-list"
height: `${size?.height || 36}px`, ? `${Math.max(size?.width || 400, 400)}px` // table-list는 최소 400px
: `${size?.width || 100}px`,
height: component.componentConfig?.type === "table-list"
? `${Math.max(size?.height || 300, 300)}px` // table-list는 최소 300px
: `${size?.height || 36}px`,
zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상 zIndex: component.type === "layout" ? 1 : position.z || 2, // 레이아웃은 z-index 1, 다른 컴포넌트는 2 이상
...componentStyle, ...componentStyle,
}; };
@ -120,7 +126,9 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{/* 동적 컴포넌트 렌더링 */} {/* 동적 컴포넌트 렌더링 */}
<div className="h-full w-full"> <div className={`h-full w-full ${
component.componentConfig?.type === "table-list" ? "overflow-visible" : ""
}`}>
<DynamicComponentRenderer <DynamicComponentRenderer
component={component} component={component}
isSelected={isSelected} isSelected={isSelected}
@ -133,6 +141,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
onZoneComponentDrop={onZoneComponentDrop} onZoneComponentDrop={onZoneComponentDrop}
onZoneClick={onZoneClick} onZoneClick={onZoneClick}
onConfigChange={onConfigChange}
/> />
</div> </div>

View File

@ -1004,7 +1004,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "600", labelFontWeight: "600",
labelMarginBottom: "8px", labelMarginBottom: "8px",
...templateComp.style, ...templateComp.style,
@ -1083,7 +1083,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "600", labelFontWeight: "600",
labelMarginBottom: "8px", labelMarginBottom: "8px",
...templateComp.style, ...templateComp.style,
@ -1134,7 +1134,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "600", labelFontWeight: "600",
labelMarginBottom: "8px", labelMarginBottom: "8px",
...templateComp.style, ...templateComp.style,
@ -1185,7 +1185,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "600", labelFontWeight: "600",
labelMarginBottom: "8px", labelMarginBottom: "8px",
...templateComp.style, ...templateComp.style,
@ -1274,7 +1274,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "600", labelFontWeight: "600",
labelMarginBottom: "8px", labelMarginBottom: "8px",
...templateComp.style, ...templateComp.style,
@ -1564,7 +1564,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
labelMarginBottom: "4px", labelMarginBottom: "4px",
}, },
@ -1653,7 +1653,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "600", labelFontWeight: "600",
labelMarginBottom: "8px", labelMarginBottom: "8px",
}, },
@ -1844,7 +1844,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정 labelDisplay: false, // 모든 컴포넌트의 기본 라벨 표시를 false로 설정
labelFontSize: "12px", labelFontSize: "12px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
labelMarginBottom: "6px", labelMarginBottom: "6px",
}, },
@ -1887,7 +1887,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
style: { style: {
labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시 labelDisplay: true, // 테이블 패널에서 드래그한 컴포넌트는 라벨을 기본적으로 표시
labelFontSize: "12px", labelFontSize: "12px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
labelMarginBottom: "6px", labelMarginBottom: "6px",
}, },
@ -3158,11 +3158,15 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
{/* 실제 작업 캔버스 (해상도 크기) */} {/* 실제 작업 캔버스 (해상도 크기) */}
<div <div
className="mx-auto bg-white shadow-lg" className="mx-auto bg-white shadow-lg"
style={{ width: screenResolution.width, height: screenResolution.height }} style={{
width: screenResolution.width,
height: Math.max(screenResolution.height, 800), // 최소 높이 보장
minHeight: screenResolution.height
}}
> >
<div <div
ref={canvasRef} ref={canvasRef}
className="relative h-full w-full overflow-hidden bg-white" className="relative h-full w-full overflow-visible bg-white" // overflow-visible로 변경
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget && !selectionDrag.wasSelecting) { if (e.target === e.currentTarget && !selectionDrag.wasSelecting) {
setSelectedComponent(null); setSelectedComponent(null);
@ -3271,6 +3275,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
// onZoneComponentDrop 제거 // onZoneComponentDrop 제거
onZoneClick={handleZoneClick} onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (테이블 페이지 크기 등 설정을 상세설정에 반영)
onConfigChange={(config) => {
console.log("📤 테이블 설정 변경을 상세설정에 알림:", config);
// 여기서 DetailSettingsPanel의 상태를 업데이트하거나
// 컴포넌트의 componentConfig를 업데이트할 수 있습니다
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
}}
> >
{/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */} {/* 컨테이너, 그룹, 영역의 자식 컴포넌트들 렌더링 (레이아웃은 독립적으로 렌더링) */}
{(component.type === "group" || component.type === "container" || component.type === "area") && {(component.type === "group" || component.type === "container" || component.type === "area") &&
@ -3351,6 +3362,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
selectedScreen={selectedScreen} selectedScreen={selectedScreen}
// onZoneComponentDrop 제거 // onZoneComponentDrop 제거
onZoneClick={handleZoneClick} onZoneClick={handleZoneClick}
// 설정 변경 핸들러 (자식 컴포넌트용)
onConfigChange={(config) => {
console.log("📤 자식 컴포넌트 설정 변경을 상세설정에 알림:", config);
// TODO: 실제 구현은 DetailSettingsPanel과의 연동 필요
}}
/> />
); );
})} })}

View File

@ -134,7 +134,25 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({ component,
<Select <Select
value={config.action?.type || "save"} value={config.action?.type || "save"}
defaultValue="save" defaultValue="save"
onValueChange={(value) => onUpdateProperty("componentConfig.action", { type: value })} onValueChange={(value) => {
// 액션 설정 업데이트
onUpdateProperty("componentConfig.action", { type: value });
// 액션에 따른 라벨 색상 자동 설정
if (value === 'delete') {
// 삭제 액션일 때 빨간색으로 설정
onUpdateProperty("style", {
...component.style,
labelColor: '#ef4444'
});
} else {
// 다른 액션일 때 기본 파란색으로 리셋
onUpdateProperty("style", {
...component.style,
labelColor: '#3b83f6'
});
}
}}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="버튼 액션 선택" /> <SelectValue placeholder="버튼 액션 선택" />

View File

@ -240,12 +240,6 @@ export const AdvancedSearchFilters: React.FC<AdvancedSearchFiltersProps> = ({
); );
case "code": case "code":
console.log("🔍 코드 필터 렌더링:", {
columnName: filter.columnName,
codeCategory: filter.codeCategory,
options: codeOptions[filter.codeCategory || ""],
loading: loadingStates[filter.codeCategory || ""],
});
return ( return (
<CodeFilter <CodeFilter
key={filter.columnName} key={filter.columnName}

View File

@ -1006,7 +1006,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<DynamicComponentConfigPanel <DynamicComponentConfigPanel
componentId={componentId} componentId={componentId}
config={selectedComponent.componentConfig || {}} config={(() => {
const config = selectedComponent.componentConfig || {};
console.log("🔍 DetailSettingsPanel에서 전달하는 config:", config);
console.log("🔍 selectedComponent 전체:", selectedComponent);
return config;
})()}
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName}
tableColumns={(() => { tableColumns={(() => {
console.log("🔍 DetailSettingsPanel tableColumns 전달:", { console.log("🔍 DetailSettingsPanel tableColumns 전달:", {

View File

@ -203,7 +203,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
: "1"), : "1"),
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "", labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px", labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#374151", labelColor: selectedComponent?.style?.labelColor || "#3b83f6",
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px", labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false, required: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).required : false) || false,
readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false, readonly: (selectedComponent?.type === "widget" ? (selectedComponent as WidgetComponent).readonly : false) || false,
@ -261,7 +261,7 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
: "1"), : "1"),
labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "", labelText: selectedComponent?.style?.labelText || selectedComponent?.label || "",
labelFontSize: selectedComponent?.style?.labelFontSize || "12px", labelFontSize: selectedComponent?.style?.labelFontSize || "12px",
labelColor: selectedComponent?.style?.labelColor || "#374151", labelColor: selectedComponent?.style?.labelColor || "#3b83f6",
labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px", labelMarginBottom: selectedComponent?.style?.labelMarginBottom || "4px",
required: widget?.required || false, required: widget?.required || false,
readonly: widget?.readonly || false, readonly: widget?.readonly || false,
@ -285,6 +285,84 @@ const PropertiesPanelComponent: React.FC<PropertiesPanelProps> = ({
dragState?.justFinishedDrag, // 드래그 완료 직후 감지 dragState?.justFinishedDrag, // 드래그 완료 직후 감지
]); ]);
// 🔴 삭제 액션일 때 라벨 색상 자동 설정
useEffect(() => {
if (selectedComponent && selectedComponent.type === "component") {
// 삭제 액션 감지 로직 (실제 필드명 사용)
const isDeleteAction = () => {
const deleteKeywords = ['삭제', 'delete', 'remove', '제거', 'del'];
return (
selectedComponent.componentConfig?.action?.type === 'delete' ||
selectedComponent.config?.action?.type === 'delete' ||
selectedComponent.webTypeConfig?.actionType === 'delete' ||
selectedComponent.text?.toLowerCase().includes('삭제') ||
selectedComponent.text?.toLowerCase().includes('delete') ||
selectedComponent.label?.toLowerCase().includes('삭제') ||
selectedComponent.label?.toLowerCase().includes('delete') ||
deleteKeywords.some(keyword =>
selectedComponent.config?.buttonText?.toLowerCase().includes(keyword) ||
selectedComponent.config?.text?.toLowerCase().includes(keyword)
)
);
};
// 🔍 디버깅: 컴포넌트 구조 확인
console.log("🔍 PropertiesPanel 삭제 액션 디버깅:", {
componentType: selectedComponent.type,
componentId: selectedComponent.id,
componentConfig: selectedComponent.componentConfig,
config: selectedComponent.config,
webTypeConfig: selectedComponent.webTypeConfig,
actionType1: selectedComponent.componentConfig?.action?.type,
actionType2: selectedComponent.config?.action?.type,
actionType3: selectedComponent.webTypeConfig?.actionType,
isDeleteAction: isDeleteAction(),
currentLabelColor: selectedComponent.style?.labelColor,
});
// 액션에 따른 라벨 색상 자동 설정
if (isDeleteAction()) {
// 삭제 액션일 때 빨간색으로 설정 (이미 빨간색이 아닌 경우에만)
if (selectedComponent.style?.labelColor !== '#ef4444') {
console.log("🔴 삭제 액션 감지: 라벨 색상을 빨간색으로 자동 설정");
onUpdateProperty("style", {
...selectedComponent.style,
labelColor: '#ef4444'
});
// 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({
...prev,
labelColor: '#ef4444'
}));
}
} else {
// 다른 액션일 때 기본 파란색으로 리셋 (현재 빨간색인 경우에만)
if (selectedComponent.style?.labelColor === '#ef4444') {
console.log("🔵 일반 액션 감지: 라벨 색상을 기본 파란색으로 리셋");
onUpdateProperty("style", {
...selectedComponent.style,
labelColor: '#3b83f6'
});
// 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({
...prev,
labelColor: '#3b83f6'
}));
}
}
}
}, [
selectedComponent?.componentConfig?.action?.type,
selectedComponent?.config?.action?.type,
selectedComponent?.webTypeConfig?.actionType,
selectedComponent?.id,
selectedComponent?.style?.labelColor, // 라벨 색상 변경도 감지
JSON.stringify(selectedComponent?.componentConfig), // 전체 componentConfig 변경 감지
onUpdateProperty
]);
// 렌더링 시마다 실행되는 직접적인 드래그 상태 체크 // 렌더링 시마다 실행되는 직접적인 드래그 상태 체크
if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) { if (dragState?.isDragging && dragState.draggedComponent?.id === selectedComponent?.id) {
console.log("🎯 렌더링 중 드래그 상태 감지:", { console.log("🎯 렌더링 중 드래그 상태 감지:", {

View File

@ -51,7 +51,7 @@ const TokenManager = {
// Axios 인스턴스 생성 // Axios 인스턴스 생성
export const apiClient = axios.create({ export const apiClient = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
timeout: 10000, timeout: 30000, // 30초로 증가 (다중 커넥션 처리 시간 고려)
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },

View File

@ -0,0 +1,154 @@
/**
* API
*/
import { apiClient } from "./client";
export interface MultiConnectionTableInfo {
tableName: string;
displayName?: string;
columnCount: number;
connectionId: number;
connectionName: string;
dbType: string;
}
export interface ColumnInfo {
columnName: string;
displayName: string;
dataType: string;
dbType: string;
webType: string;
isNullable: boolean;
isPrimaryKey: boolean;
defaultValue?: string;
maxLength?: number;
description?: string;
}
export interface ConnectionInfo {
id: number;
connection_name: string;
description?: string;
db_type: string;
host: string;
port: number;
database_name: string;
username: string;
is_active: string;
company_code: string;
created_date: Date;
updated_date: Date;
}
export interface ValidationResult {
isValid: boolean;
error?: string;
warnings?: string[];
}
/**
* ( DB )
*/
export const getActiveConnections = async (): Promise<ConnectionInfo[]> => {
const response = await apiClient.get("/external-db-connections/control/active");
return response.data.data || [];
};
/**
*
*/
export const getTablesFromConnection = async (connectionId: number): Promise<MultiConnectionTableInfo[]> => {
const response = await apiClient.get(`/multi-connection/connections/${connectionId}/tables`);
return response.data.data || [];
};
/**
*
*/
export const getColumnsFromConnection = async (connectionId: number, tableName: string): Promise<ColumnInfo[]> => {
const response = await apiClient.get(`/multi-connection/connections/${connectionId}/tables/${tableName}/columns`);
return response.data.data || [];
};
/**
*
*/
export const queryDataFromConnection = async (
connectionId: number,
tableName: string,
conditions?: Record<string, any>,
): Promise<Record<string, any>[]> => {
const response = await apiClient.post(`/multi-connection/connections/${connectionId}/query`, {
tableName,
conditions,
});
return response.data.data || [];
};
/**
*
*/
export const insertDataToConnection = async (
connectionId: number,
tableName: string,
data: Record<string, any>,
): Promise<any> => {
const response = await apiClient.post(`/multi-connection/connections/${connectionId}/insert`, {
tableName,
data,
});
return response.data.data || {};
};
/**
*
*/
export const updateDataInConnection = async (
connectionId: number,
tableName: string,
data: Record<string, any>,
conditions: Record<string, any>,
): Promise<any> => {
const response = await apiClient.put(`/multi-connection/connections/${connectionId}/update`, {
tableName,
data,
conditions,
});
return response.data.data || {};
};
/**
*
*/
export const deleteDataFromConnection = async (
connectionId: number,
tableName: string,
conditions: Record<string, any>,
maxDeleteCount?: number,
): Promise<any> => {
const response = await apiClient.delete(`/multi-connection/connections/${connectionId}/delete`, {
data: {
tableName,
conditions,
maxDeleteCount,
},
});
return response.data.data || {};
};
/**
*
*/
export const validateSelfTableOperation = async (
tableName: string,
operation: "update" | "delete",
conditions: any[],
): Promise<ValidationResult> => {
const response = await apiClient.post("/multi-connection/validate-self-operation", {
tableName,
operation,
conditions,
});
return response.data.data || {};
};

View File

@ -56,6 +56,9 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
const totalRequests = useRef(0); const totalRequests = useRef(0);
const cacheHits = useRef(0); const cacheHits = useRef(0);
const batchLoadCount = useRef(0); const batchLoadCount = useRef(0);
// 변환된 값 캐시 (중복 변환 방지)
const convertedCache = useRef(new Map<string, string>());
// 공통 코드 카테고리 추출 (메모이제이션) // 공통 코드 카테고리 추출 (메모이제이션)
const codeCategories = useMemo(() => { const codeCategories = useMemo(() => {
@ -175,29 +178,41 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
const startTime = Date.now(); const startTime = Date.now();
totalRequests.current += 1; totalRequests.current += 1;
// 🎯 디버깅: 캐시 상태 로깅 // 🎯 중복 호출 방지: 이미 변환된 값인지 확인
console.log(`🔍 optimizedConvertCode 호출: categoryCode="${categoryCode}", codeValue="${codeValue}"`); const cacheKey = `${categoryCode}:${codeValue}`;
if (convertedCache.current.has(cacheKey)) {
return convertedCache.current.get(cacheKey)!;
}
// 🎯 디버깅: 캐시 상태 로깅 (빈도 줄이기)
if (totalRequests.current % 10 === 1) { // 10번마다 한 번만 로깅
console.log(`🔍 optimizedConvertCode 호출: categoryCode="${categoryCode}", codeValue="${codeValue}"`);
}
// 캐시에서 동기적으로 조회 시도 // 캐시에서 동기적으로 조회 시도
const syncResult = codeCache.getCodeSync(categoryCode); const syncResult = codeCache.getCodeSync(categoryCode);
console.log(`🔍 getCodeSync("${categoryCode}") 결과:`, syncResult); if (totalRequests.current % 10 === 1) {
console.log(`🔍 getCodeSync("${categoryCode}") 결과:`, syncResult);
}
// 🎯 캐시 내용 상세 로깅 (키값들 확인) // 🎯 캐시 내용 상세 로깅 (키값들 확인) - 빈도 줄이기
if (syncResult) { if (syncResult && totalRequests.current % 10 === 1) {
console.log(`🔍 캐시 키값들:`, Object.keys(syncResult)); console.log(`🔍 캐시 키값들:`, Object.keys(syncResult));
console.log(`🔍 캐시 전체 데이터:`, JSON.stringify(syncResult, null, 2)); console.log(`🔍 캐시 전체 데이터:`, JSON.stringify(syncResult, null, 2));
} }
if (syncResult && Array.isArray(syncResult)) { if (syncResult && Array.isArray(syncResult)) {
cacheHits.current += 1; cacheHits.current += 1;
console.log(`🔍 배열에서 코드 검색: codeValue="${codeValue}"`); if (totalRequests.current % 10 === 1) {
console.log( console.log(`🔍 배열에서 코드 검색: codeValue="${codeValue}"`);
`🔍 캐시 배열 내용:`, console.log(
syncResult.map((item) => ({ `🔍 캐시 배열 내용:`,
code_value: item.code_value, syncResult.map((item) => ({
code_name: item.code_name, code_value: item.code_value,
})), code_name: item.code_name,
); })),
);
}
// 배열에서 해당 code_value를 가진 항목 찾기 // 배열에서 해당 code_value를 가진 항목 찾기
const foundCode = syncResult.find( const foundCode = syncResult.find(
@ -205,7 +220,13 @@ export function useEntityJoinOptimization(columnMeta: Record<string, ColumnMetaI
); );
const result = foundCode ? foundCode.code_name : codeValue; const result = foundCode ? foundCode.code_name : codeValue;
console.log(`🔍 최종 결과: "${codeValue}" → "${result}"`, { foundCode });
// 변환 결과를 캐시에 저장
convertedCache.current.set(cacheKey, result);
if (totalRequests.current % 10 === 1) {
console.log(`🔍 최종 결과: "${codeValue}" → "${result}"`, { foundCode });
}
// 응답 시간 추적 (캐시 히트) // 응답 시간 추적 (캐시 히트)
requestTimes.current.push(Date.now() - startTime); requestTimes.current.push(Date.now() - startTime);

View File

@ -217,7 +217,7 @@ export class AutoRegisteringComponentRenderer {
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
marginBottom: component.style?.labelMarginBottom || "4px", marginBottom: component.style?.labelMarginBottom || "4px",
fontWeight: "500", fontWeight: "500",
}; };

View File

@ -34,6 +34,8 @@ export interface ComponentRenderer {
refreshKey?: number; refreshKey?: number;
// 편집 모드 // 편집 모드
mode?: "view" | "edit"; mode?: "view" | "edit";
// 설정 변경 핸들러 (상세설정과 연동)
onConfigChange?: (config: any) => void;
[key: string]: any; [key: string]: any;
}): React.ReactElement; }): React.ReactElement;
} }
@ -170,6 +172,7 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
selectedRowsData, selectedRowsData,
onSelectedRowsChange, onSelectedRowsChange,
refreshKey, refreshKey,
onConfigChange,
...safeProps ...safeProps
} = props; } = props;
@ -224,6 +227,8 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
selectedRows={selectedRows} selectedRows={selectedRows}
selectedRowsData={selectedRowsData} selectedRowsData={selectedRowsData}
onSelectedRowsChange={onSelectedRowsChange} onSelectedRowsChange={onSelectedRowsChange}
// 설정 변경 핸들러 전달
onConfigChange={onConfigChange}
refreshKey={refreshKey} refreshKey={refreshKey}
/> />
); );

View File

@ -661,7 +661,7 @@ export const AccordionBasicComponent: React.FC<AccordionBasicComponentProps> = (
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -86,12 +86,69 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}; };
}, []); }, []);
// 삭제 액션 감지 로직 (실제 필드명 사용)
const isDeleteAction = () => {
const deleteKeywords = ['삭제', 'delete', 'remove', '제거', 'del'];
return (
component.componentConfig?.action?.type === 'delete' ||
component.config?.action?.type === 'delete' ||
component.webTypeConfig?.actionType === 'delete' ||
component.text?.toLowerCase().includes('삭제') ||
component.text?.toLowerCase().includes('delete') ||
component.label?.toLowerCase().includes('삭제') ||
component.label?.toLowerCase().includes('delete') ||
deleteKeywords.some(keyword =>
component.config?.buttonText?.toLowerCase().includes(keyword) ||
component.config?.text?.toLowerCase().includes(keyword)
)
);
};
// 삭제 액션일 때 라벨 색상 자동 설정
useEffect(() => {
if (isDeleteAction() && !component.style?.labelColor) {
// 삭제 액션이고 라벨 색상이 설정되지 않은 경우 빨간색으로 자동 설정
if (component.style) {
component.style.labelColor = '#ef4444';
} else {
component.style = { labelColor: '#ef4444' };
}
}
}, [component.componentConfig?.action?.type, component.config?.action?.type, component.webTypeConfig?.actionType]);
// 컴포넌트 설정 // 컴포넌트 설정
const componentConfig = { const componentConfig = {
...config, ...config,
...component.config, ...component.config,
} as ButtonPrimaryConfig; } as ButtonPrimaryConfig;
// 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
const getLabelColor = () => {
if (isDeleteAction()) {
return component.style?.labelColor || '#ef4444'; // 빨간색 기본값 (Tailwind red-500)
}
return component.style?.labelColor || '#3b83f6'; // 기본 파란색 (Tailwind blue-500)
};
const buttonColor = getLabelColor();
// 그라데이션용 어두운 색상 계산
const getDarkColor = (baseColor: string) => {
const hex = baseColor.replace('#', '');
const r = Math.max(0, parseInt(hex.substr(0, 2), 16) - 40);
const g = Math.max(0, parseInt(hex.substr(2, 2), 16) - 40);
const b = Math.max(0, parseInt(hex.substr(4, 2), 16) - 40);
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
};
const buttonDarkColor = getDarkColor(buttonColor);
console.log("🎨 동적 색상 연동:", {
labelColor: component.style?.labelColor,
buttonColor,
buttonDarkColor,
});
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환 // 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
const processedConfig = { ...componentConfig }; const processedConfig = { ...componentConfig };
if (componentConfig.action && typeof componentConfig.action === "string") { if (componentConfig.action && typeof componentConfig.action === "string") {
@ -368,26 +425,29 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
style={{ style={{
width: "100%", width: "100%",
height: "100%", height: "100%",
minHeight: "100%", // 최소 높이 강제 적용 minHeight: "100%",
maxHeight: "100%", // 최대 높이 제한 maxHeight: "100%",
border: "1px solid #3b82f6", border: "none",
borderRadius: "4px", borderRadius: "8px",
backgroundColor: "#3b82f6", background: componentConfig.disabled
color: "white", ? "linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%)"
: `linear-gradient(135deg, ${buttonColor} 0%, ${buttonDarkColor} 100%)`,
color: componentConfig.disabled ? "#9ca3af" : "white",
fontSize: "14px", fontSize: "14px",
fontWeight: "500", fontWeight: "600",
cursor: componentConfig.disabled ? "not-allowed" : "pointer", cursor: componentConfig.disabled ? "not-allowed" : "pointer",
outline: "none", outline: "none",
boxSizing: "border-box", // 패딩/보더 포함 크기 계산 boxSizing: "border-box",
display: "flex", // flex로 변경 display: "flex",
alignItems: "center", // 세로 중앙 정렬 alignItems: "center",
justifyContent: "center", // 가로 중앙 정렬 justifyContent: "center",
padding: "0", // 패딩 제거 padding: "0 16px",
margin: "0", // 마진 제거 margin: "0",
lineHeight: "1", // 라인 높이 고정 lineHeight: "1",
// 강제 높이 적용
minHeight: "36px", minHeight: "36px",
height: "36px", boxShadow: componentConfig.disabled
? "0 1px 2px 0 rgba(0, 0, 0, 0.05)"
: `0 2px 4px 0 ${buttonColor}33`, // 33은 20% 투명도
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
}} }}

View File

@ -84,7 +84,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
@ -141,7 +141,7 @@ export const CheckboxBasicComponent: React.FC<CheckboxBasicComponentProps> = ({
/> />
<span <span
style={{ style={{
color: "#374151", color: "#3b83f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
}} }}

View File

@ -298,7 +298,7 @@ export const DateInputComponent: React.FC<DateInputComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -81,7 +81,7 @@ export const DividerLineComponent: React.FC<DividerLineComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -84,7 +84,7 @@ export const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),

View File

@ -81,7 +81,7 @@ export const ImageDisplayComponent: React.FC<ImageDisplayComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -91,7 +91,7 @@ export const NumberInputComponent: React.FC<NumberInputComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -84,7 +84,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
@ -155,7 +155,7 @@ export const RadioBasicComponent: React.FC<RadioBasicComponentProps> = ({
/> />
<span <span
style={{ style={{
color: "#374151", color: "#3b83f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
}} }}

View File

@ -601,7 +601,7 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),

View File

@ -84,7 +84,7 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
@ -149,7 +149,7 @@ export const SliderBasicComponent: React.FC<SliderBasicComponentProps> = ({
width: "30%", width: "30%",
textAlign: "center", textAlign: "center",
fontSize: "14px", fontSize: "14px",
color: "#374151", color: "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),

View File

@ -22,6 +22,7 @@ interface SingleTableWithStickyProps {
renderCheckboxCell: (row: any, index: number) => React.ReactNode; renderCheckboxCell: (row: any, index: number) => React.ReactNode;
formatCellValue: (value: any, format?: string, columnName?: string) => string; formatCellValue: (value: any, format?: string, columnName?: string) => string;
getColumnWidth: (column: ColumnConfig) => number; getColumnWidth: (column: ColumnConfig) => number;
containerWidth?: string; // 컨테이너 너비 설정
} }
export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
@ -39,12 +40,28 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
renderCheckboxCell, renderCheckboxCell,
formatCellValue, formatCellValue,
getColumnWidth, getColumnWidth,
containerWidth,
}) => { }) => {
const checkboxConfig = tableConfig.checkbox || {}; const checkboxConfig = tableConfig.checkbox || {};
return ( return (
<div className="relative h-full w-full overflow-auto"> <div
<Table className="w-full"> className="relative h-full overflow-auto"
style={{
width: "100%",
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<Table
className="w-full"
style={{
width: "100%",
maxWidth: "100%",
tableLayout: "fixed",
boxSizing: "border-box",
}}
>
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}> <TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-20 bg-white" : ""}>
<TableRow> <TableRow>
{visibleColumns.map((column, colIndex) => { {visibleColumns.map((column, colIndex) => {
@ -81,6 +98,9 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
width: getColumnWidth(column), width: getColumnWidth(column),
minWidth: getColumnWidth(column), minWidth: getColumnWidth(column),
maxWidth: getColumnWidth(column), maxWidth: getColumnWidth(column),
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
// sticky 위치 설정 // sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),
@ -90,7 +110,7 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{column.columnName === "__checkbox__" ? ( {column.columnName === "__checkbox__" ? (
checkboxConfig.selectAll && ( checkboxConfig.selectAll && (
<Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" /> <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" style={{ zIndex: 1 }} />
) )
) : ( ) : (
<> <>
@ -167,6 +187,11 @@ export const SingleTableWithSticky: React.FC<SingleTableWithStickyProps> = ({
minHeight: "40px", minHeight: "40px",
height: "40px", height: "40px",
verticalAlign: "middle", verticalAlign: "middle",
width: getColumnWidth(column),
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
// sticky 위치 설정 // sticky 위치 설정
...(column.fixed === "left" && { left: leftFixedWidth }), ...(column.fixed === "left" && { left: leftFixedWidth }),
...(column.fixed === "right" && { right: rightFixedWidth }), ...(column.fixed === "right" && { right: rightFixedWidth }),

View File

@ -2,6 +2,7 @@
import React, { useState, useEffect, useMemo } from "react"; import React, { useState, useEffect, useMemo } from "react";
import { TableListConfig, ColumnConfig } from "./types"; import { TableListConfig, ColumnConfig } from "./types";
import { WebType } from "@/types/common";
import { tableTypeApi } from "@/lib/api/screen"; import { tableTypeApi } from "@/lib/api/screen";
import { entityJoinApi } from "@/lib/api/entityJoin"; import { entityJoinApi } from "@/lib/api/entityJoin";
import { codeCache } from "@/lib/caching/codeCache"; import { codeCache } from "@/lib/caching/codeCache";
@ -22,7 +23,6 @@ import {
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters"; import { AdvancedSearchFilters } from "@/components/screen/filters/AdvancedSearchFilters";
import { Separator } from "@/components/ui/separator";
import { SingleTableWithSticky } from "./SingleTableWithSticky"; import { SingleTableWithSticky } from "./SingleTableWithSticky";
export interface TableListComponentProps { export interface TableListComponentProps {
@ -54,6 +54,9 @@ export interface TableListComponentProps {
// 선택된 행 정보 전달 핸들러 // 선택된 행 정보 전달 핸들러
onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void; onSelectedRowsChange?: (selectedRows: any[], selectedRowsData: any[]) => void;
// 설정 변경 핸들러 (상세설정과 연동)
onConfigChange?: (config: any) => void;
// 테이블 새로고침 키 // 테이블 새로고침 키
refreshKey?: number; refreshKey?: number;
} }
@ -75,6 +78,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
onFormDataChange, onFormDataChange,
componentConfig, componentConfig,
onSelectedRowsChange, onSelectedRowsChange,
onConfigChange,
refreshKey, refreshKey,
}) => { }) => {
// 컴포넌트 설정 // 컴포넌트 설정
@ -84,11 +88,16 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
...componentConfig, ...componentConfig,
} as TableListConfig; } as TableListConfig;
// 🎯 디버깅: 초기 컬럼 설정 확인 // 🎨 동적 색상 설정 (속성편집 모달의 "색상" 필드와 연동)
console.log( const buttonColor = component.style?.labelColor || '#3b83f6'; // 기본 파란색
"🔍 초기 tableConfig.columns:", const buttonTextColor = component.config?.buttonTextColor || '#ffffff';
tableConfig.columns?.map((c) => c.columnName), const buttonStyle = {
); backgroundColor: buttonColor,
color: buttonTextColor,
borderColor: buttonColor
};
// 디버깅 로그 제거 (성능상 이유로)
// 상태 관리 // 상태 관리
const [data, setData] = useState<Record<string, any>[]>([]); const [data, setData] = useState<Record<string, any>[]>([]);
@ -113,7 +122,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [searchValues, setSearchValues] = useState<Record<string, any>>({}); const [searchValues, setSearchValues] = useState<Record<string, any>>({});
// 체크박스 상태 관리 // 체크박스 상태 관리
const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set()); // 선택된 행들의 키 집합 const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set());
// 드래그 상태 관리
const [isDragging, setIsDragging] = useState(false);
const [draggedRowIndex, setDraggedRowIndex] = useState<number | null>(null); // 선택된 행들의 키 집합
const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태 const [isAllSelected, setIsAllSelected] = useState(false); // 전체 선택 상태
// 🎯 Entity 조인 최적화 훅 사용 // 🎯 Entity 조인 최적화 훅 사용
@ -125,10 +138,9 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 높이 계산 함수 (메모이제이션) // 높이 계산 함수 (메모이제이션)
const optimalHeight = useMemo(() => { const optimalHeight = useMemo(() => {
// 50개 이상일 때는 20개 기준으로 높이 고정하고 스크롤 생성 // 실제 데이터 개수에 맞춰서 높이 계산 (최소 5개, 최대 20개)
// 50개 미만일 때는 실제 데이터 개수에 맞춰서 스크롤 없이 표시 const actualDataCount = data.length;
const actualDataCount = Math.min(data.length, localPageSize); const displayPageSize = Math.min(Math.max(actualDataCount, 5), 20);
const displayPageSize = localPageSize >= 50 ? 20 : Math.max(actualDataCount, 5);
const headerHeight = 50; // 테이블 헤더 const headerHeight = 50; // 테이블 헤더
const rowHeight = 42; // 각 행 높이 const rowHeight = 42; // 각 행 높이
@ -144,7 +156,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
actualDataCount, actualDataCount,
localPageSize, localPageSize,
displayPageSize, displayPageSize,
willHaveScroll: localPageSize >= 50, isDesignMode,
titleHeight, titleHeight,
searchHeight, searchHeight,
headerHeight, headerHeight,
@ -165,25 +177,70 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
}); });
return calculatedHeight; return calculatedHeight;
}, [data.length, localPageSize, tableConfig.filter?.enabled, tableConfig.showFooter, tableConfig.showHeader]); }, []);
// 스타일 계산 // 🎯 강제로 그리드 컬럼수에 맞는 크기 적용 (디자인 모드에서는 더 큰 크기 허용)
const gridColumns = component.gridColumns || 1;
let calculatedWidth: string;
if (isDesignMode) {
// 디자인 모드에서는 더 큰 최소 크기 적용
if (gridColumns === 1) {
calculatedWidth = "400px"; // 1컬럼일 때 400px (디자인 모드)
} else if (gridColumns === 2) {
calculatedWidth = "600px"; // 2컬럼일 때 600px (디자인 모드)
} else if (gridColumns <= 6) {
calculatedWidth = `${gridColumns * 250}px`; // 컬럼당 250px (디자인 모드)
} else {
calculatedWidth = "100%"; // 7컬럼 이상은 전체
}
} else {
// 일반 모드는 기존 크기 유지
if (gridColumns === 1) {
calculatedWidth = "200px"; // 1컬럼일 때 200px 고정
} else if (gridColumns === 2) {
calculatedWidth = "400px"; // 2컬럼일 때 400px
} else if (gridColumns <= 6) {
calculatedWidth = `${gridColumns * 200}px`; // 컬럼당 200px
} else {
calculatedWidth = "100%"; // 7컬럼 이상은 전체
}
}
// 디버깅 로그 제거 (성능상 이유로)
// 스타일 계산 (컨테이너에 맞춤)
const componentStyle: React.CSSProperties = { const componentStyle: React.CSSProperties = {
width: "100%", width: "100%", // 컨테이너 전체 너비 사용
height: `${optimalHeight}px`, // 20개 데이터를 모두 보여주는 높이 maxWidth: "100%", // 최대 너비 제한
minHeight: `${optimalHeight}px`, // 최소 높이 보장 height: "auto", // 항상 자동 높이로 테이블 크기에 맞춤
minHeight: isDesignMode ? `${Math.min(optimalHeight, 400)}px` : `${optimalHeight}px`, // 최소 높이 보장
...component.style, ...component.style,
...style, ...style,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
boxSizing: "border-box", // 패딩/보더 포함한 크기 계산 boxSizing: "border-box", // 패딩/보더 포함한 크기 계산
// overflow는 CSS 클래스로 처리
}; };
// 🎯 tableContainerStyle 제거 - componentStyle만 사용
// 디자인 모드 스타일 // 디자인 모드 스타일
if (isDesignMode) { if (isDesignMode) {
componentStyle.border = "1px dashed #cbd5e1"; componentStyle.border = "2px dashed #cbd5e1";
componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1"; componentStyle.borderColor = isSelected ? "#3b82f6" : "#cbd5e1";
// minHeight 제거 - 실제 데이터에 맞는 높이 사용 componentStyle.borderRadius = "8px";
componentStyle.padding = "4px"; // 약간의 패딩으로 구분감 확보
componentStyle.margin = "2px"; // 외부 여백으로 레이아웃과 구분
// 🎯 컨테이너에 맞춤
componentStyle.width = "calc(100% - 12px)"; // margin + padding 보정
componentStyle.maxWidth = "calc(100% - 12px)";
componentStyle.minWidth = "calc(100% - 12px)";
componentStyle.overflow = "hidden !important"; // 넘치는 부분 숨김 (강제)
componentStyle.boxSizing = "border-box"; // 패딩 포함 크기 계산
componentStyle.position = "relative"; // 위치 고정
// 자동 높이로 테이블 전체를 감쌈
} }
// 컬럼 라벨 정보 가져오기 // 컬럼 라벨 정보 가져오기
@ -311,7 +368,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
console.log(`🔍 조인 탭 컬럼 처리: ${col.columnName} -> ${sourceTable}.${sourceColumn}`); console.log(`🔍 조인 탭 컬럼 처리: ${col.columnName} -> ${sourceTable}.${sourceColumn}`);
return { return {
sourceTable: sourceTable, sourceTable: sourceTable || tableConfig.selectedTable || "",
sourceColumn: sourceColumn, sourceColumn: sourceColumn,
joinAlias: col.columnName, joinAlias: col.columnName,
}; };
@ -601,6 +658,20 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// 페이지 변경 // 페이지 변경
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
setCurrentPage(newPage); setCurrentPage(newPage);
// 상세설정에 현재 페이지 정보 알림 (필요한 경우)
if (onConfigChange && tableConfig.pagination) {
console.log("📤 테이블에서 페이지 변경을 상세설정에 알림:", newPage);
onConfigChange({
...tableConfig,
pagination: {
...tableConfig.pagination,
currentPage: newPage, // 현재 페이지 정보 추가
},
});
} else if (!onConfigChange) {
console.warn("⚠️ onConfigChange가 정의되지 않음 - 페이지 변경 상세설정과 연동 불가");
}
}; };
// 정렬 변경 // 정렬 변경
@ -764,6 +835,22 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
}, [refreshKey]); }, [refreshKey]);
// 상세설정에서 페이지네이션 설정 변경 시 로컬 상태 동기화
useEffect(() => {
// 페이지 크기 동기화
if (tableConfig.pagination?.pageSize && tableConfig.pagination.pageSize !== localPageSize) {
console.log("🔄 상세설정에서 페이지 크기 변경 감지:", tableConfig.pagination.pageSize);
setLocalPageSize(tableConfig.pagination.pageSize);
setCurrentPage(1); // 페이지를 1로 리셋
}
// 현재 페이지 동기화 (상세설정에서 페이지를 직접 변경한 경우)
if (tableConfig.pagination?.currentPage && tableConfig.pagination.currentPage !== currentPage) {
console.log("🔄 상세설정에서 현재 페이지 변경 감지:", tableConfig.pagination.currentPage);
setCurrentPage(tableConfig.pagination.currentPage);
}
}, [tableConfig.pagination?.pageSize, tableConfig.pagination?.currentPage]);
// 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가 + 숨김 기능) // 표시할 컬럼 계산 (Entity 조인 적용됨 + 체크박스 컬럼 추가 + 숨김 기능)
const visibleColumns = useMemo(() => { const visibleColumns = useMemo(() => {
// 기본값 처리: checkbox 설정이 없으면 기본값 사용 // 기본값 처리: checkbox 설정이 없으면 기본값 사용
@ -778,7 +865,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
// displayColumns가 있으면 우선 사용 (Entity 조인 적용된 컬럼들) // displayColumns가 있으면 우선 사용 (Entity 조인 적용된 컬럼들)
if (displayColumns && displayColumns.length > 0) { if (displayColumns && displayColumns.length > 0) {
console.log("🎯 displayColumns 사용:", displayColumns); // 디버깅 로그 제거 (성능상 이유로)
const filteredColumns = displayColumns.filter((col) => { const filteredColumns = displayColumns.filter((col) => {
// 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김 // 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김
if (isDesignMode) { if (isDesignMode) {
@ -787,11 +874,11 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만 return col.visible && !col.hidden; // 실제 화면에서는 visible이면서 hidden이 아닌 것만
} }
}); });
console.log("🎯 필터링된 컬럼:", filteredColumns); // 디버깅 로그 제거 (성능상 이유로)
columns = filteredColumns.sort((a, b) => a.order - b.order); columns = filteredColumns.sort((a, b) => a.order - b.order);
} else if (tableConfig.columns && tableConfig.columns.length > 0) { } else if (tableConfig.columns && tableConfig.columns.length > 0) {
// displayColumns가 없으면 기본 컬럼 사용 // displayColumns가 없으면 기본 컬럼 사용
console.log("🎯 tableConfig.columns 사용:", tableConfig.columns); // 디버깅 로그 제거 (성능상 이유로)
columns = tableConfig.columns columns = tableConfig.columns
.filter((col) => { .filter((col) => {
// 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김 // 디자인 모드에서는 숨김 컬럼도 표시 (연하게), 실제 화면에서는 완전히 숨김
@ -830,12 +917,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
} }
console.log("🎯 최종 visibleColumns:", columns); // 디버깅 로그 제거 (성능상 이유로)
console.log("🎯 visibleColumns 개수:", columns.length);
console.log(
"🎯 visibleColumns 컬럼명들:",
columns.map((c) => c.columnName),
);
return columns; return columns;
}, [displayColumns, tableConfig.columns, tableConfig.checkbox, isDesignMode]); }, [displayColumns, tableConfig.columns, tableConfig.checkbox, isDesignMode]);
@ -904,7 +986,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return null; return null;
} }
return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" />; return <Checkbox checked={isAllSelected} onCheckedChange={handleSelectAll} aria-label="전체 선택" style={{ zIndex: 1 }} />;
}; };
// 체크박스 셀 렌더링 // 체크박스 셀 렌더링
@ -929,6 +1011,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
checked={isSelected} checked={isSelected}
onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)} onCheckedChange={(checked) => handleRowSelection(rowKey, checked as boolean)}
aria-label={`${index + 1} 선택`} aria-label={`${index + 1} 선택`}
style={{ zIndex: 1 }}
/> />
); );
}; };
@ -938,30 +1021,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
return (value: any, format?: string, columnName?: string) => { return (value: any, format?: string, columnName?: string) => {
if (value === null || value === undefined) return ""; if (value === null || value === undefined) return "";
// 디버깅: 모든 값 변환 시도를 로깅 // 디버깅 로그 제거 (성능상 이유로)
if (
columnName &&
(columnName === "contract_type" || columnName === "domestic_foreign" || columnName === "status")
) {
console.log(`🔍 값 변환 시도: ${columnName}="${value}"`, {
columnMeta: columnMeta[columnName],
hasColumnMeta: !!columnMeta[columnName],
webType: columnMeta[columnName]?.webType,
codeCategory: columnMeta[columnName]?.codeCategory,
globalColumnMeta: Object.keys(columnMeta),
});
}
// 🎯 코드 컬럼인 경우 최적화된 코드명 변환 사용 // 🎯 코드 컬럼인 경우 최적화된 코드명 변환 사용
if (columnName && columnMeta[columnName]?.webType === "code" && columnMeta[columnName]?.codeCategory) { if (columnName && columnMeta[columnName]?.webType === "code" && columnMeta[columnName]?.codeCategory) {
const categoryCode = columnMeta[columnName].codeCategory!; const categoryCode = columnMeta[columnName].codeCategory!;
const convertedValue = optimizedConvertCode(categoryCode, String(value)); const convertedValue = optimizedConvertCode(categoryCode, String(value));
if (convertedValue !== String(value)) { // 코드 변환 로그 제거 (성능상 이유로)
console.log(`🔄 코드 변환 성공: ${columnName}[${categoryCode}] ${value}${convertedValue}`);
} else {
console.log(`⚠️ 코드 변환 실패: ${columnName}[${categoryCode}] ${value}${convertedValue} (값 동일)`);
}
value = convertedValue; value = convertedValue;
} }
@ -994,6 +1061,82 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
}; };
// 드래그 핸들러 (그리드 스냅 지원)
const handleRowDragStart = (e: React.DragEvent, row: any, index: number) => {
setIsDragging(true);
setDraggedRowIndex(index);
// 드래그 데이터에 그리드 정보 포함
const dragData = {
...row,
_dragType: 'table-row',
_gridSize: { width: 4, height: 1 }, // 기본 그리드 크기 (4칸 너비, 1칸 높이)
_snapToGrid: true
};
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
e.dataTransfer.effectAllowed = 'copy'; // move 대신 copy로 변경
// 드래그 이미지를 더 깔끔하게
const dragElement = e.currentTarget as HTMLElement;
// 커스텀 드래그 이미지 생성 (저장 버튼과 어울리는 스타일)
const dragImage = document.createElement('div');
dragImage.style.position = 'absolute';
dragImage.style.top = '-1000px';
dragImage.style.left = '-1000px';
dragImage.style.background = 'linear-gradient(135deg, #3b82f6 0%, #1d4ed8 100%)';
dragImage.style.color = 'white';
dragImage.style.padding = '12px 16px';
dragImage.style.borderRadius = '8px';
dragImage.style.fontSize = '14px';
dragImage.style.fontWeight = '600';
dragImage.style.boxShadow = '0 4px 12px rgba(59, 130, 246, 0.4)';
dragImage.style.display = 'flex';
dragImage.style.alignItems = 'center';
dragImage.style.gap = '8px';
dragImage.style.minWidth = '200px';
dragImage.style.whiteSpace = 'nowrap';
// 아이콘과 텍스트 추가
const firstValue = Object.values(row)[0] || 'Row';
dragImage.innerHTML = `
<div style="
width: 20px;
height: 20px;
background: rgba(255,255,255,0.2);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
">📋</div>
<span>${firstValue}</span>
<div style="
background: rgba(255,255,255,0.2);
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
">4×1</div>
`;
document.body.appendChild(dragImage);
e.dataTransfer.setDragImage(dragImage, 20, 20);
// 정리
setTimeout(() => {
if (document.body.contains(dragImage)) {
document.body.removeChild(dragImage);
}
}, 0);
};
const handleRowDragEnd = (e: React.DragEvent) => {
setIsDragging(false);
setDraggedRowIndex(null);
};
// DOM에 전달할 수 있는 기본 props만 정의 // DOM에 전달할 수 있는 기본 props만 정의
const domProps = { const domProps = {
onClick: handleClick, onClick: handleClick,
@ -1005,11 +1148,15 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
if (isDesignMode && !tableConfig.selectedTable) { if (isDesignMode && !tableConfig.selectedTable) {
return ( return (
<div style={componentStyle} className={className} {...domProps}> <div style={componentStyle} className={className} {...domProps}>
<div className="flex h-full items-center justify-center rounded-lg border-2 border-dashed border-gray-300"> <div className="flex h-full items-center justify-center rounded-2xl border-2 border-dashed border-blue-200 bg-gradient-to-br from-blue-50/30 to-indigo-50/20">
<div className="text-center text-gray-500"> <div className="text-center p-8">
<TableIcon className="mx-auto mb-2 h-8 w-8" /> <div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center shadow-sm">
<div className="text-sm font-medium"> </div> <TableIcon className="h-8 w-8 text-blue-600" />
<div className="text-xs text-gray-400"> </div> </div>
<div className="text-lg font-semibold text-slate-700 mb-2"> </div>
<div className="text-sm text-slate-500 bg-white/60 px-4 py-2 rounded-full">
</div>
</div> </div>
</div> </div>
</div> </div>
@ -1017,56 +1164,64 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
} }
return ( return (
<div style={componentStyle} className={cn("rounded-lg border bg-white shadow-sm", className)} {...domProps}> <div
style={{...componentStyle, zIndex: 10}} // 🎯 componentStyle + z-index 추가
className={cn(
"rounded-lg bg-white border border-gray-200 shadow-md shadow-blue-100/50",
"overflow-hidden relative", // 🎯 항상 overflow-hidden 적용 + relative 추가
className
)}
{...domProps}
>
{/* 헤더 */} {/* 헤더 */}
{tableConfig.showHeader && ( {tableConfig.showHeader && (
<div className="flex items-center justify-between border-b p-4"> <div
className="flex items-center justify-between bg-gray-100/80 border-b border-gray-200 px-6 py-4"
style={{
width: "100%",
maxWidth: "100%",
boxSizing: "border-box"
}}
>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{(tableConfig.title || tableLabel) && ( {(tableConfig.title || tableLabel) && (
<h3 className="text-lg font-medium">{tableConfig.title || tableLabel}</h3> <h3 className="text-lg font-semibold text-gray-900">
{tableConfig.title || tableLabel}
</h3>
)} )}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-3">
{/* 선택된 항목 정보 표시 */} {/* 선택된 항목 정보 표시 */}
{selectedRows.size > 0 && ( {selectedRows.size > 0 && (
<div className="mr-4 flex items-center space-x-2"> <div className="flex items-center space-x-2 bg-blue-50 px-3 py-1 rounded-md">
<span className="text-sm text-gray-600">{selectedRows.size} </span> <span className="text-sm font-medium text-blue-700">{selectedRows.size} </span>
</div> </div>
)} )}
{/* 검색 - 기존 방식은 주석처리 */} {/* 새로고침 */}
{/* {tableConfig.filter?.enabled && tableConfig.filter?.quickSearch && ( <Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={loading}
style={buttonStyle}
className="group relative shadow-sm rounded-lg [&:hover]:opacity-90"
>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="relative"> <div className="relative">
<Search className="absolute top-1/2 left-2 h-4 w-4 -translate-y-1/2 transform text-gray-400" /> <RefreshCw className={cn(
<Input "h-4 w-4",
placeholder="검색..." loading && "animate-spin"
value={searchTerm} )} style={{ color: buttonTextColor }} />
onChange={(e) => handleSearch(e.target.value)} {loading && (
className="w-64 pl-8" <div className="absolute -inset-1 bg-blue-200/30 rounded-full animate-pulse"></div>
/> )}
</div> </div>
{tableConfig.filter?.showColumnSelector && ( <span className="text-sm font-medium" style={{ color: buttonTextColor }}>
<select {loading ? "새로고침 중..." : "새로고침"}
value={selectedSearchColumn} </span>
onChange={(e) => setSelectedSearchColumn(e.target.value)}
className="min-w-[120px] rounded border px-2 py-1 text-sm"
>
<option value=""> </option>
{visibleColumns.map((column) => (
<option key={column.columnName} value={column.columnName}>
{columnLabels[column.columnName] || column.displayName || column.columnName}
</option>
))}
</select>
)}
</div> </div>
)} */}
{/* 새로고침 */}
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button> </Button>
</div> </div>
</div> </div>
@ -1075,137 +1230,218 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
{/* 고급 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */} {/* 고급 검색 필터 - 항상 표시 (컬럼 정보 기반 자동 생성) */}
{tableConfig.filter?.enabled && visibleColumns && visibleColumns.length > 0 && ( {tableConfig.filter?.enabled && visibleColumns && visibleColumns.length > 0 && (
<> <>
<Separator className="my-1" /> <div className="h-px bg-gradient-to-r from-transparent via-slate-200 to-transparent"></div>
<AdvancedSearchFilters <div className="bg-white p-4">
filters={tableConfig.filter?.filters || []} // 설정된 필터 사용, 없으면 자동 생성 <AdvancedSearchFilters
searchValues={searchValues} filters={tableConfig.filter?.filters || []} // 설정된 필터 사용, 없으면 자동 생성
onSearchValueChange={handleSearchValueChange} searchValues={searchValues}
onSearch={handleAdvancedSearch} onSearchValueChange={handleSearchValueChange}
onClearFilters={handleClearAdvancedFilters} onSearch={handleAdvancedSearch}
onClearFilters={handleClearAdvancedFilters}
tableColumns={visibleColumns.map((col) => ({ tableColumns={visibleColumns.map((col) => ({
columnName: col.columnName, columnName: col.columnName,
webType: columnMeta[col.columnName]?.webType || "text", widgetType: (columnMeta[col.columnName]?.webType || "text") as WebType,
displayName: columnLabels[col.columnName] || col.displayName || col.columnName, displayName: columnLabels[col.columnName] || col.displayName || col.columnName,
codeCategory: columnMeta[col.columnName]?.codeCategory, codeCategory: columnMeta[col.columnName]?.codeCategory,
isVisible: col.visible, isVisible: col.visible,
// 추가 메타데이터 전달 (필터 자동 생성용) // 추가 메타데이터 전달 (필터 자동 생성용)
web_type: columnMeta[col.columnName]?.webType || "text", web_type: (columnMeta[col.columnName]?.webType || "text") as WebType,
column_name: col.columnName, column_name: col.columnName,
column_label: columnLabels[col.columnName] || col.displayName || col.columnName, column_label: columnLabels[col.columnName] || col.displayName || col.columnName,
code_category: columnMeta[col.columnName]?.codeCategory, code_category: columnMeta[col.columnName]?.codeCategory,
}))} }))}
tableName={tableConfig.selectedTable} tableName={tableConfig.selectedTable}
/> />
</div>
</> </>
)} )}
{/* 테이블 컨텐츠 */} {/* 테이블 컨텐츠 */}
<div className={`w-full ${localPageSize >= 50 ? "flex-1 overflow-auto" : ""}`}> <div
className={`w-full overflow-hidden ${localPageSize >= 50 ? "flex-1" : ""}`}
style={{
width: "100%",
maxWidth: "100%",
boxSizing: "border-box"
}}
>
{loading ? ( {loading ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center bg-gradient-to-br from-slate-50/50 to-blue-50/30">
<div className="text-center"> <div className="text-center p-8">
<RefreshCw className="mx-auto mb-2 h-8 w-8 animate-spin text-gray-400" /> <div className="relative">
<div className="text-sm text-gray-500"> ...</div> <div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-blue-100 to-indigo-100 rounded-2xl flex items-center justify-center">
<RefreshCw className="h-8 w-8 animate-spin text-blue-600" />
</div>
<div className="absolute -top-1 -right-1 w-4 h-4 bg-gradient-to-br from-blue-400 to-indigo-500 rounded-full animate-pulse"></div>
</div>
<div className="text-sm font-medium text-slate-700"> ...</div>
<div className="text-xs text-slate-500 mt-1"> </div>
</div> </div>
</div> </div>
) : error ? ( ) : error ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center bg-gradient-to-br from-red-50/50 to-orange-50/30">
<div className="text-center text-red-500"> <div className="text-center p-8">
<div className="text-sm"> </div> <div className="w-16 h-16 mx-auto mb-4 bg-gradient-to-br from-red-100 to-orange-100 rounded-2xl flex items-center justify-center">
<div className="mt-1 text-xs text-gray-400">{error}</div> <div className="w-8 h-8 bg-gradient-to-br from-red-500 to-orange-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">!</span>
</div>
</div>
<div className="text-sm font-medium text-red-700"> </div>
<div className="mt-1 text-xs text-red-500 bg-red-50 px-3 py-1 rounded-full">{error}</div>
</div> </div>
</div> </div>
) : needsHorizontalScroll ? ( ) : needsHorizontalScroll ? (
// 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용 // 가로 스크롤이 필요한 경우 - 단일 테이블에서 sticky 컬럼 사용
<SingleTableWithSticky <div className="w-full overflow-hidden">
visibleColumns={visibleColumns} <SingleTableWithSticky
data={data} visibleColumns={visibleColumns}
columnLabels={columnLabels} data={data}
sortColumn={sortColumn} columnLabels={columnLabels}
sortDirection={sortDirection} sortColumn={sortColumn}
tableConfig={tableConfig} sortDirection={sortDirection}
isDesignMode={isDesignMode} tableConfig={tableConfig}
isAllSelected={isAllSelected} isDesignMode={isDesignMode}
handleSort={handleSort} isAllSelected={isAllSelected}
handleSelectAll={handleSelectAll} handleSort={handleSort}
handleRowClick={handleRowClick} handleSelectAll={handleSelectAll}
renderCheckboxCell={renderCheckboxCell} handleRowClick={handleRowClick}
formatCellValue={formatCellValue} renderCheckboxCell={renderCheckboxCell}
getColumnWidth={getColumnWidth} formatCellValue={formatCellValue}
/> getColumnWidth={getColumnWidth}
containerWidth={calculatedWidth}
/>
</div>
) : ( ) : (
// 기존 테이블 (가로 스크롤이 필요 없는 경우) // 기존 테이블 (가로 스크롤이 필요 없는 경우)
<Table> <div className="w-full overflow-hidden">
<TableHeader className={tableConfig.stickyHeader ? "sticky top-0 z-10 bg-white" : ""}> <Table
<TableRow style={{ minHeight: "40px !important", height: "40px !important", lineHeight: "1" }}> className="w-full"
{visibleColumns.map((column) => ( style={{
<TableHead width: "100%",
key={column.columnName} maxWidth: "100%",
style={{ tableLayout: "fixed" // 테이블 크기 고정
width: column.width ? `${column.width}px` : undefined, }}
minHeight: "40px !important", >
height: "40px !important", <TableHeader className={cn(
verticalAlign: "middle", tableConfig.stickyHeader ? "sticky top-0 z-20" : "",
lineHeight: "1", "bg-gray-100/80 border-b border-gray-200"
boxSizing: "border-box", )}>
}} <TableRow
className={cn( style={{
column.columnName === "__checkbox__" minHeight: "48px !important",
? "h-10 text-center align-middle" height: "48px !important",
: "h-10 cursor-pointer align-middle whitespace-nowrap select-none", lineHeight: "1",
`text-${column.align}`, width: "100%",
column.sortable && "hover:bg-gray-50", maxWidth: "100%"
)} }}
onClick={() => column.sortable && handleSort(column.columnName)} className="border-none"
> >
{column.columnName === "__checkbox__" ? ( {visibleColumns.map((column, colIndex) => (
renderCheckboxHeader() <TableHead
) : ( key={column.columnName}
<div className="flex items-center space-x-1"> style={{
<span>{columnLabels[column.columnName] || column.displayName}</span> width: column.width ? `${column.width}px` : undefined,
{column.sortable && ( minHeight: "48px !important",
<div className="flex flex-col"> height: "48px !important",
{sortColumn === column.columnName ? ( verticalAlign: "middle",
sortDirection === "asc" ? ( lineHeight: "1",
<ArrowUp className="h-3 w-3" /> boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
}}
className={cn(
"h-12 align-middle px-4 py-3 text-sm font-semibold text-gray-800",
column.columnName === "__checkbox__"
? "text-center"
: "cursor-pointer whitespace-nowrap select-none",
`text-${column.align}`,
column.sortable && "hover:bg-orange-100 transition-colors duration-150"
)}
onClick={() => column.sortable && handleSort(column.columnName)}
>
{column.columnName === "__checkbox__" ? (
renderCheckboxHeader()
) : (
<div className="flex items-center space-x-2">
<span>
{columnLabels[column.columnName] || column.displayName}
</span>
{column.sortable && (
<div className="flex flex-col">
{sortColumn === column.columnName ? (
sortDirection === "asc" ? (
<ArrowUp className="h-3 w-3 text-blue-600" />
) : (
<ArrowDown className="h-3 w-3 text-blue-600" />
)
) : ( ) : (
<ArrowDown className="h-3 w-3" /> <ArrowUpDown className="h-3 w-3 text-gray-400" />
) )}
) : ( </div>
<ArrowUpDown className="h-3 w-3 text-gray-400" /> )}
)} </div>
</div> )}
)} </TableHead>
</div> ))}
)} </TableRow>
</TableHead> </TableHeader>
))}
</TableRow>
</TableHeader>
<TableBody> <TableBody>
{data.length === 0 ? ( {data.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={visibleColumns.length} className="py-8 text-center text-gray-500"> <TableCell colSpan={visibleColumns.length} className="py-12 text-center">
<div className="flex flex-col items-center space-y-3">
<div className="w-12 h-12 bg-gradient-to-br from-slate-100 to-slate-200 rounded-2xl flex items-center justify-center">
<TableIcon className="h-6 w-6 text-slate-400" />
</div>
<div className="text-sm font-medium text-slate-600"> </div>
<div className="text-xs text-slate-400"> </div>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
data.map((row, index) => ( data.map((row, index) => (
<TableRow <TableRow
key={index} key={index}
draggable={!isDesignMode}
onDragStart={(e) => handleRowDragStart(e, row, index)}
onDragEnd={handleRowDragEnd}
className={cn( className={cn(
"h-10 cursor-pointer leading-none", "group relative h-12 cursor-pointer transition-all duration-200 border-b border-gray-100",
tableConfig.tableStyle?.hoverEffect && "hover:bg-gray-50", // 기본 스타일
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-50/50", tableConfig.tableStyle?.hoverEffect && "hover:bg-gradient-to-r hover:from-orange-200 hover:to-orange-300/90 hover:shadow-sm",
tableConfig.tableStyle?.alternateRows && index % 2 === 1 && "bg-gray-100/80",
// 드래그 상태 스타일 (미묘하게)
draggedRowIndex === index && "bg-gradient-to-r from-blue-50 to-blue-100/40 shadow-sm border-blue-200",
isDragging && draggedRowIndex !== index && "opacity-70",
// 드래그 가능 표시
!isDesignMode && "hover:cursor-grab active:cursor-grabbing"
)} )}
style={{ minHeight: "40px", height: "40px", lineHeight: "1" }} style={{
minHeight: "48px",
height: "48px",
lineHeight: "1",
width: "100%",
maxWidth: "100%"
}}
onClick={() => handleRowClick(row)} onClick={() => handleRowClick(row)}
> >
{visibleColumns.map((column) => ( {visibleColumns.map((column, colIndex) => (
<TableCell <TableCell
key={column.columnName} key={column.columnName}
className={cn("h-10 align-middle whitespace-nowrap", `text-${column.align}`)} className={cn(
style={{ minHeight: "40px", height: "40px", verticalAlign: "middle" }} "h-12 align-middle px-4 py-3 text-sm transition-all duration-200",
`text-${column.align}`
)}
style={{
minHeight: "48px",
height: "48px",
verticalAlign: "middle",
width: column.width ? `${column.width}px` : undefined,
boxSizing: "border-box",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}
> >
{column.columnName === "__checkbox__" {column.columnName === "__checkbox__"
? renderCheckboxCell(row, index) ? renderCheckboxCell(row, index)
@ -1214,15 +1450,35 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName; const mappedColumnName = joinColumnMapping[column.columnName] || column.columnName;
const cellValue = row[mappedColumnName]; const cellValue = row[mappedColumnName];
if (index === 0) { if (index === 0) {
// 첫 번째 행만 로그 출력 // 디버깅 로그 제거 (성능상 이유로)
console.log(
`🔍 셀 데이터 [${column.columnName}${mappedColumnName}]:`,
cellValue,
"전체 row:",
row,
);
} }
return formatCellValue(cellValue, column.format, column.columnName) || "\u00A0"; const formattedValue = formatCellValue(cellValue, column.format, column.columnName) || "\u00A0";
// 첫 번째 컬럼에 드래그 핸들과 아바타 추가
const isFirstColumn = colIndex === (visibleColumns[0]?.columnName === "__checkbox__" ? 1 : 0);
return (
<div className="flex items-center space-x-2">
{isFirstColumn && !isDesignMode && (
<div className="opacity-60 cursor-grab active:cursor-grabbing mr-1">
{/* 그리드 스냅 가이드 아이콘 */}
<div className="flex space-x-0.5">
<div className="flex flex-col space-y-0.5">
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
</div>
<div className="flex flex-col space-y-0.5">
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
<div className="w-0.5 h-0.5 bg-gray-400 rounded-full"></div>
</div>
</div>
</div>
)}
<span className="font-medium text-gray-700">
{formattedValue}
</span>
</div>
);
})()} })()}
</TableCell> </TableCell>
))} ))}
@ -1231,40 +1487,76 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div>
)} )}
</div> </div>
{/* 푸터/페이지네이션 */} {/* 푸터/페이지네이션 */}
{tableConfig.showFooter && tableConfig.pagination?.enabled && ( {tableConfig.showFooter && tableConfig.pagination?.enabled && (
<div className="flex items-center justify-between border-t p-4"> <div
<div className="text-sm text-gray-500"> className="flex flex-col items-center justify-center bg-gray-100/80 border-t border-gray-200 p-6 space-y-4"
{tableConfig.pagination?.showPageInfo && ( style={{
<span> width: "100%",
{totalItems.toLocaleString()} {(currentPage - 1) * localPageSize + 1}- maxWidth: "100%",
{Math.min(currentPage * localPageSize, totalItems)} boxSizing: "border-box"
}}
>
{/* 페이지 정보 - 가운데 정렬 */}
{tableConfig.pagination?.showPageInfo && (
<div className="flex items-center justify-center space-x-2 text-sm text-slate-600">
<div className="w-2 h-2 bg-blue-500 rounded-full"></div>
<span className="font-medium">
<span className="text-blue-600 font-semibold">{totalItems.toLocaleString()}</span> {" "}
<span className="text-slate-800 font-semibold">
{(currentPage - 1) * localPageSize + 1}-{Math.min(currentPage * localPageSize, totalItems)}
</span>{" "}
</span> </span>
)} </div>
</div> )}
<div className="flex items-center space-x-2"> {/* 페이지 크기 선택과 페이지네이션 버튼 - 가운데 정렬 */}
{/* 페이지 크기 선택 */} <div className="flex items-center justify-center space-x-4">
{tableConfig.pagination?.showSizeSelector && ( {/* 페이지 크기 선택 - 임시로 항상 표시 (테스트용) */}
{true && (
<select <select
value={localPageSize} value={localPageSize}
onChange={(e) => { onChange={(e) => {
console.log("🚀 페이지 크기 드롭다운 변경 감지:", e.target.value);
const newPageSize = parseInt(e.target.value); const newPageSize = parseInt(e.target.value);
// 로컬 상태만 업데이트 (데이터베이스에 저장하지 않음) console.log("🎯 페이지 크기 변경 이벤트:", {
from: localPageSize,
to: newPageSize,
hasOnConfigChange: !!onConfigChange,
onConfigChangeType: typeof onConfigChange
});
// 로컬 상태 업데이트
setLocalPageSize(newPageSize); setLocalPageSize(newPageSize);
// 페이지를 1로 리셋 // 페이지를 1로 리셋
setCurrentPage(1); setCurrentPage(1);
// 상세설정에 변경사항 알림 (pagination 설정 업데이트)
if (onConfigChange) {
console.log("📤 테이블에서 페이지 크기 변경을 상세설정에 알림:", newPageSize);
onConfigChange({
...tableConfig,
pagination: {
...tableConfig.pagination,
pageSize: newPageSize,
},
});
} else {
console.warn("⚠️ onConfigChange가 정의되지 않음 - 상세설정과 연동 불가");
}
// 데이터는 useEffect에서 자동으로 다시 로드됨 // 데이터는 useEffect에서 자동으로 다시 로드됨
}} }}
className="rounded border px-2 py-1 text-sm" className="bg-white/80 border border-slate-200 rounded-lg px-3 py-2 text-sm font-medium text-slate-700 shadow-sm hover:bg-white hover:border-slate-300 transition-colors"
> >
{tableConfig.pagination?.pageSizeOptions?.map((size) => ( {(tableConfig.pagination?.pageSizeOptions || [10, 20, 50, 100]).map((size) => (
<option key={size} value={size}> <option key={size} value={size}>
{size} {size}
</option> </option>
@ -1273,8 +1565,14 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
)} )}
{/* 페이지네이션 버튼 */} {/* 페이지네이션 버튼 */}
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-2 bg-white rounded-lg border border-gray-200 shadow-sm p-1">
<Button variant="outline" size="sm" onClick={() => handlePageChange(1)} disabled={currentPage === 1}> <Button
variant="outline"
size="sm"
onClick={() => handlePageChange(1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
>
<ChevronsLeft className="h-4 w-4" /> <ChevronsLeft className="h-4 w-4" />
</Button> </Button>
<Button <Button
@ -1282,19 +1580,27 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm" size="sm"
onClick={() => handlePageChange(currentPage - 1)} onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1} disabled={currentPage === 1}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
> >
<ChevronLeft className="h-4 w-4" /> <ChevronLeft className="h-4 w-4" />
</Button> </Button>
<span className="px-3 py-1 text-sm"> <div className="flex items-center px-4 py-1 bg-gradient-to-r from-blue-50 to-indigo-50 rounded-md border border-blue-100">
{currentPage} / {totalPages} <span className="text-sm font-semibold text-blue-800">
</span> {currentPage}
</span>
<span className="text-gray-400 mx-2 font-light">/</span>
<span className="text-sm font-medium text-gray-600">
{totalPages}
</span>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => handlePageChange(currentPage + 1)} onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
> >
<ChevronRight className="h-4 w-4" /> <ChevronRight className="h-4 w-4" />
</Button> </Button>
@ -1303,6 +1609,7 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
size="sm" size="sm"
onClick={() => handlePageChange(totalPages)} onClick={() => handlePageChange(totalPages)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="h-8 w-8 p-0 disabled:opacity-50 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-300"
> >
<ChevronsRight className="h-4 w-4" /> <ChevronsRight className="h-4 w-4" />
</Button> </Button>

View File

@ -33,7 +33,13 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
tableColumns, tableColumns,
}) => { }) => {
console.log("🔍 TableListConfigPanel props:", { console.log("🔍 TableListConfigPanel props:", {
config: config?.selectedTable, config,
configType: typeof config,
configSelectedTable: config?.selectedTable,
configPagination: config?.pagination,
paginationEnabled: config?.pagination?.enabled,
paginationPageSize: config?.pagination?.pageSize,
configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object',
screenTableName, screenTableName,
tableColumns: tableColumns?.length, tableColumns: tableColumns?.length,
tableColumnsSample: tableColumns?.[0], tableColumnsSample: tableColumns?.[0],
@ -210,13 +216,25 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
}; };
const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => { const handleNestedChange = (parentKey: keyof TableListConfig, childKey: string, value: any) => {
console.log("🔧 TableListConfigPanel handleNestedChange:", {
parentKey,
childKey,
value,
parentValue: config[parentKey],
hasOnChange: !!onChange,
onChangeType: typeof onChange,
});
const parentValue = config[parentKey] as any; const parentValue = config[parentKey] as any;
onChange({ const newConfig = {
[parentKey]: { [parentKey]: {
...parentValue, ...parentValue,
[childKey]: value, [childKey]: value,
}, },
}); };
console.log("📤 TableListConfigPanel onChange 호출:", newConfig);
onChange(newConfig);
}; };
// 컬럼 추가 // 컬럼 추가

View File

@ -13,9 +13,27 @@ export class TableListRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = TableListDefinition; static componentDefinition = TableListDefinition;
render(): React.ReactElement { render(): React.ReactElement {
return <TableListComponent {...this.props} renderer={this} />; return <TableListComponent
{...this.props}
renderer={this}
onConfigChange={this.handleConfigChange}
/>;
} }
// 설정 변경 핸들러
protected handleConfigChange = (config: any) => {
console.log("📥 TableListRenderer에서 설정 변경 받음:", config);
// 상위 컴포넌트의 onConfigChange 호출 (화면 설계자에게 알림)
if (this.props.onConfigChange) {
this.props.onConfigChange(config);
} else {
console.log("⚠️ 상위 컴포넌트에서 onConfigChange가 전달되지 않음");
}
this.updateComponent({ config });
};
/** /**
* *
*/ */

View File

@ -81,7 +81,7 @@ export const TestInputComponent: React.FC<TestInputComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -72,7 +72,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
const textStyle: React.CSSProperties = { const textStyle: React.CSSProperties = {
fontSize: componentConfig.fontSize || "14px", fontSize: componentConfig.fontSize || "14px",
fontWeight: componentConfig.fontWeight || "normal", fontWeight: componentConfig.fontWeight || "normal",
color: componentConfig.color || "#374151", color: componentConfig.color || "#3b83f6",
textAlign: componentConfig.textAlign || "left", textAlign: componentConfig.textAlign || "left",
backgroundColor: componentConfig.backgroundColor || "transparent", backgroundColor: componentConfig.backgroundColor || "transparent",
padding: componentConfig.padding || "0", padding: componentConfig.padding || "0",
@ -102,7 +102,7 @@ export const TextDisplayComponent: React.FC<TextDisplayComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -85,7 +85,7 @@ export const TextDisplayConfigPanel: React.FC<TextDisplayConfigPanelProps> = ({
<Input <Input
id="color" id="color"
type="color" type="color"
value={config.color || "#374151"} value={config.color || "#3b83f6"}
onChange={(e) => handleChange("color", e.target.value)} onChange={(e) => handleChange("color", e.target.value)}
/> />
</div> </div>

View File

@ -24,7 +24,7 @@ export const TextDisplayDefinition = createComponentDefinition({
text: "텍스트를 입력하세요", text: "텍스트를 입력하세요",
fontSize: "14px", fontSize: "14px",
fontWeight: "normal", fontWeight: "normal",
color: "#374151", color: "#3b83f6",
textAlign: "left", textAlign: "left",
}, },
defaultSize: { width: 150, height: 24 }, defaultSize: { width: 150, height: 24 },

View File

@ -190,7 +190,7 @@ export const TextInputComponent: React.FC<TextInputComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -84,7 +84,7 @@ export const TextareaBasicComponent: React.FC<TextareaBasicComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),

View File

@ -84,7 +84,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
@ -173,7 +173,7 @@ export const ToggleSwitchComponent: React.FC<ToggleSwitchComponentProps> = ({
</div> </div>
<span <span
style={{ style={{
color: "#374151", color: "#3b83f6",
// isInteractive 모드에서는 사용자 스타일 우선 적용 // isInteractive 모드에서는 사용자 스타일 우선 적용
...(isInteractive && component.style ? component.style : {}), ...(isInteractive && component.style ? component.style : {}),
}} }}

View File

@ -308,7 +308,7 @@ export class AutoRegisteringLayoutRenderer {
style: { style: {
labelDisplay: true, labelDisplay: true,
labelFontSize: "14px", labelFontSize: "14px",
labelColor: "#374151", labelColor: "#3b83f6",
labelFontWeight: "500", labelFontWeight: "500",
labelMarginBottom: "4px", labelMarginBottom: "4px",
}, },

View File

@ -60,8 +60,15 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
// 디자인 모드일 때 더 강조된 스타일 // 디자인 모드일 때 더 강조된 스타일
if (isDesignMode) { if (isDesignMode) {
zoneStyle.border = "2px dashed #cbd5e1"; // 🎯 컴포넌트가 있는 존은 테두리 제거 (컴포넌트 자체 테두리와 충돌 방지)
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)"; if (zoneChildren.length === 0) {
zoneStyle.border = "2px dashed #cbd5e1";
zoneStyle.backgroundColor = "rgba(241, 245, 249, 0.8)";
} else {
// 컴포넌트가 있는 존은 미묘한 배경만
zoneStyle.border = "1px solid transparent";
zoneStyle.backgroundColor = "rgba(248, 250, 252, 0.3)";
}
} }
// 호버 효과를 위한 추가 스타일 // 호버 효과를 위한 추가 스타일
@ -91,14 +98,26 @@ export abstract class BaseLayoutRenderer extends React.Component<LayoutRendererP
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
const element = e.currentTarget; const element = e.currentTarget;
element.style.borderColor = "#3b82f6"; // 🎯 컴포넌트가 있는 존은 호버 효과 최소화
element.style.backgroundColor = "rgba(59, 130, 246, 0.02)"; if (zoneChildren.length > 0) {
element.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)"; element.style.backgroundColor = "rgba(59, 130, 246, 0.01)";
} else {
element.style.borderColor = "#3b82f6";
element.style.backgroundColor = "rgba(59, 130, 246, 0.02)";
element.style.boxShadow = "0 1px 3px rgba(0, 0, 0, 0.1)";
}
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
const element = e.currentTarget; const element = e.currentTarget;
element.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0"; if (zoneChildren.length > 0) {
element.style.backgroundColor = isDesignMode ? "rgba(241, 245, 249, 0.8)" : "rgba(248, 250, 252, 0.5)"; // 컴포넌트가 있는 존 복원
element.style.borderColor = "transparent";
element.style.backgroundColor = isDesignMode ? "rgba(248, 250, 252, 0.3)" : "rgba(248, 250, 252, 0.5)";
} else {
// 빈 존 복원
element.style.borderColor = isDesignMode ? "#cbd5e1" : "#e2e8f0";
element.style.backgroundColor = isDesignMode ? "rgba(241, 245, 249, 0.8)" : "rgba(248, 250, 252, 0.5)";
}
element.style.boxShadow = "none"; element.style.boxShadow = "none";
}} }}
onDrop={this.handleDrop(zone.id)} onDrop={this.handleDrop(zone.id)}

View File

@ -148,7 +148,7 @@ const AccordionSection: React.FC<{
const headerStyle: React.CSSProperties = { const headerStyle: React.CSSProperties = {
padding: "12px 16px", padding: "12px 16px",
backgroundColor: isDesignMode ? "#3b82f6" : "#f8fafc", backgroundColor: isDesignMode ? "#3b82f6" : "#f8fafc",
color: isDesignMode ? "white" : "#374151", color: isDesignMode ? "white" : "#3b83f6",
border: "1px solid #e2e8f0", border: "1px solid #e2e8f0",
borderBottom: isExpanded ? "none" : "1px solid #e2e8f0", borderBottom: isExpanded ? "none" : "1px solid #e2e8f0",
cursor: "pointer", cursor: "pointer",

View File

@ -14,6 +14,10 @@ let hotReloadListeners: Array<() => void> = [];
* Hot Reload * Hot Reload
*/ */
export function initializeHotReload(): void { export function initializeHotReload(): void {
// 핫 리로드 시스템 임시 비활성화 (디버깅 목적)
console.log("🔥 컴포넌트 Hot Reload 시스템 비활성화됨 (디버깅 모드)");
return;
if (process.env.NODE_ENV !== "development" || typeof window === "undefined") { if (process.env.NODE_ENV !== "development" || typeof window === "undefined") {
return; return;
} }
@ -55,11 +59,15 @@ function setupDevServerEventListener(): void {
const originalLog = console.log; const originalLog = console.log;
let reloadPending = false; let reloadPending = false;
// console.log 메시지를 감지하여 Hot Reload 트리거 // console.log 메시지를 감지하여 Hot Reload 트리거 (특정 메시지만)
console.log = (...args: any[]) => { console.log = (...args: any[]) => {
const message = args.join(" "); const message = args.join(" ");
if (message.includes("compiled") || message.includes("Fast Refresh") || message.includes("component")) { // 핫 리로드를 트리거할 특정 메시지만 감지 (디버깅 로그는 제외)
if ((message.includes("compiled") || message.includes("Fast Refresh")) &&
!message.includes("🔍") && !message.includes("🎯") && !message.includes("📤") &&
!message.includes("📥") && !message.includes("⚠️") && !message.includes("🔄") &&
!message.includes("✅") && !message.includes("🔧") && !message.includes("📋")) {
if (!reloadPending) { if (!reloadPending) {
reloadPending = true; reloadPending = true;
setTimeout(() => { setTimeout(() => {

View File

@ -110,6 +110,8 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
screenTableName, screenTableName,
tableColumns, tableColumns,
}) => { }) => {
console.log(`🔥 DynamicComponentConfigPanel 렌더링 시작: ${componentId}`);
const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null); const [ConfigPanelComponent, setConfigPanelComponent] = React.useState<React.ComponentType<any> | null>(null);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@ -180,10 +182,21 @@ export const DynamicComponentConfigPanel: React.FC<ComponentConfigPanelProps> =
); );
} }
console.log(`🔧 DynamicComponentConfigPanel 렌더링:`, {
componentId,
ConfigPanelComponent: ConfigPanelComponent?.name,
config,
configType: typeof config,
configKeys: typeof config === 'object' ? Object.keys(config || {}) : 'not object',
screenTableName,
tableColumns: Array.isArray(tableColumns) ? tableColumns.length : tableColumns
});
return ( return (
<ConfigPanelComponent <ConfigPanelComponent
config={config} config={config}
onChange={onChange} onChange={onChange}
onConfigChange={onChange} // TableListConfigPanel을 위한 추가 prop
screenTableName={screenTableName} screenTableName={screenTableName}
tableColumns={tableColumns} tableColumns={tableColumns}
/> />

View File

@ -0,0 +1,406 @@
/**
*
* INSERT/UPDATE/DELETE .
*/
export interface ValidationResult {
isValid: boolean;
error?: string;
warnings?: string[];
}
export interface ColumnMapping {
id?: string;
fromColumnName?: string;
toColumnName: string;
sourceTable?: string;
targetTable?: string;
defaultValue?: string;
transformFunction?: string;
}
export interface UpdateCondition {
id: string;
fromColumn: string;
operator: string;
value: string | string[];
logicalOperator?: "AND" | "OR";
}
export interface WhereCondition {
id: string;
toColumn: string;
operator: string;
valueSource: string;
fromColumn?: string;
staticValue?: string;
logicalOperator?: "AND" | "OR";
}
export interface DeleteCondition {
id: string;
fromColumn: string;
operator: string;
value: string | string[];
logicalOperator?: "AND" | "OR";
}
/**
*
*/
export const validateMappingConstraints = (
actionType: "insert" | "update" | "delete",
newMapping: ColumnMapping,
existingMappings: ColumnMapping[],
): ValidationResult => {
switch (actionType) {
case "insert":
return validateInsertMapping(newMapping, existingMappings);
case "update":
return validateUpdateMapping(newMapping, existingMappings);
case "delete":
return validateDeleteConditions(newMapping, existingMappings);
default:
return { isValid: false, error: "지원하지 않는 액션 타입입니다." };
}
};
/**
* INSERT
* 규칙: 1:N , N:1
*/
export const validateInsertMapping = (
newMapping: ColumnMapping,
existingMappings: ColumnMapping[],
): ValidationResult => {
// TO 컬럼이 이미 다른 FROM 컬럼과 매핑되어 있는지 확인
const existingToMapping = existingMappings.find((mapping) => mapping.toColumnName === newMapping.toColumnName);
if (
existingToMapping &&
existingToMapping.fromColumnName &&
existingToMapping.fromColumnName !== newMapping.fromColumnName
) {
return {
isValid: false,
error: `대상 컬럼 '${newMapping.toColumnName}'은 이미 '${existingToMapping.fromColumnName}'과 매핑되어 있습니다.`,
};
}
// 기본값이 설정된 경우와 FROM 컬럼이 동시에 설정되어 있는지 확인
if (newMapping.fromColumnName && newMapping.defaultValue && newMapping.defaultValue.trim()) {
return {
isValid: false,
error: `'${newMapping.toColumnName}' 컬럼에는 FROM 컬럼과 기본값을 동시에 설정할 수 없습니다.`,
};
}
return { isValid: true };
};
/**
* UPDATE
*/
export const validateUpdateMapping = (
newMapping: ColumnMapping,
existingMappings: ColumnMapping[],
): ValidationResult => {
// 기본 INSERT 규칙 적용
const baseValidation = validateInsertMapping(newMapping, existingMappings);
if (!baseValidation.isValid) {
return baseValidation;
}
// UPDATE 특화 검증 로직 추가 가능
return { isValid: true };
};
/**
* DELETE
*/
export const validateDeleteConditions = (
newMapping: ColumnMapping,
existingMappings: ColumnMapping[],
): ValidationResult => {
// DELETE는 기본적으로 조건 기반이므로 매핑 제약이 다름
return { isValid: true };
};
/**
* UPDATE
*/
export const validateSelfTableUpdate = (
fromTable: string,
toTable: string,
updateConditions: UpdateCondition[],
whereConditions: WhereCondition[],
): ValidationResult => {
if (fromTable === toTable) {
// 1. WHERE 조건 필수
if (!whereConditions.length) {
return {
isValid: false,
error: "자기 자신 테이블 업데이트 시 WHERE 조건이 필수입니다.",
};
}
// 2. 업데이트 조건과 WHERE 조건이 겹치지 않도록 체크
const conditionColumns = updateConditions.map((c) => c.fromColumn);
const whereColumns = whereConditions.map((c) => c.toColumn);
const overlap = conditionColumns.filter((col) => whereColumns.includes(col));
if (overlap.length > 0) {
return {
isValid: false,
error: `업데이트 조건과 WHERE 조건에서 같은 컬럼(${overlap.join(", ")})을 사용하면 예상치 못한 결과가 발생할 수 있습니다.`,
};
}
// 3. 무한 루프 방지 체크
const hasInfiniteLoopRisk = updateConditions.some((condition) =>
whereConditions.some(
(where) => where.fromColumn === condition.toColumn && where.toColumn === condition.fromColumn,
),
);
if (hasInfiniteLoopRisk) {
return {
isValid: false,
error: "자기 참조 업데이트로 인한 무한 루프 위험이 있습니다.",
};
}
}
return { isValid: true };
};
/**
* DELETE
*/
export const validateSelfTableDelete = (
fromTable: string,
toTable: string,
deleteConditions: DeleteCondition[],
whereConditions: WhereCondition[],
maxDeleteCount: number,
): ValidationResult => {
if (fromTable === toTable) {
// 1. WHERE 조건 필수 체크
if (!whereConditions.length) {
return {
isValid: false,
error: "자기 자신 테이블 삭제 시 WHERE 조건이 필수입니다.",
};
}
// 2. 강화된 안전장치: 더 엄격한 제한
const selfDeleteMaxCount = Math.min(maxDeleteCount, 10);
if (maxDeleteCount > selfDeleteMaxCount) {
return {
isValid: false,
error: `자기 자신 테이블 삭제 시 최대 ${selfDeleteMaxCount}개까지만 허용됩니다.`,
};
}
// 3. 삭제 조건이 너무 광범위한지 체크
const hasBroadCondition = deleteConditions.some(
(condition) =>
condition.operator === "!=" || condition.operator === "NOT IN" || condition.operator === "NOT EXISTS",
);
if (hasBroadCondition) {
return {
isValid: false,
error: "자기 자신 테이블 삭제 시 부정 조건(!=, NOT IN, NOT EXISTS)은 위험합니다.",
};
}
// 4. WHERE 조건이 충분히 구체적인지 체크
if (whereConditions.length < 2) {
return {
isValid: false,
error: "자기 자신 테이블 삭제 시 WHERE 조건을 2개 이상 설정하는 것을 권장합니다.",
warnings: ["안전을 위해 더 구체적인 조건을 설정하세요."],
};
}
}
return { isValid: true };
};
/**
*
*/
export const validateDataTypeCompatibility = (fromColumnType: string, toColumnType: string): ValidationResult => {
// 기본 호환성 규칙
const fromType = normalizeDataType(fromColumnType);
const toType = normalizeDataType(toColumnType);
// 같은 타입이면 호환
if (fromType === toType) {
return { isValid: true };
}
// 숫자 타입 간 호환성
const numericTypes = ["int", "integer", "bigint", "smallint", "decimal", "numeric", "float", "double"];
if (numericTypes.includes(fromType) && numericTypes.includes(toType)) {
return {
isValid: true,
warnings: ["숫자 타입 간 변환 시 정밀도 손실이 발생할 수 있습니다."],
};
}
// 문자열 타입 간 호환성
const stringTypes = ["varchar", "char", "text", "string"];
if (stringTypes.includes(fromType) && stringTypes.includes(toType)) {
return {
isValid: true,
warnings: ["문자열 길이 제한을 확인하세요."],
};
}
// 날짜/시간 타입 간 호환성
const dateTypes = ["date", "datetime", "timestamp", "time"];
if (dateTypes.includes(fromType) && dateTypes.includes(toType)) {
return {
isValid: true,
warnings: ["날짜/시간 형식 변환 시 데이터 손실이 발생할 수 있습니다."],
};
}
// 호환되지 않는 타입
return {
isValid: false,
error: `'${fromColumnType}' 타입과 '${toColumnType}' 타입은 호환되지 않습니다.`,
};
};
/**
*
*/
const normalizeDataType = (dataType: string): string => {
const lowerType = dataType.toLowerCase();
// 정수 타입
if (lowerType.includes("int") || lowerType.includes("serial")) {
return "int";
}
// 실수 타입
if (
lowerType.includes("decimal") ||
lowerType.includes("numeric") ||
lowerType.includes("float") ||
lowerType.includes("double")
) {
return "decimal";
}
// 문자열 타입
if (
lowerType.includes("varchar") ||
lowerType.includes("char") ||
lowerType.includes("text") ||
lowerType.includes("string")
) {
return "varchar";
}
// 날짜 타입
if (lowerType.includes("date")) {
return "date";
}
// 시간 타입
if (lowerType.includes("time")) {
return "datetime";
}
// 불린 타입
if (lowerType.includes("bool")) {
return "boolean";
}
return lowerType;
};
/**
*
*/
export const validateMappingCompleteness = (requiredColumns: string[], mappings: ColumnMapping[]): ValidationResult => {
const mappedColumns = mappings.map((m) => m.toColumnName);
const unmappedRequired = requiredColumns.filter((col) => !mappedColumns.includes(col));
if (unmappedRequired.length > 0) {
return {
isValid: false,
error: `필수 컬럼이 매핑되지 않았습니다: ${unmappedRequired.join(", ")}`,
};
}
return { isValid: true };
};
/**
*
*/
export const validateActionConfiguration = (
actionType: "insert" | "update" | "delete",
fromTable?: string,
toTable?: string,
mappings?: ColumnMapping[],
conditions?: any[],
): ValidationResult => {
// 기본 필수 정보 체크
if (!toTable) {
return {
isValid: false,
error: "대상 테이블을 선택해야 합니다.",
};
}
// 액션 타입별 검증
switch (actionType) {
case "insert":
if (!mappings || mappings.length === 0) {
return {
isValid: false,
error: "INSERT 작업에는 최소 하나의 필드 매핑이 필요합니다.",
};
}
break;
case "update":
if (!fromTable) {
return {
isValid: false,
error: "UPDATE 작업에는 조건 확인용 소스 테이블이 필요합니다.",
};
}
if (!conditions || conditions.length === 0) {
return {
isValid: false,
error: "UPDATE 작업에는 WHERE 조건이 필요합니다.",
};
}
break;
case "delete":
if (!fromTable) {
return {
isValid: false,
error: "DELETE 작업에는 조건 확인용 소스 테이블이 필요합니다.",
};
}
if (!conditions || conditions.length === 0) {
return {
isValid: false,
error: "DELETE 작업에는 WHERE 조건이 필요합니다.",
};
}
break;
}
return { isValid: true };
};

View File

@ -661,7 +661,7 @@ function getComponentJSXByWebType(webType) {
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >
@ -709,7 +709,7 @@ function getComponentJSXByWebType(webType) {
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >
@ -785,7 +785,7 @@ function getComponentJSXByWebType(webType) {
top: "-25px", top: "-25px",
left: "0px", left: "0px",
fontSize: component.style?.labelFontSize || "14px", fontSize: component.style?.labelFontSize || "14px",
color: component.style?.labelColor || "#374151", color: component.style?.labelColor || "#3b83f6",
fontWeight: "500", fontWeight: "500",
}} }}
> >

View File

@ -68,6 +68,9 @@ export interface ComponentRendererProps {
// 새로운 기능들 // 새로운 기능들
autoGeneration?: AutoGenerationConfig; // 자동생성 설정 autoGeneration?: AutoGenerationConfig; // 자동생성 설정
hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김) hidden?: boolean; // 숨김 기능 (편집기에서는 연하게, 실제 화면에서는 숨김)
// 설정 변경 핸들러
onConfigChange?: (config: any) => void;
[key: string]: any; [key: string]: any;
} }
@ -317,7 +320,7 @@ export const COMPONENT_CATEGORIES_INFO = {
[ComponentCategory.CHART]: { [ComponentCategory.CHART]: {
name: "차트", name: "차트",
description: "데이터 시각화 컴포넌트", description: "데이터 시각화 컴포넌트",
color: "#06b6d4", color: "#3b83f6",
}, },
[ComponentCategory.FORM]: { [ComponentCategory.FORM]: {
name: "폼", name: "폼",
@ -347,7 +350,7 @@ export const COMPONENT_CATEGORIES_INFO = {
[ComponentCategory.CONTAINER]: { [ComponentCategory.CONTAINER]: {
name: "컨테이너", name: "컨테이너",
description: "다른 컴포넌트를 담는 컨테이너", description: "다른 컴포넌트를 담는 컨테이너",
color: "#374151", color: "#3b83f6",
}, },
[ComponentCategory.SYSTEM]: { [ComponentCategory.SYSTEM]: {
name: "시스템", name: "시스템",

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,269 @@
# 🔧 제어관리 외부 커넥션 통합 기능 사용 가이드
## 📋 기능 개요
제어관리 시스템에 외부 데이터베이스 커넥션 연동 기능이 추가되었습니다. 이제 데이터 저장 액션에서 외부 DB나 자기 자신의 테이블에 INSERT, UPDATE, DELETE 작업을 수행할 수 있습니다.
## 🚀 주요 기능
### 1. **다중 커넥션 지원**
- 메인 데이터베이스 (현재 시스템)
- 외부 데이터베이스 (MySQL, PostgreSQL, Oracle, SQL Server 등)
- FROM/TO 커넥션을 독립적으로 선택 가능
### 2. **액션 타입별 지원**
- **INSERT**: 외부 DB에서 데이터 조회 → 다른 DB에 삽입
- **UPDATE**: 조건 확인 후 대상 테이블 업데이트
- **DELETE**: 조건 확인 후 대상 테이블 데이터 삭제
### 3. **자기 자신 테이블 작업**
- 같은 테이블 내에서 UPDATE/DELETE 작업 지원
- 강화된 안전장치로 데이터 손실 방지
## 📖 사용 방법
### 1단계: 커넥션 선택
제어관리에서 데이터 저장 액션을 생성할 때, 먼저 FROM/TO 커넥션을 선택합니다.
```
📍 커넥션 선택 화면
┌─────────────────────────────────────────────────────────┐
│ FROM 커넥션 (소스) │ TO 커넥션 (대상) │
│ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
│ │ [현재 DB] 메인 시스템 │ │ │ [MySQL] 외부 DB 1 │ │
│ │ [PostgreSQL] 외부 DB 2 │ │ │ [Oracle] 외부 DB 3 │ │
│ └─────────────────────────┘ │ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 2단계: 테이블 선택
선택한 커넥션에서 사용할 테이블을 선택합니다.
```
📍 테이블 선택 화면
┌─────────────────────────────────────────────────────────┐
│ FROM 테이블 │ TO 테이블 │
│ ┌─────────────────────────┐ │ ┌─────────────────────────┐ │
│ │ 🔍 [검색: user] │ │ │ 🔍 [검색: order] │ │
│ │ 📊 user_info (15 컬럼) │ │ │ 📊 order_log (8 컬럼) │ │
│ │ 📊 user_auth (5 컬럼) │ │ │ 📊 order_items (12 컬럼)│ │
│ └─────────────────────────┘ │ └─────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 3단계: 액션별 설정
#### 🔄 INSERT 액션
FROM 테이블의 데이터를 TO 테이블에 삽입하는 필드 매핑을 설정합니다.
```
매핑 규칙:
✅ 1:1 매핑: FROM.user_id → TO.customer_id
✅ 1:N 매핑: FROM.name → TO.first_name, TO.display_name
❌ N:1 매핑: FROM.first_name, FROM.last_name → TO.name (금지)
```
#### 🔄 UPDATE 액션
3가지 설정 영역이 있습니다:
1. **업데이트 조건**: FROM 테이블에서 어떤 조건을 만족할 때 실행할지
2. **필드 매핑**: FROM 데이터를 TO 테이블의 어떤 필드에 업데이트할지
3. **WHERE 조건**: TO 테이블에서 어떤 레코드를 업데이트할지
```
예시 설정:
🔍 업데이트 조건: FROM.status = 'completed' AND FROM.updated_at > '2024-01-01'
📝 필드 매핑: FROM.result_value → TO.final_score
🎯 WHERE 조건: TO.user_id = FROM.user_id AND TO.status = 'pending'
```
#### 🔄 DELETE 액션
2가지 설정 영역과 안전장치가 있습니다:
1. **삭제 트리거 조건**: FROM 테이블에서 어떤 조건을 만족할 때 삭제할지
2. **WHERE 조건**: TO 테이블에서 어떤 레코드를 삭제할지
3. **안전장치**: 최대 삭제 개수, 확인 요구, Dry Run 등
```
예시 설정:
🔥 삭제 조건: FROM.is_expired = 'Y' AND FROM.cleanup_date < NOW()
🎯 WHERE 조건: TO.ref_id = FROM.id AND TO.status = 'inactive'
🛡️ 안전장치: 최대 100개, 확인 요구, Dry Run 실행
```
## ⚠️ 자기 자신 테이블 작업 주의사항
### UPDATE 작업 시
```
⚠️ 주의사항:
- WHERE 조건 필수 설정
- 업데이트 조건과 WHERE 조건 겹침 방지
- 무한 루프 위험 체크
✅ 안전한 예시:
UPDATE user_info
SET last_login = NOW(), login_count = login_count + 1
WHERE user_id = 'specific_user' AND status = 'active'
```
### DELETE 작업 시
```
🚨 강화된 안전장치:
- 최대 삭제 개수 10개로 제한
- 부정 조건(!=, NOT IN, NOT EXISTS) 금지
- WHERE 조건 2개 이상 권장
✅ 안전한 예시:
DELETE FROM temp_data
WHERE created_at < NOW() - INTERVAL '7 days'
AND status = 'processed'
AND batch_id = 'specific_batch'
LIMIT 10
```
## 🛠️ 실제 사용 시나리오
### 시나리오 1: 사용자 로그인 정보 업데이트
```
목적: 사용자 로그인 시 마지막 로그인 시간과 로그인 횟수 업데이트
설정:
- FROM: login_logs 테이블 (메인 DB)
- TO: user_info 테이블 (메인 DB)
- 액션: UPDATE
조건:
🔍 업데이트 조건: login_logs.status = 'success' AND login_logs.created_at > NOW() - INTERVAL '1 minute'
📝 필드 매핑:
- login_logs.created_at → user_info.last_login
- login_logs.user_agent → user_info.last_user_agent
🎯 WHERE 조건: user_info.user_id = login_logs.user_id
```
### 시나리오 2: 외부 시스템에서 주문 데이터 동기화
```
목적: 외부 쇼핑몰 시스템의 주문 데이터를 내부 ERP에 저장
설정:
- FROM: orders 테이블 (외부 MySQL DB)
- TO: erp_orders 테이블 (메인 PostgreSQL DB)
- 액션: INSERT
매핑:
✅ 필드 매핑:
- orders.order_id → erp_orders.external_order_id
- orders.customer_name → erp_orders.customer_name
- orders.total_amount → erp_orders.order_amount
- orders.order_date → erp_orders.received_date
```
### 시나리오 3: 만료된 임시 데이터 정리
```
목적: 배치 작업 완료 후 임시 처리 데이터 자동 삭제
설정:
- FROM: batch_jobs 테이블 (메인 DB)
- TO: temp_processing_data 테이블 (메인 DB)
- 액션: DELETE
조건:
🔥 삭제 조건: batch_jobs.status = 'completed' AND batch_jobs.completed_at < NOW() - INTERVAL '1 hour'
🎯 WHERE 조건:
- temp_processing_data.batch_id = batch_jobs.batch_id
- temp_processing_data.status = 'processed'
🛡️ 안전장치: 최대 100개, Dry Run 먼저 실행
```
## 🔧 API 사용법
### 활성 커넥션 조회
```typescript
import { getActiveConnections } from "@/lib/api/multiConnection";
const connections = await getActiveConnections();
// 결과: [{ id: 0, connection_name: '메인 데이터베이스' }, ...]
```
### 테이블 목록 조회
```typescript
import { getTablesFromConnection } from "@/lib/api/multiConnection";
const tables = await getTablesFromConnection(connectionId);
// 결과: [{ tableName: 'users', columnCount: 15 }, ...]
```
### 컬럼 정보 조회
```typescript
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
const columns = await getColumnsFromConnection(connectionId, "users");
// 결과: [{ columnName: 'id', dataType: 'int', isPrimaryKey: true }, ...]
```
## 🚨 문제 해결
### 일반적인 오류
#### 1. 커넥션 연결 실패
```
오류: "커넥션을 찾을 수 없습니다"
해결: 외부 커넥션 관리에서 해당 커넥션이 활성 상태인지 확인
```
#### 2. 테이블 접근 권한 부족
```
오류: "테이블에 접근할 수 없습니다"
해결: 데이터베이스 사용자의 테이블 권한 확인
```
#### 3. 매핑 제약사항 위반
```
오류: "대상 컬럼이 이미 매핑되어 있습니다"
해결: N:1 매핑이 아닌 1:N 매핑으로 변경
```
#### 4. 자기 자신 테이블 작업 실패
```
오류: "WHERE 조건이 필수입니다"
해결: UPDATE/DELETE 작업 시 WHERE 조건 추가
```
### 성능 최적화
1. **인덱스 활용**: WHERE 조건에 사용되는 컬럼에 인덱스 생성
2. **배치 크기 조정**: 대량 데이터 처리 시 적절한 배치 크기 설정
3. **커넥션 풀링**: 외부 DB 연결 시 커넥션 풀 최적화
## 📞 지원
문제가 발생하거나 추가 기능이 필요한 경우:
1. **로그 확인**: 브라우저 개발자 도구의 콘솔 탭 확인
2. **에러 메시지**: 상세한 에러 메시지와 함께 문의
3. **설정 정보**: 사용한 커넥션, 테이블, 액션 타입 정보 제공
---
**📝 마지막 업데이트**: 2024년 12월
**🔧 버전**: v1.0.0
**📧 문의**: 시스템 관리자