diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index fc69cdb1..2e753b56 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -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); diff --git a/backend-node/src/controllers/screenEmbeddingController.ts b/backend-node/src/controllers/screenEmbeddingController.ts new file mode 100644 index 00000000..43087589 --- /dev/null +++ b/backend-node/src/controllers/screenEmbeddingController.ts @@ -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(); + } +} diff --git a/backend-node/src/routes/screenEmbeddingRoutes.ts b/backend-node/src/routes/screenEmbeddingRoutes.ts new file mode 100644 index 00000000..6b604c15 --- /dev/null +++ b/backend-node/src/routes/screenEmbeddingRoutes.ts @@ -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; + diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index ce99a685..b4f3e521 100644 --- a/frontend/app/(main)/screens/[screenId]/page.tsx +++ b/frontend/app/(main)/screens/[screenId]/page.tsx @@ -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 ( - + + + ); } diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx new file mode 100644 index 00000000..5cbea9d7 --- /dev/null +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -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( + ({ embedding, onSelectionChanged }, ref) => { + const [layout, setLayout] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // 컴포넌트 참조 맵 + const componentRefs = useRef>(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 = {}; + + componentRefs.current.forEach((component, id) => { + allData[id] = component.getData(); + }); + + return allData; + }, + })); + + // 로딩 상태 + if (loading) { + return ( +
+
+
+

화면을 불러오는 중...

+
+
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+
+
+ + + +
+
+

화면을 불러올 수 없습니다

+

{error}

+
+ +
+
+ ); + } + + // 화면 렌더링 - 레이아웃 기반 렌더링 + return ( +
+ {layout.length === 0 ? ( +
+

화면에 컴포넌트가 없습니다.

+
+ ) : ( +
+ {layout.map((component) => ( + + ))} +
+ )} +
+ ); + }, +); + +EmbeddedScreen.displayName = "EmbeddedScreen"; diff --git a/frontend/components/screen-embedding/ScreenSplitPanel.tsx b/frontend/components/screen-embedding/ScreenSplitPanel.tsx new file mode 100644 index 00000000..a7b2cc54 --- /dev/null +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -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 ( +
+
+
+
+ +
+
+
+

화면 분할 패널

+

좌우로 화면을 나눕니다

+

+ 우측 속성 패널 → 상세 설정에서 좌측/우측 화면을 선택하세요 +

+

+ 💡 데이터 전달: 좌측 화면에 버튼 배치 후 transferData 액션 설정 +

+
+
+
+ ); + } + + return ( +
+ {/* 좌측 패널 */} +
+ +
+ + {/* 리사이저 */} + {config?.resizable !== false && ( +
{ + 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); + }} + > +
+
+ )} + + {/* 우측 패널 */} +
+ +
+
+ ); +} diff --git a/frontend/components/screen-embedding/index.ts b/frontend/components/screen-embedding/index.ts new file mode 100644 index 00000000..63742180 --- /dev/null +++ b/frontend/components/screen-embedding/index.ts @@ -0,0 +1,7 @@ +/** + * 화면 임베딩 및 데이터 전달 시스템 컴포넌트 + */ + +export { EmbeddedScreen } from "./EmbeddedScreen"; +export { ScreenSplitPanel } from "./ScreenSplitPanel"; + diff --git a/frontend/contexts/ScreenContext.tsx b/frontend/contexts/ScreenContext.tsx new file mode 100644 index 00000000..ca6b34b3 --- /dev/null +++ b/frontend/contexts/ScreenContext.tsx @@ -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; + getAllDataReceivers: () => Map; +} + +const ScreenContext = createContext(null); + +interface ScreenContextProviderProps { + screenId?: number; + tableName?: string; + children: React.ReactNode; +} + +/** + * 화면 컨텍스트 프로바이더 + */ +export function ScreenContextProvider({ screenId, tableName, children }: ScreenContextProviderProps) { + const dataProvidersRef = useRef>(new Map()); + const dataReceiversRef = useRef>(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 {children}; +} + +/** + * 화면 컨텍스트 훅 + */ +export function useScreenContext() { + const context = useContext(ScreenContext); + if (!context) { + throw new Error("useScreenContext는 ScreenContextProvider 내부에서만 사용할 수 있습니다."); + } + return context; +} + +/** + * 화면 컨텍스트 훅 (선택적) + * 컨텍스트가 없어도 에러를 발생시키지 않습니다. + */ +export function useScreenContextOptional() { + return useContext(ScreenContext); +} + diff --git a/frontend/lib/api/screenEmbedding.ts b/frontend/lib/api/screenEmbedding.ts new file mode 100644 index 00000000..4a110895 --- /dev/null +++ b/frontend/lib/api/screenEmbedding.ts @@ -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> { + 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> { + 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> { + 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 +): Promise> { + 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> { + 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> { + 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> { + 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 +): Promise> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + return getScreenSplitPanel(screenId); +} + diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 12e6e944..50cd6d62 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -62,6 +62,9 @@ import "./section-card/SectionCardRenderer"; // Section Card (제목+테두리 // 🆕 탭 컴포넌트 import "./tabs/tabs-component"; // 탭 기반 화면 전환 컴포넌트 +// 🆕 화면 임베딩 및 분할 패널 컴포넌트 +import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달) + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx new file mode 100644 index 00000000..26d55dcf --- /dev/null +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelConfigPanel.tsx @@ -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([]); + 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 ( +
+ + + + + 레이아웃 + + + + 화면 설정 + + + + {/* 레이아웃 탭 */} + + + + 분할 비율 + 좌측과 우측 패널의 너비 비율을 설정합니다 + + +
+
+ + {localConfig.splitRatio}% +
+ updateConfig("splitRatio", parseInt(e.target.value))} + className="h-2" + /> +
+ 20% + 50% + 80% +
+
+ + + +
+
+ +

사용자가 패널 크기를 조절할 수 있습니다

+
+ updateConfig("resizable", checked)} + /> +
+
+
+
+ + {/* 화면 설정 탭 */} + + + + 임베드할 화면 선택 + 좌측과 우측에 표시할 화면을 선택합니다 + + + {isLoadingScreens ? ( +
+ + 화면 목록 로딩 중... +
+ ) : ( + <> +
+ + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens.map((screen) => ( + { + updateConfig("leftScreenId", screen.screenId); + setLeftOpen(false); + }} + className="text-xs" + > + +
+ {screen.screenName} + {screen.screenCode} +
+
+ ))} +
+
+
+
+
+

데이터를 선택할 소스 화면

+
+ + + +
+ + + + + + + + + + 화면을 찾을 수 없습니다. + + {screens.map((screen) => ( + { + updateConfig("rightScreenId", screen.screenId); + setRightOpen(false); + }} + className="text-xs" + > + +
+ {screen.screenName} + {screen.screenCode} +
+
+ ))} +
+
+
+
+
+

데이터를 받을 타겟 화면

+
+ +
+

+ 💡 데이터 전달 방법: 좌측 화면에 테이블과 버튼을 배치하고, 버튼의 액션을 + "transferData"로 설정하세요. +
+ 버튼 설정에서 소스 컴포넌트(테이블), 타겟 화면, 필드 매핑을 지정할 수 있습니다. +

+
+ + )} +
+
+
+
+ + {/* 설정 요약 */} + + + 현재 설정 + + +
+
+ 좌측 화면: + + {localConfig.leftScreenId + ? screens.find((s) => s.screenId === localConfig.leftScreenId)?.screenName || + `ID: ${localConfig.leftScreenId}` + : "미설정"} + +
+
+ 우측 화면: + + {localConfig.rightScreenId + ? screens.find((s) => s.screenId === localConfig.rightScreenId)?.screenName || + `ID: ${localConfig.rightScreenId}` + : "미설정"} + +
+
+ 분할 비율: + + {localConfig.splitRatio}% / {100 - localConfig.splitRatio}% + +
+
+ 크기 조절: + {localConfig.resizable ? "가능" : "불가능"} +
+
+
+
+
+ ); +} diff --git a/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx new file mode 100644 index 00000000..7247c5c2 --- /dev/null +++ b/frontend/lib/registry/components/screen-split-panel/ScreenSplitPanelRenderer.tsx @@ -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 ( +
+ +
+ ); + } +} + +// 자동 등록 +ScreenSplitPanelRenderer.registerSelf(); + +export default ScreenSplitPanelRenderer; diff --git a/frontend/lib/utils/buttonActions.ts b/frontend/lib/utils/buttonActions.ts index eafbd814..d6a4b676 100644 --- a/frontend/lib/utils/buttonActions.ts +++ b/frontend/lib/utils/buttonActions.ts @@ -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; // 최대 선택 개수 + }; + }; } /** diff --git a/frontend/lib/utils/dataMapping.ts b/frontend/lib/utils/dataMapping.ts new file mode 100644 index 00000000..de7e2377 --- /dev/null +++ b/frontend/lib/utils/dataMapping.ts @@ -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], + }; + } +} + diff --git a/frontend/lib/utils/logger.ts b/frontend/lib/utils/logger.ts new file mode 100644 index 00000000..45ee92ce --- /dev/null +++ b/frontend/lib/utils/logger.ts @@ -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(); + diff --git a/frontend/types/data-transfer.ts b/frontend/types/data-transfer.ts new file mode 100644 index 00000000..cdb5f55f --- /dev/null +++ b/frontend/types/data-transfer.ts @@ -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; + + /** + * 현재 컴포넌트의 데이터를 가져오는 메서드 + */ + getData(): any; +} + +/** + * 데이터 제공 가능한 컴포넌트 인터페이스 + * 데이터를 제공할 수 있는 컴포넌트가 구현해야 하는 인터페이스 + */ +export interface DataProvidable { + componentId: string; + componentType: string; + + /** + * 선택된 데이터를 가져오는 메서드 + */ + getSelectedData(): any[]; + + /** + * 모든 데이터를 가져오는 메서드 + */ + getAllData(): any[]; + + /** + * 선택 초기화 메서드 + */ + clearSelection(): void; +} + diff --git a/frontend/types/screen-embedding.ts b/frontend/types/screen-embedding.ts new file mode 100644 index 00000000..6e8b4a02 --- /dev/null +++ b/frontend/types/screen-embedding.ts @@ -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; + + // 현재 데이터 가져오기 + 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; + + // 현재 데이터 가져오기 + getData(): any; +} + +// ============================================ +// 5. API 응답 타입 +// ============================================ + +/** + * API 응답 + */ +export interface ApiResponse { + 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[]; +} + diff --git a/frontend/types/unified-core.ts b/frontend/types/unified-core.ts index f80c5c39..7ec2d0c2 100644 --- a/frontend/types/unified-core.ts +++ b/frontend/types/unified-core.ts @@ -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); }; diff --git a/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md new file mode 100644 index 00000000..7aed8903 --- /dev/null +++ b/화면_임베딩_및_데이터_전달_시스템_구현_계획서.md @@ -0,0 +1,1608 @@ +# 화면 임베딩 및 데이터 전달 시스템 구현 계획서 + +## 📋 목차 +1. [개요](#개요) +2. [현재 문제점](#현재-문제점) +3. [목표](#목표) +4. [시스템 아키텍처](#시스템-아키텍처) +5. [데이터베이스 설계](#데이터베이스-설계) +6. [타입 정의](#타입-정의) +7. [컴포넌트 구조](#컴포넌트-구조) +8. [API 설계](#api-설계) +9. [구현 단계](#구현-단계) +10. [사용 시나리오](#사용-시나리오) + +--- + +## 개요 + +### 배경 +현재 화면관리 시스템은 단일 화면 단위로만 동작하며, 화면 간 데이터 전달이나 화면 임베딩이 불가능합니다. 실무에서는 "입고 등록"과 같이 **좌측에서 데이터를 선택하고 우측으로 전달하여 처리하는** 복잡한 워크플로우가 필요합니다. + +### 핵심 요구사항 +- **화면 임베딩**: 기존 화면을 다른 화면 안에 재사용 +- **데이터 전달**: 한 화면에서 선택한 데이터를 다른 화면의 컴포넌트로 전달 +- **유연한 매핑**: 테이블뿐만 아니라 입력 필드, 셀렉트 박스, 리피터 등 모든 컴포넌트에 데이터 주입 가능 +- **변환 함수**: 합계, 평균, 개수 등 데이터 변환 지원 + +--- + +## 현재 문제점 + +### 1. 화면 재사용 불가 +- 각 화면은 독립적으로만 동작 +- 동일한 기능을 여러 화면에서 중복 구현 + +### 2. 화면 간 데이터 전달 불가 +- 한 화면에서 선택한 데이터를 다른 화면으로 전달할 수 없음 +- 사용자가 수동으로 복사/붙여넣기 해야 함 + +### 3. 복잡한 워크플로우 구현 불가 +- "발주 목록 조회 → 품목 선택 → 입고 등록"과 같은 프로세스를 단일 화면에서 처리 불가 +- 여러 화면을 오가며 작업해야 하는 불편함 + +### 4. 컴포넌트별 데이터 주입 불가 +- 테이블에만 데이터를 추가할 수 있음 +- 입력 필드, 셀렉트 박스 등에 자동으로 값을 설정할 수 없음 + +--- + +## 목표 + +### 주요 목표 +1. **화면 임베딩 시스템 구축**: 기존 화면을 컨테이너로 사용 +2. **범용 데이터 전달 시스템**: 모든 컴포넌트 타입 지원 +3. **시각적 매핑 설정 UI**: 드래그앤드롭으로 매핑 규칙 설정 +4. **실시간 미리보기**: 데이터 전달 결과를 즉시 확인 + +### 부가 목표 +- 조건부 데이터 전달 (필터링) +- 데이터 변환 함수 (합계, 평균, 개수 등) +- 양방향 데이터 동기화 +- 트랜잭션 지원 (전체 성공 또는 전체 실패) + +--- + +## 시스템 아키텍처 + +### 전체 구조 + +``` +┌─────────────────────────────────────────────────────────┐ +│ Screen Split Panel │ +│ │ +│ ┌──────────────────┐ ┌──────────────────┐ │ +│ │ Left Screen │ │ Right Screen │ │ +│ │ (Source) │ │ (Target) │ │ +│ │ │ │ │ │ +│ │ ┌────────────┐ │ │ ┌────────────┐ │ │ +│ │ │ Table │ │ │ │ Form │ │ │ +│ │ │ (Select) │ │ │ │ │ │ │ +│ │ └────────────┘ │ │ └────────────┘ │ │ +│ │ │ │ │ │ +│ │ [✓] Row 1 │ │ Input: ____ │ │ +│ │ [✓] Row 2 │ │ Select: [ ] │ │ +│ │ [ ] Row 3 │ │ │ │ +│ │ │ │ ┌────────────┐ │ │ +│ └──────────────────┘ │ │ Table │ │ │ +│ │ │ │ (Append) │ │ │ +│ │ │ └────────────┘ │ │ +│ ▼ │ │ │ +│ [선택 품목 추가] ──────────▶│ Row 1 (Added) │ │ +│ │ Row 2 (Added) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +### 레이어 구조 + +``` +┌─────────────────────────────────────────┐ +│ Presentation Layer (UI) │ +│ - ScreenSplitPanel │ +│ - EmbeddedScreen │ +│ - DataMappingConfig │ +└─────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────┐ +│ Business Logic Layer │ +│ - DataTransferService │ +│ - MappingEngine │ +│ - TransformFunctions │ +└─────────────────────────────────────────┘ + │ +┌─────────────────────────────────────────┐ +│ Data Access Layer │ +│ - screen_embedding (테이블) │ +│ - screen_data_transfer (테이블) │ +│ - component_data_receiver (인터페이스) │ +└─────────────────────────────────────────┘ +``` + +--- + +## 데이터베이스 설계 + +### 1. screen_embedding (화면 임베딩 설정) + +```sql +CREATE TABLE screen_embedding ( + id SERIAL PRIMARY KEY, + + -- 부모 화면 (컨테이너) + parent_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), + + -- 자식 화면 (임베드될 화면) + child_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), + + -- 임베딩 위치 + position VARCHAR(20) NOT NULL, -- 'left', 'right', 'top', 'bottom', 'center' + + -- 임베딩 모드 + mode VARCHAR(20) NOT NULL, -- 'view', 'select', 'form', 'edit' + + -- 추가 설정 + config JSONB, + -- { + -- "width": "50%", + -- "height": "100%", + -- "resizable": true, + -- "multiSelect": true, + -- "showToolbar": true + -- } + + -- 멀티테넌시 + company_code VARCHAR(20) NOT NULL, + + -- 메타데이터 + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + CONSTRAINT fk_parent_screen FOREIGN KEY (parent_screen_id) + REFERENCES screen_info(screen_id) ON DELETE CASCADE, + CONSTRAINT fk_child_screen FOREIGN KEY (child_screen_id) + REFERENCES screen_info(screen_id) ON DELETE CASCADE +); + +-- 인덱스 +CREATE INDEX idx_screen_embedding_parent ON screen_embedding(parent_screen_id, company_code); +CREATE INDEX idx_screen_embedding_child ON screen_embedding(child_screen_id, company_code); +``` + +### 2. screen_data_transfer (데이터 전달 설정) + +```sql +CREATE TABLE screen_data_transfer ( + id SERIAL PRIMARY KEY, + + -- 소스 화면 (데이터 제공) + source_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), + + -- 타겟 화면 (데이터 수신) + target_screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), + + -- 소스 컴포넌트 (선택 영역) + source_component_id VARCHAR(100), + source_component_type VARCHAR(50), -- 'table', 'list', 'grid' + + -- 데이터 수신자 설정 (JSONB 배열) + data_receivers JSONB NOT NULL, + -- [ + -- { + -- "targetComponentId": "table-입고처리품목", + -- "targetComponentType": "table", + -- "mode": "append", + -- "mappingRules": [ + -- { + -- "sourceField": "품목코드", + -- "targetField": "품목코드", + -- "transform": null + -- } + -- ], + -- "condition": { + -- "field": "상태", + -- "operator": "equals", + -- "value": "승인" + -- } + -- } + -- ] + + -- 전달 버튼 설정 + button_config JSONB, + -- { + -- "label": "선택 품목 추가", + -- "position": "center", + -- "icon": "ArrowRight", + -- "validation": { + -- "requireSelection": true, + -- "minSelection": 1, + -- "maxSelection": 100, + -- "customValidation": "function(rows) { return rows.length > 0; }" + -- } + -- } + + -- 멀티테넌시 + company_code VARCHAR(20) NOT NULL, + + -- 메타데이터 + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + created_by VARCHAR(50), + + CONSTRAINT fk_source_screen FOREIGN KEY (source_screen_id) + REFERENCES screen_info(screen_id) ON DELETE CASCADE, + CONSTRAINT fk_target_screen FOREIGN KEY (target_screen_id) + REFERENCES screen_info(screen_id) ON DELETE CASCADE +); + +-- 인덱스 +CREATE INDEX idx_screen_data_transfer_source ON screen_data_transfer(source_screen_id, company_code); +CREATE INDEX idx_screen_data_transfer_target ON screen_data_transfer(target_screen_id, company_code); +``` + +### 3. screen_split_panel (분할 패널 설정) + +```sql +CREATE TABLE screen_split_panel ( + id SERIAL PRIMARY KEY, + + -- 부모 화면 (분할 패널 컨테이너) + screen_id INTEGER NOT NULL REFERENCES screen_info(screen_id), + + -- 좌측 화면 임베딩 + left_embedding_id INTEGER REFERENCES screen_embedding(id), + + -- 우측 화면 임베딩 + right_embedding_id INTEGER REFERENCES screen_embedding(id), + + -- 데이터 전달 설정 + data_transfer_id INTEGER REFERENCES screen_data_transfer(id), + + -- 레이아웃 설정 + layout_config JSONB, + -- { + -- "splitRatio": 50, // 좌:우 비율 (0-100) + -- "resizable": true, + -- "minLeftWidth": 300, + -- "minRightWidth": 400, + -- "orientation": "horizontal" // 'horizontal' | 'vertical' + -- } + + -- 멀티테넌시 + company_code VARCHAR(20) NOT NULL, + + -- 메타데이터 + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + + CONSTRAINT fk_screen FOREIGN KEY (screen_id) + REFERENCES screen_info(screen_id) ON DELETE CASCADE, + CONSTRAINT fk_left_embedding FOREIGN KEY (left_embedding_id) + REFERENCES screen_embedding(id) ON DELETE SET NULL, + CONSTRAINT fk_right_embedding FOREIGN KEY (right_embedding_id) + REFERENCES screen_embedding(id) ON DELETE SET NULL, + CONSTRAINT fk_data_transfer FOREIGN KEY (data_transfer_id) + REFERENCES screen_data_transfer(id) ON DELETE SET NULL +); + +-- 인덱스 +CREATE INDEX idx_screen_split_panel_screen ON screen_split_panel(screen_id, company_code); +``` + +--- + +## 타입 정의 + +### 1. 화면 임베딩 타입 + +```typescript +// 임베딩 모드 +type EmbeddingMode = + | "view" // 읽기 전용 + | "select" // 선택 모드 (체크박스) + | "form" // 폼 입력 모드 + | "edit"; // 편집 모드 + +// 임베딩 위치 +type EmbeddingPosition = + | "left" + | "right" + | "top" + | "bottom" + | "center"; + +// 화면 임베딩 설정 +interface ScreenEmbedding { + id: number; + parentScreenId: number; + childScreenId: number; + position: EmbeddingPosition; + mode: EmbeddingMode; + config: { + width?: string; // "50%", "400px" + height?: string; // "100%", "600px" + resizable?: boolean; + multiSelect?: boolean; + showToolbar?: boolean; + showSearch?: boolean; + showPagination?: boolean; + }; + companyCode: string; +} +``` + +### 2. 데이터 전달 타입 + +```typescript +// 컴포넌트 타입 +type ComponentType = + | "table" // 테이블 + | "input" // 입력 필드 + | "select" // 셀렉트 박스 + | "textarea" // 텍스트 영역 + | "checkbox" // 체크박스 + | "radio" // 라디오 버튼 + | "date" // 날짜 선택 + | "repeater" // 리피터 (반복 그룹) + | "form-group" // 폼 그룹 + | "hidden"; // 히든 필드 + +// 데이터 수신 모드 +type DataReceiveMode = + | "append" // 기존 데이터에 추가 + | "replace" // 기존 데이터 덮어쓰기 + | "merge"; // 기존 데이터와 병합 (키 기준) + +// 변환 함수 +type TransformFunction = + | "none" // 변환 없음 + | "sum" // 합계 + | "average" // 평균 + | "count" // 개수 + | "min" // 최소값 + | "max" // 최대값 + | "first" // 첫 번째 값 + | "last" // 마지막 값 + | "concat" // 문자열 결합 + | "join" // 배열 결합 + | "custom"; // 커스텀 함수 + +// 조건 연산자 +type ConditionOperator = + | "equals" + | "notEquals" + | "contains" + | "notContains" + | "greaterThan" + | "lessThan" + | "greaterThanOrEqual" + | "lessThanOrEqual" + | "in" + | "notIn"; + +// 매핑 규칙 +interface MappingRule { + sourceField: string; // 소스 필드명 + targetField: string; // 타겟 필드명 + transform?: TransformFunction; // 변환 함수 + transformConfig?: any; // 변환 함수 설정 + defaultValue?: any; // 기본값 + required?: boolean; // 필수 여부 +} + +// 조건 +interface Condition { + field: string; + operator: ConditionOperator; + value: any; +} + +// 데이터 수신자 +interface DataReceiver { + targetComponentId: string; // 타겟 컴포넌트 ID + targetComponentType: ComponentType; + mode: DataReceiveMode; + mappingRules: MappingRule[]; + condition?: Condition; // 조건부 전달 + validation?: { + required?: boolean; + minRows?: number; + maxRows?: number; + customValidation?: string; // JavaScript 함수 문자열 + }; +} + +// 버튼 설정 +interface TransferButtonConfig { + label: string; + position: "left" | "right" | "center"; + icon?: string; + variant?: "default" | "outline" | "ghost"; + size?: "sm" | "default" | "lg"; + validation?: { + requireSelection: boolean; + minSelection?: number; + maxSelection?: number; + confirmMessage?: string; + customValidation?: string; + }; +} + +// 데이터 전달 설정 +interface ScreenDataTransfer { + id: number; + sourceScreenId: number; + targetScreenId: number; + sourceComponentId?: string; + sourceComponentType?: string; + dataReceivers: DataReceiver[]; + buttonConfig: TransferButtonConfig; + companyCode: string; +} +``` + +### 3. 분할 패널 타입 + +```typescript +// 레이아웃 설정 +interface LayoutConfig { + splitRatio: number; // 0-100 (좌측 비율) + resizable: boolean; + minLeftWidth?: number; // 최소 좌측 너비 (px) + minRightWidth?: number; // 최소 우측 너비 (px) + orientation: "horizontal" | "vertical"; +} + +// 분할 패널 설정 +interface ScreenSplitPanel { + id: number; + screenId: number; + leftEmbedding: ScreenEmbedding; + rightEmbedding: ScreenEmbedding; + dataTransfer: ScreenDataTransfer; + layoutConfig: LayoutConfig; + companyCode: string; +} +``` + +### 4. 컴포넌트 인터페이스 + +```typescript +// 모든 데이터 수신 가능 컴포넌트가 구현해야 하는 인터페이스 +interface DataReceivable { + // 컴포넌트 ID + componentId: string; + + // 컴포넌트 타입 + componentType: ComponentType; + + // 데이터 수신 + receiveData(data: any[], mode: DataReceiveMode): Promise; + + // 현재 데이터 가져오기 + getData(): any; + + // 데이터 초기화 + clearData(): void; + + // 검증 + validate(): boolean; + + // 이벤트 리스너 + onDataReceived?: (data: any[]) => void; + onDataCleared?: () => void; +} + +// 선택 가능 컴포넌트 인터페이스 +interface Selectable { + // 선택된 행/항목 가져오기 + getSelectedRows(): any[]; + + // 선택 초기화 + clearSelection(): void; + + // 전체 선택 + selectAll(): void; + + // 선택 이벤트 + onSelectionChanged?: (selectedRows: any[]) => void; +} +``` + +--- + +## 컴포넌트 구조 + +### 1. ScreenSplitPanel (최상위 컨테이너) + +```tsx +interface ScreenSplitPanelProps { + config: ScreenSplitPanel; + onDataTransferred?: (data: any[]) => void; +} + +export function ScreenSplitPanel({ config, onDataTransferred }: ScreenSplitPanelProps) { + const leftScreenRef = useRef(null); + const rightScreenRef = useRef(null); + const [splitRatio, setSplitRatio] = useState(config.layoutConfig.splitRatio); + + // 데이터 전달 핸들러 + const handleTransferData = async () => { + // 1. 좌측 화면에서 선택된 데이터 가져오기 + const selectedRows = leftScreenRef.current?.getSelectedRows() || []; + + if (selectedRows.length === 0) { + toast.error("선택된 항목이 없습니다."); + return; + } + + // 2. 검증 + if (config.dataTransfer.buttonConfig.validation) { + const validation = config.dataTransfer.buttonConfig.validation; + + if (validation.minSelection && selectedRows.length < validation.minSelection) { + toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`); + return; + } + + if (validation.maxSelection && selectedRows.length > validation.maxSelection) { + toast.error(`최대 ${validation.maxSelection}개까지만 선택할 수 있습니다.`); + return; + } + + if (validation.confirmMessage) { + const confirmed = await confirm(validation.confirmMessage); + if (!confirmed) return; + } + } + + // 3. 데이터 전달 + try { + await rightScreenRef.current?.receiveData( + selectedRows, + config.dataTransfer.dataReceivers + ); + + toast.success("데이터가 전달되었습니다."); + onDataTransferred?.(selectedRows); + + // 4. 좌측 선택 초기화 (옵션) + if (config.dataTransfer.buttonConfig.clearAfterTransfer) { + leftScreenRef.current?.clearSelection(); + } + } catch (error) { + toast.error("데이터 전달 중 오류가 발생했습니다."); + console.error(error); + } + }; + + return ( +
+ {/* 좌측 패널 */} +
+ +
+ + {/* 리사이저 */} + {config.layoutConfig.resizable && ( + setSplitRatio(newRatio)} + /> + )} + + {/* 전달 버튼 */} +
+ +
+ + {/* 우측 패널 */} +
+ +
+
+ ); +} +``` + +### 2. EmbeddedScreen (임베드된 화면) + +```tsx +interface EmbeddedScreenProps { + embedding: ScreenEmbedding; +} + +export interface EmbeddedScreenHandle { + getSelectedRows(): any[]; + clearSelection(): void; + receiveData(data: any[], receivers: DataReceiver[]): Promise; + getData(): any; +} + +export const EmbeddedScreen = forwardRef( + ({ embedding }, ref) => { + const [screenData, setScreenData] = useState(null); + const [selectedRows, setSelectedRows] = useState([]); + const componentRefs = useRef>(new Map()); + + // 화면 데이터 로드 + useEffect(() => { + loadScreenData(embedding.childScreenId); + }, [embedding.childScreenId]); + + // 외부에서 호출 가능한 메서드 + useImperativeHandle(ref, () => ({ + getSelectedRows: () => selectedRows, + + clearSelection: () => { + setSelectedRows([]); + }, + + receiveData: async (data: any[], receivers: DataReceiver[]) => { + // 각 데이터 수신자에게 데이터 전달 + for (const receiver of receivers) { + const component = componentRefs.current.get(receiver.targetComponentId); + + if (!component) { + console.warn(`컴포넌트를 찾을 수 없습니다: ${receiver.targetComponentId}`); + continue; + } + + // 조건 확인 + let filteredData = data; + if (receiver.condition) { + filteredData = filterData(data, receiver.condition); + } + + // 매핑 적용 + const mappedData = applyMappingRules(filteredData, receiver.mappingRules); + + // 데이터 전달 + await component.receiveData(mappedData, receiver.mode); + } + }, + + getData: () => { + const allData: Record = {}; + componentRefs.current.forEach((component, id) => { + allData[id] = component.getData(); + }); + return allData; + } + })); + + // 컴포넌트 등록 + const registerComponent = (id: string, component: DataReceivable) => { + componentRefs.current.set(id, component); + }; + + return ( +
+ {screenData && ( + + )} +
+ ); + } +); +``` + +### 3. DataReceivable 구현 예시 + +#### TableComponent + +```typescript +class TableComponent implements DataReceivable { + componentId: string; + componentType: ComponentType = "table"; + private rows: any[] = []; + + async receiveData(data: any[], mode: DataReceiveMode): Promise { + switch (mode) { + case "append": + this.rows = [...this.rows, ...data]; + break; + case "replace": + this.rows = data; + break; + case "merge": + // 키 기반 병합 (예: id 필드) + const existingIds = new Set(this.rows.map(r => r.id)); + const newRows = data.filter(r => !existingIds.has(r.id)); + this.rows = [...this.rows, ...newRows]; + break; + } + + this.render(); + this.onDataReceived?.(data); + } + + getData(): any { + return this.rows; + } + + clearData(): void { + this.rows = []; + this.render(); + this.onDataCleared?.(); + } + + validate(): boolean { + return this.rows.length > 0; + } + + private render() { + // 테이블 리렌더링 + } +} +``` + +#### InputComponent + +```typescript +class InputComponent implements DataReceivable { + componentId: string; + componentType: ComponentType = "input"; + private value: any = ""; + + async receiveData(data: any[], mode: DataReceiveMode): Promise { + // 입력 필드는 단일 값이므로 첫 번째 항목만 사용 + if (data.length > 0) { + this.value = data[0]; + this.render(); + this.onDataReceived?.(data); + } + } + + getData(): any { + return this.value; + } + + clearData(): void { + this.value = ""; + this.render(); + this.onDataCleared?.(); + } + + validate(): boolean { + return this.value !== null && this.value !== undefined && this.value !== ""; + } + + private render() { + // 입력 필드 리렌더링 + } +} +``` + +--- + +## API 설계 + +### 1. 화면 임베딩 API + +```typescript +// GET /api/screen-embedding/:parentScreenId +export async function getScreenEmbeddings( + parentScreenId: number, + companyCode: string +): Promise> { + const query = ` + SELECT * FROM screen_embedding + WHERE parent_screen_id = $1 + AND company_code = $2 + ORDER BY position + `; + + const result = await pool.query(query, [parentScreenId, companyCode]); + return { success: true, data: result.rows }; +} + +// POST /api/screen-embedding +export async function createScreenEmbedding( + embedding: Omit, + companyCode: string +): Promise> { + const query = ` + INSERT INTO screen_embedding ( + parent_screen_id, child_screen_id, position, mode, config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + + const result = await pool.query(query, [ + embedding.parentScreenId, + embedding.childScreenId, + embedding.position, + embedding.mode, + JSON.stringify(embedding.config), + companyCode + ]); + + return { success: true, data: result.rows[0] }; +} + +// PUT /api/screen-embedding/:id +export async function updateScreenEmbedding( + id: number, + embedding: Partial, + companyCode: string +): Promise> { + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (embedding.position) { + updates.push(`position = $${paramIndex++}`); + values.push(embedding.position); + } + + if (embedding.mode) { + updates.push(`mode = $${paramIndex++}`); + values.push(embedding.mode); + } + + if (embedding.config) { + updates.push(`config = $${paramIndex++}`); + values.push(JSON.stringify(embedding.config)); + } + + 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 { success: false, message: "임베딩 설정을 찾을 수 없습니다." }; + } + + return { success: true, data: result.rows[0] }; +} + +// DELETE /api/screen-embedding/:id +export async function deleteScreenEmbedding( + id: number, + companyCode: string +): Promise> { + const query = ` + DELETE FROM screen_embedding + WHERE id = $1 AND company_code = $2 + `; + + const result = await pool.query(query, [id, companyCode]); + + if (result.rowCount === 0) { + return { success: false, message: "임베딩 설정을 찾을 수 없습니다." }; + } + + return { success: true }; +} +``` + +### 2. 데이터 전달 API + +```typescript +// GET /api/screen-data-transfer?sourceScreenId=1&targetScreenId=2 +export async function getScreenDataTransfer( + sourceScreenId: number, + targetScreenId: number, + companyCode: string +): Promise> { + const query = ` + SELECT * FROM screen_data_transfer + WHERE source_screen_id = $1 + AND target_screen_id = $2 + AND company_code = $3 + `; + + const result = await pool.query(query, [sourceScreenId, targetScreenId, companyCode]); + + if (result.rowCount === 0) { + return { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." }; + } + + return { success: true, data: result.rows[0] }; +} + +// POST /api/screen-data-transfer +export async function createScreenDataTransfer( + transfer: Omit, + companyCode: string +): Promise> { + 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 + ) VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING * + `; + + const result = await pool.query(query, [ + transfer.sourceScreenId, + transfer.targetScreenId, + transfer.sourceComponentId, + transfer.sourceComponentType, + JSON.stringify(transfer.dataReceivers), + JSON.stringify(transfer.buttonConfig), + companyCode + ]); + + return { success: true, data: result.rows[0] }; +} + +// PUT /api/screen-data-transfer/:id +export async function updateScreenDataTransfer( + id: number, + transfer: Partial, + companyCode: string +): Promise> { + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (transfer.dataReceivers) { + updates.push(`data_receivers = $${paramIndex++}`); + values.push(JSON.stringify(transfer.dataReceivers)); + } + + if (transfer.buttonConfig) { + updates.push(`button_config = $${paramIndex++}`); + values.push(JSON.stringify(transfer.buttonConfig)); + } + + 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 { success: false, message: "데이터 전달 설정을 찾을 수 없습니다." }; + } + + return { success: true, data: result.rows[0] }; +} +``` + +### 3. 분할 패널 API + +```typescript +// GET /api/screen-split-panel/:screenId +export async function getScreenSplitPanel( + screenId: number, + companyCode: string +): Promise> { + const query = ` + SELECT + ssp.*, + le.* as left_embedding, + re.* as right_embedding, + sdt.* as data_transfer + 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 { success: false, message: "분할 패널 설정을 찾을 수 없습니다." }; + } + + return { success: true, data: result.rows[0] }; +} + +// POST /api/screen-split-panel +export async function createScreenSplitPanel( + panel: Omit, + companyCode: string +): Promise> { + const client = await pool.connect(); + + try { + await client.query("BEGIN"); + + // 1. 좌측 임베딩 생성 + const leftEmbedding = await createScreenEmbedding(panel.leftEmbedding, companyCode); + + // 2. 우측 임베딩 생성 + const rightEmbedding = await createScreenEmbedding(panel.rightEmbedding, companyCode); + + // 3. 데이터 전달 설정 생성 + const dataTransfer = await createScreenDataTransfer(panel.dataTransfer, companyCode); + + // 4. 분할 패널 생성 + const query = ` + INSERT INTO screen_split_panel ( + screen_id, left_embedding_id, right_embedding_id, data_transfer_id, + layout_config, company_code + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING * + `; + + const result = await client.query(query, [ + panel.screenId, + leftEmbedding.data!.id, + rightEmbedding.data!.id, + dataTransfer.data!.id, + JSON.stringify(panel.layoutConfig), + companyCode + ]); + + await client.query("COMMIT"); + + return { success: true, data: result.rows[0] }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} +``` + +--- + +## 구현 단계 + +### Phase 1: 기본 인프라 구축 (1-2주) + +#### 1.1 데이터베이스 마이그레이션 +- [ ] `screen_embedding` 테이블 생성 +- [ ] `screen_data_transfer` 테이블 생성 +- [ ] `screen_split_panel` 테이블 생성 +- [ ] 인덱스 및 외래키 설정 +- [ ] 샘플 데이터 삽입 + +#### 1.2 타입 정의 +- [ ] TypeScript 인터페이스 작성 +- [ ] `types/screen-embedding.ts` +- [ ] `types/data-transfer.ts` +- [ ] `types/split-panel.ts` + +#### 1.3 백엔드 API +- [ ] 화면 임베딩 CRUD API +- [ ] 데이터 전달 설정 CRUD API +- [ ] 분할 패널 CRUD API +- [ ] 컨트롤러 및 서비스 레이어 구현 + +### Phase 2: 화면 임베딩 기능 (2-3주) + +#### 2.1 EmbeddedScreen 컴포넌트 +- [ ] 기본 임베딩 기능 +- [ ] 모드별 렌더링 (view, select, form, edit) +- [ ] 선택 모드 구현 (체크박스) +- [ ] 이벤트 핸들링 + +#### 2.2 DataReceivable 인터페이스 구현 +- [ ] TableComponent +- [ ] InputComponent +- [ ] SelectComponent +- [ ] TextareaComponent +- [ ] RepeaterComponent +- [ ] FormGroupComponent +- [ ] HiddenComponent + +#### 2.3 컴포넌트 등록 시스템 +- [ ] 컴포넌트 마운트 시 자동 등록 +- [ ] 컴포넌트 ID 관리 +- [ ] 컴포넌트 참조 관리 + +### Phase 3: 데이터 전달 시스템 (2-3주) + +#### 3.1 매핑 엔진 +- [ ] 매핑 규칙 파싱 +- [ ] 필드 매핑 적용 +- [ ] 변환 함수 구현 + - [ ] sum, average, count + - [ ] min, max + - [ ] first, last + - [ ] concat, join + +#### 3.2 조건부 전달 +- [ ] 조건 파싱 +- [ ] 필터링 로직 +- [ ] 복합 조건 지원 + +#### 3.3 검증 시스템 +- [ ] 필수 필드 검증 +- [ ] 최소/최대 행 수 검증 +- [ ] 커스텀 검증 함수 실행 + +### Phase 4: 분할 패널 UI (2-3주) + +#### 4.1 ScreenSplitPanel 컴포넌트 +- [ ] 기본 레이아웃 +- [ ] 리사이저 구현 +- [ ] 전달 버튼 +- [ ] 반응형 디자인 + +#### 4.2 설정 UI +- [ ] 화면 선택 드롭다운 +- [ ] 매핑 규칙 설정 UI +- [ ] 드래그앤드롭 매핑 +- [ ] 미리보기 기능 + +#### 4.3 시각적 피드백 +- [ ] 데이터 전달 애니메이션 +- [ ] 로딩 상태 표시 +- [ ] 성공/실패 토스트 + +### Phase 5: 고급 기능 (2-3주) + +#### 5.1 양방향 동기화 +- [ ] 우측 → 좌측 데이터 반영 +- [ ] 실시간 업데이트 + +#### 5.2 트랜잭션 지원 +- [ ] 전체 성공 또는 전체 실패 +- [ ] 롤백 기능 + +#### 5.3 성능 최적화 +- [ ] 대량 데이터 처리 +- [ ] 가상 스크롤링 +- [ ] 메모이제이션 + +### Phase 6: 테스트 및 문서화 (1-2주) + +#### 6.1 단위 테스트 +- [ ] 매핑 엔진 테스트 +- [ ] 변환 함수 테스트 +- [ ] 검증 로직 테스트 + +#### 6.2 통합 테스트 +- [ ] 전체 워크플로우 테스트 +- [ ] 실제 시나리오 테스트 + +#### 6.3 문서화 +- [ ] 사용자 가이드 +- [ ] 개발자 문서 +- [ ] API 문서 + +--- + +## 사용 시나리오 + +### 시나리오 1: 입고 등록 + +#### 요구사항 +- 발주 목록에서 품목을 선택하여 입고 등록 +- 선택된 품목의 정보를 입고 처리 품목 테이블에 추가 +- 공급자 정보를 자동으로 입력 필드에 설정 +- 총 품목 수를 자동 계산 + +#### 설정 + +```typescript +const 입고등록_설정: ScreenSplitPanel = { + screenId: 100, + leftEmbedding: { + childScreenId: 10, // 발주 목록 조회 화면 + position: "left", + mode: "select", + config: { + width: "50%", + multiSelect: true, + showSearch: true, + showPagination: true + } + }, + rightEmbedding: { + childScreenId: 20, // 입고 등록 폼 화면 + position: "right", + mode: "form", + config: { + width: "50%" + } + }, + dataTransfer: { + sourceScreenId: 10, + targetScreenId: 20, + sourceComponentId: "table-발주목록", + sourceComponentType: "table", + dataReceivers: [ + { + targetComponentId: "table-입고처리품목", + targetComponentType: "table", + mode: "append", + mappingRules: [ + { sourceField: "품목코드", targetField: "품목코드" }, + { 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 + } + } + }, + layoutConfig: { + splitRatio: 50, + resizable: true, + minLeftWidth: 400, + minRightWidth: 600, + orientation: "horizontal" + } +}; +``` + +### 시나리오 2: 수주 등록 + +#### 요구사항 +- 견적서 목록에서 품목을 선택하여 수주 등록 +- 고객 정보를 자동으로 폼에 설정 +- 품목별 수량 및 금액 자동 계산 +- 총 금액 합계 표시 + +#### 설정 + +```typescript +const 수주등록_설정: ScreenSplitPanel = { + screenId: 101, + leftEmbedding: { + childScreenId: 30, // 견적서 목록 조회 화면 + position: "left", + mode: "select", + config: { + width: "40%", + multiSelect: true + } + }, + rightEmbedding: { + childScreenId: 40, // 수주 등록 폼 화면 + position: "right", + mode: "form", + config: { + width: "60%" + } + }, + dataTransfer: { + sourceScreenId: 30, + targetScreenId: 40, + dataReceivers: [ + { + targetComponentId: "table-수주품목", + targetComponentType: "table", + mode: "append", + mappingRules: [ + { sourceField: "품목코드", targetField: "품목코드" }, + { sourceField: "품목명", targetField: "품목명" }, + { sourceField: "수량", targetField: "수량" }, + { sourceField: "단가", targetField: "단가" }, + { + sourceField: "수량", + targetField: "금액", + transform: "custom", + transformConfig: { + formula: "수량 * 단가" + } + } + ] + }, + { + targetComponentId: "input-고객명", + targetComponentType: "input", + mode: "replace", + mappingRules: [ + { sourceField: "고객명", targetField: "value", transform: "first" } + ] + }, + { + targetComponentId: "input-총금액", + targetComponentType: "input", + mode: "replace", + mappingRules: [ + { + sourceField: "금액", + targetField: "value", + transform: "sum" + } + ] + } + ], + buttonConfig: { + label: "견적서 불러오기", + position: "center", + icon: "Download" + } + }, + layoutConfig: { + splitRatio: 40, + resizable: true, + orientation: "horizontal" + } +}; +``` + +### 시나리오 3: 출고 등록 + +#### 요구사항 +- 재고 목록에서 품목을 선택하여 출고 등록 +- 재고 수량 확인 및 경고 +- 출고 가능 수량만 필터링 +- 창고별 재고 정보 표시 + +#### 설정 + +```typescript +const 출고등록_설정: ScreenSplitPanel = { + screenId: 102, + leftEmbedding: { + childScreenId: 50, // 재고 목록 조회 화면 + position: "left", + mode: "select", + config: { + width: "45%", + multiSelect: true + } + }, + rightEmbedding: { + childScreenId: 60, // 출고 등록 폼 화면 + position: "right", + mode: "form", + config: { + width: "55%" + } + }, + dataTransfer: { + sourceScreenId: 50, + targetScreenId: 60, + dataReceivers: [ + { + targetComponentId: "table-출고품목", + targetComponentType: "table", + mode: "append", + mappingRules: [ + { sourceField: "품목코드", targetField: "품목코드" }, + { sourceField: "품목명", targetField: "품목명" }, + { sourceField: "재고수량", targetField: "가용수량" }, + { sourceField: "창고", targetField: "출고창고" } + ], + condition: { + field: "재고수량", + operator: "greaterThan", + value: 0 + } + }, + { + targetComponentId: "input-총출고수량", + targetComponentType: "input", + mode: "replace", + mappingRules: [ + { + sourceField: "재고수량", + targetField: "value", + transform: "sum" + } + ] + } + ], + buttonConfig: { + label: "출고 품목 추가", + position: "center", + icon: "ArrowRight", + validation: { + requireSelection: true, + confirmMessage: "선택한 품목을 출고 처리하시겠습니까?" + } + } + }, + layoutConfig: { + splitRatio: 45, + resizable: true, + orientation: "horizontal" + } +}; +``` + +--- + +## 기술적 고려사항 + +### 1. 성능 최적화 + +#### 대량 데이터 처리 +- 가상 스크롤링 적용 +- 청크 단위 데이터 전달 +- 백그라운드 처리 + +#### 메모리 관리 +- 컴포넌트 언마운트 시 참조 해제 +- 이벤트 리스너 정리 +- 메모이제이션 활용 + +### 2. 보안 + +#### 권한 검증 +- 화면 접근 권한 확인 +- 데이터 전달 권한 확인 +- 멀티테넌시 격리 + +#### 데이터 검증 +- 입력값 검증 +- SQL 인젝션 방지 +- XSS 방지 + +### 3. 에러 처리 + +#### 사용자 친화적 메시지 +- 명확한 오류 메시지 +- 복구 방법 안내 +- 로그 기록 + +#### 트랜잭션 롤백 +- 부분 실패 시 전체 롤백 +- 데이터 일관성 유지 + +### 4. 확장성 + +#### 플러그인 시스템 +- 커스텀 변환 함수 등록 +- 커스텀 검증 함수 등록 +- 커스텀 컴포넌트 타입 추가 + +#### 이벤트 시스템 +- 데이터 전달 전/후 이벤트 +- 커스텀 이벤트 핸들러 + +--- + +## 마일스톤 + +### M1: 기본 인프라 (2주) +- 데이터베이스 스키마 완성 +- 백엔드 API 완성 +- 타입 정의 완성 + +### M2: 화면 임베딩 (3주) +- EmbeddedScreen 컴포넌트 완성 +- DataReceivable 인터페이스 구현 완료 +- 선택 모드 동작 확인 + +### M3: 데이터 전달 (3주) +- 매핑 엔진 완성 +- 변환 함수 구현 완료 +- 조건부 전달 동작 확인 + +### M4: 분할 패널 UI (3주) +- ScreenSplitPanel 컴포넌트 완성 +- 설정 UI 완성 +- 입고 등록 시나리오 완성 + +### M5: 고급 기능 및 최적화 (3주) +- 양방향 동기화 완성 +- 성능 최적화 완료 +- 전체 테스트 통과 + +### M6: 문서화 및 배포 (1주) +- 사용자 가이드 작성 +- 개발자 문서 작성 +- 프로덕션 배포 + +--- + +## 예상 일정 + +**총 소요 기간**: 약 15주 (3.5개월) + +- Week 1-2: Phase 1 (기본 인프라) +- Week 3-5: Phase 2 (화면 임베딩) +- Week 6-8: Phase 3 (데이터 전달) +- Week 9-11: Phase 4 (분할 패널 UI) +- Week 12-14: Phase 5 (고급 기능) +- Week 15: Phase 6 (테스트 및 문서화) + +--- + +## 성공 지표 + +### 기능적 지표 +- [ ] 입고 등록 시나리오 완벽 동작 +- [ ] 수주 등록 시나리오 완벽 동작 +- [ ] 출고 등록 시나리오 완벽 동작 +- [ ] 모든 컴포넌트 타입 데이터 수신 가능 +- [ ] 모든 변환 함수 정상 동작 + +### 성능 지표 +- [ ] 1000개 행 데이터 전달 < 1초 +- [ ] 화면 로딩 시간 < 2초 +- [ ] 메모리 사용량 < 100MB + +### 사용성 지표 +- [ ] 설정 UI 직관적 +- [ ] 에러 메시지 명확 +- [ ] 문서 완성도 90% 이상 + +--- + +## 리스크 관리 + +### 기술적 리스크 +- **복잡도 증가**: 단계별 구현으로 관리 +- **성능 문제**: 초기부터 최적화 고려 +- **호환성 문제**: 기존 시스템과 충돌 방지 + +### 일정 리스크 +- **예상 기간 초과**: 버퍼 2주 확보 +- **우선순위 변경**: 핵심 기능 먼저 구현 + +### 인력 리스크 +- **담당자 부재**: 문서화 철저히 +- **지식 공유**: 주간 리뷰 미팅 + +--- + +## 결론 + +화면 임베딩 및 데이터 전달 시스템은 복잡한 업무 워크플로우를 효율적으로 처리할 수 있는 강력한 기능입니다. 단계별로 체계적으로 구현하면 약 3.5개월 내에 완성할 수 있으며, 이를 통해 사용자 경험을 크게 향상시킬 수 있습니다. + diff --git a/화면_임베딩_시스템_Phase1-4_구현_완료.md b/화면_임베딩_시스템_Phase1-4_구현_완료.md new file mode 100644 index 00000000..cf4879c0 --- /dev/null +++ b/화면_임베딩_시스템_Phase1-4_구현_완료.md @@ -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 +- 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", + }, +}; + +// 컴포넌트 사용 + { + 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); + +// 렌더링 + +``` + +--- + +## ✅ 체크리스트 + +### 구현 완료 +- [x] 데이터베이스 스키마 (3개 테이블) +- [x] TypeScript 타입 정의 +- [x] 백엔드 API (15개 엔드포인트) +- [x] 프론트엔드 API 클라이언트 +- [x] EmbeddedScreen 컴포넌트 +- [x] 매핑 엔진 (9개 변환 함수) +- [x] ScreenSplitPanel 컴포넌트 +- [x] 로거 유틸리티 + +### 다음 단계 +- [ ] DataReceivable 구현 (각 컴포넌트 타입별) +- [ ] 설정 UI (드래그앤드롭 매핑) +- [ ] 미리보기 기능 +- [ ] 양방향 동기화 +- [ ] 트랜잭션 지원 +- [ ] 테스트 및 문서화 + +--- + +## 🎉 결론 + +**화면 임베딩 및 데이터 전달 시스템의 핵심 기능이 완성되었습니다!** + +- ✅ 데이터베이스 스키마 완성 +- ✅ 백엔드 API 완성 +- ✅ 프론트엔드 컴포넌트 완성 +- ✅ 매핑 엔진 완성 + +이제 입고 등록과 같은 복잡한 워크플로우를 구현할 수 있습니다. 다음 단계는 각 컴포넌트 타입별 DataReceivable 인터페이스 구현과 설정 UI 개발입니다. + diff --git a/화면_임베딩_시스템_충돌_분석_보고서.md b/화면_임베딩_시스템_충돌_분석_보고서.md new file mode 100644 index 00000000..00e16b8e --- /dev/null +++ b/화면_임베딩_시스템_충돌_분석_보고서.md @@ -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); + + // 컴포넌트 렌더링 + +} +``` + +**새로운 렌더링 (분할 패널)**: +```typescript +// 분할 패널 화면인 경우 +if (isSplitPanelScreen) { + const config = await getScreenSplitPanel(screenId); + return ; +} + +// 일반 화면인 경우 +return ; +``` + +**잠재적 문제**: +- ⚠️ 화면 타입 구분 로직 필요 +- ⚠️ 기존 화면 렌더링 로직 수정 필요 + +**해결 방법**: +```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 ; +} +``` + +**권장 구현**: +```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 && ( + +)} + +{screenType === 'normal' && layout && ( + +)} +``` + +--- + +### 3. 컴포넌트 등록 시스템 + +**현재 시스템**: +```typescript +// frontend/lib/registry/components.ts +const componentRegistry = new Map(); + +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; + 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(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 ; + } + + // 기존 렌더링 로직 + // ... +} +``` + +**영향도**: 중간 (기존 로직에 조건 추가) + +--- + +### 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 ; + } + } 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로 자동 삭제됨 + +### 🎉 최종 결론 + +**충돌 위험도: 낮음 (🟢)** + +새로운 시스템은 기존 시스템과 **독립적으로 동작**하며, 최소한의 수정만으로 통합 가능합니다. 화면 페이지에 조건 분기만 추가하면 바로 사용할 수 있습니다. +