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/controllers/tableCategoryValueController.ts b/backend-node/src/controllers/tableCategoryValueController.ts index c25b4127..248bb867 100644 --- a/backend-node/src/controllers/tableCategoryValueController.ts +++ b/backend-node/src/controllers/tableCategoryValueController.ts @@ -481,6 +481,52 @@ export const deleteColumnMapping = async (req: AuthenticatedRequest, res: Respon } }; +/** + * 테이블+컬럼 기준으로 모든 매핑 삭제 + * + * DELETE /api/categories/column-mapping/:tableName/:columnName + * + * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 + */ +export const deleteColumnMappingsByColumn = async (req: AuthenticatedRequest, res: Response) => { + try { + const companyCode = req.user!.companyCode; + const { tableName, columnName } = req.params; + + if (!tableName || !columnName) { + return res.status(400).json({ + success: false, + message: "tableName과 columnName은 필수입니다", + }); + } + + logger.info("테이블+컬럼 기준 매핑 삭제", { + tableName, + columnName, + companyCode, + }); + + const deletedCount = await tableCategoryValueService.deleteColumnMappingsByColumn( + tableName, + columnName, + companyCode + ); + + return res.json({ + success: true, + message: `${deletedCount}개의 컬럼 매핑이 삭제되었습니다`, + deletedCount, + }); + } catch (error: any) { + logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`); + return res.status(500).json({ + success: false, + message: error.message || "컬럼 매핑 삭제 중 오류가 발생했습니다", + error: error.message, + }); + } +}; + /** * 2레벨 메뉴 목록 조회 * 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/backend-node/src/routes/tableCategoryValueRoutes.ts b/backend-node/src/routes/tableCategoryValueRoutes.ts index c4afe66e..b79aab75 100644 --- a/backend-node/src/routes/tableCategoryValueRoutes.ts +++ b/backend-node/src/routes/tableCategoryValueRoutes.ts @@ -11,6 +11,7 @@ import { createColumnMapping, getLogicalColumns, deleteColumnMapping, + deleteColumnMappingsByColumn, getSecondLevelMenus, } from "../controllers/tableCategoryValueController"; import { authenticateToken } from "../middleware/authMiddleware"; @@ -57,7 +58,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns); // 컬럼 매핑 생성/수정 router.post("/column-mapping", createColumnMapping); -// 컬럼 매핑 삭제 +// 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용) +// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트) +router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn); + +// 컬럼 매핑 삭제 (단일) router.delete("/column-mapping/:mappingId", deleteColumnMapping); export default router; diff --git a/backend-node/src/services/menuCopyService.ts b/backend-node/src/services/menuCopyService.ts index 7d969b06..70b45af4 100644 --- a/backend-node/src/services/menuCopyService.ts +++ b/backend-node/src/services/menuCopyService.ts @@ -10,10 +10,6 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; - copiedCategories: number; - copiedCodes: number; - copiedCategorySettings: number; - copiedNumberingRules: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; @@ -129,35 +125,6 @@ interface FlowStepConnection { label: string | null; } -/** - * 코드 카테고리 - */ -interface CodeCategory { - category_code: string; - category_name: string; - category_name_eng: string | null; - description: string | null; - sort_order: number | null; - is_active: string; - company_code: string; - menu_objid: number; -} - -/** - * 코드 정보 - */ -interface CodeInfo { - code_category: string; - code_value: string; - code_name: string; - code_name_eng: string | null; - description: string | null; - sort_order: number | null; - is_active: string; - company_code: string; - menu_objid: number; -} - /** * 메뉴 복사 서비스 */ @@ -249,6 +216,24 @@ export class MenuCopyService { } } } + + // 3) 탭 컴포넌트 (tabs 배열 내부의 screenId) + if ( + props?.componentConfig?.tabs && + Array.isArray(props.componentConfig.tabs) + ) { + for (const tab of props.componentConfig.tabs) { + if (tab.screenId) { + const screenId = tab.screenId; + const numId = + typeof screenId === "number" ? screenId : parseInt(screenId); + if (!isNaN(numId)) { + referenced.push(numId); + logger.debug(` 📑 탭 컴포넌트에서 화면 참조 발견: ${numId} (탭: ${tab.label || tab.id})`); + } + } + } + } } return referenced; @@ -355,127 +340,6 @@ export class MenuCopyService { return flowIds; } - /** - * 코드 수집 - */ - private async collectCodes( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ categories: CodeCategory[]; codes: CodeInfo[] }> { - logger.info(`📋 코드 수집 시작: ${menuObjids.length}개 메뉴`); - - const categories: CodeCategory[] = []; - const codes: CodeInfo[] = []; - - for (const menuObjid of menuObjids) { - // 코드 카테고리 - const catsResult = await client.query( - `SELECT * FROM code_category - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - categories.push(...catsResult.rows); - - // 각 카테고리의 코드 정보 - for (const cat of catsResult.rows) { - const codesResult = await client.query( - `SELECT * FROM code_info - WHERE code_category = $1 AND menu_objid = $2 AND company_code = $3`, - [cat.category_code, menuObjid, sourceCompanyCode] - ); - codes.push(...codesResult.rows); - } - } - - logger.info( - `✅ 코드 수집 완료: 카테고리 ${categories.length}개, 코드 ${codes.length}개` - ); - return { categories, codes }; - } - - /** - * 카테고리 설정 수집 - */ - private async collectCategorySettings( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ - columnMappings: any[]; - categoryValues: any[]; - }> { - logger.info(`📂 카테고리 설정 수집 시작: ${menuObjids.length}개 메뉴`); - - const columnMappings: any[] = []; - const categoryValues: any[] = []; - - // 카테고리 컬럼 매핑 (메뉴별 + 공통) - const mappingsResult = await client.query( - `SELECT * FROM category_column_mapping - WHERE (menu_objid = ANY($1) OR menu_objid = 0) - AND company_code = $2`, - [menuObjids, sourceCompanyCode] - ); - columnMappings.push(...mappingsResult.rows); - - // 테이블 컬럼 카테고리 값 (메뉴별 + 공통) - const valuesResult = await client.query( - `SELECT * FROM table_column_category_values - WHERE (menu_objid = ANY($1) OR menu_objid = 0) - AND company_code = $2`, - [menuObjids, sourceCompanyCode] - ); - categoryValues.push(...valuesResult.rows); - - logger.info( - `✅ 카테고리 설정 수집 완료: 컬럼 매핑 ${columnMappings.length}개 (공통 포함), 카테고리 값 ${categoryValues.length}개 (공통 포함)` - ); - return { columnMappings, categoryValues }; - } - - /** - * 채번 규칙 수집 - */ - private async collectNumberingRules( - menuObjids: number[], - sourceCompanyCode: string, - client: PoolClient - ): Promise<{ - rules: any[]; - parts: any[]; - }> { - logger.info(`📋 채번 규칙 수집 시작: ${menuObjids.length}개 메뉴`); - - const rules: any[] = []; - const parts: any[] = []; - - for (const menuObjid of menuObjids) { - // 채번 규칙 - const rulesResult = await client.query( - `SELECT * FROM numbering_rules - WHERE menu_objid = $1 AND company_code = $2`, - [menuObjid, sourceCompanyCode] - ); - rules.push(...rulesResult.rows); - - // 각 규칙의 파트 - for (const rule of rulesResult.rows) { - const partsResult = await client.query( - `SELECT * FROM numbering_rule_parts - WHERE rule_id = $1 AND company_code = $2`, - [rule.rule_id, sourceCompanyCode] - ); - parts.push(...partsResult.rows); - } - } - - logger.info( - `✅ 채번 규칙 수집 완료: 규칙 ${rules.length}개, 파트 ${parts.length}개` - ); - return { rules, parts }; - } - /** * 다음 메뉴 objid 생성 */ @@ -709,42 +573,8 @@ export class MenuCopyService { ]); logger.info(` ✅ 메뉴 권한 삭제 완료`); - // 5-5. 채번 규칙 파트 삭제 - await client.query( - `DELETE FROM numbering_rule_parts - WHERE rule_id IN ( - SELECT rule_id FROM numbering_rules - WHERE menu_objid = ANY($1) AND company_code = $2 - )`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 채번 규칙 파트 삭제 완료`); - - // 5-6. 채번 규칙 삭제 - await client.query( - `DELETE FROM numbering_rules - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 채번 규칙 삭제 완료`); - - // 5-7. 테이블 컬럼 카테고리 값 삭제 - await client.query( - `DELETE FROM table_column_category_values - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 카테고리 값 삭제 완료`); - - // 5-8. 카테고리 컬럼 매핑 삭제 - await client.query( - `DELETE FROM category_column_mapping - WHERE menu_objid = ANY($1) AND company_code = $2`, - [existingMenuIds, targetCompanyCode] - ); - logger.info(` ✅ 카테고리 매핑 삭제 완료`); - - // 5-9. 메뉴 삭제 (역순: 하위 메뉴부터) + // 5-5. 메뉴 삭제 (역순: 하위 메뉴부터) + // 주의: 채번 규칙과 카테고리 설정은 회사마다 고유하므로 삭제하지 않음 for (let i = existingMenus.length - 1; i >= 0; i--) { await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ existingMenus[i].objid, @@ -801,33 +631,11 @@ export class MenuCopyService { const flowIds = await this.collectFlows(screenIds, client); - const codes = await this.collectCodes( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - - const categorySettings = await this.collectCategorySettings( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - - const numberingRules = await this.collectNumberingRules( - menus.map((m) => m.objid), - sourceCompanyCode, - client - ); - logger.info(` 📊 수집 완료: - 메뉴: ${menus.length}개 - 화면: ${screenIds.size}개 - 플로우: ${flowIds.size}개 - - 코드 카테고리: ${codes.categories.length}개 - - 코드: ${codes.codes.length}개 - - 카테고리 설정: 컬럼 매핑 ${categorySettings.columnMappings.length}개, 카테고리 값 ${categorySettings.categoryValues.length}개 - - 채번 규칙: 규칙 ${numberingRules.rules.length}개, 파트 ${numberingRules.parts.length}개 `); // === 2단계: 플로우 복사 === @@ -871,30 +679,6 @@ export class MenuCopyService { client ); - // === 6단계: 코드 복사 === - logger.info("\n📋 [6단계] 코드 복사"); - await this.copyCodes(codes, menuIdMap, targetCompanyCode, userId, client); - - // === 7단계: 카테고리 설정 복사 === - logger.info("\n📂 [7단계] 카테고리 설정 복사"); - await this.copyCategorySettings( - categorySettings, - menuIdMap, - targetCompanyCode, - userId, - client - ); - - // === 8단계: 채번 규칙 복사 === - logger.info("\n📋 [8단계] 채번 규칙 복사"); - await this.copyNumberingRules( - numberingRules, - menuIdMap, - targetCompanyCode, - userId, - client - ); - // 커밋 await client.query("COMMIT"); logger.info("✅ 트랜잭션 커밋 완료"); @@ -904,13 +688,6 @@ export class MenuCopyService { copiedMenus: menuIdMap.size, copiedScreens: screenIdMap.size, copiedFlows: flowIdMap.size, - copiedCategories: codes.categories.length, - copiedCodes: codes.codes.length, - copiedCategorySettings: - categorySettings.columnMappings.length + - categorySettings.categoryValues.length, - copiedNumberingRules: - numberingRules.rules.length + numberingRules.parts.length, menuIdMap: Object.fromEntries(menuIdMap), screenIdMap: Object.fromEntries(screenIdMap), flowIdMap: Object.fromEntries(flowIdMap), @@ -923,10 +700,8 @@ export class MenuCopyService { - 메뉴: ${result.copiedMenus}개 - 화면: ${result.copiedScreens}개 - 플로우: ${result.copiedFlows}개 - - 코드 카테고리: ${result.copiedCategories}개 - - 코드: ${result.copiedCodes}개 - - 카테고리 설정: ${result.copiedCategorySettings}개 - - 채번 규칙: ${result.copiedNumberingRules}개 + + ⚠️ 주의: 코드, 카테고리 설정, 채번 규칙은 복사되지 않습니다. ============================================ `); @@ -1125,13 +900,31 @@ export class MenuCopyService { const screenDef = screenDefResult.rows[0]; - // 2) 새 screen_code 생성 + // 2) 중복 체크: 같은 screen_code가 대상 회사에 이미 있는지 확인 + const existingScreenResult = await client.query<{ screen_id: number }>( + `SELECT screen_id FROM screen_definitions + WHERE screen_code = $1 AND company_code = $2 AND deleted_date IS NULL + LIMIT 1`, + [screenDef.screen_code, targetCompanyCode] + ); + + if (existingScreenResult.rows.length > 0) { + // 이미 존재하는 화면 - 복사하지 않고 기존 ID 매핑 + const existingScreenId = existingScreenResult.rows[0].screen_id; + screenIdMap.set(originalScreenId, existingScreenId); + logger.info( + ` ⏭️ 화면 이미 존재 (스킵): ${originalScreenId} → ${existingScreenId} (${screenDef.screen_code})` + ); + continue; // 레이아웃 복사도 스킵 + } + + // 3) 새 screen_code 생성 const newScreenCode = await this.generateUniqueScreenCode( targetCompanyCode, client ); - // 2-1) 화면명 변환 적용 + // 4) 화면명 변환 적용 let transformedScreenName = screenDef.screen_name; if (screenNameConfig) { // 1. 제거할 텍스트 제거 @@ -1150,7 +943,7 @@ export class MenuCopyService { } } - // 3) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) + // 5) screen_definitions 복사 (deleted 필드는 NULL로 설정, 삭제된 화면도 활성화) const newScreenResult = await client.query<{ screen_id: number }>( `INSERT INTO screen_definitions ( screen_name, screen_code, table_name, company_code, @@ -1479,383 +1272,4 @@ export class MenuCopyService { logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}개`); } - /** - * 코드 카테고리 중복 체크 - */ - private async checkCodeCategoryExists( - categoryCode: string, - companyCode: string, - menuObjid: number, - client: PoolClient - ): Promise { - const result = await client.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM code_category - WHERE category_code = $1 AND company_code = $2 AND menu_objid = $3 - ) as exists`, - [categoryCode, companyCode, menuObjid] - ); - return result.rows[0].exists; - } - - /** - * 코드 정보 중복 체크 - */ - private async checkCodeInfoExists( - categoryCode: string, - codeValue: string, - companyCode: string, - menuObjid: number, - client: PoolClient - ): Promise { - const result = await client.query<{ exists: boolean }>( - `SELECT EXISTS( - SELECT 1 FROM code_info - WHERE code_category = $1 AND code_value = $2 - AND company_code = $3 AND menu_objid = $4 - ) as exists`, - [categoryCode, codeValue, companyCode, menuObjid] - ); - return result.rows[0].exists; - } - - /** - * 코드 복사 - */ - private async copyCodes( - codes: { categories: CodeCategory[]; codes: CodeInfo[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📋 코드 복사 중...`); - - let categoryCount = 0; - let codeCount = 0; - let skippedCategories = 0; - let skippedCodes = 0; - - // 1) 코드 카테고리 복사 (중복 체크) - for (const category of codes.categories) { - const newMenuObjid = menuIdMap.get(category.menu_objid); - if (!newMenuObjid) continue; - - // 중복 체크 - const exists = await this.checkCodeCategoryExists( - category.category_code, - targetCompanyCode, - newMenuObjid, - client - ); - - if (exists) { - skippedCategories++; - logger.debug( - ` ⏭️ 카테고리 이미 존재: ${category.category_code} (menu_objid=${newMenuObjid})` - ); - continue; - } - - // 카테고리 복사 - await client.query( - `INSERT INTO code_category ( - category_code, category_name, category_name_eng, description, - sort_order, is_active, company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - [ - category.category_code, - category.category_name, - category.category_name_eng, - category.description, - category.sort_order, - category.is_active, - targetCompanyCode, // 새 회사 코드 - newMenuObjid, // 재매핑 - userId, - ] - ); - - categoryCount++; - } - - // 2) 코드 정보 복사 (중복 체크) - for (const code of codes.codes) { - const newMenuObjid = menuIdMap.get(code.menu_objid); - if (!newMenuObjid) continue; - - // 중복 체크 - const exists = await this.checkCodeInfoExists( - code.code_category, - code.code_value, - targetCompanyCode, - newMenuObjid, - client - ); - - if (exists) { - skippedCodes++; - logger.debug( - ` ⏭️ 코드 이미 존재: ${code.code_category}.${code.code_value} (menu_objid=${newMenuObjid})` - ); - continue; - } - - // 코드 복사 - await client.query( - `INSERT INTO code_info ( - code_category, code_value, code_name, code_name_eng, description, - sort_order, is_active, company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`, - [ - code.code_category, - code.code_value, - code.code_name, - code.code_name_eng, - code.description, - code.sort_order, - code.is_active, - targetCompanyCode, // 새 회사 코드 - newMenuObjid, // 재매핑 - userId, - ] - ); - - codeCount++; - } - - logger.info( - `✅ 코드 복사 완료: 카테고리 ${categoryCount}개 (${skippedCategories}개 스킵), 코드 ${codeCount}개 (${skippedCodes}개 스킵)` - ); - } - - /** - * 카테고리 설정 복사 - */ - private async copyCategorySettings( - settings: { columnMappings: any[]; categoryValues: any[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📂 카테고리 설정 복사 중...`); - - const valueIdMap = new Map(); // 원본 value_id → 새 value_id - let mappingCount = 0; - let valueCount = 0; - - // 1) 카테고리 컬럼 매핑 복사 (덮어쓰기 모드) - for (const mapping of settings.columnMappings) { - // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - let newMenuObjid: number | undefined; - - if ( - mapping.menu_objid === 0 || - mapping.menu_objid === "0" || - mapping.menu_objid == 0 - ) { - newMenuObjid = 0; // 공통 설정 - } else { - newMenuObjid = menuIdMap.get(mapping.menu_objid); - if (newMenuObjid === undefined) { - logger.debug( - ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${mapping.menu_objid}` - ); - continue; - } - } - - // 기존 매핑 삭제 (덮어쓰기) - await client.query( - `DELETE FROM category_column_mapping - WHERE table_name = $1 AND physical_column_name = $2 AND company_code = $3`, - [mapping.table_name, mapping.physical_column_name, targetCompanyCode] - ); - - // 새 매핑 추가 - await client.query( - `INSERT INTO category_column_mapping ( - table_name, logical_column_name, physical_column_name, - menu_objid, company_code, description, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - mapping.table_name, - mapping.logical_column_name, - mapping.physical_column_name, - newMenuObjid, - targetCompanyCode, - mapping.description, - userId, - ] - ); - - mappingCount++; - } - - // 2) 테이블 컬럼 카테고리 값 복사 (덮어쓰기 모드, 부모-자식 관계 유지) - const sortedValues = settings.categoryValues.sort( - (a, b) => a.depth - b.depth - ); - - // 먼저 기존 값들을 모두 삭제 (테이블+컬럼 단위) - const uniqueTableColumns = new Set(); - for (const value of sortedValues) { - uniqueTableColumns.add(`${value.table_name}:${value.column_name}`); - } - - for (const tableColumn of uniqueTableColumns) { - const [tableName, columnName] = tableColumn.split(":"); - await client.query( - `DELETE FROM table_column_category_values - WHERE table_name = $1 AND column_name = $2 AND company_code = $3`, - [tableName, columnName, targetCompanyCode] - ); - logger.debug(` 🗑️ 기존 카테고리 값 삭제: ${tableName}.${columnName}`); - } - - // 새 값 추가 - for (const value of sortedValues) { - // menu_objid = 0인 공통 설정은 그대로 0으로 유지 - let newMenuObjid: number | undefined; - - if ( - value.menu_objid === 0 || - value.menu_objid === "0" || - value.menu_objid == 0 - ) { - newMenuObjid = 0; // 공통 설정 - } else { - newMenuObjid = menuIdMap.get(value.menu_objid); - if (newMenuObjid === undefined) { - logger.debug( - ` ⏭️ 매핑할 메뉴가 없음: menu_objid=${value.menu_objid}` - ); - continue; - } - } - - // 부모 ID 재매핑 - let newParentValueId = null; - if (value.parent_value_id) { - newParentValueId = valueIdMap.get(value.parent_value_id) || null; - } - - const result = await client.query( - `INSERT INTO table_column_category_values ( - table_name, column_name, value_code, value_label, - value_order, parent_value_id, depth, description, - color, icon, is_active, is_default, - company_code, menu_objid, created_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - RETURNING value_id`, - [ - value.table_name, - value.column_name, - value.value_code, - value.value_label, - value.value_order, - newParentValueId, - value.depth, - value.description, - value.color, - value.icon, - value.is_active, - value.is_default, - targetCompanyCode, - newMenuObjid, - userId, - ] - ); - - // ID 매핑 저장 - const newValueId = result.rows[0].value_id; - valueIdMap.set(value.value_id, newValueId); - - valueCount++; - } - - logger.info( - `✅ 카테고리 설정 복사 완료: 컬럼 매핑 ${mappingCount}개, 카테고리 값 ${valueCount}개 (덮어쓰기)` - ); - } - - /** - * 채번 규칙 복사 - */ - private async copyNumberingRules( - rules: { rules: any[]; parts: any[] }, - menuIdMap: Map, - targetCompanyCode: string, - userId: string, - client: PoolClient - ): Promise { - logger.info(`📋 채번 규칙 복사 중...`); - - const ruleIdMap = new Map(); // 원본 rule_id → 새 rule_id - let ruleCount = 0; - let partCount = 0; - - // 1) 채번 규칙 복사 - for (const rule of rules.rules) { - const newMenuObjid = menuIdMap.get(rule.menu_objid); - if (!newMenuObjid) continue; - - // 새 rule_id 생성 (타임스탬프 기반) - const newRuleId = `rule-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - ruleIdMap.set(rule.rule_id, newRuleId); - - await client.query( - `INSERT INTO numbering_rules ( - rule_id, rule_name, description, separator, - reset_period, current_sequence, table_name, column_name, - company_code, menu_objid, created_by, scope_type - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, - [ - newRuleId, - rule.rule_name, - rule.description, - rule.separator, - rule.reset_period, - 1, // 시퀀스 초기화 - rule.table_name, - rule.column_name, - targetCompanyCode, - newMenuObjid, - userId, - rule.scope_type, - ] - ); - - ruleCount++; - } - - // 2) 채번 규칙 파트 복사 - for (const part of rules.parts) { - const newRuleId = ruleIdMap.get(part.rule_id); - if (!newRuleId) continue; - - await client.query( - `INSERT INTO numbering_rule_parts ( - rule_id, part_order, part_type, generation_method, - auto_config, manual_config, company_code - ) VALUES ($1, $2, $3, $4, $5, $6, $7)`, - [ - newRuleId, - part.part_order, - part.part_type, - part.generation_method, - part.auto_config, - part.manual_config, - targetCompanyCode, - ] - ); - - partCount++; - } - - logger.info( - `✅ 채번 규칙 복사 완료: 규칙 ${ruleCount}개, 파트 ${partCount}개` - ); - } } diff --git a/backend-node/src/services/menuService.ts b/backend-node/src/services/menuService.ts index 86df579c..57bddabd 100644 --- a/backend-node/src/services/menuService.ts +++ b/backend-node/src/services/menuService.ts @@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise } } +/** + * 선택한 메뉴와 그 하위 메뉴들의 OBJID 조회 + * + * 형제 메뉴는 포함하지 않고, 선택한 메뉴와 그 자식 메뉴들만 반환합니다. + * 채번 규칙 필터링 등 특정 메뉴 계층만 필요할 때 사용합니다. + * + * @param menuObjid 메뉴 OBJID + * @returns 선택한 메뉴 + 모든 하위 메뉴 OBJID 배열 (재귀적) + * + * @example + * // 메뉴 구조: + * // └── 구매관리 (100) + * // ├── 공급업체관리 (101) + * // ├── 발주관리 (102) + * // └── 입고관리 (103) + * // └── 입고상세 (104) + * + * await getMenuAndChildObjids(100); + * // 결과: [100, 101, 102, 103, 104] + */ +export async function getMenuAndChildObjids(menuObjid: number): Promise { + const pool = getPool(); + + try { + logger.debug("메뉴 및 하위 메뉴 조회 시작", { menuObjid }); + + // 재귀 CTE를 사용하여 선택한 메뉴와 모든 하위 메뉴 조회 + const query = ` + WITH RECURSIVE menu_tree AS ( + -- 시작점: 선택한 메뉴 + SELECT objid, parent_obj_id, 1 AS depth + FROM menu_info + WHERE objid = $1 + + UNION ALL + + -- 재귀: 하위 메뉴들 + SELECT m.objid, m.parent_obj_id, mt.depth + 1 + FROM menu_info m + INNER JOIN menu_tree mt ON m.parent_obj_id = mt.objid + WHERE mt.depth < 10 -- 무한 루프 방지 + ) + SELECT objid FROM menu_tree ORDER BY depth, objid + `; + + const result = await pool.query(query, [menuObjid]); + const objids = result.rows.map((row) => Number(row.objid)); + + logger.debug("메뉴 및 하위 메뉴 조회 완료", { + menuObjid, + totalCount: objids.length, + objids + }); + + return objids; + } catch (error: any) { + logger.error("메뉴 및 하위 메뉴 조회 실패", { + menuObjid, + error: error.message, + stack: error.stack + }); + // 에러 발생 시 안전하게 자기 자신만 반환 + return [menuObjid]; + } +} + /** * 여러 메뉴의 형제 메뉴 OBJID 합집합 조회 * diff --git a/backend-node/src/services/numberingRuleService.ts b/backend-node/src/services/numberingRuleService.ts index cb405b33..83b4f63b 100644 --- a/backend-node/src/services/numberingRuleService.ts +++ b/backend-node/src/services/numberingRuleService.ts @@ -4,7 +4,7 @@ import { getPool } from "../database/db"; import { logger } from "../utils/logger"; -import { getSiblingMenuObjids } from "./menuService"; +import { getMenuAndChildObjids } from "./menuService"; interface NumberingRulePart { id?: number; @@ -161,7 +161,7 @@ class NumberingRuleService { companyCode: string, menuObjid?: number ): Promise { - let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 + let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 try { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { @@ -171,14 +171,14 @@ class NumberingRuleService { const pool = getPool(); - // 1. 형제 메뉴 OBJID 조회 + // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외) if (menuObjid) { - siblingObjids = await getSiblingMenuObjids(menuObjid); - logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); + menuAndChildObjids = await getMenuAndChildObjids(menuObjid); + logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids }); } // menuObjid가 없으면 global 규칙만 반환 - if (!menuObjid || siblingObjids.length === 0) { + if (!menuObjid || menuAndChildObjids.length === 0) { let query: string; let params: any[]; @@ -280,7 +280,7 @@ class NumberingRuleService { let params: any[]; if (companyCode === "*") { - // 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) + // 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -301,8 +301,7 @@ class NumberingRuleService { WHERE scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($1)) - OR (scope_type = 'table' AND menu_objid = ANY($1)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($1)) ORDER BY CASE WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 @@ -311,10 +310,10 @@ class NumberingRuleService { END, created_at DESC `; - params = [siblingObjids]; - logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); + params = [menuAndChildObjids]; + logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids }); } else { - // 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) + // 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴) query = ` SELECT rule_id AS "ruleId", @@ -336,8 +335,7 @@ class NumberingRuleService { AND ( scope_type = 'global' OR (scope_type = 'menu' AND menu_objid = ANY($2)) - OR (scope_type = 'table' AND menu_objid = ANY($2)) -- ✅ 메뉴별로 필터링 - OR (scope_type = 'table' AND menu_objid IS NULL) -- ✅ 기존 규칙(menu_objid NULL) 포함 (하위 호환성) + OR (scope_type = 'table' AND menu_objid = ANY($2)) ) ORDER BY CASE @@ -347,8 +345,8 @@ class NumberingRuleService { END, created_at DESC `; - params = [companyCode, siblingObjids]; - logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); + params = [companyCode, menuAndChildObjids]; + logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids }); } logger.info("🔍 채번 규칙 쿼리 실행", { @@ -420,7 +418,7 @@ class NumberingRuleService { logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { companyCode, menuObjid, - siblingCount: siblingObjids.length, + menuAndChildCount: menuAndChildObjids.length, count: result.rowCount, }); @@ -432,7 +430,7 @@ class NumberingRuleService { errorStack: error.stack, companyCode, menuObjid, - siblingObjids: siblingObjids || [], + menuAndChildObjids: menuAndChildObjids || [], }); throw error; } diff --git a/backend-node/src/services/tableCategoryValueService.ts b/backend-node/src/services/tableCategoryValueService.ts index 2a379ae0..b68d5f05 100644 --- a/backend-node/src/services/tableCategoryValueService.ts +++ b/backend-node/src/services/tableCategoryValueService.ts @@ -1066,6 +1066,66 @@ class TableCategoryValueService { } } + /** + * 테이블+컬럼 기준으로 모든 매핑 삭제 + * + * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 + * + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + * @param companyCode - 회사 코드 + * @returns 삭제된 매핑 수 + */ + async deleteColumnMappingsByColumn( + tableName: string, + columnName: string, + companyCode: string + ): Promise { + const pool = getPool(); + + try { + logger.info("테이블+컬럼 기준 매핑 삭제", { tableName, columnName, companyCode }); + + // 멀티테넌시 적용 + let deleteQuery: string; + let deleteParams: any[]; + + if (companyCode === "*") { + // 최고 관리자: 해당 테이블+컬럼의 모든 매핑 삭제 + deleteQuery = ` + DELETE FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + `; + deleteParams = [tableName, columnName]; + } else { + // 일반 회사: 자신의 매핑만 삭제 + deleteQuery = ` + DELETE FROM category_column_mapping + WHERE table_name = $1 + AND logical_column_name = $2 + AND company_code = $3 + `; + deleteParams = [tableName, columnName, companyCode]; + } + + const result = await pool.query(deleteQuery, deleteParams); + const deletedCount = result.rowCount || 0; + + logger.info("테이블+컬럼 기준 매핑 삭제 완료", { + tableName, + columnName, + companyCode, + deletedCount + }); + + return deletedCount; + } catch (error: any) { + logger.error(`테이블+컬럼 기준 매핑 삭제 실패: ${error.message}`); + throw error; + } + } + /** * 논리적 컬럼명을 물리적 컬럼명으로 변환 * diff --git a/frontend/app/(main)/admin/tableMng/page.tsx b/frontend/app/(main)/admin/tableMng/page.tsx index 290109f3..5dcbb6be 100644 --- a/frontend/app/(main)/admin/tableMng/page.tsx +++ b/frontend/app/(main)/admin/tableMng/page.tsx @@ -17,7 +17,7 @@ import { apiClient } from "@/lib/api/client"; import { commonCodeApi } from "@/lib/api/commonCode"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { ddlApi } from "@/lib/api/ddl"; -import { getSecondLevelMenus, createColumnMapping } from "@/lib/api/tableCategoryValue"; +import { getSecondLevelMenus, createColumnMapping, deleteColumnMappingsByColumn } from "@/lib/api/tableCategoryValue"; import { CreateTableModal } from "@/components/admin/CreateTableModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; @@ -488,52 +488,69 @@ export default function TableManagementPage() { if (response.data.success) { console.log("✅ 컬럼 설정 저장 성공"); - // 🆕 Category 타입인 경우 컬럼 매핑 생성 + // 🆕 Category 타입인 경우 컬럼 매핑 처리 console.log("🔍 카테고리 조건 체크:", { isCategory: column.inputType === "category", hasCategoryMenus: !!column.categoryMenus, length: column.categoryMenus?.length || 0, }); - if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) { - console.log("📥 카테고리 메뉴 매핑 시작:", { + if (column.inputType === "category") { + // 1. 먼저 기존 매핑 모두 삭제 + console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", { + tableName: selectedTable, columnName: column.columnName, - categoryMenus: column.categoryMenus, - count: column.categoryMenus.length, }); - let successCount = 0; - let failCount = 0; - - for (const menuObjid of column.categoryMenus) { - try { - const mappingResponse = await createColumnMapping({ - tableName: selectedTable, - logicalColumnName: column.columnName, - physicalColumnName: column.columnName, - menuObjid, - description: `${column.displayName} (메뉴별 카테고리)`, - }); - - if (mappingResponse.success) { - successCount++; - } else { - console.error("❌ 매핑 생성 실패:", mappingResponse); - failCount++; - } - } catch (error) { - console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); - failCount++; - } + try { + const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName); + console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse); + } catch (error) { + console.error("❌ 기존 매핑 삭제 실패:", error); } + // 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) + if (column.categoryMenus && column.categoryMenus.length > 0) { + console.log("📥 카테고리 메뉴 매핑 시작:", { + columnName: column.columnName, + categoryMenus: column.categoryMenus, + count: column.categoryMenus.length, + }); - if (successCount > 0 && failCount === 0) { - toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`); - } else if (successCount > 0 && failCount > 0) { - toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); - } else if (failCount > 0) { - toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); + let successCount = 0; + let failCount = 0; + + for (const menuObjid of column.categoryMenus) { + try { + const mappingResponse = await createColumnMapping({ + tableName: selectedTable, + logicalColumnName: column.columnName, + physicalColumnName: column.columnName, + menuObjid, + description: `${column.displayName} (메뉴별 카테고리)`, + }); + + if (mappingResponse.success) { + successCount++; + } else { + console.error("❌ 매핑 생성 실패:", mappingResponse); + failCount++; + } + } catch (error) { + console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); + failCount++; + } + } + + if (successCount > 0 && failCount === 0) { + toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`); + } else if (successCount > 0 && failCount > 0) { + toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); + } else if (failCount > 0) { + toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); + } + } else { + toast.success("컬럼 설정이 저장되었습니다. (메뉴 매핑 없음)"); } } else { toast.success("컬럼 설정이 성공적으로 저장되었습니다."); @@ -596,10 +613,8 @@ export default function TableManagementPage() { ); if (response.data.success) { - // 🆕 Category 타입 컬럼들의 메뉴 매핑 생성 - const categoryColumns = columns.filter( - (col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0 - ); + // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리 + const categoryColumns = columns.filter((col) => col.inputType === "category"); console.log("📥 전체 저장: 카테고리 컬럼 확인", { totalColumns: columns.length, @@ -615,33 +630,49 @@ export default function TableManagementPage() { let totalFailCount = 0; for (const column of categoryColumns) { - for (const menuObjid of column.categoryMenus!) { - try { - console.log("🔄 매핑 API 호출:", { - tableName: selectedTable, - columnName: column.columnName, - menuObjid, - }); + // 1. 먼저 기존 매핑 모두 삭제 + console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", { + tableName: selectedTable, + columnName: column.columnName, + }); - const mappingResponse = await createColumnMapping({ - tableName: selectedTable, - logicalColumnName: column.columnName, - physicalColumnName: column.columnName, - menuObjid, - description: `${column.displayName} (메뉴별 카테고리)`, - }); + try { + const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName); + console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse); + } catch (error) { + console.error("❌ 기존 매핑 삭제 실패:", error); + } - console.log("✅ 매핑 API 응답:", mappingResponse); + // 2. 새로운 매핑 추가 (선택된 메뉴가 있는 경우만) + if (column.categoryMenus && column.categoryMenus.length > 0) { + for (const menuObjid of column.categoryMenus) { + try { + console.log("🔄 매핑 API 호출:", { + tableName: selectedTable, + columnName: column.columnName, + menuObjid, + }); - if (mappingResponse.success) { - totalSuccessCount++; - } else { - console.error("❌ 매핑 생성 실패:", mappingResponse); + const mappingResponse = await createColumnMapping({ + tableName: selectedTable, + logicalColumnName: column.columnName, + physicalColumnName: column.columnName, + menuObjid, + description: `${column.displayName} (메뉴별 카테고리)`, + }); + + console.log("✅ 매핑 API 응답:", mappingResponse); + + if (mappingResponse.success) { + totalSuccessCount++; + } else { + console.error("❌ 매핑 생성 실패:", mappingResponse); + totalFailCount++; + } + } catch (error) { + console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); totalFailCount++; } - } catch (error) { - console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error); - totalFailCount++; } } } diff --git a/frontend/app/(main)/screens/[screenId]/page.tsx b/frontend/app/(main)/screens/[screenId]/page.tsx index 74f79f8f..8ab31ff7 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(); @@ -796,7 +797,9 @@ function ScreenViewPage() { function ScreenViewPageWrapper() { return ( - + + + ); } diff --git a/frontend/components/admin/CompanyTable.tsx b/frontend/components/admin/CompanyTable.tsx index 78b9ca3e..b36a757b 100644 --- a/frontend/components/admin/CompanyTable.tsx +++ b/frontend/components/admin/CompanyTable.tsx @@ -162,7 +162,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company {formatDiskUsage(company)}
- + */}
-
+
플로우:{" "} {result.copiedFlows}개
-
- 코드 카테고리:{" "} - {result.copiedCategories}개 -
-
- 코드:{" "} - {result.copiedCodes}개 -
)} diff --git a/frontend/components/admin/department/DepartmentStructure.tsx b/frontend/components/admin/department/DepartmentStructure.tsx index c094846e..4347d612 100644 --- a/frontend/components/admin/department/DepartmentStructure.tsx +++ b/frontend/components/admin/department/DepartmentStructure.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from "react"; import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { ResizableDialog, ResizableDialogContent, ResizableDialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useToast } from "@/hooks/use-toast"; diff --git a/frontend/components/common/ScreenModal.tsx b/frontend/components/common/ScreenModal.tsx index dac897b7..3e0f1a61 100644 --- a/frontend/components/common/ScreenModal.tsx +++ b/frontend/components/common/ScreenModal.tsx @@ -59,7 +59,7 @@ export const ScreenModal: React.FC = ({ className }) => { // 연속 등록 모드 상태 (state로 변경 - 체크박스 UI 업데이트를 위해) const [continuousMode, setContinuousMode] = useState(false); - + // 화면 리셋 키 (컴포넌트 강제 리마운트용) const [resetKey, setResetKey] = useState(0); @@ -120,10 +120,17 @@ export const ScreenModal: React.FC = ({ className }) => { }; }; + // 모달이 열린 시간 추적 (저장 성공 이벤트 무시용) + const modalOpenedAtRef = React.useRef(0); + // 전역 모달 이벤트 리스너 useEffect(() => { const handleOpenModal = (event: CustomEvent) => { - const { screenId, title, description, size, urlParams } = event.detail; + const { screenId, title, description, size, urlParams, editData } = event.detail; + + // 🆕 모달 열린 시간 기록 + modalOpenedAtRef.current = Date.now(); + console.log("🕐 [ScreenModal] 모달 열림 시간 기록:", modalOpenedAtRef.current); // 🆕 URL 파라미터가 있으면 현재 URL에 추가 if (urlParams && typeof window !== "undefined") { @@ -136,6 +143,12 @@ export const ScreenModal: React.FC = ({ className }) => { console.log("✅ URL 파라미터 추가:", urlParams); } + // 🆕 editData가 있으면 formData로 설정 (수정 모드) + if (editData) { + console.log("📝 [ScreenModal] 수정 데이터 설정:", editData); + setFormData(editData); + } + setModalState({ isOpen: true, screenId, @@ -164,6 +177,7 @@ export const ScreenModal: React.FC = ({ className }) => { }); setScreenData(null); setFormData({}); + setSelectedData([]); // 🆕 선택된 데이터 초기화 setContinuousMode(false); localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 console.log("🔄 연속 모드 초기화: false"); @@ -171,6 +185,13 @@ export const ScreenModal: React.FC = ({ className }) => { // 저장 성공 이벤트 처리 (연속 등록 모드 지원) const handleSaveSuccess = () => { + // 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지) + const timeSinceOpen = Date.now() - modalOpenedAtRef.current; + if (timeSinceOpen < 500) { + console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen }); + return; + } + const isContinuousMode = continuousMode; console.log("💾 저장 성공 이벤트 수신"); console.log("📌 현재 연속 모드 상태:", isContinuousMode); @@ -182,11 +203,11 @@ export const ScreenModal: React.FC = ({ className }) => { // 1. 폼 데이터 초기화 setFormData({}); - + // 2. 리셋 키 변경 (컴포넌트 강제 리마운트) - setResetKey(prev => prev + 1); + setResetKey((prev) => prev + 1); console.log("🔄 resetKey 증가 - 컴포넌트 리마운트"); - + // 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성) if (modalState.screenId) { console.log("🔄 화면 데이터 다시 로드:", modalState.screenId); @@ -314,17 +335,17 @@ export const ScreenModal: React.FC = ({ className }) => { if (Array.isArray(data)) { return data.map(normalizeDates); } - - if (typeof data !== 'object' || data === null) { + + if (typeof data !== "object" || data === null) { return data; } - + const normalized: any = {}; for (const [key, value] of Object.entries(data)) { - if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T/.test(value)) { + if (typeof value === "string" && /^\d{4}-\d{2}-\d{2}T/.test(value)) { // ISO 날짜 형식 감지: YYYY-MM-DD만 추출 const before = value; - const after = value.split('T')[0]; + const after = value.split("T")[0]; console.log(`🔧 [날짜 정규화] ${key}: ${before} → ${after}`); normalized[key] = after; } else { @@ -333,14 +354,16 @@ export const ScreenModal: React.FC = ({ className }) => { } return normalized; }; - + console.log("📥 [ScreenModal] API 응답 원본:", JSON.stringify(response.data, null, 2)); const normalizedData = normalizeDates(response.data); console.log("📥 [ScreenModal] 정규화 후:", JSON.stringify(normalizedData, null, 2)); - + // 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용) if (Array.isArray(normalizedData)) { - console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다."); + console.log( + "⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.", + ); setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용 } else { setFormData(normalizedData); @@ -416,7 +439,7 @@ export const ScreenModal: React.FC = ({ className }) => { window.history.pushState({}, "", currentUrl.toString()); console.log("🧹 [ScreenModal] URL 파라미터 제거 (모달 닫힘)"); } - + setModalState({ isOpen: false, screenId: null, @@ -440,7 +463,7 @@ export const ScreenModal: React.FC = ({ className }) => { // 실제 모달 크기 = 컨텐츠 + 헤더 + 연속등록 체크박스 const headerHeight = 60; // DialogHeader (타이틀 + 패딩) const footerHeight = 52; // 연속 등록 모드 체크박스 영역 - + const totalHeight = screenDimensions.height + headerHeight + footerHeight; return { @@ -581,6 +604,15 @@ export const ScreenModal: React.FC = ({ className }) => { }, }; + // 🆕 formData 전달 확인 로그 + console.log("📝 [ScreenModal] InteractiveScreenViewerDynamic에 formData 전달:", { + componentId: component.id, + componentType: component.type, + componentComponentType: (component as any).componentType, // 🆕 실제 componentType 확인 + hasFormData: !!formData, + formDataKeys: formData ? Object.keys(formData) : [], + }); + return ( = ({ className }) => { userId={userId} userName={userName} companyCode={user?.companyCode} + // 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용) + groupedData={selectedData.length > 0 ? selectedData : undefined} /> ); })} diff --git a/frontend/components/screen-embedding/EmbeddedScreen.tsx b/frontend/components/screen-embedding/EmbeddedScreen.tsx new file mode 100644 index 00000000..ce7030eb --- /dev/null +++ b/frontend/components/screen-embedding/EmbeddedScreen.tsx @@ -0,0 +1,408 @@ +/** + * 임베드된 화면 컴포넌트 + * 다른 화면 안에 임베드되어 표시되는 화면 + */ + +"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"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; +import { ScreenContextProvider } from "@/contexts/ScreenContext"; +import { useAuth } from "@/hooks/useAuth"; + +interface EmbeddedScreenProps { + embedding: ScreenEmbedding; + onSelectionChanged?: (selectedRows: any[]) => void; + position?: SplitPanelPosition; // 분할 패널 내 위치 (left/right) + initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터 +} + +/** + * 임베드된 화면 컴포넌트 + */ +export const EmbeddedScreen = forwardRef( + ({ embedding, onSelectionChanged, position, initialFormData }, ref) => { + const [layout, setLayout] = useState([]); + const [selectedRows, setSelectedRows] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [screenInfo, setScreenInfo] = useState(null); + const [formData, setFormData] = useState>(initialFormData || {}); // 🆕 초기 데이터로 시작 + + // 컴포넌트 참조 맵 + const componentRefs = useRef>(new Map()); + + // 분할 패널 컨텍스트 (분할 패널 내부에 있을 때만 사용) + const splitPanelContext = useSplitPanelContext(); + + // 🆕 사용자 정보 가져오기 (저장 액션에 필요) + const { userId, userName, companyCode } = useAuth(); + + // 컴포넌트들의 실제 영역 계산 (가로폭 맞춤을 위해) + const contentBounds = React.useMemo(() => { + if (layout.length === 0) return { width: 0, height: 0 }; + + let maxRight = 0; + let maxBottom = 0; + + layout.forEach((component) => { + const { position: compPosition = { x: 0, y: 0 }, size = { width: 200, height: 40 } } = component; + const right = (compPosition.x || 0) + (size.width || 200); + const bottom = (compPosition.y || 0) + (size.height || 40); + + if (right > maxRight) maxRight = right; + if (bottom > maxBottom) maxBottom = bottom; + }); + + return { width: maxRight, height: maxBottom }; + }, [layout]); + + // 필드 값 변경 핸들러 + const handleFieldChange = useCallback((fieldName: string, value: any) => { + console.log("📝 [EmbeddedScreen] 필드 값 변경:", { fieldName, value }); + setFormData((prev) => ({ + ...prev, + [fieldName]: value, + })); + }, []); + + // 화면 데이터 로드 + useEffect(() => { + loadScreenData(); + }, [embedding.childScreenId]); + + // 🆕 initialFormData 변경 시 formData 업데이트 (수정 모드) + useEffect(() => { + if (initialFormData && Object.keys(initialFormData).length > 0) { + console.log("📝 [EmbeddedScreen] 초기 폼 데이터 설정:", initialFormData); + setFormData(initialFormData); + } + }, [initialFormData]); + + // 선택 변경 이벤트 전파 + useEffect(() => { + onSelectionChanged?.(selectedRows); + }, [selectedRows, onSelectionChanged]); + + /** + * 화면 레이아웃 로드 + */ + const loadScreenData = async () => { + try { + setLoading(true); + setError(null); + + // 화면 정보 로드 (screenApi.getScreen은 직접 ScreenDefinition 객체를 반환) + const screenData = await screenApi.getScreen(embedding.childScreenId); + console.log("📋 [EmbeddedScreen] 화면 정보 API 응답:", { + screenId: embedding.childScreenId, + hasData: !!screenData, + tableName: screenData?.tableName, + screenName: screenData?.name || screenData?.screenName, + position, + }); + if (screenData) { + setScreenInfo(screenData); + } else { + console.warn("⚠️ [EmbeddedScreen] 화면 정보 로드 실패:", { + screenId: embedding.childScreenId, + }); + } + + // 화면 레이아웃 로드 (별도 API) + const layoutData = await screenApi.getLayout(embedding.childScreenId); + + logger.info("📦 화면 레이아웃 로드 완료", { + screenId: embedding.childScreenId, + mode: embedding.mode, + hasLayoutData: !!layoutData, + componentsCount: layoutData?.components?.length || 0, + position, + }); + + 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}

+
+ +
+
+ ); + } + + // 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게) + // position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함 + return ( + +
+ {layout.length === 0 ? ( +
+

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

+
+ ) : ( +
+ {layout.map((component) => { + const { position: compPosition = { x: 0, y: 0, z: 1 }, size = { width: 200, height: 40 } } = component; + + // 컴포넌트가 컨테이너 너비를 초과하지 않도록 너비 조정 + // 부모 컨테이너의 100%를 기준으로 계산 + const componentStyle: React.CSSProperties = { + left: compPosition.x || 0, + top: compPosition.y || 0, + width: size.width || 200, + height: size.height || 40, + zIndex: compPosition.z || 1, + // 컴포넌트가 오른쪽 경계를 넘어가면 너비 조정 + maxWidth: `calc(100% - ${compPosition.x || 0}px)`, + }; + + return ( +
+ +
+ ); + })} +
+ )} +
+
+ ); + }, +); + +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..2e43fcc6 --- /dev/null +++ b/frontend/components/screen-embedding/ScreenSplitPanel.tsx @@ -0,0 +1,183 @@ +/** + * 분할 패널 컴포넌트 + * 좌측과 우측에 화면을 임베드합니다. + * + * 데이터 전달은 좌측 화면에 배치된 버튼의 transferData 액션으로 처리됩니다. + * 예: 좌측 화면에 TableListComponent + Button(transferData 액션) 배치 + */ + +"use client"; + +import React, { useState, useCallback, useMemo } from "react"; +import { EmbeddedScreen } from "./EmbeddedScreen"; +import { Columns2 } from "lucide-react"; +import { SplitPanelProvider } from "@/contexts/SplitPanelContext"; + +interface ScreenSplitPanelProps { + screenId?: number; + config?: any; // 설정 패널에서 오는 config (leftScreenId, rightScreenId, splitRatio, resizable) + initialFormData?: Record; // 🆕 수정 모드에서 전달되는 초기 데이터 +} + +/** + * 분할 패널 컴포넌트 + * 순수하게 화면 분할 기능만 제공합니다. + */ +export function ScreenSplitPanel({ screenId, config, initialFormData }: ScreenSplitPanelProps) { + // config에서 splitRatio 추출 (기본값 50) + const configSplitRatio = config?.splitRatio ?? 50; + + console.log("🎯 [ScreenSplitPanel] 렌더링됨!", { + screenId, + config, + leftScreenId: config?.leftScreenId, + rightScreenId: config?.rightScreenId, + configSplitRatio, + configKeys: config ? Object.keys(config) : [], + }); + + // 🆕 initialFormData 별도 로그 (명확한 확인) + console.log("📝 [ScreenSplitPanel] initialFormData 확인:", { + hasInitialFormData: !!initialFormData, + initialFormDataKeys: initialFormData ? Object.keys(initialFormData) : [], + initialFormData: initialFormData, + }); + + // 드래그로 조절 가능한 splitRatio 상태 + const [splitRatio, setSplitRatio] = useState(configSplitRatio); + + // config.splitRatio가 변경되면 동기화 (설정 패널에서 변경 시) + React.useEffect(() => { + console.log("📐 [ScreenSplitPanel] splitRatio 동기화:", { configSplitRatio, currentSplitRatio: splitRatio }); + setSplitRatio(configSplitRatio); + }, [configSplitRatio]); + + // 설정 패널에서 오는 간단한 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) { + return ( +
+
+
+
+ +
+
+
+

화면 분할 패널

+

좌우로 화면을 나눕니다

+

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

+

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

+
+
+
+ ); + } + + // 좌측 또는 우측 화면이 설정되지 않은 경우 안내 메시지 표시 + const hasLeftScreen = !!leftEmbedding; + const hasRightScreen = !!rightEmbedding; + + // 분할 패널 고유 ID 생성 + const splitPanelId = useMemo(() => `split-panel-${screenId || "unknown"}-${Date.now()}`, [screenId]); + + return ( + +
+ {/* 좌측 패널 */} +
+ {hasLeftScreen ? ( + + ) : ( +
+

좌측 화면을 선택하세요

+
+ )} +
+ + {/* 리사이저 */} + {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); + }} + > +
+
+ )} + + {/* 우측 패널 */} +
+ {hasRightScreen ? ( + + ) : ( +
+

우측 화면을 선택하세요

+
+ )} +
+
+ + ); +} 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/components/screen/InteractiveScreenViewerDynamic.tsx b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx index 41e321e5..e351b68c 100644 --- a/frontend/components/screen/InteractiveScreenViewerDynamic.tsx +++ b/frontend/components/screen/InteractiveScreenViewerDynamic.tsx @@ -408,6 +408,7 @@ export const InteractiveScreenViewerDynamic: React.FC handleFormDataChange(fieldName, value), onFormDataChange: handleFormDataChange, + formData: formData, // 🆕 전체 formData 전달 isInteractive: true, readonly: readonly, required: required, @@ -415,6 +416,7 @@ export const InteractiveScreenViewerDynamic: React.FC { diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 66bec96d..90d6c18d 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -528,9 +528,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) if (path === "size.width" || path === "size.height" || path === "size") { - if (!newComp.style) { - newComp.style = {}; - } + // 🔧 style 객체를 새로 복사하여 불변성 유지 + newComp.style = { ...(newComp.style || {}) }; if (path === "size.width") { newComp.style.width = `${value}px`; diff --git a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx index 91ee99e7..1a4a9608 100644 --- a/frontend/components/screen/config-panels/ButtonConfigPanel.tsx +++ b/frontend/components/screen/config-panels/ButtonConfigPanel.tsx @@ -83,6 +83,14 @@ export const ButtonConfigPanel: React.FC = ({ const [blockTablePopoverOpen, setBlockTablePopoverOpen] = useState>({}); // 블록별 테이블 Popover 열림 상태 const [blockColumnPopoverOpen, setBlockColumnPopoverOpen] = useState>({}); // 블록별 컬럼 Popover 열림 상태 + // 🆕 데이터 전달 필드 매핑용 상태 + const [mappingSourceColumns, setMappingSourceColumns] = useState>([]); + const [mappingTargetColumns, setMappingTargetColumns] = useState>([]); + const [mappingSourcePopoverOpen, setMappingSourcePopoverOpen] = useState>({}); + const [mappingTargetPopoverOpen, setMappingTargetPopoverOpen] = useState>({}); + const [mappingSourceSearch, setMappingSourceSearch] = useState>({}); + const [mappingTargetSearch, setMappingTargetSearch] = useState>({}); + // 🎯 플로우 위젯이 화면에 있는지 확인 const hasFlowWidget = useMemo(() => { const found = allComponents.some((comp: any) => { @@ -258,6 +266,58 @@ export const ButtonConfigPanel: React.FC = ({ } }; + // 🆕 데이터 전달 소스/타겟 테이블 컬럼 로드 + useEffect(() => { + const sourceTable = config.action?.dataTransfer?.sourceTable; + const targetTable = config.action?.dataTransfer?.targetTable; + + const loadColumns = async () => { + if (sourceTable) { + try { + const response = await apiClient.get(`/table-management/tables/${sourceTable}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + setMappingSourceColumns(columns); + } + } + } catch (error) { + console.error("소스 테이블 컬럼 로드 실패:", error); + } + } + + if (targetTable) { + try { + const response = await apiClient.get(`/table-management/tables/${targetTable}/columns`); + if (response.data.success) { + let columnData = response.data.data; + if (!Array.isArray(columnData) && columnData?.columns) columnData = columnData.columns; + if (!Array.isArray(columnData) && columnData?.data) columnData = columnData.data; + + if (Array.isArray(columnData)) { + const columns = columnData.map((col: any) => ({ + name: col.name || col.columnName, + label: col.displayName || col.label || col.columnLabel || col.name || col.columnName, + })); + setMappingTargetColumns(columns); + } + } + } catch (error) { + console.error("타겟 테이블 컬럼 로드 실패:", error); + } + } + }; + + loadColumns(); + }, [config.action?.dataTransfer?.sourceTable, config.action?.dataTransfer?.targetTable]); + // 화면 목록 가져오기 (현재 편집 중인 화면의 회사 코드 기준) useEffect(() => { const fetchScreens = async () => { @@ -375,40 +435,6 @@ export const ButtonConfigPanel: React.FC = ({ fetchTableColumns(); }, [config.action?.type, config.action?.historyTableName, currentTableName]); - // 🆕 geolocation/update_field 테이블 컬럼 자동 로드 - useEffect(() => { - const actionType = config.action?.type; - - // geolocation 액션일 때 - if (actionType === "geolocation") { - // 위치정보 저장 테이블 컬럼 로드 - const tableName = config.action?.geolocationTableName || currentTableName; - if (tableName) { - loadTableColumns(tableName); - } - - // 🔥 추가 필드 변경용 대상 테이블 컬럼도 로드 - const extraTableName = config.action?.geolocationExtraTableName; - if (extraTableName) { - loadTableColumns(extraTableName); - } - } - - // update_field 액션일 때 - if (actionType === "update_field") { - const tableName = config.action?.updateTableName || currentTableName; - if (tableName) { - loadTableColumns(tableName); - } - } - }, [ - config.action?.type, - config.action?.geolocationTableName, - config.action?.geolocationExtraTableName, // 🔥 추가 - config.action?.updateTableName, - currentTableName, - ]); - // 검색 필터링 함수 const filterScreens = (searchTerm: string) => { if (!searchTerm.trim()) return screens; @@ -468,6 +494,7 @@ export const ButtonConfigPanel: React.FC = ({ 편집 복사 (품목코드 초기화) 페이지 이동 + 📦 데이터 전달 데이터 전달 + 모달 열기 🆕 모달 열기 제어 흐름 @@ -544,7 +571,8 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0 text-xs" + className="h-6 w-full justify-between px-2 py-0" + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -934,7 +962,8 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0 text-xs" + className="h-6 w-full justify-between px-2 py-0" + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1011,7 +1040,8 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0 text-xs" + className="h-6 w-full justify-between px-2 py-0" + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1164,7 +1194,8 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={modalScreenOpen} - className="h-6 w-full justify-between px-2 py-0 text-xs" + className="h-6 w-full justify-between px-2 py-0" + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1379,7 +1410,8 @@ export const ButtonConfigPanel: React.FC = ({ variant="outline" role="combobox" aria-expanded={navScreenOpen} - className="h-6 w-full justify-between px-2 py-0 text-xs" + className="h-6 w-full justify-between px-2 py-0" + className="text-xs" disabled={screensLoading} > {config.action?.targetScreenId @@ -1646,7 +1678,6 @@ export const ButtonConfigPanel: React.FC = ({ value={config.action?.geolocationTableName || currentTableName || ""} onValueChange={(value) => { onUpdateProperty("componentConfig.action.geolocationTableName", value); - // 테이블 변경 시 컬럼 초기화 onUpdateProperty("componentConfig.action.geolocationLatField", ""); onUpdateProperty("componentConfig.action.geolocationLngField", ""); onUpdateProperty("componentConfig.action.geolocationAccuracyField", ""); @@ -1674,113 +1705,49 @@ export const ButtonConfigPanel: React.FC = ({ - + onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLatField", e.target.value)} + className="h-8 text-xs" + />
- + onChange={(e) => onUpdateProperty("componentConfig.action.geolocationLngField", e.target.value)} + className="h-8 text-xs" + />
- + onUpdateProperty("componentConfig.action.geolocationAccuracyField", e.target.value)} + className="h-8 text-xs" + />
- -
-
- -
-
- onUpdateProperty("componentConfig.action.geolocationTimeout", parseInt(e.target.value) || 10000)} + id="geolocation-timestamp-field" + placeholder="예: location_time" + value={config.action?.geolocationTimestampField || ""} + onChange={(e) => onUpdateProperty("componentConfig.action.geolocationTimestampField", e.target.value)} className="h-8 text-xs" />
-
- - onUpdateProperty("componentConfig.action.geolocationMaxAge", parseInt(e.target.value) || 0)} - className="h-8 text-xs" - /> -

