화면 분할패널 중간커밋

This commit is contained in:
kjs 2025-11-27 12:08:32 +09:00
parent e8c02fef5e
commit fb9de05b00
21 changed files with 5735 additions and 3 deletions

View File

@ -71,6 +71,7 @@ import codeMergeRoutes from "./routes/codeMergeRoutes"; // 코드 병합
import numberingRuleRoutes from "./routes/numberingRuleRoutes"; // 채번 규칙 관리
import entitySearchRoutes from "./routes/entitySearchRoutes"; // 엔티티 검색
import orderRoutes from "./routes/orderRoutes"; // 수주 관리
import screenEmbeddingRoutes from "./routes/screenEmbeddingRoutes"; // 화면 임베딩 및 데이터 전달
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
@ -236,6 +237,7 @@ app.use("/api/code-merge", codeMergeRoutes); // 코드 병합
app.use("/api/numbering-rules", numberingRuleRoutes); // 채번 규칙 관리
app.use("/api/entity-search", entitySearchRoutes); // 엔티티 검색
app.use("/api/orders", orderRoutes); // 수주 관리
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석
// app.use('/api/users', userRoutes);

View File

@ -0,0 +1,924 @@
/**
*
*/
import { Request, Response } from "express";
import { getPool } from "../database/db";
import { logger } from "../utils/logger";
const pool = getPool();
// ============================================
// 1. 화면 임베딩 API
// ============================================
/**
*
* GET /api/screen-embedding?parentScreenId=1
*/
export async function getScreenEmbeddings(req: Request, res: Response) {
try {
const { parentScreenId } = req.query;
const companyCode = req.user!.companyCode;
if (!parentScreenId) {
return res.status(400).json({
success: false,
message: "부모 화면 ID가 필요합니다.",
});
}
const query = `
SELECT
se.*,
ps.screen_name as parent_screen_name,
cs.screen_name as child_screen_name
FROM screen_embedding se
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
WHERE se.parent_screen_id = $1
AND se.company_code = $2
ORDER BY se.position, se.created_at
`;
const result = await pool.query(query, [parentScreenId, companyCode]);
logger.info("화면 임베딩 목록 조회", {
companyCode,
parentScreenId,
count: result.rowCount,
});
return res.json({
success: true,
data: result.rows,
});
} catch (error: any) {
logger.error("화면 임베딩 목록 조회 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 목록 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* GET /api/screen-embedding/:id
*/
export async function getScreenEmbeddingById(req: Request, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
se.*,
ps.screen_name as parent_screen_name,
cs.screen_name as child_screen_name
FROM screen_embedding se
LEFT JOIN screen_definitions ps ON se.parent_screen_id = ps.screen_id
LEFT JOIN screen_definitions cs ON se.child_screen_id = cs.screen_id
WHERE se.id = $1
AND se.company_code = $2
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 상세 조회", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 상세 조회 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 상세 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* POST /api/screen-embedding
*/
export async function createScreenEmbedding(req: Request, res: Response) {
try {
const {
parentScreenId,
childScreenId,
position,
mode,
config = {},
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!parentScreenId || !childScreenId || !position || !mode) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
const query = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING *
`;
const result = await pool.query(query, [
parentScreenId,
childScreenId,
position,
mode,
JSON.stringify(config),
companyCode,
userId,
]);
logger.info("화면 임베딩 생성", {
companyCode,
userId,
id: result.rows[0].id,
});
return res.status(201).json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 생성 실패", error);
// 유니크 제약조건 위반
if (error.code === "23505") {
return res.status(409).json({
success: false,
message: "이미 동일한 임베딩 설정이 존재합니다.",
});
}
return res.status(500).json({
success: false,
message: "화면 임베딩 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* PUT /api/screen-embedding/:id
*/
export async function updateScreenEmbedding(req: Request, res: Response) {
try {
const { id } = req.params;
const { position, mode, config } = req.body;
const companyCode = req.user!.companyCode;
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (position) {
updates.push(`position = $${paramIndex++}`);
values.push(position);
}
if (mode) {
updates.push(`mode = $${paramIndex++}`);
values.push(mode);
}
if (config) {
updates.push(`config = $${paramIndex++}`);
values.push(JSON.stringify(config));
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_embedding
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("화면 임베딩 수정 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* DELETE /api/screen-embedding/:id
*/
export async function deleteScreenEmbedding(req: Request, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
DELETE FROM screen_embedding
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "화면 임베딩 설정을 찾을 수 없습니다.",
});
}
logger.info("화면 임베딩 삭제", { companyCode, id });
return res.json({
success: true,
message: "화면 임베딩이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("화면 임베딩 삭제 실패", error);
return res.status(500).json({
success: false,
message: "화면 임베딩 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// ============================================
// 2. 데이터 전달 API
// ============================================
/**
*
* GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2
*/
export async function getScreenDataTransfer(req: Request, res: Response) {
try {
const { sourceScreenId, targetScreenId } = req.query;
const companyCode = req.user!.companyCode;
if (!sourceScreenId || !targetScreenId) {
return res.status(400).json({
success: false,
message: "소스 화면 ID와 타겟 화면 ID가 필요합니다.",
});
}
const query = `
SELECT
sdt.*,
ss.screen_name as source_screen_name,
ts.screen_name as target_screen_name
FROM screen_data_transfer sdt
LEFT JOIN screen_definitions ss ON sdt.source_screen_id = ss.screen_id
LEFT JOIN screen_definitions ts ON sdt.target_screen_id = ts.screen_id
WHERE sdt.source_screen_id = $1
AND sdt.target_screen_id = $2
AND sdt.company_code = $3
`;
const result = await pool.query(query, [
sourceScreenId,
targetScreenId,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 조회", {
companyCode,
sourceScreenId,
targetScreenId,
});
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 조회 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* POST /api/screen-data-transfer
*/
export async function createScreenDataTransfer(req: Request, res: Response) {
try {
const {
sourceScreenId,
targetScreenId,
sourceComponentId,
sourceComponentType,
dataReceivers,
buttonConfig,
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!sourceScreenId || !targetScreenId || !dataReceivers) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
const query = `
INSERT INTO screen_data_transfer (
source_screen_id, target_screen_id, source_component_id, source_component_type,
data_receivers, button_config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING *
`;
const result = await pool.query(query, [
sourceScreenId,
targetScreenId,
sourceComponentId,
sourceComponentType,
JSON.stringify(dataReceivers),
JSON.stringify(buttonConfig || {}),
companyCode,
userId,
]);
logger.info("데이터 전달 설정 생성", {
companyCode,
userId,
id: result.rows[0].id,
});
return res.status(201).json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 생성 실패", error);
// 유니크 제약조건 위반
if (error.code === "23505") {
return res.status(409).json({
success: false,
message: "이미 동일한 데이터 전달 설정이 존재합니다.",
});
}
return res.status(500).json({
success: false,
message: "데이터 전달 설정 생성 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* PUT /api/screen-data-transfer/:id
*/
export async function updateScreenDataTransfer(req: Request, res: Response) {
try {
const { id } = req.params;
const { dataReceivers, buttonConfig } = req.body;
const companyCode = req.user!.companyCode;
const updates: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dataReceivers) {
updates.push(`data_receivers = $${paramIndex++}`);
values.push(JSON.stringify(dataReceivers));
}
if (buttonConfig) {
updates.push(`button_config = $${paramIndex++}`);
values.push(JSON.stringify(buttonConfig));
}
if (updates.length === 0) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
updates.push(`updated_at = NOW()`);
values.push(id, companyCode);
const query = `
UPDATE screen_data_transfer
SET ${updates.join(", ")}
WHERE id = $${paramIndex++}
AND company_code = $${paramIndex++}
RETURNING *
`;
const result = await pool.query(query, values);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("데이터 전달 설정 수정 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* DELETE /api/screen-data-transfer/:id
*/
export async function deleteScreenDataTransfer(req: Request, res: Response) {
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
const query = `
DELETE FROM screen_data_transfer
WHERE id = $1 AND company_code = $2
RETURNING id
`;
const result = await pool.query(query, [id, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "데이터 전달 설정을 찾을 수 없습니다.",
});
}
logger.info("데이터 전달 설정 삭제", { companyCode, id });
return res.json({
success: true,
message: "데이터 전달 설정이 삭제되었습니다.",
});
} catch (error: any) {
logger.error("데이터 전달 설정 삭제 실패", error);
return res.status(500).json({
success: false,
message: "데이터 전달 설정 삭제 중 오류가 발생했습니다.",
error: error.message,
});
}
}
// ============================================
// 3. 분할 패널 API
// ============================================
/**
*
* GET /api/screen-split-panel/:screenId
*/
export async function getScreenSplitPanel(req: Request, res: Response) {
try {
const { screenId } = req.params;
const companyCode = req.user!.companyCode;
const query = `
SELECT
ssp.*,
le.parent_screen_id as le_parent_screen_id,
le.child_screen_id as le_child_screen_id,
le.position as le_position,
le.mode as le_mode,
le.config as le_config,
re.parent_screen_id as re_parent_screen_id,
re.child_screen_id as re_child_screen_id,
re.position as re_position,
re.mode as re_mode,
re.config as re_config,
sdt.source_screen_id,
sdt.target_screen_id,
sdt.source_component_id,
sdt.source_component_type,
sdt.data_receivers,
sdt.button_config
FROM screen_split_panel ssp
LEFT JOIN screen_embedding le ON ssp.left_embedding_id = le.id
LEFT JOIN screen_embedding re ON ssp.right_embedding_id = re.id
LEFT JOIN screen_data_transfer sdt ON ssp.data_transfer_id = sdt.id
WHERE ssp.screen_id = $1
AND ssp.company_code = $2
`;
const result = await pool.query(query, [screenId, companyCode]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
const row = result.rows[0];
// 데이터 구조화
const data = {
id: row.id,
screenId: row.screen_id,
leftEmbeddingId: row.left_embedding_id,
rightEmbeddingId: row.right_embedding_id,
dataTransferId: row.data_transfer_id,
layoutConfig: row.layout_config,
companyCode: row.company_code,
createdAt: row.created_at,
updatedAt: row.updated_at,
leftEmbedding: row.le_child_screen_id
? {
id: row.left_embedding_id,
parentScreenId: row.le_parent_screen_id,
childScreenId: row.le_child_screen_id,
position: row.le_position,
mode: row.le_mode,
config: row.le_config,
}
: null,
rightEmbedding: row.re_child_screen_id
? {
id: row.right_embedding_id,
parentScreenId: row.re_parent_screen_id,
childScreenId: row.re_child_screen_id,
position: row.re_position,
mode: row.re_mode,
config: row.re_config,
}
: null,
dataTransfer: row.source_screen_id
? {
id: row.data_transfer_id,
sourceScreenId: row.source_screen_id,
targetScreenId: row.target_screen_id,
sourceComponentId: row.source_component_id,
sourceComponentType: row.source_component_type,
dataReceivers: row.data_receivers,
buttonConfig: row.button_config,
}
: null,
};
logger.info("분할 패널 설정 조회", { companyCode, screenId });
return res.json({
success: true,
data,
});
} catch (error: any) {
logger.error("분할 패널 설정 조회 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* POST /api/screen-split-panel
*/
export async function createScreenSplitPanel(req: Request, res: Response) {
const client = await pool.connect();
try {
const {
screenId,
leftEmbedding,
rightEmbedding,
dataTransfer,
layoutConfig,
} = req.body;
const companyCode = req.user!.companyCode;
const userId = req.user!.userId;
// 필수 필드 검증
if (!screenId || !leftEmbedding || !rightEmbedding || !dataTransfer) {
return res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다.",
});
}
await client.query("BEGIN");
// 1. 좌측 임베딩 생성
const leftEmbeddingQuery = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id
`;
const leftResult = await client.query(leftEmbeddingQuery, [
screenId,
leftEmbedding.childScreenId,
leftEmbedding.position,
leftEmbedding.mode,
JSON.stringify(leftEmbedding.config || {}),
companyCode,
userId,
]);
const leftEmbeddingId = leftResult.rows[0].id;
// 2. 우측 임베딩 생성
const rightEmbeddingQuery = `
INSERT INTO screen_embedding (
parent_screen_id, child_screen_id, position, mode,
config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW())
RETURNING id
`;
const rightResult = await client.query(rightEmbeddingQuery, [
screenId,
rightEmbedding.childScreenId,
rightEmbedding.position,
rightEmbedding.mode,
JSON.stringify(rightEmbedding.config || {}),
companyCode,
userId,
]);
const rightEmbeddingId = rightResult.rows[0].id;
// 3. 데이터 전달 설정 생성
const dataTransferQuery = `
INSERT INTO screen_data_transfer (
source_screen_id, target_screen_id, source_component_id, source_component_type,
data_receivers, button_config, company_code, created_by, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, NOW(), NOW())
RETURNING id
`;
const dataTransferResult = await client.query(dataTransferQuery, [
dataTransfer.sourceScreenId,
dataTransfer.targetScreenId,
dataTransfer.sourceComponentId,
dataTransfer.sourceComponentType,
JSON.stringify(dataTransfer.dataReceivers),
JSON.stringify(dataTransfer.buttonConfig || {}),
companyCode,
userId,
]);
const dataTransferId = dataTransferResult.rows[0].id;
// 4. 분할 패널 생성
const splitPanelQuery = `
INSERT INTO screen_split_panel (
screen_id, left_embedding_id, right_embedding_id, data_transfer_id,
layout_config, company_code, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW())
RETURNING *
`;
const splitPanelResult = await client.query(splitPanelQuery, [
screenId,
leftEmbeddingId,
rightEmbeddingId,
dataTransferId,
JSON.stringify(layoutConfig || {}),
companyCode,
]);
await client.query("COMMIT");
logger.info("분할 패널 설정 생성", {
companyCode,
userId,
screenId,
id: splitPanelResult.rows[0].id,
});
return res.status(201).json({
success: true,
data: splitPanelResult.rows[0],
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("분할 패널 설정 생성 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 생성 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
}
/**
*
* PUT /api/screen-split-panel/:id
*/
export async function updateScreenSplitPanel(req: Request, res: Response) {
try {
const { id } = req.params;
const { layoutConfig } = req.body;
const companyCode = req.user!.companyCode;
if (!layoutConfig) {
return res.status(400).json({
success: false,
message: "수정할 내용이 없습니다.",
});
}
const query = `
UPDATE screen_split_panel
SET layout_config = $1, updated_at = NOW()
WHERE id = $2 AND company_code = $3
RETURNING *
`;
const result = await pool.query(query, [
JSON.stringify(layoutConfig),
id,
companyCode,
]);
if (result.rowCount === 0) {
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
logger.info("분할 패널 설정 수정", { companyCode, id });
return res.json({
success: true,
data: result.rows[0],
});
} catch (error: any) {
logger.error("분할 패널 설정 수정 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 수정 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
*
* DELETE /api/screen-split-panel/:id
*/
export async function deleteScreenSplitPanel(req: Request, res: Response) {
const client = await pool.connect();
try {
const { id } = req.params;
const companyCode = req.user!.companyCode;
await client.query("BEGIN");
// 1. 분할 패널 조회
const selectQuery = `
SELECT left_embedding_id, right_embedding_id, data_transfer_id
FROM screen_split_panel
WHERE id = $1 AND company_code = $2
`;
const selectResult = await client.query(selectQuery, [id, companyCode]);
if (selectResult.rowCount === 0) {
await client.query("ROLLBACK");
return res.status(404).json({
success: false,
message: "분할 패널 설정을 찾을 수 없습니다.",
});
}
const { left_embedding_id, right_embedding_id, data_transfer_id } =
selectResult.rows[0];
// 2. 분할 패널 삭제
await client.query(
"DELETE FROM screen_split_panel WHERE id = $1 AND company_code = $2",
[id, companyCode]
);
// 3. 관련 임베딩 및 데이터 전달 설정 삭제 (CASCADE로 자동 삭제되지만 명시적으로)
if (left_embedding_id) {
await client.query(
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
[left_embedding_id, companyCode]
);
}
if (right_embedding_id) {
await client.query(
"DELETE FROM screen_embedding WHERE id = $1 AND company_code = $2",
[right_embedding_id, companyCode]
);
}
if (data_transfer_id) {
await client.query(
"DELETE FROM screen_data_transfer WHERE id = $1 AND company_code = $2",
[data_transfer_id, companyCode]
);
}
await client.query("COMMIT");
logger.info("분할 패널 설정 삭제", { companyCode, id });
return res.json({
success: true,
message: "분할 패널 설정이 삭제되었습니다.",
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("분할 패널 설정 삭제 실패", error);
return res.status(500).json({
success: false,
message: "분할 패널 설정 삭제 중 오류가 발생했습니다.",
error: error.message,
});
} finally {
client.release();
}
}

View File

@ -0,0 +1,80 @@
/**
*
*/
import express from "express";
import {
// 화면 임베딩
getScreenEmbeddings,
getScreenEmbeddingById,
createScreenEmbedding,
updateScreenEmbedding,
deleteScreenEmbedding,
// 데이터 전달
getScreenDataTransfer,
createScreenDataTransfer,
updateScreenDataTransfer,
deleteScreenDataTransfer,
// 분할 패널
getScreenSplitPanel,
createScreenSplitPanel,
updateScreenSplitPanel,
deleteScreenSplitPanel,
} from "../controllers/screenEmbeddingController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = express.Router();
// ============================================
// 화면 임베딩 라우트
// ============================================
// 화면 임베딩 목록 조회
router.get("/screen-embedding", authenticateToken, getScreenEmbeddings);
// 화면 임베딩 상세 조회
router.get("/screen-embedding/:id", authenticateToken, getScreenEmbeddingById);
// 화면 임베딩 생성
router.post("/screen-embedding", authenticateToken, createScreenEmbedding);
// 화면 임베딩 수정
router.put("/screen-embedding/:id", authenticateToken, updateScreenEmbedding);
// 화면 임베딩 삭제
router.delete("/screen-embedding/:id", authenticateToken, deleteScreenEmbedding);
// ============================================
// 데이터 전달 라우트
// ============================================
// 데이터 전달 설정 조회
router.get("/screen-data-transfer", authenticateToken, getScreenDataTransfer);
// 데이터 전달 설정 생성
router.post("/screen-data-transfer", authenticateToken, createScreenDataTransfer);
// 데이터 전달 설정 수정
router.put("/screen-data-transfer/:id", authenticateToken, updateScreenDataTransfer);
// 데이터 전달 설정 삭제
router.delete("/screen-data-transfer/:id", authenticateToken, deleteScreenDataTransfer);
// ============================================
// 분할 패널 라우트
// ============================================
// 분할 패널 설정 조회
router.get("/screen-split-panel/:screenId", authenticateToken, getScreenSplitPanel);
// 분할 패널 설정 생성
router.post("/screen-split-panel", authenticateToken, createScreenSplitPanel);
// 분할 패널 설정 수정
router.put("/screen-split-panel/:id", authenticateToken, updateScreenSplitPanel);
// 분할 패널 설정 삭제
router.delete("/screen-split-panel/:id", authenticateToken, deleteScreenSplitPanel);
export default router;

View File

@ -20,6 +20,7 @@ import { useAuth } from "@/hooks/useAuth"; // 🆕 사용자 정보
import { useResponsive } from "@/lib/hooks/useResponsive"; // 🆕 반응형 감지
import { TableOptionsProvider } from "@/contexts/TableOptionsContext"; // 🆕 테이블 옵션
import { TableSearchWidgetHeightProvider, useTableSearchWidgetHeight } from "@/contexts/TableSearchWidgetHeightContext"; // 🆕 높이 관리
import { ScreenContextProvider } from "@/contexts/ScreenContext"; // 🆕 컴포넌트 간 통신
function ScreenViewPage() {
const params = useParams();
@ -840,7 +841,9 @@ function ScreenViewPage() {
function ScreenViewPageWrapper() {
return (
<TableSearchWidgetHeightProvider>
<ScreenContextProvider>
<ScreenViewPage />
</ScreenContextProvider>
</TableSearchWidgetHeightProvider>
);
}

View File

@ -0,0 +1,302 @@
/**
*
*
*/
"use client";
import React, { forwardRef, useImperativeHandle, useState, useEffect, useRef, useCallback } from "react";
import type {
ScreenEmbedding,
DataReceiver,
DataReceivable,
EmbeddedScreenHandle,
DataReceiveMode,
} from "@/types/screen-embedding";
import type { ComponentData } from "@/types/screen";
import { logger } from "@/lib/utils/logger";
import { applyMappingRules, filterDataByCondition } from "@/lib/utils/dataMapping";
import { DynamicComponentRenderer } from "@/lib/registry/DynamicComponentRenderer";
import { screenApi } from "@/lib/api/screen";
interface EmbeddedScreenProps {
embedding: ScreenEmbedding;
onSelectionChanged?: (selectedRows: any[]) => void;
}
/**
*
*/
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
({ embedding, onSelectionChanged }, ref) => {
const [layout, setLayout] = useState<ComponentData[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// 컴포넌트 참조 맵
const componentRefs = useRef<Map<string, DataReceivable>>(new Map());
// 화면 데이터 로드
useEffect(() => {
loadScreenData();
}, [embedding.childScreenId]);
// 선택 변경 이벤트 전파
useEffect(() => {
onSelectionChanged?.(selectedRows);
}, [selectedRows, onSelectionChanged]);
/**
*
*/
const loadScreenData = async () => {
try {
setLoading(true);
setError(null);
// 화면 레이아웃 로드 (별도 API)
const layoutData = await screenApi.getLayout(embedding.childScreenId);
logger.info("📦 화면 레이아웃 로드 완료", {
screenId: embedding.childScreenId,
mode: embedding.mode,
hasLayoutData: !!layoutData,
componentsCount: layoutData?.components?.length || 0,
});
if (layoutData && layoutData.components && Array.isArray(layoutData.components)) {
setLayout(layoutData.components);
logger.info("✅ 임베드 화면 컴포넌트 설정 완료", {
screenId: embedding.childScreenId,
componentsCount: layoutData.components.length,
});
} else {
logger.warn("⚠️ 화면에 컴포넌트가 없습니다", {
screenId: embedding.childScreenId,
layoutData,
});
setLayout([]);
}
} catch (err: any) {
logger.error("화면 레이아웃 로드 실패", err);
setError(err.message || "화면을 불러올 수 없습니다.");
} finally {
setLoading(false);
}
};
/**
*
*/
const registerComponent = useCallback((id: string, component: DataReceivable) => {
componentRefs.current.set(id, component);
logger.debug("컴포넌트 등록", {
componentId: id,
componentType: component.componentType,
});
}, []);
/**
*
*/
const unregisterComponent = useCallback((id: string) => {
componentRefs.current.delete(id);
logger.debug("컴포넌트 등록 해제", {
componentId: id,
});
}, []);
/**
*
*/
const handleSelectionChange = useCallback((rows: any[]) => {
setSelectedRows(rows);
}, []);
// 외부에서 호출 가능한 메서드
useImperativeHandle(ref, () => ({
/**
*
*/
getSelectedRows: () => {
return selectedRows;
},
/**
*
*/
clearSelection: () => {
setSelectedRows([]);
},
/**
*
*/
receiveData: async (data: any[], receivers: DataReceiver[]) => {
logger.info("데이터 수신 시작", {
dataCount: data.length,
receiversCount: receivers.length,
});
const errors: Array<{ componentId: string; error: string }> = [];
// 각 데이터 수신자에게 데이터 전달
for (const receiver of receivers) {
try {
const component = componentRefs.current.get(receiver.targetComponentId);
if (!component) {
const errorMsg = `컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`;
logger.warn(errorMsg);
errors.push({
componentId: receiver.targetComponentId,
error: errorMsg,
});
continue;
}
// 1. 조건 필터링
let filteredData = data;
if (receiver.condition) {
filteredData = filterDataByCondition(data, receiver.condition);
logger.debug("조건 필터링 적용", {
componentId: receiver.targetComponentId,
originalCount: data.length,
filteredCount: filteredData.length,
});
}
// 2. 매핑 규칙 적용
const mappedData = applyMappingRules(filteredData, receiver.mappingRules);
logger.debug("매핑 규칙 적용", {
componentId: receiver.targetComponentId,
mappingRulesCount: receiver.mappingRules.length,
});
// 3. 검증
if (receiver.validation) {
if (receiver.validation.required && mappedData.length === 0) {
throw new Error("필수 데이터가 없습니다.");
}
if (receiver.validation.minRows && mappedData.length < receiver.validation.minRows) {
throw new Error(`최소 ${receiver.validation.minRows}개의 데이터가 필요합니다.`);
}
if (receiver.validation.maxRows && mappedData.length > receiver.validation.maxRows) {
throw new Error(`최대 ${receiver.validation.maxRows}개까지만 허용됩니다.`);
}
}
// 4. 데이터 전달
await component.receiveData(mappedData, receiver.mode);
logger.info("데이터 전달 성공", {
componentId: receiver.targetComponentId,
componentType: receiver.targetComponentType,
mode: receiver.mode,
dataCount: mappedData.length,
});
} catch (err: any) {
logger.error("데이터 전달 실패", {
componentId: receiver.targetComponentId,
error: err.message,
});
errors.push({
componentId: receiver.targetComponentId,
error: err.message,
});
}
}
if (errors.length > 0) {
throw new Error(`일부 컴포넌트에 데이터 전달 실패: ${errors.map((e) => e.componentId).join(", ")}`);
}
},
/**
*
*/
getData: () => {
const allData: Record<string, any> = {};
componentRefs.current.forEach((component, id) => {
allData[id] = component.getData();
});
return allData;
},
}));
// 로딩 상태
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-4 text-center">
<div className="bg-destructive/10 flex h-12 w-12 items-center justify-center rounded-full">
<svg className="text-destructive h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium"> </p>
<p className="text-muted-foreground mt-1 text-xs">{error}</p>
</div>
<button onClick={loadScreenData} className="text-primary text-sm hover:underline">
</button>
</div>
</div>
);
}
// 화면 렌더링 - 레이아웃 기반 렌더링
return (
<div className="h-full w-full overflow-auto p-4">
{layout.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div className="space-y-4">
{layout.map((component) => (
<DynamicComponentRenderer
key={component.id}
component={component}
isInteractive={true}
screenId={embedding.childScreenId}
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
/>
))}
</div>
)}
</div>
);
},
);
EmbeddedScreen.displayName = "EmbeddedScreen";

View File

@ -0,0 +1,130 @@
/**
*
* .
*
* transferData .
* : 좌측 TableListComponent + Button(transferData )
*/
"use client";
import React, { useState, useCallback } from "react";
import { EmbeddedScreen } from "./EmbeddedScreen";
import { Columns2 } from "lucide-react";
interface ScreenSplitPanelProps {
screenId?: number;
config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable)
}
/**
*
* .
*/
export function ScreenSplitPanel({ screenId, config }: ScreenSplitPanelProps) {
const [splitRatio, setSplitRatio] = useState(config?.splitRatio || 50);
// 설정 패널에서 오는 간단한 config를 임베딩 설정으로 변환
const leftEmbedding = config?.leftScreenId
? {
id: 1,
parentScreenId: screenId || 0,
childScreenId: config.leftScreenId,
position: "left" as const,
mode: "view" as const, // 기본 view 모드 (select는 테이블 자체 설정)
config: {},
companyCode: "*",
createdAt: new Date(),
updatedAt: new Date(),
}
: null;
const rightEmbedding = config?.rightScreenId
? {
id: 2,
parentScreenId: screenId || 0,
childScreenId: config.rightScreenId,
position: "right" as const,
mode: "view" as const, // 기본 view 모드
config: {},
companyCode: "*",
createdAt: new Date(),
updatedAt: new Date(),
}
: null;
/**
*
*/
const handleResize = useCallback((newRatio: number) => {
setSplitRatio(Math.max(20, Math.min(80, newRatio)));
}, []);
// config가 없거나 화면 설정이 안 된 경우 (디자이너 모드)
if (!config || !leftEmbedding || !rightEmbedding) {
return (
<div className="border-muted-foreground/25 flex h-full items-center justify-center rounded-lg border-2 border-dashed">
<div className="space-y-4 p-6 text-center">
<div className="flex items-center justify-center gap-3">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Columns2 className="text-muted-foreground h-8 w-8" />
</div>
</div>
<div>
<p className="text-muted-foreground mb-2 text-base font-semibold"> </p>
<p className="text-muted-foreground/60 mb-1 text-xs"> </p>
<p className="text-muted-foreground/60 text-xs">
/
</p>
<p className="text-muted-foreground/60 mt-2 text-[10px]">
💡 전달: 좌측 transferData
</p>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full">
{/* 좌측 패널 */}
<div style={{ width: `${splitRatio}%` }} className="flex-1 overflow-hidden border-r">
<EmbeddedScreen embedding={leftEmbedding} />
</div>
{/* 리사이저 */}
{config?.resizable !== false && (
<div
className="group bg-border hover:bg-primary/20 relative w-1 cursor-col-resize transition-colors"
onMouseDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startRatio = splitRatio;
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaRatio = (deltaX / containerWidth) * 100;
handleResize(startRatio + deltaRatio);
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
>
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
)}
{/* 우측 패널 */}
<div style={{ width: `${100 - splitRatio}%` }} className="flex-1 overflow-hidden">
<EmbeddedScreen embedding={rightEmbedding} />
</div>
</div>
);
}

View File

@ -0,0 +1,7 @@
/**
*
*/
export { EmbeddedScreen } from "./EmbeddedScreen";
export { ScreenSplitPanel } from "./ScreenSplitPanel";

View File

@ -0,0 +1,116 @@
/**
*
* .
*/
"use client";
import React, { createContext, useContext, useCallback, useRef } from "react";
import type { DataProvidable, DataReceivable } from "@/types/data-transfer";
import { logger } from "@/lib/utils/logger";
interface ScreenContextValue {
screenId?: number;
tableName?: string;
// 컴포넌트 등록
registerDataProvider: (componentId: string, provider: DataProvidable) => void;
unregisterDataProvider: (componentId: string) => void;
registerDataReceiver: (componentId: string, receiver: DataReceivable) => void;
unregisterDataReceiver: (componentId: string) => void;
// 컴포넌트 조회
getDataProvider: (componentId: string) => DataProvidable | undefined;
getDataReceiver: (componentId: string) => DataReceivable | undefined;
// 모든 컴포넌트 조회
getAllDataProviders: () => Map<string, DataProvidable>;
getAllDataReceivers: () => Map<string, DataReceivable>;
}
const ScreenContext = createContext<ScreenContextValue | null>(null);
interface ScreenContextProviderProps {
screenId?: number;
tableName?: string;
children: React.ReactNode;
}
/**
*
*/
export function ScreenContextProvider({ screenId, tableName, children }: ScreenContextProviderProps) {
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
dataProvidersRef.current.set(componentId, provider);
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
}, []);
const unregisterDataProvider = useCallback((componentId: string) => {
dataProvidersRef.current.delete(componentId);
logger.debug("데이터 제공자 해제", { componentId });
}, []);
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
dataReceiversRef.current.set(componentId, receiver);
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
}, []);
const unregisterDataReceiver = useCallback((componentId: string) => {
dataReceiversRef.current.delete(componentId);
logger.debug("데이터 수신자 해제", { componentId });
}, []);
const getDataProvider = useCallback((componentId: string) => {
return dataProvidersRef.current.get(componentId);
}, []);
const getDataReceiver = useCallback((componentId: string) => {
return dataReceiversRef.current.get(componentId);
}, []);
const getAllDataProviders = useCallback(() => {
return new Map(dataProvidersRef.current);
}, []);
const getAllDataReceivers = useCallback(() => {
return new Map(dataReceiversRef.current);
}, []);
const value: ScreenContextValue = {
screenId,
tableName,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
};
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
}
/**
*
*/
export function useScreenContext() {
const context = useContext(ScreenContext);
if (!context) {
throw new Error("useScreenContext는 ScreenContextProvider 내부에서만 사용할 수 있습니다.");
}
return context;
}
/**
* ()
* .
*/
export function useScreenContextOptional() {
return useContext(ScreenContext);
}

View File

@ -0,0 +1,271 @@
/**
* API
*/
import apiClient from "./client";
import type {
ScreenEmbedding,
ScreenDataTransfer,
ScreenSplitPanel,
CreateScreenEmbeddingRequest,
CreateScreenDataTransferRequest,
CreateScreenSplitPanelRequest,
ApiResponse,
} from "@/types/screen-embedding";
// ============================================
// 1. 화면 임베딩 API
// ============================================
/**
*
*/
export async function getScreenEmbeddings(
parentScreenId: number
): Promise<ApiResponse<ScreenEmbedding[]>> {
try {
const response = await apiClient.get("/screen-embedding", {
params: { parentScreenId },
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "화면 임베딩 목록 조회 실패",
};
}
}
/**
*
*/
export async function getScreenEmbeddingById(
id: number
): Promise<ApiResponse<ScreenEmbedding>> {
try {
const response = await apiClient.get(`/screen-embedding/${id}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "화면 임베딩 조회 실패",
};
}
}
/**
*
*/
export async function createScreenEmbedding(
data: CreateScreenEmbeddingRequest
): Promise<ApiResponse<ScreenEmbedding>> {
try {
const response = await apiClient.post("/screen-embedding", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "화면 임베딩 생성 실패",
};
}
}
/**
*
*/
export async function updateScreenEmbedding(
id: number,
data: Partial<CreateScreenEmbeddingRequest>
): Promise<ApiResponse<ScreenEmbedding>> {
try {
const response = await apiClient.put(`/screen-embedding/${id}`, data);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "화면 임베딩 수정 실패",
};
}
}
/**
*
*/
export async function deleteScreenEmbedding(
id: number
): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-embedding/${id}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "화면 임베딩 삭제 실패",
};
}
}
// ============================================
// 2. 데이터 전달 API
// ============================================
/**
*
*/
export async function getScreenDataTransfer(
sourceScreenId: number,
targetScreenId: number
): Promise<ApiResponse<ScreenDataTransfer>> {
try {
const response = await apiClient.get("/screen-data-transfer", {
params: { sourceScreenId, targetScreenId },
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "데이터 전달 설정 조회 실패",
};
}
}
/**
*
*/
export async function createScreenDataTransfer(
data: CreateScreenDataTransferRequest
): Promise<ApiResponse<ScreenDataTransfer>> {
try {
const response = await apiClient.post("/screen-data-transfer", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "데이터 전달 설정 생성 실패",
};
}
}
/**
*
*/
export async function updateScreenDataTransfer(
id: number,
data: Partial<CreateScreenDataTransferRequest>
): Promise<ApiResponse<ScreenDataTransfer>> {
try {
const response = await apiClient.put(`/screen-data-transfer/${id}`, data);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "데이터 전달 설정 수정 실패",
};
}
}
/**
*
*/
export async function deleteScreenDataTransfer(
id: number
): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-data-transfer/${id}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "데이터 전달 설정 삭제 실패",
};
}
}
// ============================================
// 3. 분할 패널 API
// ============================================
/**
*
*/
export async function getScreenSplitPanel(
screenId: number
): Promise<ApiResponse<ScreenSplitPanel>> {
try {
const response = await apiClient.get(`/screen-split-panel/${screenId}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "분할 패널 설정 조회 실패",
};
}
}
/**
*
*/
export async function createScreenSplitPanel(
data: CreateScreenSplitPanelRequest
): Promise<ApiResponse<ScreenSplitPanel>> {
try {
const response = await apiClient.post("/screen-split-panel", data);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "분할 패널 설정 생성 실패",
};
}
}
/**
*
*/
export async function updateScreenSplitPanel(
id: number,
layoutConfig: any
): Promise<ApiResponse<ScreenSplitPanel>> {
try {
const response = await apiClient.put(`/screen-split-panel/${id}`, {
layoutConfig,
});
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "분할 패널 설정 수정 실패",
};
}
}
/**
*
*/
export async function deleteScreenSplitPanel(
id: number
): Promise<ApiResponse<void>> {
try {
const response = await apiClient.delete(`/screen-split-panel/${id}`);
return response.data;
} catch (error: any) {
return {
success: false,
error: error.response?.data?.message || "분할 패널 설정 삭제 실패",
};
}
}
// ============================================
// 4. 유틸리티 함수
// ============================================
/**
* ( )
*/
export async function getFullScreenEmbeddingConfig(
screenId: number
): Promise<ApiResponse<ScreenSplitPanel>> {
return getScreenSplitPanel(screenId);
}

View File

@ -62,6 +62,9 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리
// 🆕 탭 컴포넌트
import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
/**
*
*/

View File

@ -0,0 +1,329 @@
"use client";
import React, { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Settings, Layout, ArrowRight, Database, Loader2, Check, ChevronsUpDown } from "lucide-react";
import { screenApi } from "@/lib/api/screen";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
interface ScreenSplitPanelConfigPanelProps {
config: any;
onChange: (newConfig: any) => void;
}
export function ScreenSplitPanelConfigPanel({ config = {}, onChange }: ScreenSplitPanelConfigPanelProps) {
// 화면 목록 상태
const [screens, setScreens] = useState<any[]>([]);
const [isLoadingScreens, setIsLoadingScreens] = useState(true);
// Combobox 상태
const [leftOpen, setLeftOpen] = useState(false);
const [rightOpen, setRightOpen] = useState(false);
const [localConfig, setLocalConfig] = useState({
screenId: config.screenId || 0,
leftScreenId: config.leftScreenId || 0,
rightScreenId: config.rightScreenId || 0,
splitRatio: config.splitRatio || 50,
resizable: config.resizable ?? true,
buttonLabel: config.buttonLabel || "데이터 전달",
buttonPosition: config.buttonPosition || "center",
...config,
});
// 화면 목록 로드
useEffect(() => {
const loadScreens = async () => {
try {
setIsLoadingScreens(true);
const response = await screenApi.getScreens({ page: 1, size: 1000 });
if (response.data) {
setScreens(response.data);
}
} catch (error) {
console.error("화면 목록 로드 실패:", error);
} finally {
setIsLoadingScreens(false);
}
};
loadScreens();
}, []);
const updateConfig = (key: string, value: any) => {
const newConfig = {
...localConfig,
[key]: value,
};
setLocalConfig(newConfig);
// 변경 즉시 부모에게 전달
if (onChange) {
onChange(newConfig);
}
};
return (
<div className="space-y-4">
<Tabs defaultValue="layout" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="layout" className="gap-2">
<Layout className="h-4 w-4" />
</TabsTrigger>
<TabsTrigger value="screens" className="gap-2">
<Database className="h-4 w-4" />
</TabsTrigger>
</TabsList>
{/* 레이아웃 탭 */}
<TabsContent value="layout" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription className="text-xs"> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="splitRatio" className="text-xs">
(%)
</Label>
<span className="text-xs font-medium">{localConfig.splitRatio}%</span>
</div>
<Input
id="splitRatio"
type="range"
min="20"
max="80"
step="5"
value={localConfig.splitRatio}
onChange={(e) => updateConfig("splitRatio", parseInt(e.target.value))}
className="h-2"
/>
<div className="text-muted-foreground flex justify-between text-xs">
<span>20%</span>
<span>50%</span>
<span>80%</span>
</div>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="resizable" className="text-xs font-medium">
</Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Checkbox
id="resizable"
checked={localConfig.resizable}
onCheckedChange={(checked) => updateConfig("resizable", checked)}
/>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 화면 설정 탭 */}
<TabsContent value="screens" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription className="text-xs"> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoadingScreens ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-xs"> ...</span>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="leftScreenId" className="text-xs">
()
</Label>
<Popover open={leftOpen} onOpenChange={setLeftOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={leftOpen}
className="h-9 w-full justify-between text-xs"
>
{localConfig.leftScreenId
? screens.find((s) => s.screenId === localConfig.leftScreenId)?.screenName || "화면 선택..."
: "화면 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName} ${screen.screenCode}`}
onSelect={() => {
updateConfig("leftScreenId", screen.screenId);
setLeftOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.leftScreenId === screen.screenId ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-[10px] text-gray-500">{screen.screenCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="rightScreenId" className="text-xs">
()
</Label>
<Popover open={rightOpen} onOpenChange={setRightOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={rightOpen}
className="h-9 w-full justify-between text-xs"
>
{localConfig.rightScreenId
? screens.find((s) => s.screenId === localConfig.rightScreenId)?.screenName ||
"화면 선택..."
: "화면 선택..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
style={{ width: "var(--radix-popover-trigger-width)" }}
align="start"
>
<Command>
<CommandInput placeholder="화면 검색..." className="text-xs" />
<CommandList>
<CommandEmpty className="text-xs"> .</CommandEmpty>
<CommandGroup>
{screens.map((screen) => (
<CommandItem
key={screen.screenId}
value={`${screen.screenName} ${screen.screenCode}`}
onSelect={() => {
updateConfig("rightScreenId", screen.screenId);
setRightOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.rightScreenId === screen.screenId ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{screen.screenName}</span>
<span className="text-[10px] text-gray-500">{screen.screenCode}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-muted-foreground text-xs"> </p>
</div>
<div className="rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-900 dark:bg-amber-950">
<p className="text-xs text-amber-800 dark:text-amber-200">
💡 <strong> :</strong> ,
"transferData" .
<br />
(), , .
</p>
</div>
</>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 설정 요약 */}
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">
{localConfig.leftScreenId
? screens.find((s) => s.screenId === localConfig.leftScreenId)?.screenName ||
`ID: ${localConfig.leftScreenId}`
: "미설정"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">
{localConfig.rightScreenId
? screens.find((s) => s.screenId === localConfig.rightScreenId)?.screenName ||
`ID: ${localConfig.rightScreenId}`
: "미설정"}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">
{localConfig.splitRatio}% / {100 - localConfig.splitRatio}%
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground"> :</span>
<span className="font-medium">{localConfig.resizable ? "가능" : "불가능"}</span>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ComponentRendererProps } from "@/types/component";
import { ComponentCategory } from "@/types/component";
import { ScreenSplitPanel } from "@/components/screen-embedding/ScreenSplitPanel";
import { ScreenSplitPanelConfigPanel } from "./ScreenSplitPanelConfigPanel";
/**
* Renderer
*
*/
class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = {
id: "screen-split-panel",
name: "화면 분할 패널",
nameEng: "Screen Split Panel",
description: "좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 분할 패널",
category: ComponentCategory.LAYOUT,
webType: "text", // 레이아웃 컴포넌트는 기본 webType 사용
component: ScreenSplitPanel, // React 컴포넌트
configPanel: ScreenSplitPanelConfigPanel, // 설정 패널
tags: ["split", "panel", "embed", "data-transfer", "layout"],
defaultSize: {
width: 1200,
height: 600,
},
defaultConfig: {
screenId: 0,
leftScreenId: 0,
rightScreenId: 0,
splitRatio: 50,
resizable: true,
buttonLabel: "데이터 전달",
buttonPosition: "center",
},
version: "1.0.0",
author: "ERP System",
documentation: `
#
.
##
- ** **:
- ** **:
- ** **: , ,
- ** **: sum, average, concat
- ** **:
##
1. ** **: () ()
2. ** **: () ()
3. ** **: () ()
##
1. "화면 분할 패널"
2. /
3. ( )
4. (, , )
`,
};
render() {
const { config = {}, style = {} } = this.props;
return (
<div style={{ width: "100%", height: "100%", ...style }}>
<ScreenSplitPanel screenId={config.screenId} config={config} />
</div>
);
}
}
// 자동 등록
ScreenSplitPanelRenderer.registerSelf();
export default ScreenSplitPanelRenderer;

View File

@ -23,7 +23,8 @@ export type ButtonActionType =
| "excel_download" // 엑셀 다운로드
| "excel_upload" // 엑셀 업로드
| "barcode_scan" // 바코드 스캔
| "code_merge"; // 코드 병합
| "code_merge" // 코드 병합
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
/**
*
@ -95,6 +96,43 @@ export interface ButtonActionConfig {
editModalTitle?: string; // 편집 모달 제목
editModalDescription?: string; // 편집 모달 설명
groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"])
// 데이터 전달 관련 (transferData 액션용)
dataTransfer?: {
// 소스 설정
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
// 타겟 설정
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
// 타겟이 컴포넌트인 경우
targetComponentId?: string; // 타겟 컴포넌트 ID
// 타겟이 화면인 경우
targetScreenId?: number; // 타겟 화면 ID
// 데이터 매핑 규칙
mappingRules: Array<{
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수
defaultValue?: any; // 기본값
}>;
// 전달 옵션
mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append)
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
// 검증
validation?: {
requireSelection?: boolean; // 선택 필수 (기본: true)
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
};
};
}
/**

View File

@ -0,0 +1,256 @@
/**
*
*
*/
import type {
MappingRule,
Condition,
TransformFunction,
} from "@/types/screen-embedding";
import { logger } from "./logger";
/**
*
*/
export function applyMappingRules(data: any[], rules: MappingRule[]): any[] {
if (!data || data.length === 0) {
return [];
}
// 변환 함수가 있는 규칙 확인
const hasTransform = rules.some((rule) => rule.transform && rule.transform !== "none");
if (hasTransform) {
// 변환 함수가 있으면 단일 값 또는 집계 결과 반환
return [applyTransformRules(data, rules)];
}
// 일반 매핑 (각 행에 대해 매핑)
return data.map((row) => {
const mappedRow: any = {};
for (const rule of rules) {
const sourceValue = getNestedValue(row, rule.sourceField);
const targetValue = sourceValue ?? rule.defaultValue;
setNestedValue(mappedRow, rule.targetField, targetValue);
}
return mappedRow;
});
}
/**
*
*/
function applyTransformRules(data: any[], rules: MappingRule[]): any {
const result: any = {};
for (const rule of rules) {
const values = data.map((row) => getNestedValue(row, rule.sourceField));
const transformedValue = applyTransform(values, rule.transform || "none");
setNestedValue(result, rule.targetField, transformedValue);
}
return result;
}
/**
*
*/
function applyTransform(values: any[], transform: TransformFunction): any {
switch (transform) {
case "none":
return values;
case "sum":
return values.reduce((sum, val) => sum + (Number(val) || 0), 0);
case "average":
const sum = values.reduce((s, val) => s + (Number(val) || 0), 0);
return values.length > 0 ? sum / values.length : 0;
case "count":
return values.length;
case "min":
return Math.min(...values.map((v) => Number(v) || 0));
case "max":
return Math.max(...values.map((v) => Number(v) || 0));
case "first":
return values[0];
case "last":
return values[values.length - 1];
case "concat":
return values.filter((v) => v != null).join("");
case "join":
return values.filter((v) => v != null).join(", ");
case "custom":
// TODO: 커스텀 함수 실행
logger.warn("커스텀 변환 함수는 아직 구현되지 않았습니다.");
return values;
default:
return values;
}
}
/**
*
*/
export function filterDataByCondition(data: any[], condition: Condition): any[] {
return data.filter((row) => {
const value = getNestedValue(row, condition.field);
return evaluateCondition(value, condition.operator, condition.value);
});
}
/**
*
*/
function evaluateCondition(value: any, operator: string, targetValue: any): boolean {
switch (operator) {
case "equals":
return value === targetValue;
case "notEquals":
return value !== targetValue;
case "contains":
return String(value).includes(String(targetValue));
case "notContains":
return !String(value).includes(String(targetValue));
case "greaterThan":
return Number(value) > Number(targetValue);
case "lessThan":
return Number(value) < Number(targetValue);
case "greaterThanOrEqual":
return Number(value) >= Number(targetValue);
case "lessThanOrEqual":
return Number(value) <= Number(targetValue);
case "in":
return Array.isArray(targetValue) && targetValue.includes(value);
case "notIn":
return Array.isArray(targetValue) && !targetValue.includes(value);
default:
logger.warn(`알 수 없는 조건 연산자: ${operator}`);
return true;
}
}
/**
*
* : "user.address.city" -> obj.user.address.city
*/
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) {
return undefined;
}
const keys = path.split(".");
let value = obj;
for (const key of keys) {
if (value == null) {
return undefined;
}
value = value[key];
}
return value;
}
/**
*
* : "user.address.city", "Seoul" -> obj.user.address.city = "Seoul"
*/
function setNestedValue(obj: any, path: string, value: any): void {
if (!obj || !path) {
return;
}
const keys = path.split(".");
const lastKey = keys.pop()!;
let current = obj;
for (const key of keys) {
if (!(key in current)) {
current[key] = {};
}
current = current[key];
}
current[lastKey] = value;
}
/**
*
*/
export function validateMappingResult(
data: any[],
rules: MappingRule[]
): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// 필수 필드 검증
const requiredRules = rules.filter((rule) => rule.required);
for (const rule of requiredRules) {
const hasValue = data.some((row) => {
const value = getNestedValue(row, rule.targetField);
return value != null && value !== "";
});
if (!hasValue) {
errors.push(`필수 필드 누락: ${rule.targetField}`);
}
}
return {
valid: errors.length === 0,
errors,
};
}
/**
*
*
*/
export function previewMapping(
sampleData: any[],
rules: MappingRule[]
): { success: boolean; preview: any[]; errors?: string[] } {
try {
const preview = applyMappingRules(sampleData.slice(0, 5), rules);
const validation = validateMappingResult(preview, rules);
return {
success: validation.valid,
preview,
errors: validation.errors,
};
} catch (error: any) {
return {
success: false,
preview: [],
errors: [error.message],
};
}
}

View File

@ -0,0 +1,52 @@
/**
*
*/
type LogLevel = "debug" | "info" | "warn" | "error";
class Logger {
private isDevelopment = process.env.NODE_ENV === "development";
private log(level: LogLevel, message: string, data?: any) {
if (!this.isDevelopment && level === "debug") {
return;
}
const timestamp = new Date().toISOString();
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
switch (level) {
case "debug":
console.debug(prefix, message, data || "");
break;
case "info":
console.info(prefix, message, data || "");
break;
case "warn":
console.warn(prefix, message, data || "");
break;
case "error":
console.error(prefix, message, data || "");
break;
}
}
debug(message: string, data?: any) {
this.log("debug", message, data);
}
info(message: string, data?: any) {
this.log("info", message, data);
}
warn(message: string, data?: any) {
this.log("warn", message, data);
}
error(message: string, data?: any) {
this.log("error", message, data);
}
}
export const logger = new Logger();

View File

@ -0,0 +1,174 @@
/**
*
* ,
*/
/**
*
*/
export type DataReceivableComponentType =
| "table"
| "form"
| "input"
| "select"
| "repeater"
| "form-group"
| "hidden";
/**
*
*/
export type DataReceiveMode =
| "append" // 기존 데이터에 추가
| "replace" // 기존 데이터를 완전히 교체
| "merge"; // 기존 데이터와 병합 (키 기준)
/**
*
*/
export type TransformFunction =
| "sum" // 합계
| "average" // 평균
| "concat" // 문자열 결합
| "first" // 첫 번째 값
| "last" // 마지막 값
| "count" // 개수
| "custom"; // 커스텀 함수
/**
*
*/
export type ConditionOperator =
| "equals"
| "contains"
| "greaterThan"
| "lessThan"
| "notEquals";
/**
*
*
*/
export interface MappingRule {
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: TransformFunction; // 변환 함수
defaultValue?: any; // 기본값
required?: boolean; // 필수 여부
}
/**
*
*
*/
export interface DataReceiverConfig {
targetComponentId: string; // 타겟 컴포넌트 ID
targetComponentType: DataReceivableComponentType; // 타겟 컴포넌트 타입
mode: DataReceiveMode; // 수신 모드
mappingRules: MappingRule[]; // 매핑 규칙 배열
// 조건부 전달
condition?: {
field: string;
operator: ConditionOperator;
value: any;
};
// 검증 규칙
validation?: {
required?: boolean;
minRows?: number;
maxRows?: number;
};
}
/**
*
*
*/
export interface DataTransferConfig {
// 소스 설정
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
// 타겟 설정
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
// 타겟이 컴포넌트인 경우
targetComponentId?: string; // 타겟 컴포넌트 ID
targetComponentType?: DataReceivableComponentType; // 타겟 컴포넌트 타입
// 타겟이 화면인 경우
targetScreenId?: number; // 타겟 화면 ID
// 데이터 수신자 (여러 개 가능)
dataReceivers: DataReceiverConfig[];
// 전달 옵션
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
// 검증
validation?: {
requireSelection?: boolean; // 선택 필수
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
};
}
/**
*
*/
export interface DataTransferResult {
success: boolean;
transferredCount: number;
errors?: string[];
message?: string;
}
/**
*
*
*/
export interface DataReceivable {
componentId: string;
componentType: DataReceivableComponentType;
/**
*
* @param data
* @param config
*/
receiveData(data: any[], config: DataReceiverConfig): Promise<void>;
/**
*
*/
getData(): any;
}
/**
*
*
*/
export interface DataProvidable {
componentId: string;
componentType: string;
/**
*
*/
getSelectedData(): any[];
/**
*
*/
getAllData(): any[];
/**
*
*/
clearSelection(): void;
}

View File

@ -0,0 +1,379 @@
/**
*
*/
// ============================================
// 1. 화면 임베딩 타입
// ============================================
/**
*
*/
export type EmbeddingMode =
| "view" // 읽기 전용
| "select" // 선택 모드 (체크박스)
| "form" // 폼 입력 모드
| "edit"; // 편집 모드
/**
*
*/
export type EmbeddingPosition =
| "left"
| "right"
| "top"
| "bottom"
| "center";
/**
*
*/
export interface EmbeddingConfig {
width?: string; // "50%", "400px"
height?: string; // "100%", "600px"
resizable?: boolean;
multiSelect?: boolean;
showToolbar?: boolean;
showSearch?: boolean;
showPagination?: boolean;
}
/**
*
*/
export interface ScreenEmbedding {
id: number;
parentScreenId: number;
childScreenId: number;
position: EmbeddingPosition;
mode: EmbeddingMode;
config: EmbeddingConfig;
companyCode: string;
createdAt: string;
updatedAt: string;
createdBy?: string;
}
// ============================================
// 2. 데이터 전달 타입
// ============================================
/**
*
*/
export type ComponentType =
| "table" // 테이블
| "input" // 입력 필드
| "select" // 셀렉트 박스
| "textarea" // 텍스트 영역
| "checkbox" // 체크박스
| "radio" // 라디오 버튼
| "date" // 날짜 선택
| "repeater" // 리피터 (반복 그룹)
| "form-group" // 폼 그룹
| "hidden"; // 히든 필드
/**
*
*/
export type DataReceiveMode =
| "append" // 기존 데이터에 추가
| "replace" // 기존 데이터 덮어쓰기
| "merge"; // 기존 데이터와 병합 (키 기준)
/**
*
*/
export type TransformFunction =
| "none" // 변환 없음
| "sum" // 합계
| "average" // 평균
| "count" // 개수
| "min" // 최소값
| "max" // 최대값
| "first" // 첫 번째 값
| "last" // 마지막 값
| "concat" // 문자열 결합
| "join" // 배열 결합
| "custom"; // 커스텀 함수
/**
*
*/
export type ConditionOperator =
| "equals"
| "notEquals"
| "contains"
| "notContains"
| "greaterThan"
| "lessThan"
| "greaterThanOrEqual"
| "lessThanOrEqual"
| "in"
| "notIn";
/**
*
*/
export interface MappingRule {
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: TransformFunction; // 변환 함수
transformConfig?: any; // 변환 함수 설정
defaultValue?: any; // 기본값
required?: boolean; // 필수 여부
}
/**
*
*/
export interface Condition {
field: string;
operator: ConditionOperator;
value: any;
}
/**
*
*/
export interface ValidationConfig {
required?: boolean;
minRows?: number;
maxRows?: number;
customValidation?: string; // JavaScript 함수 문자열
}
/**
*
*/
export interface DataReceiver {
targetComponentId: string; // 타겟 컴포넌트 ID
targetComponentType: ComponentType;
mode: DataReceiveMode;
mappingRules: MappingRule[];
condition?: Condition; // 조건부 전달
validation?: ValidationConfig;
}
/**
*
*/
export interface ButtonValidation {
requireSelection: boolean;
minSelection?: number;
maxSelection?: number;
confirmMessage?: string;
customValidation?: string;
}
/**
*
*/
export interface TransferButtonConfig {
label: string;
position: "left" | "right" | "center";
icon?: string;
variant?: "default" | "outline" | "ghost" | "destructive";
size?: "sm" | "default" | "lg";
validation?: ButtonValidation;
clearAfterTransfer?: boolean;
}
/**
*
*/
export interface ScreenDataTransfer {
id: number;
sourceScreenId: number;
targetScreenId: number;
sourceComponentId?: string;
sourceComponentType?: string;
dataReceivers: DataReceiver[];
buttonConfig: TransferButtonConfig;
companyCode: string;
createdAt: string;
updatedAt: string;
createdBy?: string;
}
// ============================================
// 3. 분할 패널 타입
// ============================================
/**
*
*/
export interface LayoutConfig {
splitRatio: number; // 0-100 (좌측 비율)
resizable: boolean;
minLeftWidth?: number; // 최소 좌측 너비 (px)
minRightWidth?: number; // 최소 우측 너비 (px)
orientation: "horizontal" | "vertical";
}
/**
*
*/
export interface ScreenSplitPanel {
id: number;
screenId: number;
leftEmbeddingId: number;
rightEmbeddingId: number;
dataTransferId: number;
layoutConfig: LayoutConfig;
companyCode: string;
createdAt: string;
updatedAt: string;
// 조인된 데이터
leftEmbedding?: ScreenEmbedding;
rightEmbedding?: ScreenEmbedding;
dataTransfer?: ScreenDataTransfer;
}
// ============================================
// 4. 컴포넌트 인터페이스
// ============================================
/**
*
*/
export interface DataReceivable {
// 컴포넌트 ID
componentId: string;
// 컴포넌트 타입
componentType: ComponentType;
// 데이터 수신
receiveData(data: any[], mode: DataReceiveMode): Promise<void>;
// 현재 데이터 가져오기
getData(): any;
// 데이터 초기화
clearData(): void;
// 검증
validate(): boolean;
// 이벤트 리스너
onDataReceived?: (data: any[]) => void;
onDataCleared?: () => void;
}
/**
*
*/
export interface Selectable {
// 선택된 행/항목 가져오기
getSelectedRows(): any[];
// 선택 초기화
clearSelection(): void;
// 전체 선택
selectAll(): void;
// 선택 이벤트
onSelectionChanged?: (selectedRows: any[]) => void;
}
/**
*
*/
export interface EmbeddedScreenHandle {
// 선택된 행 가져오기
getSelectedRows(): any[];
// 선택 초기화
clearSelection(): void;
// 데이터 수신
receiveData(data: any[], receivers: DataReceiver[]): Promise<void>;
// 현재 데이터 가져오기
getData(): any;
}
// ============================================
// 5. API 응답 타입
// ============================================
/**
* API
*/
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
/**
*
*/
export interface CreateScreenEmbeddingRequest {
parentScreenId: number;
childScreenId: number;
position: EmbeddingPosition;
mode: EmbeddingMode;
config?: EmbeddingConfig;
}
/**
*
*/
export interface CreateScreenDataTransferRequest {
sourceScreenId: number;
targetScreenId: number;
sourceComponentId?: string;
sourceComponentType?: string;
dataReceivers: DataReceiver[];
buttonConfig: TransferButtonConfig;
}
/**
*
*/
export interface CreateScreenSplitPanelRequest {
screenId: number;
leftEmbedding: CreateScreenEmbeddingRequest;
rightEmbedding: CreateScreenEmbeddingRequest;
dataTransfer: CreateScreenDataTransferRequest;
layoutConfig: LayoutConfig;
}
// ============================================
// 6. 유틸리티 타입
// ============================================
/**
*
*/
export interface DataTransferResult {
success: boolean;
transferredCount: number;
errors?: Array<{
componentId: string;
error: string;
}>;
}
/**
*
*/
export interface MappingResult {
success: boolean;
mappedData: any[];
errors?: string[];
}
/**
*
*/
export interface ValidationResult {
valid: boolean;
errors?: string[];
}

View File

@ -69,7 +69,9 @@ export type ButtonActionType =
| "navigate"
| "newWindow"
// 제어관리 전용
| "control";
| "control"
// 데이터 전달
| "transferData"; // 선택된 데이터를 다른 컴포넌트/화면으로 전달
/**
*
@ -325,6 +327,7 @@ export const isButtonActionType = (value: string): value is ButtonActionType =>
"navigate",
"newWindow",
"control",
"transferData",
];
return actionTypes.includes(value as ButtonActionType);
};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,503 @@
# 화면 임베딩 및 데이터 전달 시스템 구현 완료 보고서
## 📋 개요
입고 등록과 같은 복잡한 워크플로우를 지원하기 위해 **화면 임베딩 및 데이터 전달 시스템**을 구현했습니다.
- **구현 기간**: 2025-11-27
- **구현 범위**: Phase 1-4 (기본 인프라 ~ 핵심 컴포넌트)
- **상태**: ✅ 핵심 기능 구현 완료
---
## ✅ 구현 완료 항목
### Phase 1: 기본 인프라 (100% 완료)
#### 1.1 데이터베이스 스키마
**파일**: `db/migrations/040_create_screen_embedding_tables.sql`
**생성된 테이블**:
1. **screen_embedding** (화면 임베딩 설정)
- 한 화면을 다른 화면 안에 임베드
- 위치 (left, right, top, bottom, center)
- 모드 (view, select, form, edit)
- 설정 (width, height, multiSelect 등)
2. **screen_data_transfer** (데이터 전달 설정)
- 소스 화면 → 타겟 화면 데이터 전달
- 데이터 수신자 배열 (JSONB)
- 매핑 규칙, 조건, 검증
- 전달 버튼 설정
3. **screen_split_panel** (분할 패널 통합)
- 좌측/우측 임베딩 참조
- 데이터 전달 설정 참조
- 레이아웃 설정 (splitRatio, resizable 등)
**샘플 데이터**:
- 입고 등록 시나리오 샘플 데이터 포함
- 발주 목록 → 입고 처리 품목 매핑 예시
#### 1.2 TypeScript 타입 정의
**파일**: `frontend/types/screen-embedding.ts`
**주요 타입**:
```typescript
// 화면 임베딩
- EmbeddingMode: "view" | "select" | "form" | "edit"
- EmbeddingPosition: "left" | "right" | "top" | "bottom" | "center"
- ScreenEmbedding
// 데이터 전달
- ComponentType: "table" | "input" | "select" | "textarea" | ...
- DataReceiveMode: "append" | "replace" | "merge"
- TransformFunction: "sum" | "average" | "count" | "first" | ...
- MappingRule, DataReceiver, ScreenDataTransfer
// 분할 패널
- LayoutConfig, ScreenSplitPanel
// 컴포넌트 인터페이스
- DataReceivable, Selectable, EmbeddedScreenHandle
```
#### 1.3 백엔드 API
**파일**:
- `backend-node/src/controllers/screenEmbeddingController.ts`
- `backend-node/src/routes/screenEmbeddingRoutes.ts`
**API 엔드포인트**:
**화면 임베딩**:
- `GET /api/screen-embedding?parentScreenId=1` - 목록 조회
- `GET /api/screen-embedding/:id` - 상세 조회
- `POST /api/screen-embedding` - 생성
- `PUT /api/screen-embedding/:id` - 수정
- `DELETE /api/screen-embedding/:id` - 삭제
**데이터 전달**:
- `GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2` - 조회
- `POST /api/screen-data-transfer` - 생성
- `PUT /api/screen-data-transfer/:id` - 수정
- `DELETE /api/screen-data-transfer/:id` - 삭제
**분할 패널**:
- `GET /api/screen-split-panel/:screenId` - 조회
- `POST /api/screen-split-panel` - 생성 (트랜잭션)
- `PUT /api/screen-split-panel/:id` - 수정
- `DELETE /api/screen-split-panel/:id` - 삭제 (CASCADE)
**특징**:
- ✅ 멀티테넌시 지원 (company_code 필터링)
- ✅ 트랜잭션 처리 (분할 패널 생성/삭제)
- ✅ 외래키 CASCADE 처리
- ✅ 에러 핸들링 및 로깅
#### 1.4 프론트엔드 API 클라이언트
**파일**: `frontend/lib/api/screenEmbedding.ts`
**함수**:
```typescript
// 화면 임베딩
- getScreenEmbeddings(parentScreenId)
- getScreenEmbeddingById(id)
- createScreenEmbedding(data)
- updateScreenEmbedding(id, data)
- deleteScreenEmbedding(id)
// 데이터 전달
- getScreenDataTransfer(sourceScreenId, targetScreenId)
- createScreenDataTransfer(data)
- updateScreenDataTransfer(id, data)
- deleteScreenDataTransfer(id)
// 분할 패널
- getScreenSplitPanel(screenId)
- createScreenSplitPanel(data)
- updateScreenSplitPanel(id, layoutConfig)
- deleteScreenSplitPanel(id)
```
---
### Phase 2: 화면 임베딩 기능 (100% 완료)
#### 2.1 EmbeddedScreen 컴포넌트
**파일**: `frontend/components/screen-embedding/EmbeddedScreen.tsx`
**주요 기능**:
- ✅ 화면 데이터 로드
- ✅ 모드별 렌더링 (view, select, form, edit)
- ✅ 선택 모드 지원 (체크박스)
- ✅ 컴포넌트 등록/해제 시스템
- ✅ 데이터 수신 처리
- ✅ 로딩/에러 상태 UI
**외부 인터페이스** (useImperativeHandle):
```typescript
- getSelectedRows(): any[]
- clearSelection(): void
- receiveData(data, receivers): Promise<void>
- getData(): any
```
**데이터 수신 프로세스**:
1. 조건 필터링 (condition)
2. 매핑 규칙 적용 (mappingRules)
3. 검증 (validation)
4. 컴포넌트에 데이터 전달
---
### Phase 3: 데이터 전달 시스템 (100% 완료)
#### 3.1 매핑 엔진
**파일**: `frontend/lib/utils/dataMapping.ts`
**주요 함수**:
1. **applyMappingRules(data, rules)**
- 일반 매핑: 각 행에 대해 필드 매핑
- 변환 매핑: 집계 함수 적용
2. **변환 함수 지원**:
- `sum`: 합계
- `average`: 평균
- `count`: 개수
- `min`, `max`: 최소/최대
- `first`, `last`: 첫/마지막 값
- `concat`, `join`: 문자열 결합
3. **filterDataByCondition(data, condition)**
- 조건 연산자: equals, notEquals, contains, greaterThan, lessThan, in, notIn
4. **validateMappingResult(data, rules)**
- 필수 필드 검증
5. **previewMapping(sampleData, rules)**
- 매핑 결과 미리보기
**특징**:
- ✅ 중첩 객체 지원 (`user.address.city`)
- ✅ 타입 안전성
- ✅ 에러 처리
#### 3.2 로거 유틸리티
**파일**: `frontend/lib/utils/logger.ts`
**기능**:
- debug, info, warn, error 레벨
- 개발 환경에서만 debug 출력
- 타임스탬프 포함
---
### Phase 4: 분할 패널 UI (100% 완료)
#### 4.1 ScreenSplitPanel 컴포넌트
**파일**: `frontend/components/screen-embedding/ScreenSplitPanel.tsx`
**주요 기능**:
- ✅ 좌우 화면 임베딩
- ✅ 리사이저 (드래그로 비율 조정)
- ✅ 데이터 전달 버튼
- ✅ 선택 카운트 표시
- ✅ 로딩 상태 표시
- ✅ 검증 (최소/최대 선택 수)
- ✅ 확인 메시지
- ✅ 전달 후 선택 초기화 (옵션)
**UI 구조**:
```
┌─────────────────────────────────────────────────────────┐
│ [좌측 패널 50%] │ [버튼] │ [우측 패널 50%] │
│ │ │ │
│ EmbeddedScreen │ [→] │ EmbeddedScreen │
│ (select 모드) │ │ (form 모드) │
│ │ │ │
│ 선택됨: 3개 │ │ │
└─────────────────────────────────────────────────────────┘
```
**이벤트 흐름**:
1. 좌측에서 행 선택 → 선택 카운트 업데이트
2. 전달 버튼 클릭 → 검증
3. 우측 화면의 컴포넌트들에 데이터 전달
4. 성공 토스트 표시
---
## 📁 파일 구조
```
ERP-node/
├── db/
│ └── migrations/
│ └── 040_create_screen_embedding_tables.sql ✅ 마이그레이션
├── backend-node/
│ └── src/
│ ├── controllers/
│ │ └── screenEmbeddingController.ts ✅ 컨트롤러
│ └── routes/
│ └── screenEmbeddingRoutes.ts ✅ 라우트
└── frontend/
├── types/
│ └── screen-embedding.ts ✅ 타입 정의
├── lib/
│ ├── api/
│ │ └── screenEmbedding.ts ✅ API 클라이언트
│ └── utils/
│ ├── dataMapping.ts ✅ 매핑 엔진
│ └── logger.ts ✅ 로거
└── components/
└── screen-embedding/
├── EmbeddedScreen.tsx ✅ 임베드 화면
├── ScreenSplitPanel.tsx ✅ 분할 패널
└── index.ts ✅ Export
```
---
## 🎯 사용 예시
### 1. 입고 등록 시나리오
```typescript
// 분할 패널 설정
const inboundConfig: ScreenSplitPanel = {
screenId: 100,
leftEmbedding: {
childScreenId: 10, // 발주 목록 조회
position: "left",
mode: "select",
config: {
width: "50%",
multiSelect: true,
},
},
rightEmbedding: {
childScreenId: 20, // 입고 등록 폼
position: "right",
mode: "form",
config: {
width: "50%",
},
},
dataTransfer: {
sourceScreenId: 10,
targetScreenId: 20,
dataReceivers: [
{
targetComponentId: "table-입고처리품목",
targetComponentType: "table",
mode: "append",
mappingRules: [
{ sourceField: "품목코드", targetField: "품목코드" },
{ sourceField: "품목명", targetField: "품목명" },
{ sourceField: "미입고수량", targetField: "입고수량" },
],
},
{
targetComponentId: "input-공급자",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{ sourceField: "공급자", targetField: "value", transform: "first" },
],
},
{
targetComponentId: "input-품목수",
targetComponentType: "input",
mode: "replace",
mappingRules: [
{ sourceField: "품목코드", targetField: "value", transform: "count" },
],
},
],
buttonConfig: {
label: "선택 품목 추가",
position: "center",
icon: "ArrowRight",
validation: {
requireSelection: true,
minSelection: 1,
confirmMessage: "선택한 품목을 추가하시겠습니까?",
},
},
},
layoutConfig: {
splitRatio: 50,
resizable: true,
orientation: "horizontal",
},
};
// 컴포넌트 사용
<ScreenSplitPanel
config={inboundConfig}
onDataTransferred={(data) => {
console.log("전달된 데이터:", data);
}}
/>
```
---
## 🔄 데이터 흐름
```
1. 좌측 화면 (발주 목록)
사용자가 품목 선택 (체크박스)
2. [선택 품목 추가] 버튼 클릭
3. 검증
- 선택 항목 있는지?
- 최소/최대 개수 충족?
- 확인 메시지 동의?
4. 데이터 전달 처리
├─ 조건 필터링 (condition)
├─ 매핑 규칙 적용 (mappingRules)
│ ├─ 일반 매핑: 품목코드 → 품목코드
│ └─ 변환 매핑: 품목코드 → count → 품목수
└─ 검증 (validation)
5. 우측 화면의 컴포넌트들에 데이터 주입
├─ table-입고처리품목: 행 추가 (append)
├─ input-공급자: 값 설정 (replace, first)
└─ input-품목수: 개수 설정 (replace, count)
6. 성공 토스트 표시
7. 좌측 선택 초기화 (옵션)
```
---
## 🚀 다음 단계 (Phase 5-6)
### Phase 5: 고급 기능 (예정)
1. **DataReceivable 인터페이스 구현**
- TableComponent
- InputComponent
- SelectComponent
- RepeaterComponent
- 기타 컴포넌트들
2. **양방향 동기화**
- 우측 → 좌측 데이터 반영
- 실시간 업데이트
3. **트랜잭션 지원**
- 전체 성공 또는 전체 실패
- 롤백 기능
### Phase 6: 설정 UI (예정)
1. **시각적 매핑 설정 UI**
- 드래그앤드롭으로 필드 매핑
- 변환 함수 선택
- 조건 설정
2. **미리보기 기능**
- 데이터 전달 결과 미리보기
- 매핑 규칙 테스트
---
## 📝 사용 가이드
### 1. 마이그레이션 실행
```bash
# PostgreSQL에서 실행
psql -U postgres -d your_database -f db/migrations/040_create_screen_embedding_tables.sql
```
### 2. 백엔드 서버 재시작
라우트가 자동으로 등록되어 있으므로 재시작만 하면 됩니다.
### 3. 분할 패널 화면 생성
1. 화면 관리에서 새 화면 생성
2. 화면 타입: "분할 패널"
3. API를 통해 설정 저장:
```typescript
import { createScreenSplitPanel } from "@/lib/api/screenEmbedding";
const result = await createScreenSplitPanel({
screenId: 100,
leftEmbedding: { ... },
rightEmbedding: { ... },
dataTransfer: { ... },
layoutConfig: { ... },
});
```
### 4. 화면에서 사용
```typescript
import { ScreenSplitPanel } from "@/components/screen-embedding";
import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
// 설정 로드
const { data: config } = await getScreenSplitPanel(screenId);
// 렌더링
<ScreenSplitPanel config={config} />
```
---
## ✅ 체크리스트
### 구현 완료
- [x] 데이터베이스 스키마 (3개 테이블)
- [x] TypeScript 타입 정의
- [x] 백엔드 API (15개 엔드포인트)
- [x] 프론트엔드 API 클라이언트
- [x] EmbeddedScreen 컴포넌트
- [x] 매핑 엔진 (9개 변환 함수)
- [x] ScreenSplitPanel 컴포넌트
- [x] 로거 유틸리티
### 다음 단계
- [ ] DataReceivable 구현 (각 컴포넌트 타입별)
- [ ] 설정 UI (드래그앤드롭 매핑)
- [ ] 미리보기 기능
- [ ] 양방향 동기화
- [ ] 트랜잭션 지원
- [ ] 테스트 및 문서화
---
## 🎉 결론
**화면 임베딩 및 데이터 전달 시스템의 핵심 기능이 완성되었습니다!**
- ✅ 데이터베이스 스키마 완성
- ✅ 백엔드 API 완성
- ✅ 프론트엔드 컴포넌트 완성
- ✅ 매핑 엔진 완성
이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다.

View File

@ -0,0 +1,470 @@
# 화면 임베딩 시스템 - 기존 시스템 충돌 분석 보고서
## 📋 분석 개요
새로 구현한 **화면 임베딩 및 데이터 전달 시스템**이 기존 화면 관리 시스템과 충돌할 가능성을 분석합니다.
---
## ✅ 충돌 없음 (안전한 부분)
### 1. 데이터베이스 스키마
#### 새로운 테이블 (독립적)
```sql
- screen_embedding (신규)
- screen_data_transfer (신규)
- screen_split_panel (신규)
```
**충돌 없는 이유**:
- ✅ 완전히 새로운 테이블명
- ✅ 기존 테이블과 이름 중복 없음
- ✅ 외래키는 기존 `screen_definitions`만 참조 (읽기 전용)
#### 기존 테이블 (영향 없음)
```sql
- screen_definitions (변경 없음)
- screen_layouts (변경 없음)
- screen_widgets (변경 없음)
- screen_templates (변경 없음)
- screen_menu_assignments (변경 없음)
```
**확인 사항**:
- ✅ 기존 테이블 구조 변경 없음
- ✅ 기존 데이터 마이그레이션 불필요
- ✅ 기존 쿼리 영향 없음
---
### 2. API 엔드포인트
#### 새로운 엔드포인트 (독립적)
```
POST /api/screen-embedding
GET /api/screen-embedding
PUT /api/screen-embedding/:id
DELETE /api/screen-embedding/:id
POST /api/screen-data-transfer
GET /api/screen-data-transfer
PUT /api/screen-data-transfer/:id
DELETE /api/screen-data-transfer/:id
POST /api/screen-split-panel
GET /api/screen-split-panel/:screenId
PUT /api/screen-split-panel/:id
DELETE /api/screen-split-panel/:id
```
**충돌 없는 이유**:
- ✅ 기존 `/api/screen-management/*` 와 다른 경로
- ✅ 새로운 라우트 추가만 (기존 라우트 수정 없음)
- ✅ 독립적인 컨트롤러 파일
#### 기존 엔드포인트 (영향 없음)
```
/api/screen-management/* (변경 없음)
/api/screen/* (변경 없음)
/api/layouts/* (변경 없음)
```
---
### 3. TypeScript 타입
#### 새로운 타입 파일 (독립적)
```typescript
frontend/types/screen-embedding.ts (신규)
```
**충돌 없는 이유**:
- ✅ 기존 `screen.ts`, `screen-management.ts` 와 별도 파일
- ✅ 타입명 중복 없음
- ✅ 독립적인 네임스페이스
#### 기존 타입 (영향 없음)
```typescript
frontend/types/screen.ts (변경 없음)
frontend/types/screen-management.ts (변경 없음)
backend-node/src/types/screen.ts (변경 없음)
```
---
### 4. 프론트엔드 컴포넌트
#### 새로운 컴포넌트 (독립적)
```
frontend/components/screen-embedding/
├── EmbeddedScreen.tsx (신규)
├── ScreenSplitPanel.tsx (신규)
└── index.ts (신규)
```
**충돌 없는 이유**:
- ✅ 별도 디렉토리 (`screen-embedding/`)
- ✅ 기존 컴포넌트 수정 없음
- ✅ 독립적으로 import 가능
#### 기존 컴포넌트 (영향 없음)
```
frontend/components/screen/ (변경 없음)
frontend/app/(main)/screens/[screenId]/page.tsx (변경 없음)
```
---
## ⚠️ 주의 필요 (잠재적 충돌 가능성)
### 1. screen_definitions 테이블 참조
**현재 구조**:
```sql
-- 새 테이블들이 screen_definitions를 참조
CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id)
REFERENCES screen_definitions(screen_id) ON DELETE CASCADE
```
**잠재적 문제**:
- ⚠️ 기존 화면 삭제 시 임베딩 설정도 함께 삭제됨 (CASCADE)
- ⚠️ 화면 ID 변경 시 임베딩 설정이 깨질 수 있음
**해결 방법**:
```sql
-- 이미 구현됨: ON DELETE CASCADE
-- 화면 삭제 시 자동으로 관련 임베딩도 삭제
-- 추가 조치 불필요
```
**권장 사항**:
- ✅ 화면 삭제 전 임베딩 사용 여부 확인 UI 추가 (Phase 6)
- ✅ 삭제 시 경고 메시지 표시
---
### 2. 화면 렌더링 로직
**현재 화면 렌더링**:
```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx
function ScreenViewPage() {
// 기존: 단일 화면 렌더링
const screenId = parseInt(params.screenId as string);
// 레이아웃 로드
const layout = await screenApi.getScreenLayout(screenId);
// 컴포넌트 렌더링
<DynamicComponentRenderer components={layout.components} />
}
```
**새로운 렌더링 (분할 패널)**:
```typescript
// 분할 패널 화면인 경우
if (isSplitPanelScreen) {
const config = await getScreenSplitPanel(screenId);
return <ScreenSplitPanel config={config} />;
}
// 일반 화면인 경우
return <DynamicComponentRenderer components={layout.components} />;
```
**잠재적 문제**:
- ⚠️ 화면 타입 구분 로직 필요
- ⚠️ 기존 화면 렌더링 로직 수정 필요
**해결 방법**:
```typescript
// 1. screen_definitions에 screen_type 컬럼 추가 (선택사항)
ALTER TABLE screen_definitions ADD COLUMN screen_type VARCHAR(20) DEFAULT 'normal';
-- 'normal', 'split_panel', 'embedded'
// 2. 또는 screen_split_panel 존재 여부로 판단
const splitPanelConfig = await getScreenSplitPanel(screenId);
if (splitPanelConfig.success && splitPanelConfig.data) {
return <ScreenSplitPanel config={splitPanelConfig.data} />;
}
```
**권장 구현**:
```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx 수정
useEffect(() => {
const loadScreen = async () => {
// 1. 분할 패널 확인
const splitPanelResult = await getScreenSplitPanel(screenId);
if (splitPanelResult.success && splitPanelResult.data) {
// 분할 패널 화면
setScreenType('split_panel');
setSplitPanelConfig(splitPanelResult.data);
return;
}
// 2. 일반 화면
const screenResult = await screenApi.getScreen(screenId);
const layoutResult = await screenApi.getScreenLayout(screenId);
setScreenType('normal');
setScreen(screenResult.data);
setLayout(layoutResult.data);
};
loadScreen();
}, [screenId]);
// 렌더링
{screenType === 'split_panel' && splitPanelConfig && (
<ScreenSplitPanel config={splitPanelConfig} />
)}
{screenType === 'normal' && layout && (
<DynamicComponentRenderer components={layout.components} />
)}
```
---
### 3. 컴포넌트 등록 시스템
**현재 시스템**:
```typescript
// frontend/lib/registry/components.ts
const componentRegistry = new Map<string, ComponentDefinition>();
export function registerComponent(id: string, component: any) {
componentRegistry.set(id, component);
}
```
**새로운 요구사항**:
```typescript
// DataReceivable 인터페이스 구현 필요
interface DataReceivable {
componentId: string;
componentType: ComponentType;
receiveData(data: any[], mode: DataReceiveMode): Promise<void>;
getData(): any;
clearData(): void;
}
```
**잠재적 문제**:
- ⚠️ 기존 컴포넌트들이 DataReceivable 인터페이스 미구현
- ⚠️ 데이터 수신 기능 없음
**해결 방법**:
```typescript
// Phase 5에서 구현 예정
// 기존 컴포넌트를 래핑하는 어댑터 패턴 사용
class TableComponentAdapter implements DataReceivable {
constructor(private tableComponent: any) {}
async receiveData(data: any[], mode: DataReceiveMode) {
if (mode === 'append') {
this.tableComponent.addRows(data);
} else if (mode === 'replace') {
this.tableComponent.setRows(data);
}
}
getData() {
return this.tableComponent.getRows();
}
clearData() {
this.tableComponent.clearRows();
}
}
```
**권장 사항**:
- ✅ 기존 컴포넌트 수정 없이 어댑터로 래핑
- ✅ 점진적으로 DataReceivable 구현
- ✅ 하위 호환성 유지
---
## 🔧 필요한 수정 사항
### 1. 화면 페이지 수정 (필수)
**파일**: `frontend/app/(main)/screens/[screenId]/page.tsx`
**수정 내용**:
```typescript
import { getScreenSplitPanel } from "@/lib/api/screenEmbedding";
import { ScreenSplitPanel } from "@/components/screen-embedding";
function ScreenViewPage() {
const [screenType, setScreenType] = useState<'normal' | 'split_panel'>('normal');
const [splitPanelConfig, setSplitPanelConfig] = useState<any>(null);
useEffect(() => {
const loadScreen = async () => {
// 분할 패널 확인
const splitResult = await getScreenSplitPanel(screenId);
if (splitResult.success && splitResult.data) {
setScreenType('split_panel');
setSplitPanelConfig(splitResult.data);
setLoading(false);
return;
}
// 일반 화면 로드 (기존 로직)
// ...
};
loadScreen();
}, [screenId]);
// 렌더링
if (screenType === 'split_panel' && splitPanelConfig) {
return <ScreenSplitPanel config={splitPanelConfig} />;
}
// 기존 렌더링 로직
// ...
}
```
**영향도**: 중간 (기존 로직에 조건 추가)
---
### 2. 화면 관리 UI 수정 (선택사항)
**파일**: 화면 관리 페이지
**추가 기능**:
- 화면 생성 시 "분할 패널" 타입 선택
- 분할 패널 설정 UI
- 임베딩 설정 UI
- 데이터 매핑 설정 UI
**영향도**: 낮음 (새로운 UI 추가)
---
## 📊 충돌 위험도 평가
| 항목 | 위험도 | 설명 | 조치 필요 |
|------|--------|------|-----------|
| 데이터베이스 스키마 | 🟢 낮음 | 독립적인 새 테이블 | ❌ 불필요 |
| API 엔드포인트 | 🟢 낮음 | 새로운 경로 추가 | ❌ 불필요 |
| TypeScript 타입 | 🟢 낮음 | 별도 파일 | ❌ 불필요 |
| 프론트엔드 컴포넌트 | 🟢 낮음 | 별도 디렉토리 | ❌ 불필요 |
| 화면 렌더링 로직 | 🟡 중간 | 조건 분기 추가 필요 | ✅ 필요 |
| 컴포넌트 등록 시스템 | 🟡 중간 | 어댑터 패턴 필요 | ✅ 필요 (Phase 5) |
| 외래키 CASCADE | 🟡 중간 | 화면 삭제 시 주의 | ⚠️ 주의 |
**전체 위험도**: 🟢 **낮음** (대부분 독립적)
---
## ✅ 안전성 체크리스트
### 데이터베이스
- [x] 새 테이블명이 기존과 중복되지 않음
- [x] 기존 테이블 구조 변경 없음
- [x] 외래키 CASCADE 설정 완료
- [x] 멀티테넌시 (company_code) 지원
### 백엔드
- [x] 새 라우트가 기존과 충돌하지 않음
- [x] 독립적인 컨트롤러 파일
- [x] 기존 API 수정 없음
- [x] 에러 핸들링 완료
### 프론트엔드
- [x] 새 컴포넌트가 별도 디렉토리
- [x] 기존 컴포넌트 수정 없음
- [x] 독립적인 타입 정의
- [ ] 화면 페이지 수정 필요 (조건 분기)
### 호환성
- [x] 기존 화면 동작 영향 없음
- [x] 하위 호환성 유지
- [ ] 컴포넌트 어댑터 구현 (Phase 5)
---
## 🎯 권장 조치 사항
### 즉시 조치 (필수)
1. **화면 페이지 수정**
```typescript
// frontend/app/(main)/screens/[screenId]/page.tsx
// 분할 패널 확인 로직 추가
```
2. **에러 처리 강화**
```typescript
// 분할 패널 로드 실패 시 일반 화면으로 폴백
try {
const splitResult = await getScreenSplitPanel(screenId);
if (splitResult.success) {
return <ScreenSplitPanel />;
}
} catch (error) {
// 일반 화면으로 폴백
}
```
### 단계적 조치 (Phase 5-6)
1. **컴포넌트 어댑터 구현**
- TableComponent → DataReceivable
- InputComponent → DataReceivable
- 기타 컴포넌트들
2. **설정 UI 개발**
- 분할 패널 생성 UI
- 매핑 규칙 설정 UI
- 미리보기 기능
3. **테스트**
- 기존 화면 정상 동작 확인
- 분할 패널 화면 동작 확인
- 화면 전환 테스트
---
## 📝 결론
### ✅ 안전성 평가: 높음
**이유**:
1. ✅ 대부분의 코드가 독립적으로 추가됨
2. ✅ 기존 시스템 수정 최소화
3. ✅ 하위 호환성 유지
4. ✅ 외래키 CASCADE로 데이터 무결성 보장
### ⚠️ 주의 사항
1. **화면 페이지 수정 필요**
- 분할 패널 확인 로직 추가
- 조건부 렌더링 구현
2. **점진적 구현 권장**
- Phase 5: 컴포넌트 어댑터
- Phase 6: 설정 UI
- 단계별 테스트
3. **화면 삭제 시 주의**
- 임베딩 사용 여부 확인
- CASCADE로 자동 삭제됨
### 🎉 최종 결론
**충돌 위험도: 낮음 (🟢)**
새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다.