0 = 항상 새로운 위치

-
@@ -1807,158 +1774,6 @@ export const ButtonConfigPanel: React.FC = ({ />
- {/* 추가 필드 변경 (위치정보 + 상태변경) */} -
-
-
- -

위치정보와 함께 다른 테이블/필드 값도 변경합니다

-
- { - onUpdateProperty("componentConfig.action.geolocationUpdateField", checked); - if (!checked) { - // 비활성화 시 관련 필드 초기화 - onUpdateProperty("componentConfig.action.geolocationExtraTableName", ""); - onUpdateProperty("componentConfig.action.geolocationExtraField", ""); - onUpdateProperty("componentConfig.action.geolocationExtraValue", ""); - onUpdateProperty("componentConfig.action.geolocationExtraKeyField", ""); - onUpdateProperty("componentConfig.action.geolocationExtraKeySourceField", ""); - } - }} - /> -
- - {config.action?.geolocationUpdateField && ( -
- {/* 대상 테이블 선택 */} -
- - -

- 다른 테이블 선택 시 해당 테이블의 레코드를 UPDATE합니다 -

-
- - {/* 다른 테이블 선택 시 연결 키 설정 */} - {config.action?.geolocationExtraTableName && - config.action?.geolocationExtraTableName !== (config.action?.geolocationTableName || currentTableName) && ( -
-
- - -

예: vehicle_id

-
-
- - -

예: vehicle_id

-
-
- )} - - {/* 변경할 필드와 값 */} -
-
- - -
-
- - onUpdateProperty("componentConfig.action.geolocationExtraValue", e.target.value)} - className="h-8 text-xs" - /> -
-
-

- {config.action?.geolocationExtraTableName && - config.action?.geolocationExtraTableName !== (config.action?.geolocationTableName || currentTableName) - ? `다른 테이블(${config.action?.geolocationExtraTableName})의 레코드를 UPDATE합니다` - : "예: status 필드를 \"active\"로 변경하여 운행 시작 상태로 표시"} -

-
- )} -
-

사용 방법: @@ -1968,12 +1783,6 @@ export const ButtonConfigPanel: React.FC = ({ 2. 사용자가 허용하면 현재 GPS 좌표를 가져옵니다
3. 위도/경도가 지정된 필드에 자동으로 입력됩니다 - {config.action?.geolocationUpdateField && ( - <> -
- 4. 추가로 지정한 필드 값도 함께 변경됩니다 - - )}

참고: HTTPS 환경에서만 위치정보가 작동합니다. @@ -1996,8 +1805,6 @@ export const ButtonConfigPanel: React.FC = ({ onValueChange={(value) => { onUpdateProperty("componentConfig.action.updateTableName", value); onUpdateProperty("componentConfig.action.updateTargetField", ""); - // 테이블 컬럼 로드 - loadTableColumns(value); }} > @@ -2021,22 +1828,13 @@ export const ButtonConfigPanel: React.FC = ({ - + onChange={(e) => onUpdateProperty("componentConfig.action.updateTargetField", e.target.value)} + className="h-8 text-xs" + />

변경할 DB 컬럼

@@ -2115,6 +1913,626 @@ export const ButtonConfigPanel: React.FC = ({
)} + {/* 데이터 전달 액션 설정 */} + {(component.componentConfig?.action?.type || "save") === "transferData" && ( +
+

📦 데이터 전달 설정

+ + {/* 소스 컴포넌트 선택 (Combobox) */} +
+ + +

+ 테이블, 반복 필드 그룹 등 데이터를 제공하는 컴포넌트 +

+
+ +
+ + + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +

+ 이 버튼이 분할 패널 내부에 있어야 합니다. 좌측 화면에서 우측으로, 또는 우측에서 좌측으로 데이터가 전달됩니다. +

+ )} +
+ + {/* 타겟 컴포넌트 선택 (같은 화면의 컴포넌트일 때만) */} + {config.action?.dataTransfer?.targetType === "component" && ( +
+ + +

+ 테이블, 반복 필드 그룹 등 데이터를 받는 컴포넌트 +

+
+ )} + + {/* 분할 패널 반대편 타겟 설정 */} + {config.action?.dataTransfer?.targetType === "splitPanel" && ( +
+ + onUpdateProperty("componentConfig.action.dataTransfer.targetComponentId", e.target.value)} + placeholder="비워두면 첫 번째 수신 가능 컴포넌트로 전달" + className="h-8 text-xs" + /> +

+ 반대편 화면의 특정 컴포넌트 ID를 지정하거나, 비워두면 자동으로 첫 번째 수신 가능 컴포넌트로 전달됩니다. +

+
+ )} + +
+ + +

+ 기존 데이터를 어떻게 처리할지 선택 +

+
+ +
+
+ +

데이터 전달 후 소스의 선택을 해제합니다

+
+ onUpdateProperty("componentConfig.action.dataTransfer.clearAfterTransfer", checked)} + /> +
+ +
+
+ +

데이터 전달 전 확인 다이얼로그를 표시합니다

+
+ onUpdateProperty("componentConfig.action.dataTransfer.confirmBeforeTransfer", checked)} + /> +
+ + {config.action?.dataTransfer?.confirmBeforeTransfer && ( +
+ + onUpdateProperty("componentConfig.action.dataTransfer.confirmMessage", e.target.value)} + className="h-8 text-xs" + /> +
+ )} + +
+ +
+
+ + onUpdateProperty("componentConfig.action.dataTransfer.validation.minSelection", parseInt(e.target.value) || 0)} + className="h-8 w-20 text-xs" + /> +
+
+ + onUpdateProperty("componentConfig.action.dataTransfer.validation.maxSelection", parseInt(e.target.value) || undefined)} + className="h-8 w-20 text-xs" + /> +
+
+
+ +
+ +

+ 조건부 컨테이너의 카테고리 값 등 추가 데이터를 함께 전달할 수 있습니다 +

+
+
+ + +

+ 조건부 컨테이너, 셀렉트박스 등 (카테고리 값 전달용) +

+
+
+ + { + const currentSources = config.action?.dataTransfer?.additionalSources || []; + const newSources = [...currentSources]; + if (newSources.length === 0) { + newSources.push({ componentId: "", fieldName: e.target.value }); + } else { + newSources[0] = { ...newSources[0], fieldName: e.target.value }; + } + onUpdateProperty("componentConfig.action.dataTransfer.additionalSources", newSources); + }} + className="h-8 text-xs" + /> +

+ 타겟 테이블에 저장될 필드명 +

+
+
+
+ + {/* 필드 매핑 규칙 */} +
+ + + {/* 소스/타겟 테이블 선택 */} +
+
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.sourceTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+ +
+ + + + + + + + + + 테이블을 찾을 수 없습니다 + + {availableTables.map((table) => ( + { + onUpdateProperty("componentConfig.action.dataTransfer.targetTable", table.name); + }} + className="text-xs" + > + + {table.label} + ({table.name}) + + ))} + + + + + +
+
+ + {/* 필드 매핑 규칙 */} +
+
+ + +
+

+ 소스 필드를 타겟 필드에 매핑합니다. 비워두면 같은 이름의 필드로 자동 매핑됩니다. +

+ + {(!config.action?.dataTransfer?.sourceTable || !config.action?.dataTransfer?.targetTable) ? ( +
+

+ 먼저 소스 테이블과 타겟 테이블을 선택하세요. +

+
+ ) : (config.action?.dataTransfer?.mappingRules || []).length === 0 ? ( +
+

+ 매핑 규칙이 없습니다. 같은 이름의 필드로 자동 매핑됩니다. +

+
+ ) : ( +
+ {(config.action?.dataTransfer?.mappingRules || []).map((rule: any, index: number) => ( +
+ {/* 소스 필드 선택 (Combobox) */} +
+ setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setMappingSourceSearch((prev) => ({ ...prev, [index]: value }))} + /> + + 컬럼을 찾을 수 없습니다 + + {mappingSourceColumns.map((col) => ( + { + const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; + rules[index] = { ...rules[index], sourceField: col.name }; + onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); + setMappingSourcePopoverOpen((prev) => ({ ...prev, [index]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + + + {/* 타겟 필드 선택 (Combobox) */} +
+ setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: open }))} + > + + + + + + setMappingTargetSearch((prev) => ({ ...prev, [index]: value }))} + /> + + 컬럼을 찾을 수 없습니다 + + {mappingTargetColumns.map((col) => ( + { + const rules = [...(config.action?.dataTransfer?.mappingRules || [])]; + rules[index] = { ...rules[index], targetField: col.name }; + onUpdateProperty("componentConfig.action.dataTransfer.mappingRules", rules); + setMappingTargetPopoverOpen((prev) => ({ ...prev, [index]: false })); + }} + className="text-xs" + > + + {col.label} + {col.label !== col.name && ( + ({col.name}) + )} + + ))} + + + + + +
+ + +
+ ))} +
+ )} +
+
+ +
+

+ 사용 방법: +
+ 1. 소스 컴포넌트에서 데이터를 선택합니다 +
+ 2. 필드 매핑 규칙을 설정합니다 (예: 품번 → 품목코드) +
+ 3. 이 버튼을 클릭하면 매핑된 데이터가 타겟으로 전달됩니다 +

+
+
+ )} + {/* 제어 기능 섹션 */}
diff --git a/frontend/components/screen/panels/DetailSettingsPanel.tsx b/frontend/components/screen/panels/DetailSettingsPanel.tsx index e3e8cbb3..d3196fb2 100644 --- a/frontend/components/screen/panels/DetailSettingsPanel.tsx +++ b/frontend/components/screen/panels/DetailSettingsPanel.tsx @@ -740,6 +740,12 @@ export const DetailSettingsPanel: React.FC = ({ const handleConfigChange = (newConfig: WebTypeConfig) => { // 강제 새 객체 생성으로 React 변경 감지 보장 const freshConfig = { ...newConfig }; + console.log("🔧 [DetailSettingsPanel] handleConfigChange 호출:", { + widgetId: widget.id, + widgetLabel: widget.label, + widgetType: widget.widgetType, + newConfig: freshConfig, + }); onUpdateProperty(widget.id, "webTypeConfig", freshConfig); // TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑 @@ -863,27 +869,23 @@ export const DetailSettingsPanel: React.FC = ({ }); // 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 - const ConfigPanelWrapper = () => { - // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 - const config = currentConfig || definition.defaultProps?.componentConfig || {}; - - const handleConfigChange = (newConfig: any) => { - // componentConfig 전체를 업데이트 - onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); - }; - - return ( -
-
- -

{definition.name} 설정

-
- -
- ); + // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 + const config = currentConfig || definition.defaultProps?.componentConfig || {}; + + const handleConfigChange = (newConfig: any) => { + // componentConfig 전체를 업데이트 + onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); }; - return ; + return ( +
+
+ +

{definition.name} 설정

+
+ +
+ ); } else { console.warn("⚠️ ConfigPanel 없음:", { componentId, diff --git a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx index 6e27fb93..964196ba 100644 --- a/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx +++ b/frontend/components/screen/panels/UnifiedPropertiesPanel.tsx @@ -114,7 +114,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ }) => { const { webTypes } = useWebTypes({ active: "Y" }); const [localComponentDetailType, setLocalComponentDetailType] = useState(""); - + // 높이/너비 입력 로컬 상태 (자유 입력 허용) const [localHeight, setLocalHeight] = useState(""); const [localWidth, setLocalWidth] = useState(""); @@ -147,7 +147,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ } } }, [selectedComponent?.type, selectedComponent?.componentConfig?.webType, selectedComponent?.id]); - + // 높이 값 동기화 useEffect(() => { if (selectedComponent?.size?.height !== undefined) { @@ -179,7 +179,10 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 최대 컬럼 수 계산 const MIN_COLUMN_WIDTH = 30; const maxColumns = currentResolution - ? Math.floor((currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / (MIN_COLUMN_WIDTH + gridSettings.gap)) + ? Math.floor( + (currentResolution.width - gridSettings.padding * 2 + gridSettings.gap) / + (MIN_COLUMN_WIDTH + gridSettings.gap), + ) : 24; const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한 @@ -189,7 +192,7 @@ export const UnifiedPropertiesPanel: React.FC = ({

격자 설정

- +
{/* 토글들 */}
@@ -226,9 +229,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ {/* 10px 단위 스냅 안내 */}
-

- 모든 컴포넌트는 10px 단위로 자동 배치됩니다. -

+

모든 컴포넌트는 10px 단위로 자동 배치됩니다.

@@ -238,9 +239,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 if (!selectedComponent) { return ( -
+
{/* 해상도 설정과 격자 설정 표시 */} -
+
{/* 해상도 설정 */} {currentResolution && onResolutionChange && ( @@ -287,9 +288,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ if (!selectedComponent) return null; // 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지 - const componentType = - selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등) - selectedComponent.componentConfig?.type || + const componentType = + selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등) + selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id || selectedComponent.type; @@ -305,15 +306,15 @@ export const UnifiedPropertiesPanel: React.FC = ({ }; // 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도 - const componentId = - selectedComponent.componentType || // ⭐ section-card 등 - selectedComponent.componentConfig?.type || + const componentId = + selectedComponent.componentType || // ⭐ section-card 등 + selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.id || (selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등) - + if (componentId) { const definition = ComponentRegistry.getComponent(componentId); - + if (definition?.configPanel) { const ConfigPanelComponent = definition.configPanel; const currentConfig = selectedComponent.componentConfig || {}; @@ -325,41 +326,48 @@ export const UnifiedPropertiesPanel: React.FC = ({ currentConfig, }); - // 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 - const ConfigPanelWrapper = () => { - // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 - const config = currentConfig || definition.defaultProps?.componentConfig || {}; - - const handleConfigChange = (newConfig: any) => { - // componentConfig 전체를 업데이트 - onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); - }; + // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지) + const config = currentConfig || definition.defaultProps?.componentConfig || {}; - return ( -
-
- -

{definition.name} 설정

-
- -
설정 패널 로딩 중...
-
- }> - - -
- ); + const handlePanelConfigChange = (newConfig: any) => { + // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합 + const mergedConfig = { + ...currentConfig, // 기존 설정 유지 + ...newConfig, // 새 설정 병합 + }; + console.log("🔧 [ConfigPanel] handleConfigChange:", { + componentId: selectedComponent.id, + currentConfig, + newConfig, + mergedConfig, + }); + onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig); }; - return ; + return ( +
+
+ +

{definition.name} 설정

+
+ +
설정 패널 로딩 중...
+
+ } + > + + +
+ ); } else { console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", { componentId, @@ -418,9 +426,7 @@ export const UnifiedPropertiesPanel: React.FC = ({

Section Card 설정

-

- 제목과 테두리가 있는 명확한 그룹화 컨테이너 -

+

제목과 테두리가 있는 명확한 그룹화 컨테이너

{/* 헤더 표시 */} @@ -432,7 +438,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked); }} /> -
@@ -462,7 +468,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value); }} placeholder="섹션 설명 입력" - className="text-xs resize-none" + className="resize-none text-xs" rows={2} />
@@ -530,7 +536,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{/* 접기/펼치기 기능 */} -
+
= ({ handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked); }} /> -
{selectedComponent.componentConfig?.collapsible && ( -
+
= ({ handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked); }} /> -
@@ -567,9 +573,7 @@ export const UnifiedPropertiesPanel: React.FC = ({

Section Paper 설정

-

- 배경색 기반의 미니멀한 그룹화 컨테이너 -

+

배경색 기반의 미니멀한 그룹화 컨테이너

{/* 배경색 */} @@ -680,7 +684,7 @@ export const UnifiedPropertiesPanel: React.FC = ({ handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked); }} /> -
@@ -691,9 +695,9 @@ export const UnifiedPropertiesPanel: React.FC = ({ // ConfigPanel이 없는 경우 경고 표시 return (
- +

⚠️ 설정 패널 없음

-

+

컴포넌트 "{componentId || componentType}"에 대한 설정 패널이 없습니다.

@@ -1418,7 +1422,7 @@ export const UnifiedPropertiesPanel: React.FC = ({
{/* 통합 컨텐츠 (탭 제거) */} -
+
{/* 해상도 설정 - 항상 맨 위에 표시 */} {currentResolution && onResolutionChange && ( diff --git a/frontend/components/webtypes/RepeaterInput.tsx b/frontend/components/webtypes/RepeaterInput.tsx index f81e8c9c..ade700e1 100644 --- a/frontend/components/webtypes/RepeaterInput.tsx +++ b/frontend/components/webtypes/RepeaterInput.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; @@ -8,8 +8,9 @@ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; -import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition } from "@/types/repeater"; +import { RepeaterFieldGroupConfig, RepeaterData, RepeaterItemData, RepeaterFieldDefinition, CalculationFormula } from "@/types/repeater"; import { cn } from "@/lib/utils"; import { useBreakpoint } from "@/hooks/useBreakpoint"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; @@ -21,6 +22,7 @@ export interface RepeaterInputProps { disabled?: boolean; readonly?: boolean; className?: string; + menuObjid?: number; // 카테고리 조회용 메뉴 ID } /** @@ -34,6 +36,7 @@ export const RepeaterInput: React.FC = ({ disabled = false, readonly = false, className, + menuObjid, }) => { // 현재 브레이크포인트 감지 const globalBreakpoint = useBreakpoint(); @@ -42,6 +45,9 @@ export const RepeaterInput: React.FC = ({ // 미리보기 모달 내에서는 previewBreakpoint 우선 사용 const breakpoint = previewBreakpoint || globalBreakpoint; + // 카테고리 매핑 데이터 (값 -> {label, color}) + const [categoryMappings, setCategoryMappings] = useState>>({}); + // 설정 기본값 const { fields = [], @@ -72,6 +78,12 @@ export const RepeaterInput: React.FC = ({ // 접힌 상태 관리 (각 항목별) const [collapsedItems, setCollapsedItems] = useState>(new Set()); + + // 🆕 초기 계산 완료 여부 추적 (무한 루프 방지) + const initialCalcDoneRef = useRef(false); + + // 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영) + const deletedItemIdsRef = useRef([]); // 빈 항목 생성 function createEmptyItem(): RepeaterItemData { @@ -82,10 +94,39 @@ export const RepeaterInput: React.FC = ({ return item; } - // 외부 value 변경 시 동기화 + // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트 useEffect(() => { if (value.length > 0) { - setItems(value); + // 🆕 초기 로드 시 계산식 필드 자동 업데이트 (한 번만 실행) + const calculatedFields = fields.filter(f => f.type === "calculated"); + + if (calculatedFields.length > 0 && !initialCalcDoneRef.current) { + const updatedValue = value.map(item => { + const updatedItem = { ...item }; + let hasChange = false; + + calculatedFields.forEach(calcField => { + const calculatedValue = calculateValue(calcField.formula, updatedItem); + if (calculatedValue !== null && updatedItem[calcField.name] !== calculatedValue) { + updatedItem[calcField.name] = calculatedValue; + hasChange = true; + } + }); + + return hasChange ? updatedItem : item; + }); + + setItems(updatedValue); + initialCalcDoneRef.current = true; + + // 계산된 값이 있으면 onChange 호출 (초기 1회만) + const dataWithMeta = config.targetTable + ? updatedValue.map((item) => ({ ...item, _targetTable: config.targetTable })) + : updatedValue; + onChange?.(dataWithMeta); + } else { + setItems(value); + } } }, [value]); @@ -111,14 +152,32 @@ export const RepeaterInput: React.FC = ({ if (items.length <= minItems) { return; } + + // 🆕 삭제되는 항목의 ID 저장 (DB에서 삭제할 때 필요) + const removedItem = items[index]; + if (removedItem?.id) { + console.log("🗑️ [RepeaterInput] 삭제할 항목 ID 추가:", removedItem.id); + deletedItemIdsRef.current = [...deletedItemIdsRef.current, removedItem.id]; + } + const newItems = items.filter((_, i) => i !== index); setItems(newItems); // targetTable이 설정된 경우 각 항목에 메타데이터 추가 + // 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용) + const currentDeletedIds = deletedItemIdsRef.current; + console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds); + const dataWithMeta = config.targetTable - ? newItems.map((item) => ({ ...item, _targetTable: config.targetTable })) + ? newItems.map((item, idx) => ({ + ...item, + _targetTable: config.targetTable, + // 첫 번째 항목에만 삭제 ID 목록 포함 + ...(idx === 0 ? { _deletedItemIds: currentDeletedIds } : {}), + })) : newItems; + console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta); onChange?.(dataWithMeta); // 접힌 상태도 업데이트 @@ -134,6 +193,16 @@ export const RepeaterInput: React.FC = ({ ...newItems[itemIndex], [fieldName]: value, }; + + // 🆕 계산식 필드 자동 업데이트: 변경된 항목의 모든 계산식 필드 값을 재계산 + const calculatedFields = fields.filter(f => f.type === "calculated"); + calculatedFields.forEach(calcField => { + const calculatedValue = calculateValue(calcField.formula, newItems[itemIndex]); + if (calculatedValue !== null) { + newItems[itemIndex][calcField.name] = calculatedValue; + } + }); + setItems(newItems); console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { itemIndex, @@ -143,8 +212,15 @@ export const RepeaterInput: React.FC = ({ }); // targetTable이 설정된 경우 각 항목에 메타데이터 추가 + // 🆕 삭제된 항목 ID 목록도 유지 + const currentDeletedIds = deletedItemIdsRef.current; const dataWithMeta = config.targetTable - ? newItems.map((item) => ({ ...item, _targetTable: config.targetTable })) + ? newItems.map((item, idx) => ({ + ...item, + _targetTable: config.targetTable, + // 첫 번째 항목에만 삭제 ID 목록 포함 (삭제된 항목이 있는 경우에만) + ...(idx === 0 && currentDeletedIds.length > 0 ? { _deletedItemIds: currentDeletedIds } : {}), + })) : newItems; onChange?.(dataWithMeta); @@ -192,24 +268,183 @@ export const RepeaterInput: React.FC = ({ setDraggedIndex(null); }; + /** + * 계산식 실행 + * @param formula 계산식 정의 + * @param item 현재 항목 데이터 + * @returns 계산 결과 + */ + const calculateValue = (formula: CalculationFormula | undefined, item: RepeaterItemData): number | null => { + if (!formula || !formula.field1) return null; + + const value1 = parseFloat(item[formula.field1]) || 0; + const value2 = formula.field2 + ? (parseFloat(item[formula.field2]) || 0) + : (formula.constantValue ?? 0); + + let result: number; + + switch (formula.operator) { + case "+": + result = value1 + value2; + break; + case "-": + result = value1 - value2; + break; + case "*": + result = value1 * value2; + break; + case "/": + result = value2 !== 0 ? value1 / value2 : 0; + break; + case "%": + result = value2 !== 0 ? value1 % value2 : 0; + break; + case "round": + const decimalPlaces = formula.decimalPlaces ?? 0; + const multiplier = Math.pow(10, decimalPlaces); + result = Math.round(value1 * multiplier) / multiplier; + break; + case "floor": + const floorMultiplier = Math.pow(10, formula.decimalPlaces ?? 0); + result = Math.floor(value1 * floorMultiplier) / floorMultiplier; + break; + case "ceil": + const ceilMultiplier = Math.pow(10, formula.decimalPlaces ?? 0); + result = Math.ceil(value1 * ceilMultiplier) / ceilMultiplier; + break; + case "abs": + result = Math.abs(value1); + break; + default: + result = value1; + } + + return result; + }; + + /** + * 숫자 포맷팅 + * @param value 숫자 값 + * @param format 포맷 설정 + * @returns 포맷된 문자열 + */ + const formatNumber = ( + value: number | null, + format?: RepeaterFieldDefinition["numberFormat"] + ): string => { + if (value === null || isNaN(value)) return "-"; + + let formattedValue = value; + + // 소수점 자릿수 적용 + if (format?.decimalPlaces !== undefined) { + formattedValue = parseFloat(value.toFixed(format.decimalPlaces)); + } + + // 천 단위 구분자 + let result = format?.useThousandSeparator !== false + ? formattedValue.toLocaleString("ko-KR", { + minimumFractionDigits: format?.minimumFractionDigits ?? 0, + maximumFractionDigits: format?.maximumFractionDigits ?? format?.decimalPlaces ?? 0, + }) + : formattedValue.toString(); + + // 접두사/접미사 추가 + if (format?.prefix) result = format.prefix + result; + if (format?.suffix) result = result + format.suffix; + + return result; + }; + // 개별 필드 렌더링 const renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => { + const isReadonly = disabled || readonly || field.readonly; + const commonProps = { value: value || "", - disabled: disabled || readonly, + disabled: isReadonly, placeholder: field.placeholder, required: field.required, }; + // 계산식 필드: 자동으로 계산된 값을 표시 (읽기 전용) + if (field.type === "calculated") { + const item = items[itemIndex]; + const calculatedValue = calculateValue(field.formula, item); + const formattedValue = formatNumber(calculatedValue, field.numberFormat); + + return ( + + {formattedValue} + + ); + } + + // 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용) + if (field.type === "category") { + if (!value) return -; + + // field.name을 키로 사용 (테이블 리스트와 동일) + const mapping = categoryMappings[field.name]; + const valueStr = String(value); // 값을 문자열로 변환 + const categoryData = mapping?.[valueStr]; + const displayLabel = categoryData?.label || valueStr; + const displayColor = categoryData?.color || "#64748b"; // 기본 색상 (slate) + + console.log(`🏷️ [RepeaterInput] 카테고리 배지 렌더링:`, { + fieldName: field.name, + value: valueStr, + mapping, + categoryData, + displayLabel, + displayColor, + }); + + // 색상이 "none"이면 일반 텍스트로 표시 + if (displayColor === "none") { + return {displayLabel}; + } + + return ( + + {displayLabel} + + ); + } + + // 읽기 전용 모드: 텍스트로 표시 + // displayMode가 "readonly"이면 isReadonly 여부와 관계없이 텍스트로 표시 + if (field.displayMode === "readonly") { + // select 타입인 경우 옵션에서 라벨 찾기 + if (field.type === "select" && value && field.options) { + const option = field.options.find(opt => opt.value === value); + return {option?.label || value}; + } + + // 일반 텍스트 + return ( + + {value || "-"} + + ); + } + switch (field.type) { case "select": return ( handleFieldChange(itemIndex, field.name, e.target.value)} + min={field.validation?.min} + max={field.validation?.max} + className="pr-1" + /> + {value && ( +
+ {formattedDisplay} +
+ )} +
+ ); + } + return ( = ({ onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} min={field.validation?.min} max={field.validation?.max} + className="min-w-[80px]" /> ); @@ -258,6 +529,7 @@ export const RepeaterInput: React.FC = ({ {...commonProps} type="email" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} + className="min-w-[120px]" /> ); @@ -267,6 +539,7 @@ export const RepeaterInput: React.FC = ({ {...commonProps} type="tel" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} + className="min-w-[100px]" /> ); @@ -277,11 +550,69 @@ export const RepeaterInput: React.FC = ({ type="text" onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} maxLength={field.validation?.maxLength} + className="min-w-[80px]" /> ); } }; + // 카테고리 매핑 로드 (카테고리 필드가 있을 때 자동 로드) + // 테이블 리스트와 동일한 API 사용: /table-categories/{tableName}/{columnName}/values + useEffect(() => { + const categoryFields = fields.filter(f => f.type === "category"); + if (categoryFields.length === 0) return; + + const loadCategoryMappings = async () => { + const apiClient = (await import("@/lib/api/client")).apiClient; + + for (const field of categoryFields) { + const columnName = field.name; // 실제 컬럼명 + const categoryCode = field.categoryCode || columnName; + + // 이미 로드된 경우 스킵 + if (categoryMappings[columnName]) continue; + + try { + // config에서 targetTable 가져오기, 없으면 스킵 + const tableName = config.targetTable; + if (!tableName) { + console.warn(`[RepeaterInput] targetTable이 설정되지 않아 카테고리 매핑을 로드할 수 없습니다.`); + continue; + } + + console.log(`📡 [RepeaterInput] 카테고리 매핑 로드: ${tableName}/${columnName}`); + + // 테이블 리스트와 동일한 API 사용 + const response = await apiClient.get(`/table-categories/${tableName}/${columnName}/values`); + + if (response.data.success && response.data.data && Array.isArray(response.data.data)) { + const mapping: Record = {}; + + response.data.data.forEach((item: any) => { + // valueCode를 문자열로 변환하여 키로 사용 (테이블 리스트와 동일) + const key = String(item.valueCode); + mapping[key] = { + label: item.valueLabel || key, + color: item.color || "#64748b", // color 필드 사용 (DB 컬럼명과 동일) + }; + }); + + console.log(`✅ [RepeaterInput] 카테고리 매핑 로드 완료 [${columnName}]:`, mapping); + + setCategoryMappings(prev => ({ + ...prev, + [columnName]: mapping, + })); + } + } catch (error) { + console.error(`❌ [RepeaterInput] 카테고리 매핑 로드 실패 (${columnName}):`, error); + } + } + }; + + loadCategoryMappings(); + }, [fields, config.targetTable]); + // 필드가 정의되지 않았을 때 if (fields.length === 0) { return ( @@ -324,18 +655,18 @@ export const RepeaterInput: React.FC = ({ {showIndex && ( - # + # )} {allowReorder && ( - + )} {fields.map((field) => ( - + {field.label} {field.required && *} ))} - 작업 + 작업 @@ -354,27 +685,27 @@ export const RepeaterInput: React.FC = ({ > {/* 인덱스 번호 */} {showIndex && ( - + {itemIndex + 1} )} {/* 드래그 핸들 */} {allowReorder && !readonly && !disabled && ( - + )} {/* 필드들 */} {fields.map((field) => ( - + {renderField(field, itemIndex, item[field.name])} ))} {/* 삭제 버튼 */} - + {!readonly && !disabled && items.length > minItems && (
+ {/* 그룹화 컬럼 설정 */} +
+ + +

+ 수정 모드에서 이 컬럼 값을 기준으로 관련된 모든 데이터를 조회합니다. +
+ 예: 입고번호를 선택하면 같은 입고번호를 가진 모든 품목이 표시됩니다. +

+
+ {/* 필드 정의 */}
@@ -235,10 +261,23 @@ export const RepeaterConfigPanel: React.FC = ({ key={column.columnName} value={column.columnName} onSelect={() => { + // input_type (DB에서 설정한 타입) 우선 사용, 없으면 webType/widgetType + const col = column as any; + const fieldType = col.input_type || col.inputType || col.webType || col.widgetType || "text"; + + console.log("🔍 [RepeaterConfigPanel] 필드 타입 결정:", { + columnName: column.columnName, + input_type: col.input_type, + inputType: col.inputType, + webType: col.webType, + widgetType: col.widgetType, + finalType: fieldType, + }); + updateField(index, { name: column.columnName, label: column.columnLabel || column.columnName, - type: (column.widgetType as RepeaterFieldType) || "text", + type: fieldType as RepeaterFieldType, }); // 로컬 입력 상태도 업데이트 setLocalInputs(prev => ({ @@ -293,13 +332,25 @@ export const RepeaterConfigPanel: React.FC = ({ - 텍스트 - 숫자 - 이메일 - 전화번호 - 날짜 - 선택박스 - 텍스트영역 + {/* 테이블 타입 관리에서 사용하는 input_type 목록 */} + 텍스트 (text) + 숫자 (number) + 텍스트영역 (textarea) + 날짜 (date) + 선택박스 (select) + 체크박스 (checkbox) + 라디오 (radio) + 카테고리 (category) + 엔티티 참조 (entity) + 공통코드 (code) + 이미지 (image) + 직접입력 (direct) + + + + 계산식 (calculated) + +
@@ -316,16 +367,316 @@ export const RepeaterConfigPanel: React.FC = ({
-
- updateField(index, { required: checked as boolean })} - /> - -
+ {/* 계산식 타입일 때 계산식 설정 */} + {field.type === "calculated" && ( +
+
+ + +
+ + {/* 필드 1 선택 */} +
+ + +
+ + {/* 연산자 선택 */} +
+ + +
+ + {/* 두 번째 필드 또는 상수값 */} + {!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? ( +
+ + +
+ ) : ( +
+ + updateField(index, { + formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula + })} + className="h-8 text-xs" + /> +
+ )} + + {/* 상수값 입력 필드 */} + {field.formula?.constantValue !== undefined && ( +
+ + updateField(index, { + formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula + })} + placeholder="숫자 입력" + className="h-8 text-xs" + /> +
+ )} + + {/* 숫자 포맷 설정 */} +
+ +
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean } + })} + /> + +
+
+ + updateField(index, { + numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 } + })} + type="number" + min={0} + max={10} + className="h-6 w-12 text-[10px]" + /> +
+
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, prefix: e.target.value } + })} + placeholder="접두사 (₩)" + className="h-7 text-[10px]" + /> + updateField(index, { + numberFormat: { ...field.numberFormat, suffix: e.target.value } + })} + placeholder="접미사 (원)" + className="h-7 text-[10px]" + /> +
+
+ + {/* 계산식 미리보기 */} +
+ 계산식: + + {field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} { + field.formula?.field2 || + (field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2") + } + +
+
+ )} + + {/* 숫자 타입일 때 숫자 표시 형식 설정 */} + {field.type === "number" && ( +
+ +
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean } + })} + /> + +
+
+ + updateField(index, { + numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 } + })} + type="number" + min={0} + max={10} + className="h-6 w-12 text-[10px]" + /> +
+
+
+ updateField(index, { + numberFormat: { ...field.numberFormat, prefix: e.target.value } + })} + placeholder="접두사 (₩)" + className="h-7 text-[10px]" + /> + updateField(index, { + numberFormat: { ...field.numberFormat, suffix: e.target.value } + })} + placeholder="접미사 (원)" + className="h-7 text-[10px]" + /> +
+
+ )} + + {/* 카테고리 타입일 때 카테고리 코드 입력 */} + {field.type === "category" && ( +
+ + updateField(index, { categoryCode: e.target.value })} + placeholder="카테고리 코드 (예: INBOUND_TYPE)" + className="h-8 w-full text-xs" + /> +

+ 카테고리 관리에서 설정한 색상으로 배지가 표시됩니다 +

+
+ )} + + {/* 카테고리 타입이 아닐 때만 표시 모드 선택 */} + {field.type !== "category" && ( +
+
+ + +
+ +
+
+ updateField(index, { required: checked as boolean })} + /> + +
+
+
+ )} + + {/* 카테고리 타입일 때는 필수만 표시 */} + {field.type === "category" && ( +
+ updateField(index, { required: checked as boolean })} + /> + +
+ )} ))} diff --git a/frontend/contexts/ScreenContext.tsx b/frontend/contexts/ScreenContext.tsx new file mode 100644 index 00000000..f8c703dd --- /dev/null +++ b/frontend/contexts/ScreenContext.tsx @@ -0,0 +1,133 @@ +/** + * 화면 컨텍스트 + * 같은 화면 내의 컴포넌트들이 서로 통신할 수 있도록 합니다. + */ + +"use client"; + +import React, { createContext, useContext, useCallback, useRef } from "react"; +import type { DataProvidable, DataReceivable } from "@/types/data-transfer"; +import { logger } from "@/lib/utils/logger"; +import type { SplitPanelPosition } from "@/contexts/SplitPanelContext"; + +interface ScreenContextValue { + screenId?: number; + tableName?: string; + splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 (left/right) + + // 컴포넌트 등록 + 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; + splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치 + children: React.ReactNode; +} + +/** + * 화면 컨텍스트 프로바이더 + */ +export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, 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); + }, []); + + // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) + const value = React.useMemo(() => ({ + screenId, + tableName, + splitPanelPosition, + registerDataProvider, + unregisterDataProvider, + registerDataReceiver, + unregisterDataReceiver, + getDataProvider, + getDataReceiver, + getAllDataProviders, + getAllDataReceivers, + }), [ + screenId, + tableName, + splitPanelPosition, + 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/contexts/SplitPanelContext.tsx b/frontend/contexts/SplitPanelContext.tsx new file mode 100644 index 00000000..e5052295 --- /dev/null +++ b/frontend/contexts/SplitPanelContext.tsx @@ -0,0 +1,237 @@ +"use client"; + +import React, { createContext, useContext, useCallback, useRef, useState } from "react"; +import { logger } from "@/lib/utils/logger"; + +/** + * 분할 패널 내 화면 위치 + */ +export type SplitPanelPosition = "left" | "right"; + +/** + * 데이터 수신자 인터페이스 + */ +export interface SplitPanelDataReceiver { + componentId: string; + componentType: string; + receiveData: (data: any[], mode: "append" | "replace" | "merge") => Promise; +} + +/** + * 분할 패널 컨텍스트 값 + */ +interface SplitPanelContextValue { + // 분할 패널 ID + splitPanelId: string; + + // 좌측/우측 화면 ID + leftScreenId: number | null; + rightScreenId: number | null; + + // 데이터 수신자 등록/해제 + registerReceiver: (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => void; + unregisterReceiver: (position: SplitPanelPosition, componentId: string) => void; + + // 반대편 화면으로 데이터 전달 + transferToOtherSide: ( + fromPosition: SplitPanelPosition, + data: any[], + targetComponentId?: string, // 특정 컴포넌트 지정 (없으면 첫 번째 수신자) + mode?: "append" | "replace" | "merge" + ) => Promise<{ success: boolean; message: string }>; + + // 반대편 화면의 수신자 목록 가져오기 + getOtherSideReceivers: (fromPosition: SplitPanelPosition) => SplitPanelDataReceiver[]; + + // 현재 위치 확인 + isInSplitPanel: boolean; + + // screenId로 위치 찾기 + getPositionByScreenId: (screenId: number) => SplitPanelPosition | null; +} + +const SplitPanelContext = createContext(null); + +interface SplitPanelProviderProps { + splitPanelId: string; + leftScreenId: number | null; + rightScreenId: number | null; + children: React.ReactNode; +} + +/** + * 분할 패널 컨텍스트 프로바이더 + */ +export function SplitPanelProvider({ + splitPanelId, + leftScreenId, + rightScreenId, + children, +}: SplitPanelProviderProps) { + // 좌측/우측 화면의 데이터 수신자 맵 + const leftReceiversRef = useRef>(new Map()); + const rightReceiversRef = useRef>(new Map()); + + // 강제 리렌더링용 상태 + const [, forceUpdate] = useState(0); + + /** + * 데이터 수신자 등록 + */ + const registerReceiver = useCallback( + (position: SplitPanelPosition, componentId: string, receiver: SplitPanelDataReceiver) => { + const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef; + receiversRef.current.set(componentId, receiver); + + logger.debug(`[SplitPanelContext] 수신자 등록: ${position} - ${componentId}`, { + componentType: receiver.componentType, + }); + + forceUpdate((n) => n + 1); + }, + [] + ); + + /** + * 데이터 수신자 해제 + */ + const unregisterReceiver = useCallback( + (position: SplitPanelPosition, componentId: string) => { + const receiversRef = position === "left" ? leftReceiversRef : rightReceiversRef; + receiversRef.current.delete(componentId); + + logger.debug(`[SplitPanelContext] 수신자 해제: ${position} - ${componentId}`); + + forceUpdate((n) => n + 1); + }, + [] + ); + + /** + * 반대편 화면의 수신자 목록 가져오기 + */ + const getOtherSideReceivers = useCallback( + (fromPosition: SplitPanelPosition): SplitPanelDataReceiver[] => { + const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef; + return Array.from(receiversRef.current.values()); + }, + [] + ); + + /** + * 반대편 화면으로 데이터 전달 + */ + const transferToOtherSide = useCallback( + async ( + fromPosition: SplitPanelPosition, + data: any[], + targetComponentId?: string, + mode: "append" | "replace" | "merge" = "append" + ): Promise<{ success: boolean; message: string }> => { + const toPosition = fromPosition === "left" ? "right" : "left"; + const receiversRef = fromPosition === "left" ? rightReceiversRef : leftReceiversRef; + + logger.info(`[SplitPanelContext] 데이터 전달 시작: ${fromPosition} → ${toPosition}`, { + dataCount: data.length, + targetComponentId, + mode, + availableReceivers: Array.from(receiversRef.current.keys()), + }); + + if (receiversRef.current.size === 0) { + const message = `${toPosition === "left" ? "좌측" : "우측"} 화면에 데이터를 받을 수 있는 컴포넌트가 없습니다.`; + logger.warn(`[SplitPanelContext] ${message}`); + return { success: false, message }; + } + + try { + let targetReceiver: SplitPanelDataReceiver | undefined; + + if (targetComponentId) { + // 특정 컴포넌트 지정 + targetReceiver = receiversRef.current.get(targetComponentId); + if (!targetReceiver) { + const message = `타겟 컴포넌트 '${targetComponentId}'를 찾을 수 없습니다.`; + logger.warn(`[SplitPanelContext] ${message}`); + return { success: false, message }; + } + } else { + // 첫 번째 수신자 사용 + targetReceiver = receiversRef.current.values().next().value; + } + + if (!targetReceiver) { + return { success: false, message: "데이터 수신자를 찾을 수 없습니다." }; + } + + await targetReceiver.receiveData(data, mode); + + const message = `${data.length}개 항목이 ${toPosition === "left" ? "좌측" : "우측"} 화면으로 전달되었습니다.`; + logger.info(`[SplitPanelContext] ${message}`); + + return { success: true, message }; + } catch (error: any) { + const message = error.message || "데이터 전달 중 오류가 발생했습니다."; + logger.error(`[SplitPanelContext] 데이터 전달 실패`, error); + return { success: false, message }; + } + }, + [] + ); + + /** + * screenId로 위치 찾기 + */ + const getPositionByScreenId = useCallback( + (screenId: number): SplitPanelPosition | null => { + if (leftScreenId === screenId) return "left"; + if (rightScreenId === screenId) return "right"; + return null; + }, + [leftScreenId, rightScreenId] + ); + + // 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지) + const value = React.useMemo(() => ({ + splitPanelId, + leftScreenId, + rightScreenId, + registerReceiver, + unregisterReceiver, + transferToOtherSide, + getOtherSideReceivers, + isInSplitPanel: true, + getPositionByScreenId, + }), [ + splitPanelId, + leftScreenId, + rightScreenId, + registerReceiver, + unregisterReceiver, + transferToOtherSide, + getOtherSideReceivers, + getPositionByScreenId, + ]); + + return ( + + {children} + + ); +} + +/** + * 분할 패널 컨텍스트 훅 + */ +export function useSplitPanelContext() { + return useContext(SplitPanelContext); +} + +/** + * 분할 패널 내부인지 확인하는 훅 + */ +export function useIsInSplitPanel(): boolean { + const context = useContext(SplitPanelContext); + return context?.isInSplitPanel ?? false; +} + diff --git a/frontend/lib/api/menu.ts b/frontend/lib/api/menu.ts index a39fc7c6..8d917e3d 100644 --- a/frontend/lib/api/menu.ts +++ b/frontend/lib/api/menu.ts @@ -199,8 +199,6 @@ export interface MenuCopyResult { copiedMenus: number; copiedScreens: number; copiedFlows: number; - copiedCategories: number; - copiedCodes: number; menuIdMap: Record; screenIdMap: Record; flowIdMap: Record; 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/api/tableCategoryValue.ts b/frontend/lib/api/tableCategoryValue.ts index ba830457..3c5380d1 100644 --- a/frontend/lib/api/tableCategoryValue.ts +++ b/frontend/lib/api/tableCategoryValue.ts @@ -259,6 +259,28 @@ export async function deleteColumnMapping(mappingId: number) { } } +/** + * 테이블+컬럼 기준으로 모든 매핑 삭제 + * + * 메뉴 선택 변경 시 기존 매핑을 모두 삭제하고 새로운 매핑만 추가하기 위해 사용 + * + * @param tableName - 테이블명 + * @param columnName - 컬럼명 + */ +export async function deleteColumnMappingsByColumn(tableName: string, columnName: string) { + try { + const response = await apiClient.delete<{ + success: boolean; + message: string; + deletedCount: number; + }>(`/table-categories/column-mapping/${tableName}/${columnName}/all`); + return response.data; + } catch (error: any) { + console.error("테이블+컬럼 기준 매핑 삭제 실패:", error); + return { success: false, error: error.message, deletedCount: 0 }; + } +} + /** * 2레벨 메뉴 목록 조회 * diff --git a/frontend/lib/registry/DynamicComponentRenderer.tsx b/frontend/lib/registry/DynamicComponentRenderer.tsx index 245e2527..fe93f4af 100644 --- a/frontend/lib/registry/DynamicComponentRenderer.tsx +++ b/frontend/lib/registry/DynamicComponentRenderer.tsx @@ -224,6 +224,19 @@ export const DynamicComponentRenderer: React.FC = // 1. 새 컴포넌트 시스템에서 먼저 조회 const newComponent = ComponentRegistry.getComponent(componentType); + // 🔍 디버깅: screen-split-panel 조회 결과 확인 + if (componentType === "screen-split-panel") { + console.log("🔍 [DynamicComponentRenderer] screen-split-panel 조회:", { + componentType, + found: !!newComponent, + componentId: component.id, + componentConfig: component.componentConfig, + hasFormData: !!props.formData, + formDataKeys: props.formData ? Object.keys(props.formData) : [], + registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), + }); + } + // 🔍 디버깅: select-basic 조회 결과 확인 if (componentType === "select-basic") { console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", { @@ -234,6 +247,20 @@ export const DynamicComponentRenderer: React.FC = }); } + // 🔍 디버깅: text-input 컴포넌트 조회 결과 확인 + if (componentType === "text-input" || component.id?.includes("text") || (component as any).webType === "text") { + console.log("🔍 [DynamicComponentRenderer] text-input 조회:", { + componentType, + componentId: component.id, + componentLabel: component.label, + componentConfig: component.componentConfig, + webTypeConfig: (component as any).webTypeConfig, + autoGeneration: (component as any).autoGeneration, + found: !!newComponent, + registeredComponents: ComponentRegistry.getAllComponents().map(c => c.id), + }); + } + if (newComponent) { // 새 컴포넌트 시스템으로 렌더링 try { @@ -294,6 +321,19 @@ export const DynamicComponentRenderer: React.FC = } else { currentValue = formData?.[fieldName] || ""; } + + // 🆕 디버깅: text-input 값 추출 확인 + if (componentType === "text-input" && formData && Object.keys(formData).length > 0) { + console.log("🔍 [DynamicComponentRenderer] text-input 값 추출:", { + componentId: component.id, + componentLabel: component.label, + columnName: (component as any).columnName, + fieldName, + currentValue, + hasFormData: !!formData, + formDataKeys: Object.keys(formData).slice(0, 10), // 처음 10개만 + }); + } // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리 const handleChange = (value: any) => { @@ -422,8 +462,14 @@ export const DynamicComponentRenderer: React.FC = if (!renderer) { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { component: component, + componentId: component.id, + componentLabel: component.label, componentType: componentType, + originalType: component.type, + originalComponentType: (component as any).componentType, componentConfig: component.componentConfig, + webTypeConfig: (component as any).webTypeConfig, + autoGeneration: (component as any).autoGeneration, availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id), availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(), }); diff --git a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx index f4ea2b94..1e00442f 100644 --- a/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx +++ b/frontend/lib/registry/components/button-primary/ButtonPrimaryComponent.tsx @@ -23,6 +23,9 @@ import { toast } from "sonner"; import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext"; +import { applyMappingRules } from "@/lib/utils/dataMapping"; export interface ButtonPrimaryComponentProps extends ComponentRendererProps { config?: ButtonPrimaryConfig; @@ -97,6 +100,14 @@ export const ButtonPrimaryComponent: React.FC = ({ ...props }) => { const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 + const screenContext = useScreenContextOptional(); // 화면 컨텍스트 + const splitPanelContext = useSplitPanelContext(); // 분할 패널 컨텍스트 + // 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동) + const splitPanelPosition = screenContext?.splitPanelPosition; + + // 🆕 tableName이 props로 전달되지 않으면 ScreenContext에서 가져오기 + const effectiveTableName = tableName || screenContext?.tableName; + const effectiveScreenId = screenId || screenContext?.screenId; // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출) const propsOnSave = (props as any).onSave as (() => Promise) | undefined; @@ -375,6 +386,261 @@ export const ButtonPrimaryComponent: React.FC = ({ }; // 이벤트 핸들러 + /** + * transferData 액션 처리 + */ + const handleTransferDataAction = async (actionConfig: any) => { + const dataTransferConfig = actionConfig.dataTransfer; + + if (!dataTransferConfig) { + toast.error("데이터 전달 설정이 없습니다."); + return; + } + + if (!screenContext) { + toast.error("화면 컨텍스트를 찾을 수 없습니다."); + return; + } + + try { + // 1. 소스 컴포넌트에서 데이터 가져오기 + let sourceProvider = screenContext.getDataProvider(dataTransferConfig.sourceComponentId); + + // 🆕 소스 컴포넌트를 찾을 수 없으면, 현재 화면에서 테이블 리스트 자동 탐색 + // (조건부 컨테이너의 다른 섹션으로 전환했을 때 이전 컴포넌트 ID가 남아있는 경우 대응) + if (!sourceProvider) { + console.log(`⚠️ [ButtonPrimary] 지정된 소스 컴포넌트를 찾을 수 없음: ${dataTransferConfig.sourceComponentId}`); + console.log(`🔍 [ButtonPrimary] 현재 화면에서 DataProvider 자동 탐색...`); + + const allProviders = screenContext.getAllDataProviders(); + + // 테이블 리스트 우선 탐색 + for (const [id, provider] of allProviders) { + if (provider.componentType === "table-list") { + sourceProvider = provider; + console.log(`✅ [ButtonPrimary] 테이블 리스트 자동 발견: ${id}`); + break; + } + } + + // 테이블 리스트가 없으면 첫 번째 DataProvider 사용 + if (!sourceProvider && allProviders.size > 0) { + const firstEntry = allProviders.entries().next().value; + if (firstEntry) { + sourceProvider = firstEntry[1]; + console.log(`✅ [ButtonPrimary] 첫 번째 DataProvider 사용: ${firstEntry[0]} (${sourceProvider.componentType})`); + } + } + + if (!sourceProvider) { + toast.error("데이터를 제공할 수 있는 컴포넌트를 찾을 수 없습니다."); + return; + } + } + + const rawSourceData = sourceProvider.getSelectedData(); + + // 🆕 배열이 아닌 경우 배열로 변환 + const sourceData = Array.isArray(rawSourceData) ? rawSourceData : (rawSourceData ? [rawSourceData] : []); + + console.log("📦 소스 데이터:", { rawSourceData, sourceData, isArray: Array.isArray(rawSourceData) }); + + if (!sourceData || sourceData.length === 0) { + toast.warning("선택된 데이터가 없습니다."); + return; + } + + // 1.5. 추가 데이터 소스 처리 (예: 조건부 컨테이너의 카테고리 값) + let additionalData: Record = {}; + + // 방법 1: additionalSources 설정에서 가져오기 + if (dataTransferConfig.additionalSources && Array.isArray(dataTransferConfig.additionalSources)) { + for (const additionalSource of dataTransferConfig.additionalSources) { + const additionalProvider = screenContext.getDataProvider(additionalSource.componentId); + + if (additionalProvider) { + const additionalValues = additionalProvider.getSelectedData(); + + if (additionalValues && additionalValues.length > 0) { + // 첫 번째 값 사용 (조건부 컨테이너는 항상 1개) + const firstValue = additionalValues[0]; + + // fieldName이 지정되어 있으면 그 필드만 추출 + if (additionalSource.fieldName) { + additionalData[additionalSource.fieldName] = firstValue[additionalSource.fieldName] || firstValue.condition || firstValue; + } else { + // fieldName이 없으면 전체 객체 병합 + additionalData = { ...additionalData, ...firstValue }; + } + + console.log("📦 추가 데이터 수집 (additionalSources):", { + sourceId: additionalSource.componentId, + fieldName: additionalSource.fieldName, + value: additionalData[additionalSource.fieldName || 'all'], + }); + } + } + } + } + + // 방법 2: formData에서 조건부 컨테이너 값 가져오기 (자동) + // ConditionalSectionViewer가 __conditionalContainerValue, __conditionalContainerControlField를 formData에 포함시킴 + if (formData && formData.__conditionalContainerValue) { + // includeConditionalValue 설정이 true이거나 설정이 없으면 자동 포함 + if (dataTransferConfig.includeConditionalValue !== false) { + const conditionalValue = formData.__conditionalContainerValue; + const conditionalLabel = formData.__conditionalContainerLabel; + const controlField = formData.__conditionalContainerControlField; // 🆕 제어 필드명 직접 사용 + + // 🆕 controlField가 있으면 그것을 필드명으로 사용 (자동 매핑!) + if (controlField) { + additionalData[controlField] = conditionalValue; + console.log("📦 조건부 컨테이너 값 자동 매핑:", { + controlField, + value: conditionalValue, + label: conditionalLabel, + }); + } else { + // controlField가 없으면 기존 방식: formData에서 같은 값을 가진 키 찾기 + for (const [key, value] of Object.entries(formData)) { + if (value === conditionalValue && !key.startsWith('__')) { + additionalData[key] = conditionalValue; + console.log("📦 조건부 컨테이너 값 자동 포함:", { + fieldName: key, + value: conditionalValue, + label: conditionalLabel, + }); + break; + } + } + + // 못 찾았으면 기본 필드명 사용 + if (!Object.keys(additionalData).some(k => !k.startsWith('__'))) { + additionalData['condition_type'] = conditionalValue; + console.log("📦 조건부 컨테이너 값 (기본 필드명):", { + fieldName: 'condition_type', + value: conditionalValue, + }); + } + } + } + } + + // 2. 검증 + const validation = dataTransferConfig.validation; + if (validation) { + if (validation.minSelection && sourceData.length < validation.minSelection) { + toast.error(`최소 ${validation.minSelection}개 이상 선택해야 합니다.`); + return; + } + if (validation.maxSelection && sourceData.length > validation.maxSelection) { + toast.error(`최대 ${validation.maxSelection}개까지 선택할 수 있습니다.`); + return; + } + } + + // 3. 확인 메시지 + if (dataTransferConfig.confirmBeforeTransfer) { + const confirmMessage = dataTransferConfig.confirmMessage || `${sourceData.length}개 항목을 전달하시겠습니까?`; + if (!window.confirm(confirmMessage)) { + return; + } + } + + // 4. 매핑 규칙 적용 + 추가 데이터 병합 + const mappedData = sourceData.map((row) => { + const mappedRow = applyMappingRules(row, dataTransferConfig.mappingRules || []); + + // 추가 데이터를 모든 행에 포함 + return { + ...mappedRow, + ...additionalData, + }; + }); + + console.log("📦 데이터 전달:", { + sourceData, + mappedData, + targetType: dataTransferConfig.targetType, + targetComponentId: dataTransferConfig.targetComponentId, + targetScreenId: dataTransferConfig.targetScreenId, + }); + + // 5. 타겟으로 데이터 전달 + if (dataTransferConfig.targetType === "component") { + // 같은 화면의 컴포넌트로 전달 + const targetReceiver = screenContext.getDataReceiver(dataTransferConfig.targetComponentId); + + if (!targetReceiver) { + toast.error(`타겟 컴포넌트를 찾을 수 없습니다: ${dataTransferConfig.targetComponentId}`); + return; + } + + await targetReceiver.receiveData(mappedData, { + targetComponentId: dataTransferConfig.targetComponentId, + targetComponentType: targetReceiver.componentType, + mode: dataTransferConfig.mode || "append", + mappingRules: dataTransferConfig.mappingRules || [], + }); + + toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); + } else if (dataTransferConfig.targetType === "splitPanel") { + // 🆕 분할 패널의 반대편 화면으로 전달 + if (!splitPanelContext) { + toast.error("분할 패널 컨텍스트를 찾을 수 없습니다. 이 버튼이 분할 패널 내부에 있는지 확인하세요."); + return; + } + + // 🆕 useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동) + // screenId로 찾는 것은 직접 임베드된 화면에서만 작동하므로, + // SplitPanelPositionProvider로 전달된 위치를 우선 사용 + const currentPosition = splitPanelPosition || (screenId ? splitPanelContext.getPositionByScreenId(screenId) : null); + + if (!currentPosition) { + toast.error("분할 패널 내 위치를 확인할 수 없습니다. screenId: " + screenId); + return; + } + + console.log("📦 분할 패널 데이터 전달:", { + currentPosition, + splitPanelPositionFromHook: splitPanelPosition, + screenId, + leftScreenId: splitPanelContext.leftScreenId, + rightScreenId: splitPanelContext.rightScreenId, + }); + + const result = await splitPanelContext.transferToOtherSide( + currentPosition, + mappedData, + dataTransferConfig.targetComponentId, // 특정 컴포넌트 지정 (선택사항) + dataTransferConfig.mode || "append" + ); + + if (result.success) { + toast.success(result.message); + } else { + toast.error(result.message); + return; + } + } else if (dataTransferConfig.targetType === "screen") { + // 다른 화면으로 전달 (구현 예정) + toast.info("다른 화면으로의 데이터 전달은 추후 구현 예정입니다."); + return; + } else { + toast.success(`${sourceData.length}개 항목이 전달되었습니다.`); + } + + // 6. 전달 후 정리 + if (dataTransferConfig.clearAfterTransfer) { + sourceProvider.clearSelection(); + } + + } catch (error: any) { + console.error("❌ 데이터 전달 실패:", error); + toast.error(error.message || "데이터 전달 중 오류가 발생했습니다."); + } + }; + const handleClick = async (e: React.MouseEvent) => { e.stopPropagation(); @@ -391,6 +657,12 @@ export const ButtonPrimaryComponent: React.FC = ({ // 인터랙티브 모드에서 액션 실행 if (isInteractive && processedConfig.action) { + // transferData 액션 처리 (화면 컨텍스트 필요) + if (processedConfig.action.type === "transferData") { + await handleTransferDataAction(processedConfig.action); + return; + } + // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 const hasDataToDelete = (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); @@ -410,11 +682,21 @@ export const ButtonPrimaryComponent: React.FC = ({ } } + // 🆕 디버깅: tableName 확인 + console.log("🔍 [ButtonPrimaryComponent] context 생성:", { + propsTableName: tableName, + contextTableName: screenContext?.tableName, + effectiveTableName, + propsScreenId: screenId, + contextScreenId: screenContext?.screenId, + effectiveScreenId, + }); + const context: ButtonActionContext = { formData: formData || {}, originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 - screenId, - tableName, + screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용 + tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용 userId, // 🆕 사용자 ID userName, // 🆕 사용자 이름 companyCode, // 🆕 회사 코드 diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx index 6f2ab183..db3fde4c 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerComponent.tsx @@ -12,6 +12,8 @@ import { import { ConditionalContainerProps, ConditionalSection } from "./types"; import { ConditionalSectionViewer } from "./ConditionalSectionViewer"; import { cn } from "@/lib/utils"; +import { useScreenContextOptional } from "@/contexts/ScreenContext"; +import type { DataProvidable } from "@/types/data-transfer"; /** * 조건부 컨테이너 컴포넌트 @@ -42,6 +44,9 @@ export function ConditionalContainerComponent({ onSave, // 🆕 EditModal의 handleSave 콜백 }: ConditionalContainerProps) { + // 화면 컨텍스트 (데이터 제공자로 등록) + const screenContext = useScreenContextOptional(); + // config prop 우선, 없으면 개별 prop 사용 const controlField = config?.controlField || propControlField || "condition"; const controlLabel = config?.controlLabel || propControlLabel || "조건 선택"; @@ -50,30 +55,86 @@ export function ConditionalContainerComponent({ const showBorder = config?.showBorder ?? propShowBorder ?? true; const spacing = config?.spacing || propSpacing || "normal"; + // 초기값 계산 (한 번만) + const initialValue = React.useMemo(() => { + return value || formData?.[controlField] || defaultValue || ""; + }, []); // 의존성 없음 - 마운트 시 한 번만 계산 + // 현재 선택된 값 - const [selectedValue, setSelectedValue] = useState( - value || formData?.[controlField] || defaultValue || "" - ); + const [selectedValue, setSelectedValue] = useState(initialValue); + + // 최신 값을 ref로 유지 (클로저 문제 방지) + const selectedValueRef = React.useRef(selectedValue); + selectedValueRef.current = selectedValue; // 렌더링마다 업데이트 (useEffect 대신) - // formData 변경 시 동기화 - useEffect(() => { - if (formData?.[controlField]) { - setSelectedValue(formData[controlField]); - } - }, [formData, controlField]); - - // 값 변경 핸들러 - const handleValueChange = (newValue: string) => { + // 콜백 refs (의존성 제거) + const onChangeRef = React.useRef(onChange); + const onFormDataChangeRef = React.useRef(onFormDataChange); + onChangeRef.current = onChange; + onFormDataChangeRef.current = onFormDataChange; + + // 값 변경 핸들러 - 의존성 없음 + const handleValueChange = React.useCallback((newValue: string) => { + // 같은 값이면 무시 + if (newValue === selectedValueRef.current) return; + setSelectedValue(newValue); - if (onChange) { - onChange(newValue); + if (onChangeRef.current) { + onChangeRef.current(newValue); } - if (onFormDataChange) { - onFormDataChange(controlField, newValue); + if (onFormDataChangeRef.current) { + onFormDataChangeRef.current(controlField, newValue); } - }; + }, [controlField]); + + // sectionsRef 추가 (dataProvider에서 사용) + const sectionsRef = React.useRef(sections); + React.useEffect(() => { + sectionsRef.current = sections; + }, [sections]); + + // dataProvider를 useMemo로 감싸서 불필요한 재생성 방지 + const dataProvider = React.useMemo(() => ({ + componentId: componentId || "conditional-container", + componentType: "conditional-container", + + getSelectedData: () => { + // ref를 통해 최신 값 참조 (클로저 문제 방지) + const currentValue = selectedValueRef.current; + const currentSections = sectionsRef.current; + return [{ + [controlField]: currentValue, + condition: currentValue, + label: currentSections.find(s => s.condition === currentValue)?.label || currentValue, + }]; + }, + + getAllData: () => { + const currentSections = sectionsRef.current; + return currentSections.map(section => ({ + condition: section.condition, + label: section.label, + })); + }, + + clearSelection: () => { + // 조건부 컨테이너는 초기화하지 않음 + console.log("조건부 컨테이너는 선택 초기화를 지원하지 않습니다."); + }, + }), [componentId, controlField]); // selectedValue, sections는 ref로 참조 + + // 화면 컨텍스트에 데이터 제공자로 등록 + useEffect(() => { + if (screenContext && componentId) { + screenContext.registerDataProvider(componentId, dataProvider); + + return () => { + screenContext.unregisterDataProvider(componentId); + }; + } + }, [screenContext, componentId, dataProvider]); // 컨테이너 높이 측정용 ref const containerRef = useRef(null); @@ -158,6 +219,8 @@ export function ConditionalContainerComponent({ onFormDataChange={onFormDataChange} groupedData={groupedData} onSave={onSave} + controlField={controlField} + selectedCondition={selectedValue} /> ))}
@@ -179,6 +242,8 @@ export function ConditionalContainerComponent({ onFormDataChange={onFormDataChange} groupedData={groupedData} onSave={onSave} + controlField={controlField} + selectedCondition={selectedValue} /> ) : null ) diff --git a/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx b/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx index 173bebc6..ff850346 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalContainerConfigPanel.tsx @@ -12,19 +12,38 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Plus, Trash2, GripVertical, Loader2 } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Plus, Trash2, GripVertical, Loader2, Check, ChevronsUpDown, Database } from "lucide-react"; import { ConditionalContainerConfig, ConditionalSection } from "./types"; import { screenApi } from "@/lib/api/screen"; +import { cn } from "@/lib/utils"; +import { getCategoryColumnsByMenu, getCategoryValues, getSecondLevelMenus } from "@/lib/api/tableCategoryValue"; interface ConditionalContainerConfigPanelProps { config: ConditionalContainerConfig; - onConfigChange: (config: ConditionalContainerConfig) => void; + onChange?: (config: ConditionalContainerConfig) => void; + onConfigChange?: (config: ConditionalContainerConfig) => void; } export function ConditionalContainerConfigPanel({ config, + onChange, onConfigChange, }: ConditionalContainerConfigPanelProps) { + // onChange 또는 onConfigChange 둘 다 지원 + const handleConfigChange = onChange || onConfigChange; const [localConfig, setLocalConfig] = useState({ controlField: config.controlField || "condition", controlLabel: config.controlLabel || "조건 선택", @@ -38,6 +57,21 @@ export function ConditionalContainerConfigPanel({ const [screens, setScreens] = useState([]); const [screensLoading, setScreensLoading] = useState(false); + // 🆕 메뉴 기반 카테고리 관련 상태 + const [availableMenus, setAvailableMenus] = useState>([]); + const [menusLoading, setMenusLoading] = useState(false); + const [selectedMenuObjid, setSelectedMenuObjid] = useState(null); + const [menuPopoverOpen, setMenuPopoverOpen] = useState(false); + + const [categoryColumns, setCategoryColumns] = useState>([]); + const [categoryColumnsLoading, setCategoryColumnsLoading] = useState(false); + const [selectedCategoryColumn, setSelectedCategoryColumn] = useState(""); + const [selectedCategoryTableName, setSelectedCategoryTableName] = useState(""); + const [columnPopoverOpen, setColumnPopoverOpen] = useState(false); + + const [categoryValues, setCategoryValues] = useState>([]); + const [categoryValuesLoading, setCategoryValuesLoading] = useState(false); + // 화면 목록 로드 useEffect(() => { const loadScreens = async () => { @@ -56,11 +90,122 @@ export function ConditionalContainerConfigPanel({ loadScreens(); }, []); + // 🆕 2레벨 메뉴 목록 로드 + useEffect(() => { + const loadMenus = async () => { + setMenusLoading(true); + try { + const response = await getSecondLevelMenus(); + console.log("🔍 [ConditionalContainer] 메뉴 목록 응답:", response); + if (response.success && response.data) { + setAvailableMenus(response.data); + } + } catch (error) { + console.error("메뉴 목록 로드 실패:", error); + } finally { + setMenusLoading(false); + } + }; + loadMenus(); + }, []); + + // 🆕 선택된 메뉴의 카테고리 컬럼 목록 로드 + useEffect(() => { + if (!selectedMenuObjid) { + setCategoryColumns([]); + setSelectedCategoryColumn(""); + setSelectedCategoryTableName(""); + setCategoryValues([]); + return; + } + + const loadCategoryColumns = async () => { + setCategoryColumnsLoading(true); + try { + console.log("🔍 [ConditionalContainer] 메뉴별 카테고리 컬럼 로드:", selectedMenuObjid); + const response = await getCategoryColumnsByMenu(selectedMenuObjid); + console.log("✅ [ConditionalContainer] 카테고리 컬럼 응답:", response); + + if (response.success && response.data) { + setCategoryColumns(response.data.map((col: any) => ({ + columnName: col.columnName || col.column_name, + columnLabel: col.columnLabel || col.column_label || col.columnName || col.column_name, + tableName: col.tableName || col.table_name, + }))); + } else { + setCategoryColumns([]); + } + } catch (error) { + console.error("카테고리 컬럼 로드 실패:", error); + setCategoryColumns([]); + } finally { + setCategoryColumnsLoading(false); + } + }; + loadCategoryColumns(); + }, [selectedMenuObjid]); + + // 🆕 선택된 카테고리 컬럼의 값 목록 로드 + useEffect(() => { + if (!selectedCategoryTableName || !selectedCategoryColumn || !selectedMenuObjid) { + setCategoryValues([]); + return; + } + + const loadCategoryValues = async () => { + setCategoryValuesLoading(true); + try { + console.log("🔍 [ConditionalContainer] 카테고리 값 로드:", selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid); + const response = await getCategoryValues(selectedCategoryTableName, selectedCategoryColumn, false, selectedMenuObjid); + console.log("✅ [ConditionalContainer] 카테고리 값 응답:", response); + + if (response.success && response.data) { + const values = response.data.map((v: any) => ({ + value: v.valueCode || v.value_code, + label: v.valueLabel || v.value_label || v.valueCode || v.value_code, + })); + setCategoryValues(values); + } else { + setCategoryValues([]); + } + } catch (error) { + console.error("카테고리 값 로드 실패:", error); + setCategoryValues([]); + } finally { + setCategoryValuesLoading(false); + } + }; + loadCategoryValues(); + }, [selectedCategoryTableName, selectedCategoryColumn, selectedMenuObjid]); + + // 🆕 테이블 카테고리에서 섹션 자동 생성 + const generateSectionsFromCategory = () => { + if (categoryValues.length === 0) { + alert("먼저 테이블과 카테고리 컬럼을 선택하고 값을 로드해주세요."); + return; + } + + const newSections: ConditionalSection[] = categoryValues.map((option, index) => ({ + id: `section_${Date.now()}_${index}`, + condition: option.value, + label: option.label, + screenId: null, + screenName: undefined, + })); + + updateConfig({ + sections: newSections, + controlField: selectedCategoryColumn, // 카테고리 컬럼명을 제어 필드로 사용 + }); + + alert(`${newSections.length}개의 섹션이 생성되었습니다.`); + }; + // 설정 업데이트 헬퍼 const updateConfig = (updates: Partial) => { const newConfig = { ...localConfig, ...updates }; setLocalConfig(newConfig); - onConfigChange(newConfig); + handleConfigChange?.(newConfig); }; // 새 섹션 추가 @@ -134,6 +279,207 @@ export function ConditionalContainerConfigPanel({
+ {/* 🆕 메뉴별 카테고리에서 섹션 자동 생성 */} +
+
+ + +
+ + {/* 1. 메뉴 선택 */} +
+ + + + + + + + + + 메뉴를 찾을 수 없습니다 + + {availableMenus.map((menu) => ( + { + setSelectedMenuObjid(menu.menuObjid); + setSelectedCategoryColumn(""); + setSelectedCategoryTableName(""); + setMenuPopoverOpen(false); + }} + className="text-xs" + > + +
+ {menu.parentMenuName} > {menu.menuName} + {menu.screenCode && ( + + {menu.screenCode} + + )} +
+
+ ))} +
+
+
+
+
+
+ + {/* 2. 카테고리 컬럼 선택 */} + {selectedMenuObjid && ( +
+ + {categoryColumnsLoading ? ( +
+ + 로딩 중... +
+ ) : categoryColumns.length > 0 ? ( + + + + + + + + + 카테고리 컬럼이 없습니다 + + {categoryColumns.map((col) => ( + { + setSelectedCategoryColumn(col.columnName); + setSelectedCategoryTableName(col.tableName); + setColumnPopoverOpen(false); + }} + className="text-xs" + > + +
+ {col.columnLabel} + + {col.tableName}.{col.columnName} + +
+
+ ))} +
+
+
+
+
+ ) : ( +

+ 이 메뉴에 설정된 카테고리 컬럼이 없습니다. + 카테고리 관리에서 먼저 설정해주세요. +

+ )} +
+ )} + + {/* 3. 카테고리 값 미리보기 */} + {selectedCategoryColumn && ( +
+ + {categoryValuesLoading ? ( +
+ + 로딩 중... +
+ ) : categoryValues.length > 0 ? ( +
+ {categoryValues.map((option) => ( + + {option.label} + + ))} +
+ ) : ( +

+ 이 컬럼에 등록된 카테고리 값이 없습니다. + 카테고리 관리에서 값을 먼저 등록해주세요. +

+ )} +
+ )} + + + +

+ 선택한 메뉴의 카테고리 값들로 조건별 섹션을 자동으로 생성합니다. + 각 섹션에 표시할 화면은 아래에서 개별 설정하세요. +

+
+ {/* 조건별 섹션 설정 */}
diff --git a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx index 735fac6d..d5686f6c 100644 --- a/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx +++ b/frontend/lib/registry/components/conditional-container/ConditionalSectionViewer.tsx @@ -27,6 +27,8 @@ export function ConditionalSectionViewer({ onFormDataChange, groupedData, // 🆕 그룹 데이터 onSave, // 🆕 EditModal의 handleSave 콜백 + controlField, // 🆕 조건부 컨테이너의 제어 필드명 + selectedCondition, // 🆕 현재 선택된 조건 값 }: ConditionalSectionViewerProps) { const { userId, userName, user } = useAuth(); const [isLoading, setIsLoading] = useState(false); @@ -34,6 +36,24 @@ export function ConditionalSectionViewer({ const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null); const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | null>(null); + // 🆕 조건 값을 포함한 formData 생성 + const enhancedFormData = React.useMemo(() => { + const base = formData || {}; + + // 조건부 컨테이너의 현재 선택 값을 formData에 포함 + if (controlField && selectedCondition) { + return { + ...base, + [controlField]: selectedCondition, + __conditionalContainerValue: selectedCondition, + __conditionalContainerLabel: label, + __conditionalContainerControlField: controlField, // 🆕 제어 필드명도 포함 + }; + } + + return base; + }, [formData, controlField, selectedCondition, label]); + // 화면 로드 useEffect(() => { if (!screenId) { @@ -154,18 +174,18 @@ export function ConditionalSectionViewer({ }} > + />
); })} diff --git a/frontend/lib/registry/components/conditional-container/types.ts b/frontend/lib/registry/components/conditional-container/types.ts index bcd701ef..284e0855 100644 --- a/frontend/lib/registry/components/conditional-container/types.ts +++ b/frontend/lib/registry/components/conditional-container/types.ts @@ -79,5 +79,8 @@ export interface ConditionalSectionViewerProps { onFormDataChange?: (fieldName: string, value: any) => void; groupedData?: Record[]; // 🆕 그룹 데이터 onSave?: () => Promise; // 🆕 EditModal의 handleSave 콜백 + // 🆕 조건부 컨테이너 정보 (자식 화면에 전달) + controlField?: string; // 제어 필드명 (예: "inbound_type") + selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN") } diff --git a/frontend/lib/registry/components/index.ts b/frontend/lib/registry/components/index.ts index 8a9b38ff..fb7cd30b 100644 --- a/frontend/lib/registry/components/index.ts +++ b/frontend/lib/registry/components/index.ts @@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처 import "./autocomplete-search-input/AutocompleteSearchInputRenderer"; import "./entity-search-input/EntitySearchInputRenderer"; import "./modal-repeater-table/ModalRepeaterTableRenderer"; +import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블 +import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태) import "./order-registration-modal/OrderRegistrationModalRenderer"; // 🆕 조건부 컨테이너 컴포넌트 @@ -68,6 +70,9 @@ import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 출발지/도착지 선택 컴포넌트 import "./location-swap-selector/LocationSwapSelectorRenderer"; +// 🆕 화면 임베딩 및 분할 패널 컴포넌트 +import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달) + /** * 컴포넌트 초기화 함수 */ diff --git a/frontend/lib/registry/components/repeat-screen-modal/README.md b/frontend/lib/registry/components/repeat-screen-modal/README.md new file mode 100644 index 00000000..6ba2783a --- /dev/null +++ b/frontend/lib/registry/components/repeat-screen-modal/README.md @@ -0,0 +1,206 @@ +# RepeatScreenModal 컴포넌트 v3 + +## 개요 + +`RepeatScreenModal`은 선택한 데이터를 기반으로 여러 개의 카드를 생성하고, 각 카드의 내부 레이아웃을 자유롭게 구성할 수 있는 컴포넌트입니다. + +## v3 주요 변경사항 + +### 자유 레이아웃 시스템 + +기존의 "simple 모드 / withTable 모드" 구분을 없애고, **행(Row)을 추가하고 각 행마다 타입을 선택**하는 방식으로 변경되었습니다. + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 카드 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 1] 타입: 헤더 → 품목코드, 품목명, 규격 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 2] 타입: 집계 → 총수주잔량, 현재고, 가용재고 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 3] 타입: 테이블 → 수주번호, 거래처, 납기일, 출하계획 │ +├─────────────────────────────────────────────────────────────────┤ +│ [행 4] 타입: 테이블 → 또 다른 테이블도 가능! │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 행 타입 + +| 타입 | 설명 | 사용 시나리오 | +|------|------|---------------| +| **헤더 (header)** | 필드들을 가로/세로로 나열 | 품목정보, 거래처정보 표시 | +| **필드 (fields)** | 헤더와 동일, 편집 가능 | 폼 입력 영역 | +| **집계 (aggregation)** | 그룹 내 데이터 집계값 표시 | 총수량, 합계금액 등 | +| **테이블 (table)** | 그룹 내 각 행을 테이블로 표시 | 수주목록, 품목목록 등 | + +### 자유로운 조합 + +``` +예시 1: 헤더 + 집계 + 테이블 (출하계획) +├── [행 1] 헤더: 품목코드, 품목명 +├── [행 2] 집계: 총수주잔량, 현재고 +└── [행 3] 테이블: 수주별 출하계획 + +예시 2: 집계만 +└── [행 1] 집계: 총매출, 총비용, 순이익 + +예시 3: 테이블만 +└── [행 1] 테이블: 품목 목록 + +예시 4: 테이블 2개 +├── [행 1] 테이블: 입고 내역 +└── [행 2] 테이블: 출고 내역 + +예시 5: 헤더 + 헤더 + 필드 +├── [행 1] 헤더: 기본 정보 (읽기전용) +├── [행 2] 헤더: 상세 정보 (읽기전용) +└── [행 3] 필드: 입력 필드 (편집가능) +``` + +## 설정 방법 + +### 1. 기본 설정 탭 + +- **카드 제목 표시**: 카드 상단에 제목을 표시할지 여부 +- **카드 제목 템플릿**: `{field_name}` 형식으로 동적 제목 생성 +- **카드 간격**: 카드 사이의 간격 (8px ~ 32px) +- **테두리**: 카드 테두리 표시 여부 +- **저장 모드**: 전체 저장 / 개별 저장 + +### 2. 데이터 소스 탭 + +- **소스 테이블**: 데이터를 조회할 테이블 +- **필터 필드**: formData에서 필터링할 필드 (예: selectedIds) + +### 3. 그룹 탭 + +- **그룹핑 활성화**: 여러 행을 하나의 카드로 묶을지 여부 +- **그룹 기준 필드**: 그룹핑할 필드 (예: part_code) +- **집계 설정**: + - 원본 필드: 합계할 필드 (예: balance_qty) + - 집계 타입: sum, count, avg, min, max + - 결과 필드명: 집계 결과를 저장할 필드명 + - 라벨: 표시될 라벨 + +### 4. 레이아웃 탭 + +#### 행 추가 + +4가지 타입의 행을 추가할 수 있습니다: +- **헤더**: 필드 정보 표시 (읽기전용) +- **집계**: 그룹 집계값 표시 +- **테이블**: 그룹 내 행들을 테이블로 표시 +- **필드**: 입력 필드 (편집가능) + +#### 헤더/필드 행 설정 + +- **방향**: 가로 / 세로 +- **배경색**: 없음, 파랑, 초록, 보라, 주황 +- **컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능, 필수 +- **소스 설정**: 직접 / 조인 / 수동 +- **저장 설정**: 저장할 테이블과 컬럼 + +#### 집계 행 설정 + +- **레이아웃**: 가로 나열 / 그리드 +- **그리드 컬럼 수**: 2, 3, 4개 +- **집계 필드**: 그룹 탭에서 정의한 집계 결과 선택 +- **스타일**: 배경색, 폰트 크기 + +#### 테이블 행 설정 + +- **테이블 제목**: 선택사항 +- **헤더 표시**: 테이블 헤더 표시 여부 +- **테이블 컬럼**: 필드명, 라벨, 타입, 너비, 편집 가능 +- **저장 설정**: 편집 가능한 컬럼의 저장 위치 + +## 데이터 흐름 + +``` +1. formData에서 selectedIds 가져오기 + ↓ +2. 소스 테이블에서 해당 ID들의 데이터 조회 + ↓ +3. 그룹핑 활성화 시 groupByField 기준으로 그룹화 + ↓ +4. 각 그룹에 대해 집계값 계산 + ↓ +5. 카드 렌더링 (contentRows 기반) + ↓ +6. 사용자 편집 + ↓ +7. 저장 시 targetConfig에 따라 테이블별로 데이터 분류 후 저장 +``` + +## 사용 예시 + +### 출하계획 등록 + +```typescript +{ + showCardTitle: true, + cardTitle: "{part_code} - {part_name}", + dataSource: { + sourceTable: "sales_order_mng", + filterField: "selectedIds" + }, + grouping: { + enabled: true, + groupByField: "part_code", + aggregations: [ + { sourceField: "balance_qty", type: "sum", resultField: "total_balance", label: "총수주잔량" }, + { sourceField: "id", type: "count", resultField: "order_count", label: "수주건수" } + ] + }, + contentRows: [ + { + id: "row-1", + type: "header", + columns: [ + { id: "c1", field: "part_code", label: "품목코드", type: "text", editable: false }, + { id: "c2", field: "part_name", label: "품목명", type: "text", editable: false } + ], + layout: "horizontal" + }, + { + id: "row-2", + type: "aggregation", + aggregationLayout: "horizontal", + aggregationFields: [ + { aggregationResultField: "total_balance", label: "총수주잔량", backgroundColor: "blue" }, + { aggregationResultField: "order_count", label: "수주건수", backgroundColor: "green" } + ] + }, + { + id: "row-3", + type: "table", + tableTitle: "수주 목록", + showTableHeader: true, + tableColumns: [ + { id: "tc1", field: "order_no", label: "수주번호", type: "text", editable: false }, + { id: "tc2", field: "partner_name", label: "거래처", type: "text", editable: false }, + { id: "tc3", field: "balance_qty", label: "미출하", type: "number", editable: false }, + { + id: "tc4", + field: "plan_qty", + label: "출하계획", + type: "number", + editable: true, + targetConfig: { targetTable: "shipment_plan", targetColumn: "plan_qty", saveEnabled: true } + } + ] + } + ] +} +``` + +## 레거시 호환 + +v2에서 사용하던 `cardMode`, `cardLayout`, `tableLayout` 설정도 계속 지원됩니다. +새로운 프로젝트에서는 `contentRows`를 사용하는 것을 권장합니다. + +## 주의사항 + +1. **집계는 그룹핑 필수**: 집계 행은 그룹핑이 활성화되어 있어야 의미가 있습니다. +2. **테이블은 그룹핑 필수**: 테이블 행도 그룹핑이 활성화되어 있어야 그룹 내 행들을 표시할 수 있습니다. +3. **단순 모드**: 그룹핑 없이 사용하면 1행 = 1카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다. diff --git a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx index 7506c3a3..997b381c 100644 --- a/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx +++ b/frontend/lib/registry/components/repeat-screen-modal/RepeatScreenModalComponent.tsx @@ -1,236 +1,1409 @@ "use client"; -import React from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import React, { useState, useEffect, useMemo } from "react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Loader2, Save, X, Layers, Table as TableIcon } from "lucide-react"; +import { + RepeatScreenModalProps, + CardData, + CardColumnConfig, + GroupedCardData, + CardRowData, + AggregationConfig, + TableColumnConfig, + CardContentRowConfig, + AggregationDisplayConfig, +} from "./types"; +import { ComponentRendererProps } from "@/types/component"; +import { cn } from "@/lib/utils"; +import { apiClient } from "@/lib/api/client"; -interface ContentRow { - label?: string; - field?: string; - type?: string; +export interface RepeatScreenModalComponentProps extends ComponentRendererProps { + config?: RepeatScreenModalProps; + groupedData?: Record[]; // EditModal에서 전달하는 그룹 데이터 } -interface DataSource { - filterField?: string; - sourceTable?: string; -} - -interface Grouping { - enabled?: boolean; - aggregations?: any[]; - groupField?: string; -} - -interface TableLayout { - headerRows?: any[]; - tableColumns?: any[]; -} - -interface CardLayoutItem { - field?: string; - label?: string; - width?: string; -} - -export interface RepeatScreenModalProps { - // 기본 props - id?: string; - label?: string; - style?: React.CSSProperties; - - // 컴포넌트 설정 - cardMode?: "simple" | "detailed"; - cardSpacing?: string; - cardTitle?: string; - contentRows?: ContentRow[]; - dataSource?: DataSource; - grouping?: Grouping; - saveMode?: "all" | "single"; - showCardBorder?: boolean; - showCardTitle?: boolean; - tableLayout?: TableLayout; - cardLayout?: CardLayoutItem[]; - - // 컴포넌트 config (componentConfig에서 전달됨) - componentConfig?: { - type?: string; - webType?: string; - cardMode?: string; - cardSpacing?: string; - cardTitle?: string; - contentRows?: ContentRow[]; - dataSource?: DataSource; - grouping?: Grouping; - saveMode?: string; - showCardBorder?: boolean; - showCardTitle?: boolean; - tableLayout?: TableLayout; - cardLayout?: CardLayoutItem[]; +export function RepeatScreenModalComponent({ + component, + isDesignMode = false, + formData, + onFormDataChange, + config, + className, + groupedData: propsGroupedData, // EditModal에서 전달받는 그룹 데이터 + ...props +}: RepeatScreenModalComponentProps) { + // props에서도 groupedData를 추출 (DynamicWebTypeRenderer에서 전달될 수 있음) + const groupedData = propsGroupedData || (props as any).groupedData; + const componentConfig = { + ...config, + ...component?.config, }; -} -/** - * RepeatScreenModal 컴포넌트 - * 카드/테이블 형태로 데이터를 반복 표시하고 편집할 수 있는 모달 - */ -export function RepeatScreenModalComponent(props: RepeatScreenModalProps) { - const { - id, - label = "반복 화면 모달", - style, - componentConfig, - } = props; + // 설정 값 추출 + const dataSource = componentConfig?.dataSource; + const saveMode = componentConfig?.saveMode || "all"; + const cardSpacing = componentConfig?.cardSpacing || "24px"; + const showCardBorder = componentConfig?.showCardBorder ?? true; + const showCardTitle = componentConfig?.showCardTitle ?? true; + const cardTitle = componentConfig?.cardTitle || "카드 {index}"; + const grouping = componentConfig?.grouping; + + // 🆕 v3: 자유 레이아웃 + const contentRows = componentConfig?.contentRows || []; + + // (레거시 호환) + const cardLayout = componentConfig?.cardLayout || []; + const cardMode = componentConfig?.cardMode || "simple"; + const tableLayout = componentConfig?.tableLayout; - // componentConfig에서 설정 가져오기 - const config = componentConfig || {}; - const { - cardMode = "simple", - cardSpacing = "24px", - cardTitle = "", - contentRows = [], - dataSource, - grouping, - saveMode = "all", - showCardBorder = true, - showCardTitle = true, - tableLayout, - cardLayout = [], - } = config; + // 상태 + const [rawData, setRawData] = useState([]); // 원본 데이터 + const [cardsData, setCardsData] = useState([]); // simple 모드용 + const [groupedCardsData, setGroupedCardsData] = useState([]); // withTable 모드용 + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [isSaving, setIsSaving] = useState(false); - // 스타일에서 width, height 추출 - const { width, height, ...restStyle } = style || {}; + // 초기 데이터 로드 + useEffect(() => { + const loadInitialData = async () => { + console.log("[RepeatScreenModal] 데이터 로드 시작"); + console.log("[RepeatScreenModal] groupedData (from EditModal):", groupedData); + console.log("[RepeatScreenModal] formData:", formData); + console.log("[RepeatScreenModal] dataSource:", dataSource); - return ( -
- - {showCardTitle && ( - - {label} - {cardTitle && ( -

{cardTitle}

- )} -
+ setIsLoading(true); + setLoadError(null); + + try { + let loadedData: any[] = []; + + // 🆕 우선순위 1: EditModal에서 전달받은 groupedData 사용 + if (groupedData && groupedData.length > 0) { + console.log("[RepeatScreenModal] groupedData 사용:", groupedData.length, "건"); + loadedData = groupedData; + } + // 우선순위 2: API 호출 + else if (dataSource && dataSource.sourceTable) { + // 필터 조건 생성 + const filters: Record = {}; + + // formData에서 선택된 행 ID 가져오기 + let selectedIds: any[] = []; + + if (formData) { + // 1. 명시적으로 설정된 filterField 확인 + if (dataSource.filterField) { + const filterValue = formData[dataSource.filterField]; + if (filterValue) { + selectedIds = Array.isArray(filterValue) ? filterValue : [filterValue]; + } + } + + // 2. 일반적인 선택 필드 확인 (fallback) + if (selectedIds.length === 0) { + const commonFields = ['selectedRows', 'selectedIds', 'checkedRows', 'checkedIds', 'ids']; + for (const field of commonFields) { + if (formData[field]) { + const value = formData[field]; + selectedIds = Array.isArray(value) ? value : [value]; + console.log(`[RepeatScreenModal] ${field}에서 선택된 ID 발견:`, selectedIds); + break; + } + } + } + + // 3. formData에 id가 있으면 단일 행 + if (selectedIds.length === 0 && formData.id) { + selectedIds = [formData.id]; + console.log("[RepeatScreenModal] formData.id 사용:", selectedIds); + } + } + + console.log("[RepeatScreenModal] 최종 선택된 ID:", selectedIds); + + // 선택된 ID가 있으면 필터 적용 + if (selectedIds.length > 0) { + filters.id = selectedIds; + } else { + console.warn("[RepeatScreenModal] 선택된 데이터가 없습니다."); + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + console.log("[RepeatScreenModal] API 필터:", filters); + + // API 호출 + const response = await apiClient.post(`/table-management/tables/${dataSource.sourceTable}/data`, { + search: filters, + page: 1, + size: 1000, + }); + + if (response.data.success && response.data.data?.data) { + loadedData = response.data.data.data; + } + } else { + console.log("[RepeatScreenModal] 데이터 소스 없음"); + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + console.log("[RepeatScreenModal] 로드된 데이터:", loadedData.length, "건"); + + if (loadedData.length === 0) { + setRawData([]); + setCardsData([]); + setGroupedCardsData([]); + setIsLoading(false); + return; + } + + setRawData(loadedData); + + // 🆕 v3: contentRows가 있으면 새로운 방식 사용 + const useNewLayout = contentRows && contentRows.length > 0; + + // 그룹핑 모드 확인 (groupByField가 없어도 enabled면 그룹핑 모드로 처리) + const useGrouping = grouping?.enabled; + + if (useGrouping) { + // 그룹핑 모드 + const grouped = processGroupedData(loadedData, grouping); + setGroupedCardsData(grouped); + } else { + // 단순 모드: 각 행이 하나의 카드 + const initialCards: CardData[] = await Promise.all( + loadedData.map(async (row: any, index: number) => ({ + _cardId: `card-${index}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + ...(await loadCardData(row)), + })) + ); + setCardsData(initialCards); + } + } catch (error: any) { + console.error("데이터 로드 실패:", error); + setLoadError(error.message || "데이터 로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + } + }; + + loadInitialData(); + }, [dataSource, formData, groupedData, contentRows, grouping?.enabled, grouping?.groupByField]); + + // 그룹화된 데이터 처리 + const processGroupedData = (data: any[], groupingConfig: typeof grouping): GroupedCardData[] => { + if (!groupingConfig?.enabled) { + return []; + } + + const groupByField = groupingConfig.groupByField; + const groupMap = new Map(); + + // groupByField가 없으면 각 행을 개별 그룹으로 처리 + if (!groupByField) { + // 각 행이 하나의 카드 (그룹) + data.forEach((row, index) => { + const groupKey = `row-${index}`; + groupMap.set(groupKey, [row]); + }); + } else { + // 그룹별로 데이터 분류 + data.forEach((row) => { + const groupKey = String(row[groupByField] || ""); + if (!groupMap.has(groupKey)) { + groupMap.set(groupKey, []); + } + groupMap.get(groupKey)!.push(row); + }); + } + + // GroupedCardData 생성 + const result: GroupedCardData[] = []; + let cardIndex = 0; + + groupMap.forEach((rows, groupKey) => { + // 집계 계산 + const aggregations: Record = {}; + if (groupingConfig.aggregations) { + groupingConfig.aggregations.forEach((agg) => { + aggregations[agg.resultField] = calculateAggregation(rows, agg); + }); + } + + // 행 데이터 생성 + const cardRows: CardRowData[] = rows.map((row, idx) => ({ + _rowId: `row-${cardIndex}-${idx}-${Date.now()}`, + _originalData: { ...row }, + _isDirty: false, + ...row, + })); + + result.push({ + _cardId: `grouped-card-${cardIndex}-${Date.now()}`, + _groupKey: groupKey, + _groupField: groupByField || "", + _aggregations: aggregations, + _rows: cardRows, + _representativeData: rows[0] || {}, + }); + + cardIndex++; + }); + + return result; + }; + + // 집계 계산 + const calculateAggregation = (rows: any[], agg: AggregationConfig): number => { + const values = rows.map((row) => Number(row[agg.sourceField]) || 0); + + switch (agg.type) { + case "sum": + return values.reduce((a, b) => a + b, 0); + case "count": + return values.length; + case "avg": + return values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0; + case "min": + return values.length > 0 ? Math.min(...values) : 0; + case "max": + return values.length > 0 ? Math.max(...values) : 0; + default: + return 0; + } + }; + + // 카드 데이터 로드 (소스 설정에 따라) + const loadCardData = async (originalData: any): Promise> => { + const cardData: Record = {}; + + // 🆕 v3: contentRows 사용 + if (contentRows && contentRows.length > 0) { + for (const contentRow of contentRows) { + // 헤더/필드 타입의 컬럼 처리 + if ((contentRow.type === "header" || contentRow.type === "fields") && contentRow.columns) { + for (const col of contentRow.columns) { + if (col.sourceConfig) { + if (col.sourceConfig.type === "direct") { + cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; + } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { + cardData[col.field] = null; // 조인은 나중에 일괄 처리 + } else if (col.sourceConfig.type === "manual") { + cardData[col.field] = null; + } + } else { + // sourceConfig가 없으면 원본 데이터에서 직접 가져옴 + cardData[col.field] = originalData[col.field]; + } + } + } + + // 테이블 타입의 컬럼 처리 + if (contentRow.type === "table" && contentRow.tableColumns) { + for (const col of contentRow.tableColumns) { + cardData[col.field] = originalData[col.field]; + } + } + } + } else { + // 레거시: cardLayout 사용 + for (const row of cardLayout) { + for (const col of row.columns) { + if (col.sourceConfig) { + if (col.sourceConfig.type === "direct") { + cardData[col.field] = originalData[col.sourceConfig.sourceColumn || col.field]; + } else if (col.sourceConfig.type === "join" && col.sourceConfig.joinTable) { + cardData[col.field] = null; // 조인은 나중에 일괄 처리 + } else if (col.sourceConfig.type === "manual") { + cardData[col.field] = null; + } + } else { + cardData[col.field] = originalData[col.field]; + } + } + } + } + + return cardData; + }; + + // Simple 모드: 카드 데이터 변경 + const handleCardDataChange = (cardId: string, field: string, value: any) => { + setCardsData((prev) => + prev.map((card) => (card._cardId === cardId ? { ...card, [field]: value, _isDirty: true } : card)) + ); + }; + + // WithTable 모드: 행 데이터 변경 + const handleRowDataChange = (cardId: string, rowId: string, field: string, value: any) => { + setGroupedCardsData((prev) => + prev.map((card) => { + if (card._cardId !== cardId) return card; + + const updatedRows = card._rows.map((row) => + row._rowId === rowId ? { ...row, [field]: value, _isDirty: true } : row + ); + + // 집계값 재계산 + const newAggregations: Record = {}; + if (grouping?.aggregations) { + grouping.aggregations.forEach((agg) => { + newAggregations[agg.resultField] = calculateAggregation(updatedRows, agg); + }); + } + + return { + ...card, + _rows: updatedRows, + _aggregations: newAggregations, + }; + }) + ); + }; + + // 카드 제목 생성 + const getCardTitle = (data: Record, index: number): string => { + let title = cardTitle; + title = title.replace("{index}", String(index + 1)); + + const matches = title.match(/\{(\w+)\}/g); + if (matches) { + matches.forEach((match) => { + const field = match.slice(1, -1); + const value = data[field] || ""; + title = title.replace(match, String(value)); + }); + } + + return title; + }; + + // 전체 저장 + const handleSaveAll = async () => { + setIsSaving(true); + + try { + if (cardMode === "withTable") { + await saveGroupedData(); + } else { + await saveSimpleData(); + } + + alert("저장되었습니다."); + } catch (error: any) { + console.error("저장 실패:", error); + alert(`저장 중 오류가 발생했습니다: ${error.message}`); + } finally { + setIsSaving(false); + } + }; + + // Simple 모드 저장 + const saveSimpleData = async () => { + const dirtyCards = cardsData.filter((card) => card._isDirty); + + if (dirtyCards.length === 0) { + alert("변경된 데이터가 없습니다."); + return; + } + + const groupedData: Record = {}; + + for (const card of dirtyCards) { + for (const row of cardLayout) { + for (const col of row.columns) { + if (col.targetConfig && col.targetConfig.saveEnabled !== false) { + const targetTable = col.targetConfig.targetTable; + const targetColumn = col.targetConfig.targetColumn; + const value = card[col.field]; + + if (!groupedData[targetTable]) { + groupedData[targetTable] = []; + } + + let existingRow = groupedData[targetTable].find((r) => r._cardId === card._cardId); + + if (!existingRow) { + existingRow = { + _cardId: card._cardId, + _originalData: card._originalData, + }; + groupedData[targetTable].push(existingRow); + } + + existingRow[targetColumn] = value; + } + } + } + } + + await saveToTables(groupedData); + + setCardsData((prev) => prev.map((card) => ({ ...card, _isDirty: false }))); + }; + + // WithTable 모드 저장 + const saveGroupedData = async () => { + const dirtyCards = groupedCardsData.filter((card) => card._rows.some((row) => row._isDirty)); + + if (dirtyCards.length === 0) { + alert("변경된 데이터가 없습니다."); + return; + } + + const groupedData: Record = {}; + + for (const card of dirtyCards) { + const dirtyRows = card._rows.filter((row) => row._isDirty); + + for (const row of dirtyRows) { + // 테이블 컬럼에서 저장 대상 추출 + if (tableLayout?.tableColumns) { + for (const col of tableLayout.tableColumns) { + if (col.editable && col.targetConfig && col.targetConfig.saveEnabled !== false) { + const targetTable = col.targetConfig.targetTable; + const targetColumn = col.targetConfig.targetColumn; + const value = row[col.field]; + + if (!groupedData[targetTable]) { + groupedData[targetTable] = []; + } + + let existingRow = groupedData[targetTable].find((r) => r._rowId === row._rowId); + + if (!existingRow) { + existingRow = { + _rowId: row._rowId, + _originalData: row._originalData, + }; + groupedData[targetTable].push(existingRow); + } + + existingRow[targetColumn] = value; + } + } + } + } + } + + await saveToTables(groupedData); + + setGroupedCardsData((prev) => + prev.map((card) => ({ + ...card, + _rows: card._rows.map((row) => ({ ...row, _isDirty: false })), + })) + ); + }; + + // 테이블별 저장 + const saveToTables = async (groupedData: Record) => { + const savePromises = Object.entries(groupedData).map(async ([tableName, rows]) => { + return Promise.all( + rows.map(async (row) => { + const { _cardId, _rowId, _originalData, ...dataToSave } = row; + const id = _originalData?.id; + + if (id) { + await apiClient.put(`/table-management/tables/${tableName}/data/${id}`, dataToSave); + } else { + await apiClient.post(`/table-management/tables/${tableName}/data`, dataToSave); + } + }) + ); + }); + + await Promise.all(savePromises); + }; + + // 수정 여부 확인 + const hasDirtyData = useMemo(() => { + if (cardMode === "withTable") { + return groupedCardsData.some((card) => card._rows.some((row) => row._isDirty)); + } + return cardsData.some((c) => c._isDirty); + }, [cardMode, cardsData, groupedCardsData]); + + // 디자인 모드 렌더링 + if (isDesignMode) { + // 행 타입별 개수 계산 + const rowTypeCounts = { + header: contentRows.filter((r) => r.type === "header").length, + aggregation: contentRows.filter((r) => r.type === "aggregation").length, + table: contentRows.filter((r) => r.type === "table").length, + fields: contentRows.filter((r) => r.type === "fields").length, + }; + + return ( +
- {/* 데이터 소스 정보 표시 */} - {dataSource?.sourceTable && ( -
-

- 데이터 소스:{" "} - {dataSource.sourceTable} -

- {dataSource.filterField && ( -

- 필터 필드:{" "} - {dataSource.filterField} -

- )} -
- )} + > +
+ {/* 아이콘 */} +
+ +
- {/* 카드 레이아웃 표시 */} - {cardLayout && cardLayout.length > 0 && ( -
- {cardLayout.map((item, index) => ( -
-

{item.label || item.field}

-

{item.field || "-"}

-
- ))} -
- )} + {/* 제목 */} +
+
Repeat Screen Modal
+
반복 화면 모달
+ v3 자유 레이아웃 +
- {/* 컨텐츠 행 표시 */} - {contentRows && contentRows.length > 0 && ( -
- {contentRows.map((row, index) => ( -
- - {row.label || row.field || `Row ${index + 1}`} - - - {row.field || "-"} - -
- ))} -
- )} - - {/* 테이블 레이아웃이 있으면 테이블 형태로 표시 */} - {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( -
- - - - {tableLayout.tableColumns.map((col: any, index: number) => ( - - ))} - - - - - - - -
- {col.label || col.field || `Column ${index + 1}`} -
- 데이터가 없습니다 -
-
- )} - - {/* 빈 상태 표시 */} - {(!contentRows || contentRows.length === 0) && - (!cardLayout || cardLayout.length === 0) && - (!tableLayout?.tableColumns || tableLayout.tableColumns.length === 0) && ( -
-
-

- 반복 화면 모달 -

-

- 컴포넌트 설정을 구성해주세요 -

-
-
+ {/* 행 구성 정보 */} +
+ {contentRows.length > 0 ? ( + <> + {rowTypeCounts.header > 0 && ( + + 헤더 {rowTypeCounts.header}개 + + )} + {rowTypeCounts.aggregation > 0 && ( + + 집계 {rowTypeCounts.aggregation}개 + + )} + {rowTypeCounts.table > 0 && ( + + 테이블 {rowTypeCounts.table}개 + + )} + {rowTypeCounts.fields > 0 && ( + + 필드 {rowTypeCounts.fields}개 + + )} + + ) : ( + 행 없음 )} +
+ + {/* 통계 정보 */} +
+
+
{contentRows.length}
+
행 (Rows)
+
+
+
+
{grouping?.aggregations?.length || 0}
+
집계 설정
+
+
+
+
{dataSource?.sourceTable ? 1 : 0}
+
데이터 소스
+
+
+ + {/* 데이터 소스 정보 */} + {dataSource?.sourceTable && ( +
+ 소스 테이블: {dataSource.sourceTable} + {dataSource.filterField && (필터: {dataSource.filterField})} +
+ )} {/* 그룹핑 정보 */} {grouping?.enabled && ( -
- 그룹핑 활성화됨 {grouping.groupField && `(${grouping.groupField})`} +
+ 그룹핑: {grouping.groupByField}
)} - {/* 저장 모드 표시 */} -
- 저장 모드: {saveMode === "all" ? "전체 저장" : "개별 저장"} + {/* 카드 제목 정보 */} + {showCardTitle && cardTitle && ( +
+ 카드 제목: {cardTitle} +
+ )} + + {/* 설정 안내 */} +
+ 오른쪽 패널에서 행을 추가하고 타입(헤더/집계/테이블/필드)을 선택하세요
- - +
+
+ ); + } + + // 로딩 상태 + if (isLoading) { + return ( +
+ + 데이터를 불러오는 중... +
+ ); + } + + // 오류 상태 + if (loadError) { + return ( +
+
+ + 데이터 로드 실패 +
+

{loadError}

+
+ ); + } + + // 🆕 v3: 자유 레이아웃 렌더링 (contentRows 사용) + const useNewLayout = contentRows && contentRows.length > 0; + const useGrouping = grouping?.enabled; + + // 그룹핑 모드 렌더링 + if (useGrouping) { + return ( +
+
+ {groupedCardsData.map((card, cardIndex) => ( + r._isDirty) && "border-primary shadow-lg" + )} + > + {/* 카드 제목 (선택사항) */} + {showCardTitle && ( + + + {getCardTitle(card._representativeData, cardIndex)} + {card._rows.some((r) => r._isDirty) && ( + + 수정됨 + + )} + + + )} + + {/* 🆕 v3: contentRows 기반 렌더링 */} + {useNewLayout ? ( + contentRows.map((contentRow, rowIndex) => ( +
+ {renderContentRow(contentRow, card, grouping?.aggregations || [], handleRowDataChange)} +
+ )) + ) : ( + // 레거시: tableLayout 사용 + <> + {tableLayout?.headerRows && tableLayout.headerRows.length > 0 && ( +
+ {tableLayout.headerRows.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderHeaderColumn(col, card, grouping?.aggregations || [])} +
+ ))} +
+ ))} +
+ )} + + {tableLayout?.tableColumns && tableLayout.tableColumns.length > 0 && ( +
+ + + + {tableLayout.tableColumns.map((col) => ( + + {col.label} + + ))} + + + + {card._rows.map((row) => ( + + {tableLayout.tableColumns.map((col) => ( + + {renderTableCell(col, row, (value) => + handleRowDataChange(card._cardId, row._rowId, col.field, value) + )} + + ))} + + ))} + +
+
+ )} + + )} +
+
+ ))} +
+ + {/* 저장 버튼 */} + {groupedCardsData.length > 0 && ( +
+ +
+ )} + + {/* 데이터 없음 */} + {groupedCardsData.length === 0 && !isLoading && ( +
표시할 데이터가 없습니다.
+ )} +
+ ); + } + + // 단순 모드 렌더링 (그룹핑 없음) + return ( +
+
+ {cardsData.map((card, cardIndex) => ( + + {/* 카드 제목 (선택사항) */} + {showCardTitle && ( + + + {getCardTitle(card, cardIndex)} + {card._isDirty && (수정됨)} + + + )} + + {/* 🆕 v3: contentRows 기반 렌더링 */} + {useNewLayout ? ( + contentRows.map((contentRow, rowIndex) => ( +
+ {renderSimpleContentRow(contentRow, card, (value, field) => + handleCardDataChange(card._cardId, field, value) + )} +
+ )) + ) : ( + // 레거시: cardLayout 사용 + cardLayout.map((row, rowIndex) => ( +
+ {row.columns.map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => handleCardDataChange(card._cardId, col.field, value))} +
+ ))} +
+ )) + )} +
+
+ ))} +
+ + {/* 저장 버튼 */} + {cardsData.length > 0 && ( +
+ +
+ )} + + {/* 데이터 없음 */} + {cardsData.length === 0 && !isLoading && ( +
표시할 데이터가 없습니다.
+ )}
); } +// 🆕 v3: contentRow 렌더링 (그룹핑 모드) +function renderContentRow( + contentRow: CardContentRowConfig, + card: GroupedCardData, + aggregations: AggregationConfig[], + onRowDataChange: (cardId: string, rowId: string, field: string, value: any) => void +) { + switch (contentRow.type) { + case "header": + case "fields": + // contentRow에서 직접 columns 가져오기 (v3 구조) + const headerColumns = contentRow.columns || []; + + if (headerColumns.length === 0) { + return ( +
+ 헤더 컬럼이 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {headerColumns.map((col, colIndex) => ( +
+ {renderHeaderColumn(col, card, aggregations)} +
+ ))} +
+ ); + + case "aggregation": + // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) + const aggFields = contentRow.aggregationFields || []; + + if (aggFields.length === 0) { + return ( +
+ 집계 필드가 설정되지 않았습니다. (레이아웃 탭에서 집계 필드를 추가하세요) +
+ ); + } + + return ( +
+ {aggFields.map((aggField, fieldIndex) => { + // 집계 결과에서 값 가져오기 (aggregationResultField 사용) + const value = card._aggregations?.[aggField.aggregationResultField] || 0; + return ( +
+
{aggField.label || aggField.aggregationResultField}
+
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} +
+
+ ); + })} +
+ ); + + case "table": + // contentRow에서 직접 tableColumns 가져오기 (v3 구조) + const tableColumns = contentRow.tableColumns || []; + + if (tableColumns.length === 0) { + return ( +
+ 테이블 컬럼이 설정되지 않았습니다. (레이아웃 탭에서 테이블 컬럼을 추가하세요) +
+ ); + } + + return ( +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {tableColumns.map((col) => ( + + {col.label} + + ))} + + + )} + + {card._rows.map((row) => ( + + {tableColumns.map((col) => ( + + {renderTableCell(col, row, (value) => + onRowDataChange(card._cardId, row._rowId, col.field, value) + )} + + ))} + + ))} + +
+
+ ); + + default: + return null; + } +} + +// 🆕 v3: contentRow 렌더링 (단순 모드) +function renderSimpleContentRow( + contentRow: CardContentRowConfig, + card: CardData, + onChange: (value: any, field: string) => void +) { + switch (contentRow.type) { + case "header": + case "fields": + return ( +
+ {(contentRow.columns || []).map((col, colIndex) => ( +
+ {renderColumn(col, card, (value) => onChange(value, col.field))} +
+ ))} +
+ ); + + case "aggregation": + // 단순 모드에서도 집계 표시 (단일 카드 기준) + // contentRow에서 직접 aggregationFields 가져오기 (v3 구조) + const aggFields = contentRow.aggregationFields || []; + + if (aggFields.length === 0) { + return ( +
+ 집계 필드가 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {aggFields.map((aggField, fieldIndex) => { + // 단순 모드에서는 카드 데이터에서 직접 값을 가져옴 (aggregationResultField 사용) + const value = card[aggField.aggregationResultField] || card._originalData?.[aggField.aggregationResultField]; + return ( +
+
{aggField.label || aggField.aggregationResultField}
+
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} +
+
+ ); + })} +
+ ); + + case "table": + // 단순 모드에서도 테이블 표시 (단일 행) + // contentRow에서 직접 tableColumns 가져오기 (v3 구조) + const tableColumns = contentRow.tableColumns || []; + + if (tableColumns.length === 0) { + return ( +
+ 테이블 컬럼이 설정되지 않았습니다. +
+ ); + } + + return ( +
+ {contentRow.tableTitle && ( +
+ {contentRow.tableTitle} +
+ )} + + {contentRow.showTableHeader !== false && ( + + + {tableColumns.map((col) => ( + + {col.label} + + ))} + + + )} + + {/* 단순 모드: 카드 자체가 하나의 행 */} + + {tableColumns.map((col) => ( + + {renderSimpleTableCell(col, card, (value) => onChange(value, col.field))} + + ))} + + +
+
+ ); + + default: + return null; + } +} + +// 단순 모드 테이블 셀 렌더링 +function renderSimpleTableCell( + col: TableColumnConfig, + card: CardData, + onChange: (value: any) => void +) { + const value = card[col.field] || card._originalData?.[col.field]; + + if (!col.editable) { + // 읽기 전용 + if (col.type === "number") { + return typeof value === "number" ? value.toLocaleString() : value || "-"; + } + return value || "-"; + } + + // 편집 가능 + switch (col.type) { + case "number": + return ( + onChange(parseFloat(e.target.value) || 0)} + className="h-8 text-sm" + /> + ); + case "date": + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + case "select": + return ( + + ); + default: + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + } +} + +// 배경색 클래스 변환 +function getBackgroundClass(color: string): string { + const colorMap: Record = { + blue: "bg-blue-50 dark:bg-blue-950", + green: "bg-green-50 dark:bg-green-950", + purple: "bg-purple-50 dark:bg-purple-950", + orange: "bg-orange-50 dark:bg-orange-950", + }; + return colorMap[color] || ""; +} + +// 헤더 컬럼 렌더링 (집계값 포함) +function renderHeaderColumn( + col: CardColumnConfig, + card: GroupedCardData, + aggregations: AggregationConfig[] +) { + let value: any; + + // 집계값 타입이면 집계 결과에서 가져옴 + if (col.type === "aggregation" && col.aggregationField) { + value = card._aggregations[col.aggregationField]; + const aggConfig = aggregations.find((a) => a.resultField === col.aggregationField); + + return ( +
+ +
+ {typeof value === "number" ? value.toLocaleString() : value || "-"} + {aggConfig && ({aggConfig.type})} +
+
+ ); + } + + // 일반 필드는 대표 데이터에서 가져옴 + value = card._representativeData[col.field]; + + return ( +
+ +
+ {value || "-"} +
+
+ ); +} + +// 테이블 셀 렌더링 +function renderTableCell(col: TableColumnConfig, row: CardRowData, onChange: (value: any) => void) { + const value = row[col.field]; + + // Badge 타입 + if (col.type === "badge") { + const badgeColor = col.badgeColorMap?.[value] || col.badgeVariant || "default"; + return {value || "-"}; + } + + // 읽기 전용 + if (!col.editable) { + if (col.type === "number") { + return {typeof value === "number" ? value.toLocaleString() : value || "-"}; + } + return {value || "-"}; + } + + // 편집 가능 + switch (col.type) { + case "text": + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + case "number": + return ( + onChange(Number(e.target.value) || 0)} + className="h-8 text-sm text-right" + /> + ); + case "date": + return ( + onChange(e.target.value)} + className="h-8 text-sm" + /> + ); + default: + return {value || "-"}; + } +} + +// 컬럼 렌더링 함수 (Simple 모드) +function renderColumn(col: CardColumnConfig, card: CardData, onChange: (value: any) => void) { + const value = card[col.field]; + const isReadOnly = !col.editable; + + return ( +
+ + + {isReadOnly && ( +
+ {value || "-"} +
+ )} + + {!isReadOnly && ( + <> + {col.type === "text" && ( + onChange(e.target.value)} + placeholder={col.placeholder} + className="h-10 text-sm" + /> + )} + + {col.type === "number" && ( + onChange(e.target.value)} + placeholder={col.placeholder} + className="h-10 text-sm" + /> + )} + + {col.type === "date" && ( + onChange(e.target.value)} + className="h-10 text-sm" + /> + )} + + {col.type === "select" && ( + + )} + + {col.type === "textarea" && ( +