Merge origin/main and resolve conflicts - add geolocation/update_field actions

This commit is contained in:
leeheejin 2025-11-28 18:45:41 +09:00
commit 9f97a16d6a
67 changed files with 15848 additions and 1548 deletions

View File

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

View File

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

View File

@ -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 * 2
* *

View File

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

View File

@ -11,6 +11,7 @@ import {
createColumnMapping, createColumnMapping,
getLogicalColumns, getLogicalColumns,
deleteColumnMapping, deleteColumnMapping,
deleteColumnMappingsByColumn,
getSecondLevelMenus, getSecondLevelMenus,
} from "../controllers/tableCategoryValueController"; } from "../controllers/tableCategoryValueController";
import { authenticateToken } from "../middleware/authMiddleware"; import { authenticateToken } from "../middleware/authMiddleware";
@ -57,7 +58,11 @@ router.get("/logical-columns/:tableName/:menuObjid", getLogicalColumns);
// 컬럼 매핑 생성/수정 // 컬럼 매핑 생성/수정
router.post("/column-mapping", createColumnMapping); router.post("/column-mapping", createColumnMapping);
// 컬럼 매핑 삭제 // 테이블+컬럼 기준 매핑 삭제 (메뉴 선택 변경 시 기존 매핑 모두 삭제용)
// 주의: 더 구체적인 라우트가 먼저 와야 함 (3개 세그먼트 > 1개 세그먼트)
router.delete("/column-mapping/:tableName/:columnName/all", deleteColumnMappingsByColumn);
// 컬럼 매핑 삭제 (단일)
router.delete("/column-mapping/:mappingId", deleteColumnMapping); router.delete("/column-mapping/:mappingId", deleteColumnMapping);
export default router; export default router;

View File

@ -10,10 +10,6 @@ export interface MenuCopyResult {
copiedMenus: number; copiedMenus: number;
copiedScreens: number; copiedScreens: number;
copiedFlows: number; copiedFlows: number;
copiedCategories: number;
copiedCodes: number;
copiedCategorySettings: number;
copiedNumberingRules: number;
menuIdMap: Record<number, number>; menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>; screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>; flowIdMap: Record<number, number>;
@ -129,35 +125,6 @@ interface FlowStepConnection {
label: string | null; 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; return referenced;
@ -355,127 +340,6 @@ export class MenuCopyService {
return flowIds; 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<CodeCategory>(
`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<CodeInfo>(
`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 * objid
*/ */
@ -709,42 +573,8 @@ export class MenuCopyService {
]); ]);
logger.info(` ✅ 메뉴 권한 삭제 완료`); logger.info(` ✅ 메뉴 권한 삭제 완료`);
// 5-5. 채번 규칙 파트 삭제 // 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. 메뉴 삭제 (역순: 하위 메뉴부터)
for (let i = existingMenus.length - 1; i >= 0; i--) { for (let i = existingMenus.length - 1; i >= 0; i--) {
await client.query(`DELETE FROM menu_info WHERE objid = $1`, [ await client.query(`DELETE FROM menu_info WHERE objid = $1`, [
existingMenus[i].objid, existingMenus[i].objid,
@ -801,33 +631,11 @@ export class MenuCopyService {
const flowIds = await this.collectFlows(screenIds, client); 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(` logger.info(`
📊 : 📊 :
- 메뉴: ${menus.length} - 메뉴: ${menus.length}
- 화면: ${screenIds.size} - 화면: ${screenIds.size}
- 플로우: ${flowIds.size} - 플로우: ${flowIds.size}
- 카테고리: ${codes.categories.length}
- 코드: ${codes.codes.length}
- 설정: 컬럼 ${categorySettings.columnMappings.length}, ${categorySettings.categoryValues.length}
- 규칙: 규칙 ${numberingRules.rules.length}, ${numberingRules.parts.length}
`); `);
// === 2단계: 플로우 복사 === // === 2단계: 플로우 복사 ===
@ -871,30 +679,6 @@ export class MenuCopyService {
client 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"); await client.query("COMMIT");
logger.info("✅ 트랜잭션 커밋 완료"); logger.info("✅ 트랜잭션 커밋 완료");
@ -904,13 +688,6 @@ export class MenuCopyService {
copiedMenus: menuIdMap.size, copiedMenus: menuIdMap.size,
copiedScreens: screenIdMap.size, copiedScreens: screenIdMap.size,
copiedFlows: flowIdMap.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), menuIdMap: Object.fromEntries(menuIdMap),
screenIdMap: Object.fromEntries(screenIdMap), screenIdMap: Object.fromEntries(screenIdMap),
flowIdMap: Object.fromEntries(flowIdMap), flowIdMap: Object.fromEntries(flowIdMap),
@ -923,10 +700,8 @@ export class MenuCopyService {
- 메뉴: ${result.copiedMenus} - 메뉴: ${result.copiedMenus}
- 화면: ${result.copiedScreens} - 화면: ${result.copiedScreens}
- 플로우: ${result.copiedFlows} - 플로우: ${result.copiedFlows}
- 카테고리: ${result.copiedCategories}
- 코드: ${result.copiedCodes} 주의: 코드, , .
- 설정: ${result.copiedCategorySettings}
- 규칙: ${result.copiedNumberingRules}
============================================ ============================================
`); `);
@ -1125,13 +900,31 @@ export class MenuCopyService {
const screenDef = screenDefResult.rows[0]; 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( const newScreenCode = await this.generateUniqueScreenCode(
targetCompanyCode, targetCompanyCode,
client client
); );
// 2-1) 화면명 변환 적용 // 4) 화면명 변환 적용
let transformedScreenName = screenDef.screen_name; let transformedScreenName = screenDef.screen_name;
if (screenNameConfig) { if (screenNameConfig) {
// 1. 제거할 텍스트 제거 // 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 }>( const newScreenResult = await client.query<{ screen_id: number }>(
`INSERT INTO screen_definitions ( `INSERT INTO screen_definitions (
screen_name, screen_code, table_name, company_code, screen_name, screen_code, table_name, company_code,
@ -1479,383 +1272,4 @@ export class MenuCopyService {
logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}`); logger.info(`✅ 화면-메뉴 할당 완료: ${assignmentCount}`);
} }
/**
*
*/
private async checkCodeCategoryExists(
categoryCode: string,
companyCode: string,
menuObjid: number,
client: PoolClient
): Promise<boolean> {
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<boolean> {
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<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
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<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📂 카테고리 설정 복사 중...`);
const valueIdMap = new Map<number, number>(); // 원본 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<string>();
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<number, number>,
targetCompanyCode: string,
userId: string,
client: PoolClient
): Promise<void> {
logger.info(`📋 채번 규칙 복사 중...`);
const ruleIdMap = new Map<string, string>(); // 원본 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}`
);
}
} }

View File

@ -102,6 +102,72 @@ export async function getSiblingMenuObjids(menuObjid: number): Promise<number[]>
} }
} }
/**
* 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<number[]> {
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 * OBJID
* *

View File

@ -4,7 +4,7 @@
import { getPool } from "../database/db"; import { getPool } from "../database/db";
import { logger } from "../utils/logger"; import { logger } from "../utils/logger";
import { getSiblingMenuObjids } from "./menuService"; import { getMenuAndChildObjids } from "./menuService";
interface NumberingRulePart { interface NumberingRulePart {
id?: number; id?: number;
@ -161,7 +161,7 @@ class NumberingRuleService {
companyCode: string, companyCode: string,
menuObjid?: number menuObjid?: number
): Promise<NumberingRuleConfig[]> { ): Promise<NumberingRuleConfig[]> {
let siblingObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언 let menuAndChildObjids: number[] = []; // catch 블록에서 접근 가능하도록 함수 최상단에 선언
try { try {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", { logger.info("메뉴별 사용 가능한 채번 규칙 조회 시작 (메뉴 스코프)", {
@ -171,14 +171,14 @@ class NumberingRuleService {
const pool = getPool(); const pool = getPool();
// 1. 형제 메뉴 OBJID 조회 // 1. 선택한 메뉴와 하위 메뉴 OBJID 조회 (형제 메뉴 제외)
if (menuObjid) { if (menuObjid) {
siblingObjids = await getSiblingMenuObjids(menuObjid); menuAndChildObjids = await getMenuAndChildObjids(menuObjid);
logger.info("형제 메뉴 OBJID 목록", { menuObjid, siblingObjids }); logger.info("선택한 메뉴 및 하위 메뉴 OBJID 목록", { menuObjid, menuAndChildObjids });
} }
// menuObjid가 없으면 global 규칙만 반환 // menuObjid가 없으면 global 규칙만 반환
if (!menuObjid || siblingObjids.length === 0) { if (!menuObjid || menuAndChildObjids.length === 0) {
let query: string; let query: string;
let params: any[]; let params: any[];
@ -280,7 +280,7 @@ class NumberingRuleService {
let params: any[]; let params: any[];
if (companyCode === "*") { if (companyCode === "*") {
// 최고 관리자: 모든 규칙 조회 (형제 메뉴 포함) // 최고 관리자: 모든 규칙 조회 (선택한 메뉴 + 하위 메뉴)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -301,8 +301,7 @@ class NumberingRuleService {
WHERE WHERE
scope_type = 'global' scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($1)) 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 = ANY($1))
OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( )
ORDER BY ORDER BY
CASE CASE
WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1 WHEN scope_type = 'menu' OR (scope_type = 'table' AND menu_objid = ANY($1)) THEN 1
@ -311,10 +310,10 @@ class NumberingRuleService {
END, END,
created_at DESC created_at DESC
`; `;
params = [siblingObjids]; params = [menuAndChildObjids];
logger.info("최고 관리자: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { siblingObjids }); logger.info("최고 관리자: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { menuAndChildObjids });
} else { } else {
// 일반 회사: 자신의 규칙만 조회 (형제 메뉴 포함, 메뉴별 필터링) // 일반 회사: 자신의 규칙만 조회 (선택한 메뉴 + 하위 메뉴)
query = ` query = `
SELECT SELECT
rule_id AS "ruleId", rule_id AS "ruleId",
@ -336,8 +335,7 @@ class NumberingRuleService {
AND ( AND (
scope_type = 'global' scope_type = 'global'
OR (scope_type = 'menu' AND menu_objid = ANY($2)) 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 = ANY($2))
OR (scope_type = 'table' AND menu_objid IS NULL) -- (menu_objid NULL) ( )
) )
ORDER BY ORDER BY
CASE CASE
@ -347,8 +345,8 @@ class NumberingRuleService {
END, END,
created_at DESC created_at DESC
`; `;
params = [companyCode, siblingObjids]; params = [companyCode, menuAndChildObjids];
logger.info("회사별: 형제 메뉴 기반 채번 규칙 조회 (메뉴별 필터링)", { companyCode, siblingObjids }); logger.info("회사별: 메뉴 및 하위 메뉴 기반 채번 규칙 조회", { companyCode, menuAndChildObjids });
} }
logger.info("🔍 채번 규칙 쿼리 실행", { logger.info("🔍 채번 규칙 쿼리 실행", {
@ -420,7 +418,7 @@ class NumberingRuleService {
logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", { logger.info("메뉴별 사용 가능한 채번 규칙 조회 완료", {
companyCode, companyCode,
menuObjid, menuObjid,
siblingCount: siblingObjids.length, menuAndChildCount: menuAndChildObjids.length,
count: result.rowCount, count: result.rowCount,
}); });
@ -432,7 +430,7 @@ class NumberingRuleService {
errorStack: error.stack, errorStack: error.stack,
companyCode, companyCode,
menuObjid, menuObjid,
siblingObjids: siblingObjids || [], menuAndChildObjids: menuAndChildObjids || [],
}); });
throw error; throw error;
} }

View File

@ -1066,6 +1066,66 @@ class TableCategoryValueService {
} }
} }
/**
* +
*
*
*
* @param tableName -
* @param columnName -
* @param companyCode -
* @returns
*/
async deleteColumnMappingsByColumn(
tableName: string,
columnName: string,
companyCode: string
): Promise<number> {
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;
}
}
/** /**
* *
* *

View File

@ -17,7 +17,7 @@ import { apiClient } from "@/lib/api/client";
import { commonCodeApi } from "@/lib/api/commonCode"; import { commonCodeApi } from "@/lib/api/commonCode";
import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin"; import { entityJoinApi, ReferenceTableColumn } from "@/lib/api/entityJoin";
import { ddlApi } from "@/lib/api/ddl"; 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 { CreateTableModal } from "@/components/admin/CreateTableModal";
import { AddColumnModal } from "@/components/admin/AddColumnModal"; import { AddColumnModal } from "@/components/admin/AddColumnModal";
import { DDLLogViewer } from "@/components/admin/DDLLogViewer"; import { DDLLogViewer } from "@/components/admin/DDLLogViewer";
@ -488,52 +488,69 @@ export default function TableManagementPage() {
if (response.data.success) { if (response.data.success) {
console.log("✅ 컬럼 설정 저장 성공"); console.log("✅ 컬럼 설정 저장 성공");
// 🆕 Category 타입인 경우 컬럼 매핑 생성 // 🆕 Category 타입인 경우 컬럼 매핑 처리
console.log("🔍 카테고리 조건 체크:", { console.log("🔍 카테고리 조건 체크:", {
isCategory: column.inputType === "category", isCategory: column.inputType === "category",
hasCategoryMenus: !!column.categoryMenus, hasCategoryMenus: !!column.categoryMenus,
length: column.categoryMenus?.length || 0, length: column.categoryMenus?.length || 0,
}); });
if (column.inputType === "category" && column.categoryMenus && column.categoryMenus.length > 0) { if (column.inputType === "category") {
console.log("📥 카테고리 메뉴 매핑 시작:", { // 1. 먼저 기존 매핑 모두 삭제
console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제 시작:", {
tableName: selectedTable,
columnName: column.columnName, columnName: column.columnName,
categoryMenus: column.categoryMenus,
count: column.categoryMenus.length,
}); });
let successCount = 0; try {
let failCount = 0; const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
for (const menuObjid of column.categoryMenus) { } catch (error) {
try { console.error("❌ 기존 매핑 삭제 실패:", error);
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++;
}
} }
// 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) { let successCount = 0;
toast.success(`컬럼 설정 및 ${successCount}개 메뉴 매핑이 저장되었습니다.`); let failCount = 0;
} else if (successCount > 0 && failCount > 0) {
toast.warning(`컬럼 설정 저장 성공. ${successCount}개 메뉴 매핑 성공, ${failCount}개 실패.`); for (const menuObjid of column.categoryMenus) {
} else if (failCount > 0) { try {
toast.error(`컬럼 설정 저장 성공. 메뉴 매핑 생성 실패.`); 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 { } else {
toast.success("컬럼 설정이 성공적으로 저장되었습니다."); toast.success("컬럼 설정이 성공적으로 저장되었습니다.");
@ -596,10 +613,8 @@ export default function TableManagementPage() {
); );
if (response.data.success) { if (response.data.success) {
// 🆕 Category 타입 컬럼들의 메뉴 매핑 생성 // 🆕 Category 타입 컬럼들의 메뉴 매핑 처리
const categoryColumns = columns.filter( const categoryColumns = columns.filter((col) => col.inputType === "category");
(col) => col.inputType === "category" && col.categoryMenus && col.categoryMenus.length > 0
);
console.log("📥 전체 저장: 카테고리 컬럼 확인", { console.log("📥 전체 저장: 카테고리 컬럼 확인", {
totalColumns: columns.length, totalColumns: columns.length,
@ -615,33 +630,49 @@ export default function TableManagementPage() {
let totalFailCount = 0; let totalFailCount = 0;
for (const column of categoryColumns) { for (const column of categoryColumns) {
for (const menuObjid of column.categoryMenus!) { // 1. 먼저 기존 매핑 모두 삭제
try { console.log("🗑️ 기존 카테고리 메뉴 매핑 삭제:", {
console.log("🔄 매핑 API 호출:", { tableName: selectedTable,
tableName: selectedTable, columnName: column.columnName,
columnName: column.columnName, });
menuObjid,
});
const mappingResponse = await createColumnMapping({ try {
tableName: selectedTable, const deleteResponse = await deleteColumnMappingsByColumn(selectedTable, column.columnName);
logicalColumnName: column.columnName, console.log("🗑️ 기존 매핑 삭제 결과:", deleteResponse);
physicalColumnName: column.columnName, } catch (error) {
menuObjid, console.error("❌ 기존 매핑 삭제 실패:", error);
description: `${column.displayName} (메뉴별 카테고리)`, }
});
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) { const mappingResponse = await createColumnMapping({
totalSuccessCount++; tableName: selectedTable,
} else { logicalColumnName: column.columnName,
console.error("❌ 매핑 생성 실패:", mappingResponse); 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++; totalFailCount++;
} }
} catch (error) {
console.error(`❌ 메뉴 ${menuObjid}에 대한 매핑 생성 실패:`, error);
totalFailCount++;
} }
} }
} }

View File

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

View File

@ -162,7 +162,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
<TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell> <TableCell className="h-16 px-6 py-3">{formatDiskUsage(company)}</TableCell>
<TableCell className="h-16 px-6 py-3"> <TableCell className="h-16 px-6 py-3">
<div className="flex gap-2"> <div className="flex gap-2">
<Button {/* <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleManageDepartments(company)} onClick={() => handleManageDepartments(company)}
@ -170,7 +170,7 @@ export function CompanyTable({ companies, isLoading, onEdit, onDelete }: Company
aria-label="부서관리" aria-label="부서관리"
> >
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
</Button> </Button> */}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"

View File

@ -294,18 +294,10 @@ export function MenuCopyDialog({
<span className="text-muted-foreground">:</span>{" "} <span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedScreens}</span> <span className="font-medium">{result.copiedScreens}</span>
</div> </div>
<div> <div className="col-span-2">
<span className="text-muted-foreground">:</span>{" "} <span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedFlows}</span> <span className="font-medium">{result.copiedFlows}</span>
</div> </div>
<div>
<span className="text-muted-foreground"> :</span>{" "}
<span className="font-medium">{result.copiedCategories}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">:</span>{" "}
<span className="font-medium">{result.copiedCodes}</span>
</div>
</div> </div>
</div> </div>
)} )}

View File

@ -3,7 +3,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react"; import { Plus, ChevronDown, ChevronRight, Users, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";

View File

@ -120,10 +120,17 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}; };
}; };
// 모달이 열린 시간 추적 (저장 성공 이벤트 무시용)
const modalOpenedAtRef = React.useRef<number>(0);
// 전역 모달 이벤트 리스너 // 전역 모달 이벤트 리스너
useEffect(() => { useEffect(() => {
const handleOpenModal = (event: CustomEvent) => { 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에 추가 // 🆕 URL 파라미터가 있으면 현재 URL에 추가
if (urlParams && typeof window !== "undefined") { if (urlParams && typeof window !== "undefined") {
@ -136,6 +143,12 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
console.log("✅ URL 파라미터 추가:", urlParams); console.log("✅ URL 파라미터 추가:", urlParams);
} }
// 🆕 editData가 있으면 formData로 설정 (수정 모드)
if (editData) {
console.log("📝 [ScreenModal] 수정 데이터 설정:", editData);
setFormData(editData);
}
setModalState({ setModalState({
isOpen: true, isOpen: true,
screenId, screenId,
@ -164,6 +177,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
}); });
setScreenData(null); setScreenData(null);
setFormData({}); setFormData({});
setSelectedData([]); // 🆕 선택된 데이터 초기화
setContinuousMode(false); setContinuousMode(false);
localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장 localStorage.setItem("screenModal_continuousMode", "false"); // localStorage에 저장
console.log("🔄 연속 모드 초기화: false"); console.log("🔄 연속 모드 초기화: false");
@ -171,6 +185,13 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 저장 성공 이벤트 처리 (연속 등록 모드 지원) // 저장 성공 이벤트 처리 (연속 등록 모드 지원)
const handleSaveSuccess = () => { const handleSaveSuccess = () => {
// 🆕 모달이 열린 후 500ms 이내의 저장 성공 이벤트는 무시 (이전 이벤트 방지)
const timeSinceOpen = Date.now() - modalOpenedAtRef.current;
if (timeSinceOpen < 500) {
console.log("⏭️ [ScreenModal] 모달 열린 직후 저장 성공 이벤트 무시:", { timeSinceOpen });
return;
}
const isContinuousMode = continuousMode; const isContinuousMode = continuousMode;
console.log("💾 저장 성공 이벤트 수신"); console.log("💾 저장 성공 이벤트 수신");
console.log("📌 현재 연속 모드 상태:", isContinuousMode); console.log("📌 현재 연속 모드 상태:", isContinuousMode);
@ -184,7 +205,7 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
setFormData({}); setFormData({});
// 2. 리셋 키 변경 (컴포넌트 강제 리마운트) // 2. 리셋 키 변경 (컴포넌트 강제 리마운트)
setResetKey(prev => prev + 1); setResetKey((prev) => prev + 1);
console.log("🔄 resetKey 증가 - 컴포넌트 리마운트"); console.log("🔄 resetKey 증가 - 컴포넌트 리마운트");
// 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성) // 3. 화면 데이터 다시 로드 (채번 규칙 새로 생성)
@ -315,16 +336,16 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
return data.map(normalizeDates); return data.map(normalizeDates);
} }
if (typeof data !== 'object' || data === null) { if (typeof data !== "object" || data === null) {
return data; return data;
} }
const normalized: any = {}; const normalized: any = {};
for (const [key, value] of Object.entries(data)) { 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만 추출 // ISO 날짜 형식 감지: YYYY-MM-DD만 추출
const before = value; const before = value;
const after = value.split('T')[0]; const after = value.split("T")[0];
console.log(`🔧 [날짜 정규화] ${key}: ${before}${after}`); console.log(`🔧 [날짜 정규화] ${key}: ${before}${after}`);
normalized[key] = after; normalized[key] = after;
} else { } else {
@ -340,7 +361,9 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
// 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용) // 🔧 배열 데이터는 formData로 설정하지 않음 (SelectedItemsDetailInput만 사용)
if (Array.isArray(normalizedData)) { if (Array.isArray(normalizedData)) {
console.log("⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다."); console.log(
"⚠️ [ScreenModal] 그룹 레코드(배열)는 formData로 설정하지 않음. SelectedItemsDetailInput만 사용합니다.",
);
setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용 setFormData(normalizedData); // SelectedItemsDetailInput이 직접 사용
} else { } else {
setFormData(normalizedData); setFormData(normalizedData);
@ -581,6 +604,15 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ 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 ( return (
<InteractiveScreenViewerDynamic <InteractiveScreenViewerDynamic
key={`${component.id}-${resetKey}`} key={`${component.id}-${resetKey}`}
@ -605,6 +637,8 @@ export const ScreenModal: React.FC<ScreenModalProps> = ({ className }) => {
userId={userId} userId={userId}
userName={userName} userName={userName}
companyCode={user?.companyCode} companyCode={user?.companyCode}
// 🆕 선택된 데이터 전달 (RepeatScreenModal 등에서 사용)
groupedData={selectedData.length > 0 ? selectedData : undefined}
/> />
); );
})} })}

View File

@ -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<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
}
/**
*
*/
export const EmbeddedScreen = forwardRef<EmbeddedScreenHandle, EmbeddedScreenProps>(
({ embedding, onSelectionChanged, position, initialFormData }, ref) => {
const [layout, setLayout] = useState<ComponentData[]>([]);
const [selectedRows, setSelectedRows] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [screenInfo, setScreenInfo] = useState<any>(null);
const [formData, setFormData] = useState<Record<string, any>>(initialFormData || {}); // 🆕 초기 데이터로 시작
// 컴포넌트 참조 맵
const componentRefs = useRef<Map<string, DataReceivable>>(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<string, any> = {};
componentRefs.current.forEach((component, id) => {
allData[id] = component.getData();
});
return allData;
},
}));
// 로딩 상태
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-4">
<div className="border-primary h-8 w-8 animate-spin rounded-full border-4 border-t-transparent" />
<p className="text-muted-foreground text-sm"> ...</p>
</div>
</div>
);
}
// 에러 상태
if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="flex flex-col items-center gap-4 text-center">
<div className="bg-destructive/10 flex h-12 w-12 items-center justify-center rounded-full">
<svg className="text-destructive h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium"> </p>
<p className="text-muted-foreground mt-1 text-xs">{error}</p>
</div>
<button onClick={loadScreenData} className="text-primary text-sm hover:underline">
</button>
</div>
</div>
);
}
// 화면 렌더링 - 절대 위치 기반 레이아웃 (원본 화면과 동일하게)
// position을 ScreenContextProvider에 전달하여 중첩된 화면에서도 위치를 알 수 있게 함
return (
<ScreenContextProvider
screenId={embedding.childScreenId}
tableName={screenInfo?.tableName}
splitPanelPosition={position}
>
<div className="relative h-full w-full overflow-auto p-4">
{layout.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm"> .</p>
</div>
) : (
<div
className="relative w-full"
style={{
minHeight: contentBounds.height + 20, // 여유 공간 추가
}}
>
{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 (
<div
key={component.id}
className="absolute"
style={componentStyle}
>
<DynamicComponentRenderer
component={component}
isInteractive={true}
screenId={embedding.childScreenId}
tableName={screenInfo?.tableName}
formData={formData}
onFormDataChange={handleFieldChange}
onSelectionChange={embedding.mode === "select" ? handleSelectionChange : undefined}
userId={userId}
userName={userName}
companyCode={companyCode}
/>
</div>
);
})}
</div>
)}
</div>
</ScreenContextProvider>
);
},
);
EmbeddedScreen.displayName = "EmbeddedScreen";

View File

@ -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<string, any>; // 🆕 수정 모드에서 전달되는 초기 데이터
}
/**
*
* .
*/
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 (
<div className="border-muted-foreground/25 flex h-full items-center justify-center rounded-lg border-2 border-dashed">
<div className="space-y-4 p-6 text-center">
<div className="flex items-center justify-center gap-3">
<div className="bg-muted flex h-16 w-16 items-center justify-center rounded-lg">
<Columns2 className="text-muted-foreground h-8 w-8" />
</div>
</div>
<div>
<p className="text-muted-foreground mb-2 text-base font-semibold"> </p>
<p className="text-muted-foreground/60 mb-1 text-xs"> </p>
<p className="text-muted-foreground/60 text-xs">
/
</p>
<p className="text-muted-foreground/60 mt-2 text-[10px]">
💡 전달: 좌측 transferData
</p>
</div>
</div>
</div>
);
}
// 좌측 또는 우측 화면이 설정되지 않은 경우 안내 메시지 표시
const hasLeftScreen = !!leftEmbedding;
const hasRightScreen = !!rightEmbedding;
// 분할 패널 고유 ID 생성
const splitPanelId = useMemo(() => `split-panel-${screenId || "unknown"}-${Date.now()}`, [screenId]);
return (
<SplitPanelProvider
splitPanelId={splitPanelId}
leftScreenId={config?.leftScreenId || null}
rightScreenId={config?.rightScreenId || null}
>
<div className="flex h-full">
{/* 좌측 패널 */}
<div style={{ width: `${splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden border-r">
{hasLeftScreen ? (
<EmbeddedScreen embedding={leftEmbedding!} position="left" initialFormData={initialFormData} />
) : (
<div className="flex h-full items-center justify-center bg-muted/30">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</div>
{/* 리사이저 */}
{config?.resizable !== false && (
<div
className="group bg-border hover:bg-primary/20 relative w-1 flex-shrink-0 cursor-col-resize transition-colors"
onMouseDown={(e) => {
e.preventDefault();
const startX = e.clientX;
const startRatio = splitRatio;
const containerWidth = e.currentTarget.parentElement!.offsetWidth;
const handleMouseMove = (moveEvent: MouseEvent) => {
const deltaX = moveEvent.clientX - startX;
const deltaRatio = (deltaX / containerWidth) * 100;
handleResize(startRatio + deltaRatio);
};
const handleMouseUp = () => {
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
}}
>
<div className="bg-primary absolute inset-y-0 left-1/2 w-1 -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100" />
</div>
)}
{/* 우측 패널 */}
<div style={{ width: `${100 - splitRatio}%` }} className="h-full flex-shrink-0 overflow-hidden">
{hasRightScreen ? (
<EmbeddedScreen embedding={rightEmbedding!} position="right" initialFormData={initialFormData} />
) : (
<div className="flex h-full items-center justify-center bg-muted/30">
<p className="text-muted-foreground text-sm"> </p>
</div>
)}
</div>
</div>
</SplitPanelProvider>
);
}

View File

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

View File

@ -408,6 +408,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
value: currentValue, value: currentValue,
onChange: (value: any) => handleFormDataChange(fieldName, value), onChange: (value: any) => handleFormDataChange(fieldName, value),
onFormDataChange: handleFormDataChange, onFormDataChange: handleFormDataChange,
formData: formData, // 🆕 전체 formData 전달
isInteractive: true, isInteractive: true,
readonly: readonly, readonly: readonly,
required: required, required: required,
@ -415,6 +416,7 @@ export const InteractiveScreenViewerDynamic: React.FC<InteractiveScreenViewerPro
className: "w-full h-full", className: "w-full h-full",
isInModal: isInModal, // 🆕 EditModal 내부 여부 전달 isInModal: isInModal, // 🆕 EditModal 내부 여부 전달
onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달 onSave: onSave, // 🆕 EditModal의 handleSave 콜백 전달
groupedData: groupedData, // 🆕 그룹 데이터 전달 (RepeatScreenModal용)
}} }}
config={widget.webTypeConfig} config={widget.webTypeConfig}
onEvent={(event: string, data: any) => { onEvent={(event: string, data: any) => {

View File

@ -528,9 +528,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
// 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화) // 🆕 size 변경 시 style도 함께 업데이트 (파란 테두리와 실제 크기 동기화)
if (path === "size.width" || path === "size.height" || path === "size") { if (path === "size.width" || path === "size.height" || path === "size") {
if (!newComp.style) { // 🔧 style 객체를 새로 복사하여 불변성 유지
newComp.style = {}; newComp.style = { ...(newComp.style || {}) };
}
if (path === "size.width") { if (path === "size.width") {
newComp.style.width = `${value}px`; newComp.style.width = `${value}px`;

File diff suppressed because it is too large Load Diff

View File

@ -740,6 +740,12 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
const handleConfigChange = (newConfig: WebTypeConfig) => { const handleConfigChange = (newConfig: WebTypeConfig) => {
// 강제 새 객체 생성으로 React 변경 감지 보장 // 강제 새 객체 생성으로 React 변경 감지 보장
const freshConfig = { ...newConfig }; const freshConfig = { ...newConfig };
console.log("🔧 [DetailSettingsPanel] handleConfigChange 호출:", {
widgetId: widget.id,
widgetLabel: widget.label,
widgetType: widget.widgetType,
newConfig: freshConfig,
});
onUpdateProperty(widget.id, "webTypeConfig", freshConfig); onUpdateProperty(widget.id, "webTypeConfig", freshConfig);
// TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑 // TextTypeConfig의 자동입력 설정을 autoGeneration으로도 매핑
@ -863,27 +869,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
}); });
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 // 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤
const ConfigPanelWrapper = () => { // Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장 const config = currentConfig || definition.defaultProps?.componentConfig || {};
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => { const handleConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트 // componentConfig 전체를 업데이트
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); onUpdateProperty(selectedComponent.id, "componentConfig", newConfig);
};
return (
<div className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
}; };
return <ConfigPanelWrapper key={selectedComponent.id} />; return (
<div className="space-y-4" key={selectedComponent.id}>
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="h-4 w-4 text-primary" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<ConfigPanelComponent config={config} onChange={handleConfigChange} />
</div>
);
} else { } else {
console.warn("⚠️ ConfigPanel 없음:", { console.warn("⚠️ ConfigPanel 없음:", {
componentId, componentId,

View File

@ -179,7 +179,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 최대 컬럼 수 계산 // 최대 컬럼 수 계산
const MIN_COLUMN_WIDTH = 30; const MIN_COLUMN_WIDTH = 30;
const maxColumns = currentResolution 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; : 24;
const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한 const safeMaxColumns = Math.max(1, Math.min(maxColumns, 100)); // 최대 100개로 제한
@ -226,9 +229,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
{/* 10px 단위 스냅 안내 */} {/* 10px 단위 스냅 안내 */}
<div className="bg-muted/50 rounded-md p-2"> <div className="bg-muted/50 rounded-md p-2">
<p className="text-[10px] text-muted-foreground"> <p className="text-muted-foreground text-[10px]"> 10px .</p>
10px .
</p>
</div> </div>
</div> </div>
</div> </div>
@ -238,9 +239,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시 // 컴포넌트가 선택되지 않았을 때도 해상도 설정과 격자 설정은 표시
if (!selectedComponent) { if (!selectedComponent) {
return ( return (
<div className="flex h-full flex-col bg-white"> <div className="flex h-full flex-col overflow-x-auto bg-white">
{/* 해상도 설정과 격자 설정 표시 */} {/* 해상도 설정과 격자 설정 표시 */}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* 해상도 설정 */} {/* 해상도 설정 */}
{currentResolution && onResolutionChange && ( {currentResolution && onResolutionChange && (
@ -288,7 +289,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지 // 🎯 Section Card, Section Paper 등 신규 컴포넌트는 componentType에서 감지
const componentType = const componentType =
selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등) selectedComponent.componentType || // ⭐ 1순위: ScreenDesigner가 설정한 componentType (section-card 등)
selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id || selectedComponent.componentConfig?.id ||
selectedComponent.type; selectedComponent.type;
@ -306,7 +307,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도 // 🆕 ComponentRegistry에서 ConfigPanel 가져오기 시도
const componentId = const componentId =
selectedComponent.componentType || // ⭐ section-card 등 selectedComponent.componentType || // ⭐ section-card 등
selectedComponent.componentConfig?.type || selectedComponent.componentConfig?.type ||
selectedComponent.componentConfig?.id || selectedComponent.componentConfig?.id ||
(selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등) (selectedComponent.type === "component" ? selectedComponent.id : null); // 🆕 독립 컴포넌트 (table-search-widget 등)
@ -325,41 +326,48 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
currentConfig, currentConfig,
}); });
// 래퍼 컴포넌트: 새 ConfigPanel 인터페이스를 기존 패턴에 맞춤 // 🔧 ConfigPanelWrapper를 인라인 함수 대신 직접 JSX 반환 (리마운트 방지)
const ConfigPanelWrapper = () => { const config = currentConfig || definition.defaultProps?.componentConfig || {};
// Section Card, Section Paper 등 신규 컴포넌트는 componentConfig 바로 아래에 설정 저장
const config = currentConfig || definition.defaultProps?.componentConfig || {};
const handleConfigChange = (newConfig: any) => { const handlePanelConfigChange = (newConfig: any) => {
// componentConfig 전체를 업데이트 // 🔧 Partial 업데이트: 기존 componentConfig를 유지하면서 새 설정만 병합
onUpdateProperty(selectedComponent.id, "componentConfig", newConfig); const mergedConfig = {
...currentConfig, // 기존 설정 유지
...newConfig, // 새 설정 병합
}; };
console.log("🔧 [ConfigPanel] handleConfigChange:", {
return ( componentId: selectedComponent.id,
<div className="space-y-4"> currentConfig,
<div className="flex items-center gap-2 border-b pb-2"> newConfig,
<Settings className="h-4 w-4 text-primary" /> mergedConfig,
<h3 className="text-sm font-semibold">{definition.name} </h3> });
</div> onUpdateProperty(selectedComponent.id, "componentConfig", mergedConfig);
<Suspense fallback={
<div className="flex items-center justify-center py-8">
<div className="text-sm text-muted-foreground"> ...</div>
</div>
}>
<ConfigPanelComponent
config={config}
onChange={handleConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
</Suspense>
</div>
);
}; };
return <ConfigPanelWrapper key={selectedComponent.id} />; return (
<div key={selectedComponent.id} className="space-y-4">
<div className="flex items-center gap-2 border-b pb-2">
<Settings className="text-primary h-4 w-4" />
<h3 className="text-sm font-semibold">{definition.name} </h3>
</div>
<Suspense
fallback={
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground text-sm"> ...</div>
</div>
}
>
<ConfigPanelComponent
config={config}
onChange={handlePanelConfigChange}
tables={tables} // 테이블 정보 전달
allTables={allTables} // 🆕 전체 테이블 목록 전달 (selected-items-detail-input 등에서 사용)
screenTableName={selectedComponent.tableName || currentTable?.tableName || currentTableName} // 🔧 화면 테이블명 전달
tableColumns={currentTable?.columns || []} // 🔧 테이블 컬럼 정보 전달
/>
</Suspense>
</div>
);
} else { } else {
console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", { console.warn("⚠️ ComponentRegistry에서 ConfigPanel을 찾을 수 없음 - switch case로 이동:", {
componentId, componentId,
@ -418,9 +426,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">Section Card </h3> <h3 className="text-sm font-semibold">Section Card </h3>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> </p>
</p>
</div> </div>
{/* 헤더 표시 */} {/* 헤더 표시 */}
@ -432,7 +438,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.showHeader", checked);
}} }}
/> />
<Label htmlFor="showHeader" className="text-xs cursor-pointer"> <Label htmlFor="showHeader" className="cursor-pointer text-xs">
</Label> </Label>
</div> </div>
@ -462,7 +468,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value); handleUpdateProperty(selectedComponent.id, "componentConfig.description", e.target.value);
}} }}
placeholder="섹션 설명 입력" placeholder="섹션 설명 입력"
className="text-xs resize-none" className="resize-none text-xs"
rows={2} rows={2}
/> />
</div> </div>
@ -530,7 +536,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
{/* 접기/펼치기 기능 */} {/* 접기/펼치기 기능 */}
<div className="space-y-2 pt-2 border-t"> <div className="space-y-2 border-t pt-2">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="collapsible" id="collapsible"
@ -539,13 +545,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.collapsible", checked);
}} }}
/> />
<Label htmlFor="collapsible" className="text-xs cursor-pointer"> <Label htmlFor="collapsible" className="cursor-pointer text-xs">
/ /
</Label> </Label>
</div> </div>
{selectedComponent.componentConfig?.collapsible && ( {selectedComponent.componentConfig?.collapsible && (
<div className="flex items-center space-x-2 ml-6"> <div className="ml-6 flex items-center space-x-2">
<Checkbox <Checkbox
id="defaultOpen" id="defaultOpen"
checked={selectedComponent.componentConfig?.defaultOpen !== false} checked={selectedComponent.componentConfig?.defaultOpen !== false}
@ -553,7 +559,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.defaultOpen", checked);
}} }}
/> />
<Label htmlFor="defaultOpen" className="text-xs cursor-pointer"> <Label htmlFor="defaultOpen" className="cursor-pointer text-xs">
</Label> </Label>
</div> </div>
@ -567,9 +573,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
<div className="space-y-4 p-4"> <div className="space-y-4 p-4">
<div className="space-y-2"> <div className="space-y-2">
<h3 className="text-sm font-semibold">Section Paper </h3> <h3 className="text-sm font-semibold">Section Paper </h3>
<p className="text-xs text-muted-foreground"> <p className="text-muted-foreground text-xs"> </p>
</p>
</div> </div>
{/* 배경색 */} {/* 배경색 */}
@ -680,7 +684,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked); handleUpdateProperty(selectedComponent.id, "componentConfig.showBorder", checked);
}} }}
/> />
<Label htmlFor="showBorder" className="text-xs cursor-pointer"> <Label htmlFor="showBorder" className="cursor-pointer text-xs">
</Label> </Label>
</div> </div>
@ -691,9 +695,9 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
// ConfigPanel이 없는 경우 경고 표시 // ConfigPanel이 없는 경우 경고 표시
return ( return (
<div className="flex h-full flex-col items-center justify-center p-6 text-center"> <div className="flex h-full flex-col items-center justify-center p-6 text-center">
<Settings className="mb-4 h-12 w-12 text-muted-foreground" /> <Settings className="text-muted-foreground mb-4 h-12 w-12" />
<h3 className="mb-2 text-base font-medium"> </h3> <h3 className="mb-2 text-base font-medium"> </h3>
<p className="text-sm text-muted-foreground"> <p className="text-muted-foreground text-sm">
"{componentId || componentType}" . "{componentId || componentType}" .
</p> </p>
</div> </div>
@ -1418,7 +1422,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</div> </div>
{/* 통합 컨텐츠 (탭 제거) */} {/* 통합 컨텐츠 (탭 제거) */}
<div className="flex-1 overflow-y-auto p-2"> <div className="flex-1 overflow-x-auto overflow-y-auto p-2">
<div className="space-y-4 text-xs"> <div className="space-y-4 text-xs">
{/* 해상도 설정 - 항상 맨 위에 표시 */} {/* 해상도 설정 - 항상 맨 위에 표시 */}
{currentResolution && onResolutionChange && ( {currentResolution && onResolutionChange && (

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import React, { useState, useEffect, useRef } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge";
import { Plus, X, GripVertical, ChevronDown, ChevronUp } from "lucide-react"; 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 { cn } from "@/lib/utils";
import { useBreakpoint } from "@/hooks/useBreakpoint"; import { useBreakpoint } from "@/hooks/useBreakpoint";
import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal"; import { usePreviewBreakpoint } from "@/components/screen/ResponsivePreviewModal";
@ -21,6 +22,7 @@ export interface RepeaterInputProps {
disabled?: boolean; disabled?: boolean;
readonly?: boolean; readonly?: boolean;
className?: string; className?: string;
menuObjid?: number; // 카테고리 조회용 메뉴 ID
} }
/** /**
@ -34,6 +36,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
disabled = false, disabled = false,
readonly = false, readonly = false,
className, className,
menuObjid,
}) => { }) => {
// 현재 브레이크포인트 감지 // 현재 브레이크포인트 감지
const globalBreakpoint = useBreakpoint(); const globalBreakpoint = useBreakpoint();
@ -42,6 +45,9 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 미리보기 모달 내에서는 previewBreakpoint 우선 사용 // 미리보기 모달 내에서는 previewBreakpoint 우선 사용
const breakpoint = previewBreakpoint || globalBreakpoint; const breakpoint = previewBreakpoint || globalBreakpoint;
// 카테고리 매핑 데이터 (값 -> {label, color})
const [categoryMappings, setCategoryMappings] = useState<Record<string, Record<string, { label: string; color: string }>>>({});
// 설정 기본값 // 설정 기본값
const { const {
fields = [], fields = [],
@ -73,6 +79,12 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
// 접힌 상태 관리 (각 항목별) // 접힌 상태 관리 (각 항목별)
const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set()); const [collapsedItems, setCollapsedItems] = useState<Set<number>>(new Set());
// 🆕 초기 계산 완료 여부 추적 (무한 루프 방지)
const initialCalcDoneRef = useRef(false);
// 🆕 삭제된 항목 ID 목록 추적 (ref로 관리하여 즉시 반영)
const deletedItemIdsRef = useRef<string[]>([]);
// 빈 항목 생성 // 빈 항목 생성
function createEmptyItem(): RepeaterItemData { function createEmptyItem(): RepeaterItemData {
const item: RepeaterItemData = {}; const item: RepeaterItemData = {};
@ -82,10 +94,39 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
return item; return item;
} }
// 외부 value 변경 시 동기화 // 외부 value 변경 시 동기화 및 초기 계산식 필드 업데이트
useEffect(() => { useEffect(() => {
if (value.length > 0) { 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]); }, [value]);
@ -111,14 +152,32 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
if (items.length <= minItems) { if (items.length <= minItems) {
return; 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); const newItems = items.filter((_, i) => i !== index);
setItems(newItems); setItems(newItems);
// targetTable이 설정된 경우 각 항목에 메타데이터 추가 // targetTable이 설정된 경우 각 항목에 메타데이터 추가
// 🆕 삭제된 항목 ID 목록도 함께 전달 (ref에서 최신값 사용)
const currentDeletedIds = deletedItemIdsRef.current;
console.log("🗑️ [RepeaterInput] 현재 삭제 목록:", currentDeletedIds);
const dataWithMeta = config.targetTable 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; : newItems;
console.log("🗑️ [RepeaterInput] onChange 호출 - dataWithMeta:", dataWithMeta);
onChange?.(dataWithMeta); onChange?.(dataWithMeta);
// 접힌 상태도 업데이트 // 접힌 상태도 업데이트
@ -134,6 +193,16 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
...newItems[itemIndex], ...newItems[itemIndex],
[fieldName]: value, [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); setItems(newItems);
console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", { console.log("✏️ RepeaterInput 필드 변경, onChange 호출:", {
itemIndex, itemIndex,
@ -143,8 +212,15 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
}); });
// targetTable이 설정된 경우 각 항목에 메타데이터 추가 // targetTable이 설정된 경우 각 항목에 메타데이터 추가
// 🆕 삭제된 항목 ID 목록도 유지
const currentDeletedIds = deletedItemIdsRef.current;
const dataWithMeta = config.targetTable 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; : newItems;
onChange?.(dataWithMeta); onChange?.(dataWithMeta);
@ -192,24 +268,183 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
setDraggedIndex(null); 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 renderField = (field: RepeaterFieldDefinition, itemIndex: number, value: any) => {
const isReadonly = disabled || readonly || field.readonly;
const commonProps = { const commonProps = {
value: value || "", value: value || "",
disabled: disabled || readonly, disabled: isReadonly,
placeholder: field.placeholder, placeholder: field.placeholder,
required: field.required, required: field.required,
}; };
// 계산식 필드: 자동으로 계산된 값을 표시 (읽기 전용)
if (field.type === "calculated") {
const item = items[itemIndex];
const calculatedValue = calculateValue(field.formula, item);
const formattedValue = formatNumber(calculatedValue, field.numberFormat);
return (
<span className="text-sm font-medium text-blue-700 min-w-[80px] inline-block">
{formattedValue}
</span>
);
}
// 카테고리 타입은 항상 배지로 표시 (카테고리 관리에서 설정한 색상 적용)
if (field.type === "category") {
if (!value) return <span className="text-muted-foreground text-sm">-</span>;
// 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 <span className="text-sm">{displayLabel}</span>;
}
return (
<Badge
style={{
backgroundColor: displayColor,
borderColor: displayColor,
}}
className="text-white"
>
{displayLabel}
</Badge>
);
}
// 읽기 전용 모드: 텍스트로 표시
// 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 <span className="text-sm">{option?.label || value}</span>;
}
// 일반 텍스트
return (
<span className="text-sm text-foreground">
{value || "-"}
</span>
);
}
switch (field.type) { switch (field.type) {
case "select": case "select":
return ( return (
<Select <Select
value={value || ""} value={value || ""}
onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)} onValueChange={(val) => handleFieldChange(itemIndex, field.name, val)}
disabled={disabled || readonly} disabled={isReadonly}
> >
<SelectTrigger className="w-full"> <SelectTrigger className="w-full min-w-[80px]">
<SelectValue placeholder={field.placeholder || "선택하세요"} /> <SelectValue placeholder={field.placeholder || "선택하세요"} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -228,7 +463,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
rows={3} rows={3}
className="resize-none" className="resize-none min-w-[100px]"
/> />
); );
@ -238,10 +473,45 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
type="date" type="date"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[120px]"
/> />
); );
case "number": case "number":
// 숫자 포맷이 설정된 경우 포맷팅된 텍스트로 표시
if (field.numberFormat?.useThousandSeparator || field.numberFormat?.prefix || field.numberFormat?.suffix) {
const numValue = parseFloat(value) || 0;
const formattedDisplay = formatNumber(numValue, field.numberFormat);
// 읽기 전용이면 포맷팅된 텍스트만 표시
if (isReadonly) {
return (
<span className="text-sm min-w-[80px] inline-block">
{formattedDisplay}
</span>
);
}
// 편집 가능: 입력은 숫자로, 표시는 포맷팅
return (
<div className="relative min-w-[80px]">
<Input
{...commonProps}
type="number"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min}
max={field.validation?.max}
className="pr-1"
/>
{value && (
<div className="text-muted-foreground text-[10px] mt-0.5">
{formattedDisplay}
</div>
)}
</div>
);
}
return ( return (
<Input <Input
{...commonProps} {...commonProps}
@ -249,6 +519,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
min={field.validation?.min} min={field.validation?.min}
max={field.validation?.max} max={field.validation?.max}
className="min-w-[80px]"
/> />
); );
@ -258,6 +529,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
type="email" type="email"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[120px]"
/> />
); );
@ -267,6 +539,7 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
{...commonProps} {...commonProps}
type="tel" type="tel"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
className="min-w-[100px]"
/> />
); );
@ -277,11 +550,69 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
type="text" type="text"
onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)} onChange={(e) => handleFieldChange(itemIndex, field.name, e.target.value)}
maxLength={field.validation?.maxLength} 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<string, { label: string; color: string }> = {};
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) { if (fields.length === 0) {
return ( return (
@ -324,18 +655,18 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
<TableHeader> <TableHeader>
<TableRow className="bg-background"> <TableRow className="bg-background">
{showIndex && ( {showIndex && (
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold">#</TableHead> <TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold">#</TableHead>
)} )}
{allowReorder && ( {allowReorder && (
<TableHead className="h-12 w-12 px-6 py-3 text-center text-sm font-semibold"></TableHead> <TableHead className="h-10 w-10 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
)} )}
{fields.map((field) => ( {fields.map((field) => (
<TableHead key={field.name} className="h-12 px-6 py-3 text-sm font-semibold"> <TableHead key={field.name} className="h-10 px-2.5 py-2 text-sm font-semibold">
{field.label} {field.label}
{field.required && <span className="ml-1 text-destructive">*</span>} {field.required && <span className="ml-1 text-destructive">*</span>}
</TableHead> </TableHead>
))} ))}
<TableHead className="h-12 w-20 px-6 py-3 text-center text-sm font-semibold"></TableHead> <TableHead className="h-10 w-14 px-2.5 py-2 text-center text-sm font-semibold"></TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
@ -354,27 +685,27 @@ export const RepeaterInput: React.FC<RepeaterInputProps> = ({
> >
{/* 인덱스 번호 */} {/* 인덱스 번호 */}
{showIndex && ( {showIndex && (
<TableCell className="h-16 px-6 py-3 text-center text-sm font-medium"> <TableCell className="h-12 px-2.5 py-2 text-center text-sm font-medium">
{itemIndex + 1} {itemIndex + 1}
</TableCell> </TableCell>
)} )}
{/* 드래그 핸들 */} {/* 드래그 핸들 */}
{allowReorder && !readonly && !disabled && ( {allowReorder && !readonly && !disabled && (
<TableCell className="h-16 px-6 py-3 text-center"> <TableCell className="h-12 px-2.5 py-2 text-center">
<GripVertical className="h-4 w-4 cursor-move text-muted-foreground" /> <GripVertical className="h-4 w-4 cursor-move text-muted-foreground" />
</TableCell> </TableCell>
)} )}
{/* 필드들 */} {/* 필드들 */}
{fields.map((field) => ( {fields.map((field) => (
<TableCell key={field.name} className="h-16 px-6 py-3"> <TableCell key={field.name} className="h-12 px-2.5 py-2">
{renderField(field, itemIndex, item[field.name])} {renderField(field, itemIndex, item[field.name])}
</TableCell> </TableCell>
))} ))}
{/* 삭제 버튼 */} {/* 삭제 버튼 */}
<TableCell className="h-16 px-6 py-3 text-center"> <TableCell className="h-12 px-2.5 py-2 text-center">
{!readonly && !disabled && items.length > minItems && ( {!readonly && !disabled && items.length > minItems && (
<Button <Button
type="button" type="button"

View File

@ -9,8 +9,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Plus, X, GripVertical, Check, ChevronsUpDown } from "lucide-react"; import { Plus, X, GripVertical, Check, ChevronsUpDown, Calculator } from "lucide-react";
import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType } from "@/types/repeater"; import { RepeaterFieldGroupConfig, RepeaterFieldDefinition, RepeaterFieldType, CalculationOperator, CalculationFormula } from "@/types/repeater";
import { ColumnInfo } from "@/types/screen"; import { ColumnInfo } from "@/types/screen";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -192,6 +192,32 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<p className="text-xs text-gray-500"> .</p> <p className="text-xs text-gray-500"> .</p>
</div> </div>
{/* 그룹화 컬럼 설정 */}
<div className="space-y-2">
<Label className="text-sm font-semibold"> ()</Label>
<Select
value={config.groupByColumn || "__none__"}
onValueChange={(value) => handleChange("groupByColumn", value === "__none__" ? undefined : value)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="그룹화 컬럼 선택 (선택사항)" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__"> </SelectItem>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.columnLabel || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
.
<br />
: 입고번호를 .
</p>
</div>
{/* 필드 정의 */} {/* 필드 정의 */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-semibold"> </Label> <Label className="text-sm font-semibold"> </Label>
@ -235,10 +261,23 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
key={column.columnName} key={column.columnName}
value={column.columnName} value={column.columnName}
onSelect={() => { 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, { updateField(index, {
name: column.columnName, name: column.columnName,
label: column.columnLabel || column.columnName, label: column.columnLabel || column.columnName,
type: (column.widgetType as RepeaterFieldType) || "text", type: fieldType as RepeaterFieldType,
}); });
// 로컬 입력 상태도 업데이트 // 로컬 입력 상태도 업데이트
setLocalInputs(prev => ({ setLocalInputs(prev => ({
@ -293,13 +332,25 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="text"></SelectItem> {/* 테이블 타입 관리에서 사용하는 input_type 목록 */}
<SelectItem value="number"></SelectItem> <SelectItem value="text"> (text)</SelectItem>
<SelectItem value="email"></SelectItem> <SelectItem value="number"> (number)</SelectItem>
<SelectItem value="tel"></SelectItem> <SelectItem value="textarea"> (textarea)</SelectItem>
<SelectItem value="date"></SelectItem> <SelectItem value="date"> (date)</SelectItem>
<SelectItem value="select"></SelectItem> <SelectItem value="select"> (select)</SelectItem>
<SelectItem value="textarea"></SelectItem> <SelectItem value="checkbox"> (checkbox)</SelectItem>
<SelectItem value="radio"> (radio)</SelectItem>
<SelectItem value="category"> (category)</SelectItem>
<SelectItem value="entity"> (entity)</SelectItem>
<SelectItem value="code"> (code)</SelectItem>
<SelectItem value="image"> (image)</SelectItem>
<SelectItem value="direct"> (direct)</SelectItem>
<SelectItem value="calculated">
<span className="flex items-center gap-1">
<Calculator className="h-3 w-3" />
(calculated)
</span>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -316,16 +367,316 @@ export const RepeaterConfigPanel: React.FC<RepeaterConfigPanelProps> = ({
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> {/* 계산식 타입일 때 계산식 설정 */}
<Checkbox {field.type === "calculated" && (
id={`required-${index}`} <div className="space-y-3 rounded-lg border border-blue-200 bg-blue-50 p-3">
checked={field.required ?? false} <div className="flex items-center gap-2">
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })} <Calculator className="h-4 w-4 text-blue-600" />
/> <Label className="text-xs font-semibold text-blue-800"> </Label>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal"> </div>
</Label> {/* 필드 1 선택 */}
</div> <div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 1</Label>
<Select
value={field.formula?.field1 || ""}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, field1: value } as CalculationFormula
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{localFields
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 연산자 선택 */}
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"></Label>
<Select
value={field.formula?.operator || "+"}
onValueChange={(value) => updateField(index, {
formula: { ...field.formula, operator: value as CalculationOperator } as CalculationFormula
})}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent className="z-[9999]">
<SelectItem value="+" className="text-xs">+ </SelectItem>
<SelectItem value="-" className="text-xs">- </SelectItem>
<SelectItem value="*" className="text-xs">× </SelectItem>
<SelectItem value="/" className="text-xs">÷ </SelectItem>
<SelectItem value="%" className="text-xs">% </SelectItem>
<SelectItem value="round" className="text-xs"></SelectItem>
<SelectItem value="floor" className="text-xs"></SelectItem>
<SelectItem value="ceil" className="text-xs"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 두 번째 필드 또는 상수값 */}
{!["round", "floor", "ceil", "abs"].includes(field.formula?.operator || "") ? (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 2 / </Label>
<Select
value={field.formula?.field2 || (field.formula?.constantValue !== undefined ? `__const__${field.formula.constantValue}` : "")}
onValueChange={(value) => {
if (value.startsWith("__const__")) {
updateField(index, {
formula: {
...field.formula,
field2: undefined,
constantValue: 0
} as CalculationFormula
});
} else {
updateField(index, {
formula: {
...field.formula,
field2: value,
constantValue: undefined
} as CalculationFormula
});
}
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent className="z-[9999]">
{localFields
.filter((f, i) => i !== index && f.type !== "calculated" && f.type !== "category")
.map((f) => (
<SelectItem key={f.name} value={f.name} className="text-xs">
{f.label || f.name}
</SelectItem>
))}
<SelectItem value="__const__0" className="text-xs text-blue-600">
</SelectItem>
</SelectContent>
</Select>
</div>
) : (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"> 릿</Label>
<Input
type="number"
min={0}
max={10}
value={field.formula?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
formula: { ...field.formula, decimalPlaces: parseInt(e.target.value) || 0 } as CalculationFormula
})}
className="h-8 text-xs"
/>
</div>
)}
{/* 상수값 입력 필드 */}
{field.formula?.constantValue !== undefined && (
<div className="space-y-1">
<Label className="text-[10px] text-blue-700"></Label>
<Input
type="number"
value={field.formula.constantValue}
onChange={(e) => updateField(index, {
formula: { ...field.formula, constantValue: parseFloat(e.target.value) || 0 } as CalculationFormula
})}
placeholder="숫자 입력"
className="h-8 text-xs"
/>
</div>
)}
{/* 숫자 포맷 설정 */}
<div className="space-y-2 border-t border-blue-200 pt-2">
<Label className="text-[10px] text-blue-700"> </Label>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? true}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
/>
<Label htmlFor={`thousand-sep-${index}`} className="cursor-pointer text-[10px]">
</Label>
</div>
<div className="flex items-center gap-1">
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
type="number"
min={0}
max={10}
className="h-6 w-12 text-[10px]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
</div>
</div>
{/* 계산식 미리보기 */}
<div className="rounded bg-white p-2 text-xs">
<span className="text-gray-500">: </span>
<code className="font-mono text-blue-700">
{field.formula?.field1 || "필드1"} {field.formula?.operator || "+"} {
field.formula?.field2 ||
(field.formula?.constantValue !== undefined ? field.formula.constantValue : "필드2")
}
</code>
</div>
</div>
)}
{/* 숫자 타입일 때 숫자 표시 형식 설정 */}
{field.type === "number" && (
<div className="space-y-2 rounded-lg border border-gray-200 bg-gray-50 p-3">
<Label className="text-xs font-semibold text-gray-700"> </Label>
<div className="flex items-center gap-4">
<div className="flex items-center space-x-2">
<Checkbox
id={`number-thousand-sep-${index}`}
checked={field.numberFormat?.useThousandSeparator ?? false}
onCheckedChange={(checked) => updateField(index, {
numberFormat: { ...field.numberFormat, useThousandSeparator: checked as boolean }
})}
/>
<Label htmlFor={`number-thousand-sep-${index}`} className="cursor-pointer text-[10px]">
</Label>
</div>
<div className="flex items-center gap-1">
<Label className="text-[10px]">:</Label>
<Input
value={field.numberFormat?.decimalPlaces ?? 0}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, decimalPlaces: parseInt(e.target.value) || 0 }
})}
type="number"
min={0}
max={10}
className="h-6 w-12 text-[10px]"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<Input
value={field.numberFormat?.prefix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, prefix: e.target.value }
})}
placeholder="접두사 (₩)"
className="h-7 text-[10px]"
/>
<Input
value={field.numberFormat?.suffix || ""}
onChange={(e) => updateField(index, {
numberFormat: { ...field.numberFormat, suffix: e.target.value }
})}
placeholder="접미사 (원)"
className="h-7 text-[10px]"
/>
</div>
</div>
)}
{/* 카테고리 타입일 때 카테고리 코드 입력 */}
{field.type === "category" && (
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Input
value={field.categoryCode || field.name || ""}
onChange={(e) => updateField(index, { categoryCode: e.target.value })}
placeholder="카테고리 코드 (예: INBOUND_TYPE)"
className="h-8 w-full text-xs"
/>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
)}
{/* 카테고리 타입이 아닐 때만 표시 모드 선택 */}
{field.type !== "category" && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> </Label>
<Select
value={field.displayMode || "input"}
onValueChange={(value) => updateField(index, { displayMode: value as any })}
>
<SelectTrigger className="h-8 w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="input"> ( )</SelectItem>
<SelectItem value="readonly"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-4 pt-5">
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${index}`}
checked={field.required ?? false}
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
/>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
</Label>
</div>
</div>
</div>
)}
{/* 카테고리 타입일 때는 필수만 표시 */}
{field.type === "category" && (
<div className="flex items-center space-x-2">
<Checkbox
id={`required-${index}`}
checked={field.required ?? false}
onCheckedChange={(checked) => updateField(index, { required: checked as boolean })}
/>
<Label htmlFor={`required-${index}`} className="cursor-pointer text-xs font-normal">
</Label>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
))} ))}

View File

@ -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<string, DataProvidable>;
getAllDataReceivers: () => Map<string, DataReceivable>;
}
const ScreenContext = createContext<ScreenContextValue | null>(null);
interface ScreenContextProviderProps {
screenId?: number;
tableName?: string;
splitPanelPosition?: SplitPanelPosition; // 🆕 분할 패널 위치
children: React.ReactNode;
}
/**
*
*/
export function ScreenContextProvider({ screenId, tableName, splitPanelPosition, children }: ScreenContextProviderProps) {
const dataProvidersRef = useRef<Map<string, DataProvidable>>(new Map());
const dataReceiversRef = useRef<Map<string, DataReceivable>>(new Map());
const registerDataProvider = useCallback((componentId: string, provider: DataProvidable) => {
dataProvidersRef.current.set(componentId, provider);
logger.debug("데이터 제공자 등록", { componentId, componentType: provider.componentType });
}, []);
const unregisterDataProvider = useCallback((componentId: string) => {
dataProvidersRef.current.delete(componentId);
logger.debug("데이터 제공자 해제", { componentId });
}, []);
const registerDataReceiver = useCallback((componentId: string, receiver: DataReceivable) => {
dataReceiversRef.current.set(componentId, receiver);
logger.debug("데이터 수신자 등록", { componentId, componentType: receiver.componentType });
}, []);
const unregisterDataReceiver = useCallback((componentId: string) => {
dataReceiversRef.current.delete(componentId);
logger.debug("데이터 수신자 해제", { componentId });
}, []);
const getDataProvider = useCallback((componentId: string) => {
return dataProvidersRef.current.get(componentId);
}, []);
const getDataReceiver = useCallback((componentId: string) => {
return dataReceiversRef.current.get(componentId);
}, []);
const getAllDataProviders = useCallback(() => {
return new Map(dataProvidersRef.current);
}, []);
const getAllDataReceivers = useCallback(() => {
return new Map(dataReceiversRef.current);
}, []);
// 🆕 useMemo로 value 객체 메모이제이션 (무한 루프 방지)
const value = React.useMemo<ScreenContextValue>(() => ({
screenId,
tableName,
splitPanelPosition,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
}), [
screenId,
tableName,
splitPanelPosition,
registerDataProvider,
unregisterDataProvider,
registerDataReceiver,
unregisterDataReceiver,
getDataProvider,
getDataReceiver,
getAllDataProviders,
getAllDataReceivers,
]);
return <ScreenContext.Provider value={value}>{children}</ScreenContext.Provider>;
}
/**
*
*/
export function useScreenContext() {
const context = useContext(ScreenContext);
if (!context) {
throw new Error("useScreenContext는 ScreenContextProvider 내부에서만 사용할 수 있습니다.");
}
return context;
}
/**
* ()
* .
*/
export function useScreenContextOptional() {
return useContext(ScreenContext);
}

View File

@ -0,0 +1,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<void>;
}
/**
*
*/
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<SplitPanelContextValue | null>(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<Map<string, SplitPanelDataReceiver>>(new Map());
const rightReceiversRef = useRef<Map<string, SplitPanelDataReceiver>>(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<SplitPanelContextValue>(() => ({
splitPanelId,
leftScreenId,
rightScreenId,
registerReceiver,
unregisterReceiver,
transferToOtherSide,
getOtherSideReceivers,
isInSplitPanel: true,
getPositionByScreenId,
}), [
splitPanelId,
leftScreenId,
rightScreenId,
registerReceiver,
unregisterReceiver,
transferToOtherSide,
getOtherSideReceivers,
getPositionByScreenId,
]);
return (
<SplitPanelContext.Provider value={value}>
{children}
</SplitPanelContext.Provider>
);
}
/**
*
*/
export function useSplitPanelContext() {
return useContext(SplitPanelContext);
}
/**
*
*/
export function useIsInSplitPanel(): boolean {
const context = useContext(SplitPanelContext);
return context?.isInSplitPanel ?? false;
}

View File

@ -199,8 +199,6 @@ export interface MenuCopyResult {
copiedMenus: number; copiedMenus: number;
copiedScreens: number; copiedScreens: number;
copiedFlows: number; copiedFlows: number;
copiedCategories: number;
copiedCodes: number;
menuIdMap: Record<number, number>; menuIdMap: Record<number, number>;
screenIdMap: Record<number, number>; screenIdMap: Record<number, number>;
flowIdMap: Record<number, number>; flowIdMap: Record<number, number>;

View File

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

View File

@ -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 * 2
* *

View File

@ -224,6 +224,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
// 1. 새 컴포넌트 시스템에서 먼저 조회 // 1. 새 컴포넌트 시스템에서 먼저 조회
const newComponent = ComponentRegistry.getComponent(componentType); 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 조회 결과 확인 // 🔍 디버깅: select-basic 조회 결과 확인
if (componentType === "select-basic") { if (componentType === "select-basic") {
console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", { console.log("🔍 [DynamicComponentRenderer] select-basic 조회:", {
@ -234,6 +247,20 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
}); });
} }
// 🔍 디버깅: 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) { if (newComponent) {
// 새 컴포넌트 시스템으로 렌더링 // 새 컴포넌트 시스템으로 렌더링
try { try {
@ -295,6 +322,19 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
currentValue = formData?.[fieldName] || ""; 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 핸들러 - 컴포넌트 타입에 따라 다르게 처리 // onChange 핸들러 - 컴포넌트 타입에 따라 다르게 처리
const handleChange = (value: any) => { const handleChange = (value: any) => {
// React 이벤트 객체인 경우 값 추출 // React 이벤트 객체인 경우 값 추출
@ -422,8 +462,14 @@ export const DynamicComponentRenderer: React.FC<DynamicComponentRendererProps> =
if (!renderer) { if (!renderer) {
console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, { console.error(`⚠️ 등록되지 않은 컴포넌트 타입: ${componentType}`, {
component: component, component: component,
componentId: component.id,
componentLabel: component.label,
componentType: componentType, componentType: componentType,
originalType: component.type,
originalComponentType: (component as any).componentType,
componentConfig: component.componentConfig, componentConfig: component.componentConfig,
webTypeConfig: (component as any).webTypeConfig,
autoGeneration: (component as any).autoGeneration,
availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id), availableNewComponents: ComponentRegistry.getAllComponents().map((c) => c.id),
availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(), availableLegacyComponents: legacyComponentRegistry.getRegisteredTypes(),
}); });

View File

@ -23,6 +23,9 @@ import { toast } from "sonner";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCurrentFlowStep } from "@/stores/flowStepStore"; import { useCurrentFlowStep } from "@/stores/flowStepStore";
import { useScreenPreview } from "@/contexts/ScreenPreviewContext"; 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 { export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
config?: ButtonPrimaryConfig; config?: ButtonPrimaryConfig;
@ -97,6 +100,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
...props ...props
}) => { }) => {
const { isPreviewMode } = useScreenPreview(); // 프리뷰 모드 확인 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에서 추출) // 🆕 props에서 onSave 추출 (명시적으로 선언되지 않은 경우 ...props에서 추출)
const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined; const propsOnSave = (props as any).onSave as (() => Promise<void>) | undefined;
@ -375,6 +386,261 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
}; };
// 이벤트 핸들러 // 이벤트 핸들러
/**
* 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<string, any> = {};
// 방법 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) => { const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -391,6 +657,12 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
// 인터랙티브 모드에서 액션 실행 // 인터랙티브 모드에서 액션 실행
if (isInteractive && processedConfig.action) { if (isInteractive && processedConfig.action) {
// transferData 액션 처리 (화면 컨텍스트 필요)
if (processedConfig.action.type === "transferData") {
await handleTransferDataAction(processedConfig.action);
return;
}
// 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단 // 삭제 액션인데 선택된 데이터가 없으면 경고 메시지 표시하고 중단
const hasDataToDelete = const hasDataToDelete =
(selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0); (selectedRowsData && selectedRowsData.length > 0) || (flowSelectedData && flowSelectedData.length > 0);
@ -410,11 +682,21 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
} }
} }
// 🆕 디버깅: tableName 확인
console.log("🔍 [ButtonPrimaryComponent] context 생성:", {
propsTableName: tableName,
contextTableName: screenContext?.tableName,
effectiveTableName,
propsScreenId: screenId,
contextScreenId: screenContext?.screenId,
effectiveScreenId,
});
const context: ButtonActionContext = { const context: ButtonActionContext = {
formData: formData || {}, formData: formData || {},
originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가 originalData: originalData || {}, // 부분 업데이트용 원본 데이터 추가
screenId, screenId: effectiveScreenId, // 🆕 ScreenContext에서 가져온 값 사용
tableName, tableName: effectiveTableName, // 🆕 ScreenContext에서 가져온 값 사용
userId, // 🆕 사용자 ID userId, // 🆕 사용자 ID
userName, // 🆕 사용자 이름 userName, // 🆕 사용자 이름
companyCode, // 🆕 회사 코드 companyCode, // 🆕 회사 코드

View File

@ -12,6 +12,8 @@ import {
import { ConditionalContainerProps, ConditionalSection } from "./types"; import { ConditionalContainerProps, ConditionalSection } from "./types";
import { ConditionalSectionViewer } from "./ConditionalSectionViewer"; import { ConditionalSectionViewer } from "./ConditionalSectionViewer";
import { cn } from "@/lib/utils"; 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 콜백 onSave, // 🆕 EditModal의 handleSave 콜백
}: ConditionalContainerProps) { }: ConditionalContainerProps) {
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
// config prop 우선, 없으면 개별 prop 사용 // config prop 우선, 없으면 개별 prop 사용
const controlField = config?.controlField || propControlField || "condition"; const controlField = config?.controlField || propControlField || "condition";
const controlLabel = config?.controlLabel || propControlLabel || "조건 선택"; const controlLabel = config?.controlLabel || propControlLabel || "조건 선택";
@ -50,30 +55,86 @@ export function ConditionalContainerComponent({
const showBorder = config?.showBorder ?? propShowBorder ?? true; const showBorder = config?.showBorder ?? propShowBorder ?? true;
const spacing = config?.spacing || propSpacing || "normal"; const spacing = config?.spacing || propSpacing || "normal";
// 초기값 계산 (한 번만)
const initialValue = React.useMemo(() => {
return value || formData?.[controlField] || defaultValue || "";
}, []); // 의존성 없음 - 마운트 시 한 번만 계산
// 현재 선택된 값 // 현재 선택된 값
const [selectedValue, setSelectedValue] = useState<string>( const [selectedValue, setSelectedValue] = useState<string>(initialValue);
value || formData?.[controlField] || defaultValue || ""
);
// formData 변경 시 동기화 // 최신 값을 ref로 유지 (클로저 문제 방지)
useEffect(() => { const selectedValueRef = React.useRef(selectedValue);
if (formData?.[controlField]) { selectedValueRef.current = selectedValue; // 렌더링마다 업데이트 (useEffect 대신)
setSelectedValue(formData[controlField]);
} // 콜백 refs (의존성 제거)
}, [formData, controlField]); 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;
// 값 변경 핸들러
const handleValueChange = (newValue: string) => {
setSelectedValue(newValue); setSelectedValue(newValue);
if (onChange) { if (onChangeRef.current) {
onChange(newValue); onChangeRef.current(newValue);
} }
if (onFormDataChange) { if (onFormDataChangeRef.current) {
onFormDataChange(controlField, newValue); onFormDataChangeRef.current(controlField, newValue);
} }
}; }, [controlField]);
// sectionsRef 추가 (dataProvider에서 사용)
const sectionsRef = React.useRef(sections);
React.useEffect(() => {
sectionsRef.current = sections;
}, [sections]);
// dataProvider를 useMemo로 감싸서 불필요한 재생성 방지
const dataProvider = React.useMemo<DataProvidable>(() => ({
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 // 컨테이너 높이 측정용 ref
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@ -158,6 +219,8 @@ export function ConditionalContainerComponent({
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
groupedData={groupedData} groupedData={groupedData}
onSave={onSave} onSave={onSave}
controlField={controlField}
selectedCondition={selectedValue}
/> />
))} ))}
</div> </div>
@ -179,6 +242,8 @@ export function ConditionalContainerComponent({
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
groupedData={groupedData} groupedData={groupedData}
onSave={onSave} onSave={onSave}
controlField={controlField}
selectedCondition={selectedValue}
/> />
) : null ) : null
) )

View File

@ -12,19 +12,38 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } 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 { ConditionalContainerConfig, ConditionalSection } from "./types";
import { screenApi } from "@/lib/api/screen"; import { screenApi } from "@/lib/api/screen";
import { cn } from "@/lib/utils";
import { getCategoryColumnsByMenu, getCategoryValues, getSecondLevelMenus } from "@/lib/api/tableCategoryValue";
interface ConditionalContainerConfigPanelProps { interface ConditionalContainerConfigPanelProps {
config: ConditionalContainerConfig; config: ConditionalContainerConfig;
onConfigChange: (config: ConditionalContainerConfig) => void; onChange?: (config: ConditionalContainerConfig) => void;
onConfigChange?: (config: ConditionalContainerConfig) => void;
} }
export function ConditionalContainerConfigPanel({ export function ConditionalContainerConfigPanel({
config, config,
onChange,
onConfigChange, onConfigChange,
}: ConditionalContainerConfigPanelProps) { }: ConditionalContainerConfigPanelProps) {
// onChange 또는 onConfigChange 둘 다 지원
const handleConfigChange = onChange || onConfigChange;
const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({ const [localConfig, setLocalConfig] = useState<ConditionalContainerConfig>({
controlField: config.controlField || "condition", controlField: config.controlField || "condition",
controlLabel: config.controlLabel || "조건 선택", controlLabel: config.controlLabel || "조건 선택",
@ -38,6 +57,21 @@ export function ConditionalContainerConfigPanel({
const [screens, setScreens] = useState<any[]>([]); const [screens, setScreens] = useState<any[]>([]);
const [screensLoading, setScreensLoading] = useState(false); const [screensLoading, setScreensLoading] = useState(false);
// 🆕 메뉴 기반 카테고리 관련 상태
const [availableMenus, setAvailableMenus] = useState<Array<{ menuObjid: number; menuName: string; parentMenuName: string; screenCode?: string }>>([]);
const [menusLoading, setMenusLoading] = useState(false);
const [selectedMenuObjid, setSelectedMenuObjid] = useState<number | null>(null);
const [menuPopoverOpen, setMenuPopoverOpen] = useState(false);
const [categoryColumns, setCategoryColumns] = useState<Array<{ columnName: string; columnLabel: string; tableName: string }>>([]);
const [categoryColumnsLoading, setCategoryColumnsLoading] = useState(false);
const [selectedCategoryColumn, setSelectedCategoryColumn] = useState<string>("");
const [selectedCategoryTableName, setSelectedCategoryTableName] = useState<string>("");
const [columnPopoverOpen, setColumnPopoverOpen] = useState(false);
const [categoryValues, setCategoryValues] = useState<Array<{ value: string; label: string }>>([]);
const [categoryValuesLoading, setCategoryValuesLoading] = useState(false);
// 화면 목록 로드 // 화면 목록 로드
useEffect(() => { useEffect(() => {
const loadScreens = async () => { const loadScreens = async () => {
@ -56,11 +90,122 @@ export function ConditionalContainerConfigPanel({
loadScreens(); 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<ConditionalContainerConfig>) => { const updateConfig = (updates: Partial<ConditionalContainerConfig>) => {
const newConfig = { ...localConfig, ...updates }; const newConfig = { ...localConfig, ...updates };
setLocalConfig(newConfig); setLocalConfig(newConfig);
onConfigChange(newConfig); handleConfigChange?.(newConfig);
}; };
// 새 섹션 추가 // 새 섹션 추가
@ -134,6 +279,207 @@ export function ConditionalContainerConfigPanel({
</div> </div>
</div> </div>
{/* 🆕 메뉴별 카테고리에서 섹션 자동 생성 */}
<div className="space-y-3 p-3 border rounded-lg bg-blue-50/50 dark:bg-blue-950/20">
<div className="flex items-center gap-2">
<Database className="h-4 w-4 text-blue-600" />
<Label className="text-xs font-semibold text-blue-700 dark:text-blue-400">
</Label>
</div>
{/* 1. 메뉴 선택 */}
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">
1.
</Label>
<Popover open={menuPopoverOpen} onOpenChange={setMenuPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={menuPopoverOpen}
className="h-8 w-full justify-between text-xs"
disabled={menusLoading}
>
{menusLoading ? (
<>
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
...
</>
) : selectedMenuObjid ? (
(() => {
const menu = availableMenus.find((m) => m.menuObjid === selectedMenuObjid);
return menu ? `${menu.parentMenuName} > ${menu.menuName}` : `메뉴 ${selectedMenuObjid}`;
})()
) : (
"메뉴 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[350px] p-0">
<Command>
<CommandInput placeholder="메뉴 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
<CommandGroup>
{availableMenus.map((menu) => (
<CommandItem
key={menu.menuObjid}
value={`${menu.parentMenuName} ${menu.menuName}`}
onSelect={() => {
setSelectedMenuObjid(menu.menuObjid);
setSelectedCategoryColumn("");
setSelectedCategoryTableName("");
setMenuPopoverOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
selectedMenuObjid === menu.menuObjid ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{menu.parentMenuName} &gt; {menu.menuName}</span>
{menu.screenCode && (
<span className="text-[10px] text-muted-foreground">
{menu.screenCode}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 2. 카테고리 컬럼 선택 */}
{selectedMenuObjid && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">
2.
</Label>
{categoryColumnsLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground h-8 px-3 border rounded">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : categoryColumns.length > 0 ? (
<Popover open={columnPopoverOpen} onOpenChange={setColumnPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={columnPopoverOpen}
className="h-8 w-full justify-between text-xs"
>
{selectedCategoryColumn ? (
categoryColumns.find((c) => c.columnName === selectedCategoryColumn)?.columnLabel || selectedCategoryColumn
) : (
"카테고리 컬럼 선택..."
)}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[280px] p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="py-2 text-xs"> </CommandEmpty>
<CommandGroup>
{categoryColumns.map((col) => (
<CommandItem
key={`${col.tableName}.${col.columnName}`}
value={col.columnName}
onSelect={() => {
setSelectedCategoryColumn(col.columnName);
setSelectedCategoryTableName(col.tableName);
setColumnPopoverOpen(false);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
selectedCategoryColumn === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span>{col.columnLabel}</span>
<span className="text-[10px] text-muted-foreground">
{col.tableName}.{col.columnName}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<p className="text-[10px] text-amber-600 dark:text-amber-400">
.
.
</p>
)}
</div>
)}
{/* 3. 카테고리 값 미리보기 */}
{selectedCategoryColumn && (
<div className="space-y-2">
<Label className="text-[10px] text-muted-foreground">
3.
</Label>
{categoryValuesLoading ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
...
</div>
) : categoryValues.length > 0 ? (
<div className="flex flex-wrap gap-1">
{categoryValues.map((option) => (
<span
key={option.value}
className="px-2 py-0.5 text-[10px] bg-blue-100 text-blue-800 rounded dark:bg-blue-900 dark:text-blue-200"
>
{option.label}
</span>
))}
</div>
) : (
<p className="text-[10px] text-amber-600 dark:text-amber-400">
.
.
</p>
)}
</div>
)}
<Button
onClick={generateSectionsFromCategory}
size="sm"
variant="default"
className="h-7 w-full text-xs"
disabled={!selectedCategoryColumn || categoryValues.length === 0 || categoryValuesLoading}
>
<Plus className="h-3 w-3 mr-1" />
{categoryValues.length > 0 ? `${categoryValues.length}개 섹션 자동 생성` : "섹션 자동 생성"}
</Button>
<p className="text-[10px] text-muted-foreground">
.
.
</p>
</div>
{/* 조건별 섹션 설정 */} {/* 조건별 섹션 설정 */}
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">

View File

@ -27,6 +27,8 @@ export function ConditionalSectionViewer({
onFormDataChange, onFormDataChange,
groupedData, // 🆕 그룹 데이터 groupedData, // 🆕 그룹 데이터
onSave, // 🆕 EditModal의 handleSave 콜백 onSave, // 🆕 EditModal의 handleSave 콜백
controlField, // 🆕 조건부 컨테이너의 제어 필드명
selectedCondition, // 🆕 현재 선택된 조건 값
}: ConditionalSectionViewerProps) { }: ConditionalSectionViewerProps) {
const { userId, userName, user } = useAuth(); const { userId, userName, user } = useAuth();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -34,6 +36,24 @@ export function ConditionalSectionViewer({
const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null); const [screenInfo, setScreenInfo] = useState<{ id: number; tableName?: string } | null>(null);
const [screenResolution, setScreenResolution] = useState<{ width: number; height: number } | 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(() => { useEffect(() => {
if (!screenId) { if (!screenId) {
@ -154,18 +174,18 @@ export function ConditionalSectionViewer({
}} }}
> >
<DynamicComponentRenderer <DynamicComponentRenderer
component={component} component={component}
isInteractive={true} isInteractive={true}
screenId={screenInfo?.id} screenId={screenInfo?.id}
tableName={screenInfo?.tableName} tableName={screenInfo?.tableName}
userId={userId} userId={userId}
userName={userName} userName={userName}
companyCode={user?.companyCode} companyCode={user?.companyCode}
formData={formData} formData={enhancedFormData}
onFormDataChange={onFormDataChange} onFormDataChange={onFormDataChange}
groupedData={groupedData} groupedData={groupedData}
onSave={onSave} onSave={onSave}
/> />
</div> </div>
); );
})} })}

View File

@ -79,5 +79,8 @@ export interface ConditionalSectionViewerProps {
onFormDataChange?: (fieldName: string, value: any) => void; onFormDataChange?: (fieldName: string, value: any) => void;
groupedData?: Record<string, any>[]; // 🆕 그룹 데이터 groupedData?: Record<string, any>[]; // 🆕 그룹 데이터
onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백 onSave?: () => Promise<void>; // 🆕 EditModal의 handleSave 콜백
// 🆕 조건부 컨테이너 정보 (자식 화면에 전달)
controlField?: string; // 제어 필드명 (예: "inbound_type")
selectedCondition?: string; // 현재 선택된 조건 값 (예: "PURCHASE_IN")
} }

View File

@ -49,6 +49,8 @@ import "./customer-item-mapping/CustomerItemMappingRenderer"; // 🆕 거래처
import "./autocomplete-search-input/AutocompleteSearchInputRenderer"; import "./autocomplete-search-input/AutocompleteSearchInputRenderer";
import "./entity-search-input/EntitySearchInputRenderer"; import "./entity-search-input/EntitySearchInputRenderer";
import "./modal-repeater-table/ModalRepeaterTableRenderer"; import "./modal-repeater-table/ModalRepeaterTableRenderer";
import "./simple-repeater-table/SimpleRepeaterTableRenderer"; // 🆕 단순 반복 테이블
import "./repeat-screen-modal/RepeatScreenModalRenderer"; // 🆕 반복 화면 모달 (카드 형태)
import "./order-registration-modal/OrderRegistrationModalRenderer"; import "./order-registration-modal/OrderRegistrationModalRenderer";
// 🆕 조건부 컨테이너 컴포넌트 // 🆕 조건부 컨테이너 컴포넌트
@ -68,6 +70,9 @@ import "./repeat-screen-modal/RepeatScreenModalRenderer";
// 🆕 출발지/도착지 선택 컴포넌트 // 🆕 출발지/도착지 선택 컴포넌트
import "./location-swap-selector/LocationSwapSelectorRenderer"; import "./location-swap-selector/LocationSwapSelectorRenderer";
// 🆕 화면 임베딩 및 분할 패널 컴포넌트
import "./screen-split-panel/ScreenSplitPanelRenderer"; // 화면 분할 패널 (좌우 화면 임베딩 + 데이터 전달)
/** /**
* *
*/ */

View File

@ -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카드로 동작합니다. 이 경우 헤더/필드 타입만 사용 가능합니다.

View File

@ -1,26 +1,13 @@
"use client"; "use client";
import React from "react"; import { ComponentRegistry } from "@/lib/registry/ComponentRegistry";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { RepeatScreenModalDefinition } from "./index"; import { RepeatScreenModalDefinition } from "./index";
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
/** // 컴포넌트 자동 등록
* RepeatScreenModal if (typeof window !== "undefined") {
*/ ComponentRegistry.registerComponent(RepeatScreenModalDefinition);
export class RepeatScreenModalRenderer extends AutoRegisteringComponentRenderer { console.log("✅ RepeatScreenModal 컴포넌트 등록 완료");
static componentDefinition = RepeatScreenModalDefinition;
render(): React.ReactElement {
return <RepeatScreenModalComponent {...this.props} />;
}
} }
// 자동 등록 실행 export {};
RepeatScreenModalRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
RepeatScreenModalRenderer.enableHotReload();
}

View File

@ -4,49 +4,111 @@ import { createComponentDefinition } from "../../utils/createComponentDefinition
import { ComponentCategory } from "@/types/component"; import { ComponentCategory } from "@/types/component";
import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent"; import { RepeatScreenModalComponent } from "./RepeatScreenModalComponent";
import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel"; import { RepeatScreenModalConfigPanel } from "./RepeatScreenModalConfigPanel";
import type {
RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
DataSourceConfig,
CardData,
GroupingConfig,
AggregationConfig,
TableLayoutConfig,
TableColumnConfig,
GroupedCardData,
CardRowData,
CardContentRowConfig,
AggregationDisplayConfig,
} from "./types";
/** /**
* RepeatScreenModal * RepeatScreenModal v3
* - / * - ,
*
* :
* - 🆕 v3: 자유 - (Row) (///)
* - 그룹핑: 특정
* - 집계: 그룹 //
* - 테이블: 그룹
* - 레이아웃: ,
* - 설정: 직접 / /
* - 설정: 어느
* - 저장: 하나의
*
* :
* - ( + )
* - ( + )
* - ( + )
* - ( + )
*/ */
export const RepeatScreenModalDefinition = createComponentDefinition({ export const RepeatScreenModalDefinition = createComponentDefinition({
id: "repeat-screen-modal", id: "repeat-screen-modal",
name: "반복 화면 모달", name: "반복 화면 모달",
nameEng: "Repeat Screen Modal", nameEng: "Repeat Screen Modal",
description: "카드/테이블 형태로 데이터를 반복 표시하고 편집할 수 있는 모달 컴포넌트", description:
"선택한 행을 그룹핑하여 카드로 표시하고, 각 카드는 헤더/집계/테이블을 자유롭게 구성 가능한 폼 (출하계획, 구매발주 등)",
category: ComponentCategory.DATA, category: ComponentCategory.DATA,
webType: "form", webType: "form",
component: RepeatScreenModalComponent, component: RepeatScreenModalComponent,
defaultConfig: { defaultConfig: {
cardMode: "simple", // 기본 설정
showCardTitle: true,
cardTitle: "카드 {index}",
cardSpacing: "24px", cardSpacing: "24px",
cardTitle: "", showCardBorder: true,
contentRows: [], saveMode: "all",
// 데이터 소스
dataSource: { dataSource: {
filterField: "",
sourceTable: "", sourceTable: "",
filterField: "selectedIds",
}, },
// 그룹핑 설정
grouping: { grouping: {
enabled: false, enabled: false,
groupByField: "",
aggregations: [], aggregations: [],
}, },
saveMode: "all",
showCardBorder: true, // 🆕 v3: 자유 레이아웃 (행 추가 후 타입 선택)
showCardTitle: true, contentRows: [],
// (레거시 호환)
cardMode: "simple",
cardLayout: [],
tableLayout: { tableLayout: {
headerRows: [], headerRows: [],
tableColumns: [], tableColumns: [],
}, },
}, } as Partial<RepeatScreenModalProps>,
defaultSize: { width: 1000, height: 800 }, defaultSize: { width: 1000, height: 800 },
configPanel: RepeatScreenModalConfigPanel, configPanel: RepeatScreenModalConfigPanel,
icon: "LayoutGrid", icon: "LayoutGrid",
tags: ["모달", "반복", "카드", "테이블", "폼"], tags: ["모달", "폼", "반복", "카드", "그룹핑", "집계", "테이블", "편집", "데이터", "출하계획", "일괄등록", "자유레이아웃"],
version: "1.0.0", version: "3.0.0",
author: "개발팀", author: "개발팀",
}); });
// 컴포넌트 내보내기 // 타입 재 export
export { RepeatScreenModalComponent } from "./RepeatScreenModalComponent"; export type {
export { RepeatScreenModalRenderer } from "./RepeatScreenModalRenderer"; RepeatScreenModalProps,
CardRowConfig,
CardColumnConfig,
ColumnSourceConfig,
ColumnTargetConfig,
DataSourceConfig,
CardData,
GroupingConfig,
AggregationConfig,
TableLayoutConfig,
TableColumnConfig,
GroupedCardData,
CardRowData,
CardContentRowConfig,
AggregationDisplayConfig,
};
// 컴포넌트 재 export
export { RepeatScreenModalComponent, RepeatScreenModalConfigPanel };

View File

@ -0,0 +1,256 @@
import { ComponentRendererProps } from "@/types/component";
/**
* RepeatScreenModal Props
* ,
*
* 🆕 v3: (Row) - (//)
*/
export interface RepeatScreenModalProps {
// === 기본 설정 ===
showCardTitle?: boolean; // 카드 제목 표시 여부
cardTitle?: string; // 카드 제목 템플릿 (예: "{order_no} - {item_code}")
cardSpacing?: string; // 카드 간 간격 (기본: 24px)
showCardBorder?: boolean; // 카드 테두리 표시 여부
saveMode?: "all" | "individual"; // 저장 모드
// === 데이터 소스 ===
dataSource?: DataSourceConfig; // 데이터 소스 설정
// === 그룹핑 설정 ===
grouping?: GroupingConfig; // 그룹핑 설정
// === 🆕 v3: 자유 레이아웃 ===
contentRows?: CardContentRowConfig[]; // 카드 내부 행들 (각 행마다 타입 선택)
// === (레거시 호환) ===
cardMode?: "simple" | "withTable"; // @deprecated - contentRows 사용 권장
cardLayout?: CardRowConfig[]; // @deprecated - contentRows 사용 권장
tableLayout?: TableLayoutConfig; // @deprecated - contentRows 사용 권장
// === 값 ===
value?: any[];
onChange?: (newData: any[]) => void;
}
/**
*
*/
export interface DataSourceConfig {
sourceTable: string; // 조회할 테이블 (예: "sales_order_mng")
filterField?: string; // formData에서 필터링할 필드 (예: "selectedIds")
selectColumns?: string[]; // 선택할 컬럼 목록
}
/**
*
*
*/
export interface GroupingConfig {
enabled: boolean; // 그룹핑 활성화 여부
groupByField: string; // 그룹 기준 필드 (예: "part_code")
// 집계 설정 (그룹별 합계, 개수 등)
aggregations?: AggregationConfig[];
}
/**
* 🆕 v3: 카드
* (//)
*/
export interface CardContentRowConfig {
id: string; // 행 고유 ID
type: "header" | "aggregation" | "table" | "fields"; // 행 타입
// === header/fields 타입일 때 ===
columns?: CardColumnConfig[]; // 컬럼 설정
layout?: "horizontal" | "vertical"; // 레이아웃 방향
gap?: string; // 컬럼 간 간격
backgroundColor?: string; // 배경색
padding?: string; // 패딩
// === aggregation 타입일 때 ===
aggregationFields?: AggregationDisplayConfig[]; // 표시할 집계 필드들
aggregationLayout?: "horizontal" | "grid"; // 집계 레이아웃 (가로 나열 / 그리드)
aggregationColumns?: number; // grid일 때 컬럼 수 (기본: 4)
// === table 타입일 때 ===
tableColumns?: TableColumnConfig[]; // 테이블 컬럼 설정
tableTitle?: string; // 테이블 제목
showTableHeader?: boolean; // 테이블 헤더 표시 여부
tableMaxHeight?: string; // 테이블 최대 높이
}
/**
* 🆕 v3: 집계
*/
export interface AggregationDisplayConfig {
aggregationResultField: string; // 그룹핑 설정의 resultField 참조
label: string; // 표시 라벨
icon?: string; // 아이콘 (lucide 아이콘명)
backgroundColor?: string; // 배경색
textColor?: string; // 텍스트 색상
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
}
/**
*
*/
export interface AggregationConfig {
sourceField: string; // 원본 필드 (예: "balance_qty")
type: "sum" | "count" | "avg" | "min" | "max"; // 집계 타입
resultField: string; // 결과 필드명 (예: "total_balance_qty")
label: string; // 표시 라벨 (예: "총수주잔량")
}
/**
* @deprecated v3에서는 contentRows
*
*/
export interface TableLayoutConfig {
headerRows: CardRowConfig[];
tableColumns: TableColumnConfig[];
tableTitle?: string;
showTableHeader?: boolean;
tableMaxHeight?: string;
}
/**
*
*/
export interface TableColumnConfig {
id: string; // 컬럼 고유 ID
field: string; // 필드명
label: string; // 헤더 라벨
type: "text" | "number" | "date" | "select" | "badge"; // 타입
width?: string; // 너비 (예: "100px", "20%")
align?: "left" | "center" | "right"; // 정렬
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];
// Badge 타입 설정
badgeVariant?: "default" | "secondary" | "destructive" | "outline";
badgeColorMap?: Record<string, string>; // 값별 색상 매핑
// 데이터 소스 설정
sourceConfig?: ColumnSourceConfig;
// 데이터 타겟 설정
targetConfig?: ColumnTargetConfig;
}
/**
*
* (Row) ,
*/
export interface CardRowConfig {
id: string; // 행 고유 ID
columns: CardColumnConfig[]; // 이 행에 배치할 컬럼들
gap?: string; // 컬럼 간 간격 (기본: 16px)
layout?: "horizontal" | "vertical"; // 레이아웃 방향 (기본: horizontal)
// 🆕 행 스타일 설정
backgroundColor?: string; // 배경색 (예: "blue", "green")
padding?: string; // 패딩
rounded?: boolean; // 둥근 모서리
}
/**
*
*/
export interface CardColumnConfig {
id: string; // 컬럼 고유 ID
field: string; // 필드명 (데이터 바인딩)
label: string; // 라벨
type: "text" | "number" | "date" | "select" | "textarea" | "component" | "aggregation"; // 🆕 aggregation 추가
width?: string; // 너비 (예: "50%", "200px", "1fr")
editable: boolean; // 편집 가능 여부
required?: boolean; // 필수 입력 여부
placeholder?: string; // 플레이스홀더
// Select 타입 옵션
selectOptions?: { value: string; label: string }[];
// 데이터 소스 설정 (어디서 조회?)
sourceConfig?: ColumnSourceConfig;
// 데이터 타겟 설정 (어디에 저장?)
targetConfig?: ColumnTargetConfig;
// Component 타입일 때
componentType?: string; // 삽입할 컴포넌트 타입 (예: "simple-repeater-table")
componentConfig?: any; // 컴포넌트 설정
// 🆕 Aggregation 타입일 때 (집계값 표시)
aggregationField?: string; // 표시할 집계 필드명 (GroupingConfig.aggregations의 resultField)
// 🆕 스타일 설정
textColor?: string; // 텍스트 색상
fontSize?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl"; // 폰트 크기
fontWeight?: "normal" | "medium" | "semibold" | "bold"; // 폰트 굵기
}
/**
* (SimpleRepeaterTable과 )
*/
export interface ColumnSourceConfig {
type: "direct" | "join" | "manual"; // 조회 타입
sourceTable?: string; // type: "direct" - 조회할 테이블
sourceColumn?: string; // type: "direct" - 조회할 컬럼
joinTable?: string; // type: "join" - 조인할 테이블
joinColumn?: string; // type: "join" - 조인 테이블에서 가져올 컬럼
joinKey?: string; // type: "join" - 현재 데이터의 조인 키 컬럼
joinRefKey?: string; // type: "join" - 조인 테이블의 참조 키 컬럼
}
/**
* (SimpleRepeaterTable과 )
*/
export interface ColumnTargetConfig {
targetTable: string; // 저장할 테이블
targetColumn: string; // 저장할 컬럼
saveEnabled?: boolean; // 저장 활성화 여부 (기본 true)
}
/**
* ( )
*/
export interface CardData {
_cardId: string; // 카드 고유 ID
_originalData: Record<string, any>; // 원본 데이터 (조회된 데이터)
_isDirty: boolean; // 수정 여부
[key: string]: any; // 실제 필드 데이터
}
/**
* 🆕
*/
export interface GroupedCardData {
_cardId: string; // 카드 고유 ID
_groupKey: string; // 그룹 키 값 (예: "PROD-001")
_groupField: string; // 그룹 기준 필드명 (예: "part_code")
_aggregations: Record<string, number>; // 집계 결과 (예: { total_balance_qty: 100 })
_rows: CardRowData[]; // 그룹 내 각 행 데이터
_representativeData: Record<string, any>; // 그룹 대표 데이터 (첫 번째 행 기준)
}
/**
* 🆕
*/
export interface CardRowData {
_rowId: string; // 행 고유 ID
_originalData: Record<string, any>; // 원본 데이터
_isDirty: boolean; // 수정 여부
[key: string]: any; // 실제 필드 데이터
}
/**
* (API )
*/
export interface TableInfo {
tableName: string;
displayName?: string;
}

View File

@ -1,33 +1,316 @@
"use client"; "use client";
import React from "react"; import React, { useEffect, useRef, useCallback, useMemo, useState } from "react";
import { Layers } from "lucide-react"; import { Layers } from "lucide-react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer"; import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component"; import { ComponentDefinition, ComponentCategory, ComponentRendererProps } from "@/types/component";
import { RepeaterInput } from "@/components/webtypes/RepeaterInput"; import { RepeaterInput } from "@/components/webtypes/RepeaterInput";
import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel"; import { RepeaterConfigPanel } from "@/components/webtypes/config/RepeaterConfigPanel";
import { useScreenContextOptional, DataReceivable } from "@/contexts/ScreenContext";
import { useSplitPanelContext } from "@/contexts/SplitPanelContext";
import { applyMappingRules } from "@/lib/utils/dataMapping";
import { toast } from "sonner";
import { apiClient } from "@/lib/api/client";
/** /**
* Repeater Field Group * Repeater Field Group
*/ */
const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => { const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) => {
const { component, value, onChange, readonly, disabled } = props; const { component, value, onChange, readonly, disabled, formData, onFormDataChange, menuObjid } = props;
const screenContext = useScreenContextOptional();
const splitPanelContext = useSplitPanelContext();
const receiverRef = useRef<DataReceivable | null>(null);
// 🆕 그룹화된 데이터를 저장하는 상태
const [groupedData, setGroupedData] = useState<any[] | null>(null);
const [isLoadingGroupData, setIsLoadingGroupData] = useState(false);
const groupDataLoadedRef = useRef(false);
// 🆕 원본 데이터 ID 목록 (삭제 추적용)
const [originalItemIds, setOriginalItemIds] = useState<string[]>([]);
// 컴포넌트의 필드명 (formData 키)
const fieldName = (component as any).columnName || component.id;
// repeaterConfig 또는 componentConfig에서 설정 가져오기 // repeaterConfig 또는 componentConfig에서 설정 가져오기
const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] }; const config = (component as any).repeaterConfig || component.componentConfig || { fields: [] };
// 🆕 그룹화 설정 (예: groupByColumn: "inbound_number")
const groupByColumn = config.groupByColumn;
const targetTable = config.targetTable;
// formData에서 값 가져오기 (value prop보다 우선)
const rawValue = formData?.[fieldName] ?? value;
// 🆕 수정 모드 감지: formData에 id가 있고, fieldName으로 값을 찾지 못한 경우
// formData 자체를 배열의 첫 번째 항목으로 사용 (단일 행 수정 시)
const isEditMode = formData?.id && !rawValue && !value;
// 🆕 반복 필드 그룹의 필드들이 formData에 있는지 확인
const configFields = config.fields || [];
const hasRepeaterFieldsInFormData = configFields.length > 0 &&
configFields.some((field: any) => formData?.[field.name] !== undefined);
// 🆕 formData와 config.fields의 필드 이름 매칭 확인
const matchingFields = configFields.filter((field: any) => formData?.[field.name] !== undefined);
// 🆕 그룹 키 값 (예: formData.inbound_number)
const groupKeyValue = groupByColumn ? formData?.[groupByColumn] : null;
console.log("🔄 [RepeaterFieldGroup] 렌더링:", {
fieldName,
hasFormData: !!formData,
formDataId: formData?.id,
formDataValue: formData?.[fieldName],
propsValue: value,
rawValue,
isEditMode,
hasRepeaterFieldsInFormData,
configFieldNames: configFields.map((f: any) => f.name),
formDataKeys: formData ? Object.keys(formData) : [],
matchingFieldNames: matchingFields.map((f: any) => f.name),
groupByColumn,
groupKeyValue,
targetTable,
hasGroupedData: groupedData !== null,
groupedDataLength: groupedData?.length,
});
// 🆕 수정 모드에서 그룹화된 데이터 로드
useEffect(() => {
const loadGroupedData = async () => {
// 이미 로드했거나 조건이 맞지 않으면 스킵
if (groupDataLoadedRef.current) return;
if (!isEditMode || !groupByColumn || !groupKeyValue || !targetTable) return;
console.log("📥 [RepeaterFieldGroup] 그룹 데이터 로드 시작:", {
groupByColumn,
groupKeyValue,
targetTable,
});
setIsLoadingGroupData(true);
groupDataLoadedRef.current = true;
try {
// API 호출: 같은 그룹 키를 가진 모든 데이터 조회
// search 파라미터 사용 (filters가 아닌 search)
const response = await apiClient.post(`/table-management/tables/${targetTable}/data`, {
page: 1,
size: 100, // 충분히 큰 값
search: { [groupByColumn]: groupKeyValue },
});
console.log("🔍 [RepeaterFieldGroup] API 응답 구조:", {
success: response.data?.success,
hasData: !!response.data?.data,
dataType: typeof response.data?.data,
dataKeys: response.data?.data ? Object.keys(response.data.data) : [],
});
// 응답 구조: { success, data: { data: [...], total, page, totalPages } }
if (response.data?.success && response.data?.data?.data) {
const items = response.data.data.data; // 실제 데이터 배열
console.log("✅ [RepeaterFieldGroup] 그룹 데이터 로드 완료:", {
count: items.length,
groupByColumn,
groupKeyValue,
firstItem: items[0],
});
setGroupedData(items);
// 🆕 원본 데이터 ID 목록 저장 (삭제 추적용)
const itemIds = items.map((item: any) => item.id).filter(Boolean);
setOriginalItemIds(itemIds);
console.log("📋 [RepeaterFieldGroup] 원본 데이터 ID 목록 저장:", itemIds);
// onChange 호출하여 부모에게 알림
if (onChange && items.length > 0) {
const dataWithMeta = items.map((item: any) => ({
...item,
_targetTable: targetTable,
_originalItemIds: itemIds, // 🆕 원본 ID 목록도 함께 전달
}));
onChange(dataWithMeta);
}
} else {
console.warn("⚠️ [RepeaterFieldGroup] 그룹 데이터 로드 실패:", response.data);
setGroupedData([]);
}
} catch (error) {
console.error("❌ [RepeaterFieldGroup] 그룹 데이터 로드 오류:", error);
setGroupedData([]);
} finally {
setIsLoadingGroupData(false);
}
};
loadGroupedData();
}, [isEditMode, groupByColumn, groupKeyValue, targetTable, onChange]);
// 값이 JSON 문자열인 경우 파싱 // 값이 JSON 문자열인 경우 파싱
let parsedValue: any[] = []; let parsedValue: any[] = [];
if (typeof value === "string") {
// 🆕 그룹화된 데이터가 있으면 우선 사용
if (groupedData !== null && groupedData.length > 0) {
parsedValue = groupedData;
} else if (isEditMode && hasRepeaterFieldsInFormData && !groupByColumn) {
// 그룹화 설정이 없는 경우에만 단일 행 사용
console.log("📝 [RepeaterFieldGroup] 수정 모드 - formData를 초기 데이터로 사용", {
formDataId: formData?.id,
matchingFieldsCount: matchingFields.length,
});
parsedValue = [{ ...formData }];
} else if (typeof rawValue === "string" && rawValue.trim() !== "") {
// 빈 문자열이 아닌 경우에만 JSON 파싱 시도
try { try {
parsedValue = JSON.parse(value); parsedValue = JSON.parse(rawValue);
} catch { } catch {
parsedValue = []; parsedValue = [];
} }
} else if (Array.isArray(value)) { } else if (Array.isArray(rawValue)) {
parsedValue = value; parsedValue = rawValue;
} }
// parsedValue를 ref로 관리하여 최신 값 유지
const parsedValueRef = useRef(parsedValue);
parsedValueRef.current = parsedValue;
// onChange를 ref로 관리
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
// onFormDataChange를 ref로 관리
const onFormDataChangeRef = useRef(onFormDataChange);
onFormDataChangeRef.current = onFormDataChange;
// fieldName을 ref로 관리
const fieldNameRef = useRef(fieldName);
fieldNameRef.current = fieldName;
// config를 ref로 관리
const configRef = useRef(config);
configRef.current = config;
// 데이터 수신 핸들러
const handleReceiveData = useCallback((data: any[], mappingRulesOrMode?: any[] | string) => {
console.log("📥 [RepeaterFieldGroup] 데이터 수신:", { data, mappingRulesOrMode });
if (!data || data.length === 0) {
toast.warning("전달할 데이터가 없습니다");
return;
}
// 매핑 규칙이 배열인 경우에만 적용
let processedData = data;
if (Array.isArray(mappingRulesOrMode) && mappingRulesOrMode.length > 0) {
processedData = applyMappingRules(data, mappingRulesOrMode);
}
// 데이터 정규화: 각 항목에서 실제 데이터 추출
// 데이터가 {0: {...}, inbound_type: "..."} 형태인 경우 처리
const normalizedData = processedData.map((item: any) => {
// item이 {0: {...실제데이터...}, 추가필드: 값} 형태인 경우
if (item && typeof item === "object" && item[0] && typeof item[0] === "object") {
// 0번 인덱스의 데이터와 나머지 필드를 병합
const { 0: originalData, ...additionalFields } = item;
return { ...originalData, ...additionalFields };
}
return item;
});
// 🆕 정의된 필드만 필터링 (불필요한 필드 제거)
// 반복 필드 그룹에 정의된 필드 + 시스템 필드만 유지
const definedFields = configRef.current.fields || [];
const definedFieldNames = new Set(definedFields.map((f: any) => f.name));
// 시스템 필드 및 필수 필드 추가
const systemFields = new Set(['id', '_targetTable', 'created_date', 'updated_date', 'writer', 'company_code']);
const filteredData = normalizedData.map((item: any) => {
const filteredItem: Record<string, any> = {};
Object.keys(item).forEach(key => {
// 정의된 필드이거나 시스템 필드인 경우만 포함
if (definedFieldNames.has(key) || systemFields.has(key)) {
filteredItem[key] = item[key];
}
});
return filteredItem;
});
console.log("📥 [RepeaterFieldGroup] 정규화된 데이터:", normalizedData);
console.log("📥 [RepeaterFieldGroup] 필터링된 데이터:", filteredData);
// 기존 데이터에 새 데이터 추가 (기본 모드: append)
const currentValue = parsedValueRef.current;
// mode가 "replace"인 경우 기존 데이터 대체, 그 외에는 추가
// 🆕 필터링된 데이터 사용
const mode = typeof mappingRulesOrMode === "string" ? mappingRulesOrMode : "append";
const newItems = mode === "replace" ? filteredData : [...currentValue, ...filteredData];
console.log("📥 [RepeaterFieldGroup] 최종 데이터:", { currentValue, newItems, mode });
// JSON 문자열로 변환하여 저장
const jsonValue = JSON.stringify(newItems);
console.log("📥 [RepeaterFieldGroup] onChange/onFormDataChange 호출:", {
jsonValue,
hasOnChange: !!onChangeRef.current,
hasOnFormDataChange: !!onFormDataChangeRef.current,
fieldName: fieldNameRef.current,
});
// onFormDataChange가 있으면 우선 사용 (EmbeddedScreen의 formData 상태 업데이트)
if (onFormDataChangeRef.current) {
onFormDataChangeRef.current(fieldNameRef.current, jsonValue);
}
// 그렇지 않으면 onChange 사용
else if (onChangeRef.current) {
onChangeRef.current(jsonValue);
}
toast.success(`${filteredData.length}개 항목이 추가되었습니다`);
}, []);
// DataReceivable 인터페이스 구현
const dataReceiver = useMemo<DataReceivable>(() => ({
componentId: component.id,
componentType: "repeater-field-group",
receiveData: handleReceiveData,
}), [component.id, handleReceiveData]);
// ScreenContext에 데이터 수신자로 등록
useEffect(() => {
if (screenContext && component.id) {
console.log("📋 [RepeaterFieldGroup] ScreenContext에 데이터 수신자 등록:", component.id);
screenContext.registerDataReceiver(component.id, dataReceiver);
return () => {
screenContext.unregisterDataReceiver(component.id);
};
}
}, [screenContext, component.id, dataReceiver]);
// SplitPanelContext에 데이터 수신자로 등록 (분할 패널 내에서만)
useEffect(() => {
const splitPanelPosition = screenContext?.splitPanelPosition;
if (splitPanelContext?.isInSplitPanel && splitPanelPosition && component.id) {
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에 데이터 수신자 등록:", {
componentId: component.id,
position: splitPanelPosition,
});
splitPanelContext.registerReceiver(splitPanelPosition, component.id, dataReceiver);
receiverRef.current = dataReceiver;
return () => {
console.log("🔗 [RepeaterFieldGroup] SplitPanelContext에서 데이터 수신자 해제:", component.id);
splitPanelContext.unregisterReceiver(splitPanelPosition, component.id);
receiverRef.current = null;
};
}
}, [splitPanelContext, screenContext?.splitPanelPosition, component.id, dataReceiver]);
return ( return (
<RepeaterInput <RepeaterInput
value={parsedValue} value={parsedValue}
@ -39,6 +322,7 @@ const RepeaterFieldGroupComponent: React.FC<ComponentRendererProps> = (props) =>
config={config} config={config}
disabled={disabled} disabled={disabled}
readonly={readonly} readonly={readonly}
menuObjid={menuObjid}
className="w-full" className="w-full"
/> />
); );

View File

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

View File

@ -0,0 +1,110 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { ComponentRendererProps } from "@/types/component";
import { ComponentCategory } from "@/types/component";
import { ScreenSplitPanel } from "@/components/screen-embedding/ScreenSplitPanel";
import { ScreenSplitPanelConfigPanel } from "./ScreenSplitPanelConfigPanel";
/**
* Renderer
*
*/
class ScreenSplitPanelRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = {
id: "screen-split-panel",
name: "화면 분할 패널",
nameEng: "Screen Split Panel",
description: "좌우 화면 임베딩 및 데이터 전달 기능을 제공하는 분할 패널",
category: ComponentCategory.LAYOUT,
webType: "text", // 레이아웃 컴포넌트는 기본 webType 사용
component: ScreenSplitPanelRenderer, // 🆕 Renderer 클래스 자체를 등록 (ScreenSplitPanel 아님)
configPanel: ScreenSplitPanelConfigPanel, // 설정 패널
tags: ["split", "panel", "embed", "data-transfer", "layout"],
defaultSize: {
width: 1200,
height: 600,
},
defaultConfig: {
screenId: 0,
leftScreenId: 0,
rightScreenId: 0,
splitRatio: 50,
resizable: true,
buttonLabel: "데이터 전달",
buttonPosition: "center",
},
version: "1.0.0",
author: "ERP System",
documentation: `
#
.
##
- ** **:
- ** **:
- ** **: , ,
- ** **: sum, average, concat
- ** **:
##
1. ** **: () ()
2. ** **: () ()
3. ** **: () ()
##
1. "화면 분할 패널"
2. /
3. ( )
4. (, , )
`,
};
render() {
console.log("🚀 [ScreenSplitPanelRenderer] render() 호출됨!", this.props);
const { component, style = {}, componentConfig, config, screenId, formData } = this.props as any;
// componentConfig 또는 config 또는 component.componentConfig 사용
const finalConfig = componentConfig || config || component?.componentConfig || {};
console.log("🔍 [ScreenSplitPanelRenderer] 설정 분석:", {
hasComponentConfig: !!componentConfig,
hasConfig: !!config,
hasComponentComponentConfig: !!component?.componentConfig,
finalConfig,
splitRatio: finalConfig.splitRatio,
leftScreenId: finalConfig.leftScreenId,
rightScreenId: finalConfig.rightScreenId,
componentType: component?.componentType,
componentId: component?.id,
});
// 🆕 formData 별도 로그 (명확한 확인)
console.log("📝 [ScreenSplitPanelRenderer] formData 확인:", {
hasFormData: !!formData,
formDataKeys: formData ? Object.keys(formData) : [],
formData: formData,
});
return (
<div style={{ width: "100%", height: "100%", ...style }}>
<ScreenSplitPanel
screenId={screenId || finalConfig.screenId}
config={finalConfig}
initialFormData={formData} // 🆕 수정 데이터 전달
/>
</div>
);
}
}
// 자동 등록
ScreenSplitPanelRenderer.registerSelf();
export default ScreenSplitPanelRenderer;

View File

@ -2,6 +2,8 @@ import React, { useState, useEffect, useRef, useMemo } from "react";
import { filterDOMProps } from "@/lib/utils/domPropsFilter"; import { filterDOMProps } from "@/lib/utils/domPropsFilter";
import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes"; import { useCodeOptions, useTableCodeCategory } from "@/hooks/queries/useCodes";
import { cn } from "@/lib/registry/components/common/inputStyles"; import { cn } from "@/lib/registry/components/common/inputStyles";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import type { DataProvidable } from "@/types/data-transfer";
interface Option { interface Option {
value: string; value: string;
@ -50,6 +52,9 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
menuObjid, // 🆕 메뉴 OBJID menuObjid, // 🆕 메뉴 OBJID
...props ...props
}) => { }) => {
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
// 🚨 최초 렌더링 확인용 (테스트 후 제거) // 🚨 최초 렌더링 확인용 (테스트 후 제거)
console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", { console.log("🚨🚨🚨 [SelectBasicComponent] 렌더링됨!!!!", {
componentId: component.id, componentId: component.id,
@ -249,6 +254,47 @@ const SelectBasicComponent: React.FC<SelectBasicComponentProps> = ({
// - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거 // - 중복 요청 방지: 동일한 queryKey에 대해 자동 중복 제거
// - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유 // - 상태 동기화: 모든 컴포넌트가 같은 캐시 공유
// 📦 DataProvidable 인터페이스 구현 (데이터 전달 시 셀렉트 값 제공)
const dataProvider: DataProvidable = {
componentId: component.id,
componentType: "select",
getSelectedData: () => {
// 현재 선택된 값을 배열로 반환
const fieldName = component.columnName || "selectedValue";
return [{
[fieldName]: selectedValue,
value: selectedValue,
label: selectedLabel,
}];
},
getAllData: () => {
// 모든 옵션 반환
const configOptions = config.options || [];
return [...codeOptions, ...categoryOptions, ...configOptions];
},
clearSelection: () => {
setSelectedValue("");
setSelectedLabel("");
if (isMultiple) {
setSelectedValues([]);
}
},
};
// 화면 컨텍스트에 데이터 제공자로 등록
useEffect(() => {
if (screenContext && component.id) {
screenContext.registerDataProvider(component.id, dataProvider);
return () => {
screenContext.unregisterDataProvider(component.id);
};
}
}, [screenContext, component.id, selectedValue, selectedLabel, selectedValues]);
// 선택된 값에 따른 라벨 업데이트 // 선택된 값에 따른 라벨 업데이트
useEffect(() => { useEffect(() => {
const getAllOptions = () => { const getAllOptions = () => {

View File

@ -0,0 +1,535 @@
"use client";
import React, { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { Trash2, Loader2, X } from "lucide-react";
import { SimpleRepeaterTableProps, SimpleRepeaterColumnConfig } from "./types";
import { cn } from "@/lib/utils";
import { ComponentRendererProps } from "@/types/component";
import { useCalculation } from "./useCalculation";
import { apiClient } from "@/lib/api/client";
export interface SimpleRepeaterTableComponentProps extends ComponentRendererProps {
config?: SimpleRepeaterTableProps;
// SimpleRepeaterTableProps의 개별 prop들도 지원 (호환성)
value?: any[];
onChange?: (newData: any[]) => void;
columns?: SimpleRepeaterColumnConfig[];
calculationRules?: any[];
readOnly?: boolean;
showRowNumber?: boolean;
allowDelete?: boolean;
maxHeight?: string;
}
export function SimpleRepeaterTableComponent({
// ComponentRendererProps (자동 전달)
component,
isDesignMode = false,
isSelected = false,
isInteractive = false,
onClick,
className,
formData,
onFormDataChange,
// SimpleRepeaterTable 전용 props
config,
value: propValue,
onChange: propOnChange,
columns: propColumns,
calculationRules: propCalculationRules,
readOnly: propReadOnly,
showRowNumber: propShowRowNumber,
allowDelete: propAllowDelete,
maxHeight: propMaxHeight,
...props
}: SimpleRepeaterTableComponentProps) {
// config 또는 component.config 또는 개별 prop 우선순위로 병합
const componentConfig = {
...config,
...component?.config,
};
// config prop 우선, 없으면 개별 prop 사용
const columns = componentConfig?.columns || propColumns || [];
const calculationRules = componentConfig?.calculationRules || propCalculationRules || [];
const readOnly = componentConfig?.readOnly ?? propReadOnly ?? false;
const showRowNumber = componentConfig?.showRowNumber ?? propShowRowNumber ?? true;
const allowDelete = componentConfig?.allowDelete ?? propAllowDelete ?? true;
const maxHeight = componentConfig?.maxHeight || propMaxHeight || "240px";
// value는 formData[columnName] 우선, 없으면 prop 사용
const columnName = component?.columnName;
const value = (columnName && formData?.[columnName]) || componentConfig?.value || propValue || [];
// 🆕 로딩 상태
const [isLoading, setIsLoading] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
// onChange 래퍼 (기존 onChange 콜백 + onFormDataChange 호출)
const handleChange = (newData: any[]) => {
// 기존 onChange 콜백 호출 (호환성)
const externalOnChange = componentConfig?.onChange || propOnChange;
if (externalOnChange) {
externalOnChange(newData);
}
// onFormDataChange 호출하여 EditModal의 groupData 업데이트
if (onFormDataChange && columnName) {
onFormDataChange(columnName, newData);
}
};
// 계산 hook
const { calculateRow, calculateAll } = useCalculation(calculationRules);
// 🆕 초기 데이터 로드
useEffect(() => {
const loadInitialData = async () => {
const initialConfig = componentConfig?.initialDataConfig;
if (!initialConfig || !initialConfig.sourceTable) {
return; // 초기 데이터 설정이 없으면 로드하지 않음
}
setIsLoading(true);
setLoadError(null);
try {
// 필터 조건 생성
const filters: Record<string, any> = {};
if (initialConfig.filterConditions) {
for (const condition of initialConfig.filterConditions) {
let filterValue = condition.value;
// formData에서 값 가져오기
if (condition.valueFromField && formData) {
filterValue = formData[condition.valueFromField];
}
filters[condition.field] = filterValue;
}
}
// API 호출
const response = await apiClient.post(
`/table-management/tables/${initialConfig.sourceTable}/data`,
{
search: filters,
page: 1,
size: 1000, // 대량 조회
}
);
if (response.data.success && response.data.data?.data) {
const loadedData = response.data.data.data;
// 1. 기본 데이터 매핑 (Direct & Manual)
const baseMappedData = loadedData.map((row: any) => {
const mappedRow: any = { ...row }; // 원본 데이터 유지 (조인 키 참조용)
for (const col of columns) {
if (col.sourceConfig) {
if (col.sourceConfig.type === "direct" && col.sourceConfig.sourceColumn) {
mappedRow[col.field] = row[col.sourceConfig.sourceColumn];
} else if (col.sourceConfig.type === "manual") {
mappedRow[col.field] = col.defaultValue;
}
// Join은 2단계에서 처리
} else {
mappedRow[col.field] = row[col.field] ?? col.defaultValue;
}
}
return mappedRow;
});
// 2. 조인 데이터 처리
const joinColumns = columns.filter(
(col) => col.sourceConfig?.type === "join" && col.sourceConfig.joinTable && col.sourceConfig.joinKey
);
if (joinColumns.length > 0) {
// 조인 테이블별로 그룹화
const joinGroups = new Map<string, { key: string; refKey: string; cols: typeof columns }>();
joinColumns.forEach((col) => {
const table = col.sourceConfig!.joinTable!;
const key = col.sourceConfig!.joinKey!;
// refKey가 없으면 key와 동일하다고 가정 (하위 호환성)
const refKey = col.sourceConfig!.joinRefKey || key;
const groupKey = `${table}:${key}:${refKey}`;
if (!joinGroups.has(groupKey)) {
joinGroups.set(groupKey, { key, refKey, cols: [] });
}
joinGroups.get(groupKey)!.cols.push(col);
});
// 각 그룹별로 데이터 조회 및 병합
await Promise.all(
Array.from(joinGroups.entries()).map(async ([groupKey, { key, refKey, cols }]) => {
const [tableName] = groupKey.split(":");
// 조인 키 값 수집 (중복 제거)
const keyValues = Array.from(new Set(
baseMappedData
.map((row: any) => row[key])
.filter((v: any) => v !== undefined && v !== null)
));
if (keyValues.length === 0) return;
try {
// 조인 테이블 조회
// refKey(타겟 테이블 컬럼)로 검색
const response = await apiClient.post(
`/table-management/tables/${tableName}/data`,
{
search: { [refKey]: keyValues }, // { id: [1, 2, 3] }
page: 1,
size: 1000,
}
);
if (response.data.success && response.data.data?.data) {
const joinedRows = response.data.data.data;
// 조인 데이터 맵 생성 (refKey -> row)
const joinMap = new Map(joinedRows.map((r: any) => [r[refKey], r]));
// 데이터 병합
baseMappedData.forEach((row: any) => {
const keyValue = row[key];
const joinedRow = joinMap.get(keyValue);
if (joinedRow) {
cols.forEach((col) => {
if (col.sourceConfig?.joinColumn) {
row[col.field] = joinedRow[col.sourceConfig.joinColumn];
}
});
}
});
}
} catch (error) {
console.error(`조인 실패 (${tableName}):`, error);
// 실패 시 무시하고 진행 (값은 undefined)
}
})
);
}
const mappedData = baseMappedData;
// 계산 필드 적용
const calculatedData = calculateAll(mappedData);
handleChange(calculatedData);
}
} catch (error: any) {
console.error("초기 데이터 로드 실패:", error);
setLoadError(error.message || "데이터를 불러올 수 없습니다");
} finally {
setIsLoading(false);
}
};
loadInitialData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentConfig?.initialDataConfig]);
// 초기 데이터에 계산 필드 적용
useEffect(() => {
if (value.length > 0 && calculationRules.length > 0) {
const calculated = calculateAll(value);
// 값이 실제로 변경된 경우만 업데이트
if (JSON.stringify(calculated) !== JSON.stringify(value)) {
handleChange(calculated);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 🆕 저장 요청 시 테이블별로 데이터 그룹화 (beforeFormSave 이벤트 리스너)
useEffect(() => {
const handleSaveRequest = async (event: Event) => {
if (value.length === 0) {
console.warn("⚠️ [SimpleRepeaterTable] 저장할 데이터 없음");
return;
}
// 🆕 테이블별로 데이터 그룹화
const dataByTable: Record<string, any[]> = {};
for (const row of value) {
// 각 행의 데이터를 테이블별로 분리
for (const col of columns) {
// 저장 설정이 있고 저장이 활성화된 경우에만
if (col.targetConfig && col.targetConfig.targetTable && col.targetConfig.saveEnabled !== false) {
const targetTable = col.targetConfig.targetTable;
const targetColumn = col.targetConfig.targetColumn || col.field;
// 테이블 그룹 초기화
if (!dataByTable[targetTable]) {
dataByTable[targetTable] = [];
}
// 해당 테이블의 데이터 찾기 또는 생성
let tableRow = dataByTable[targetTable].find((r: any) => r._rowIndex === row._rowIndex);
if (!tableRow) {
tableRow = { _rowIndex: row._rowIndex };
dataByTable[targetTable].push(tableRow);
}
// 컬럼 값 저장
tableRow[targetColumn] = row[col.field];
}
}
}
// _rowIndex 제거
Object.keys(dataByTable).forEach((tableName) => {
dataByTable[tableName] = dataByTable[tableName].map((row: any) => {
const { _rowIndex, ...rest } = row;
return rest;
});
});
console.log("✅ [SimpleRepeaterTable] 테이블별 저장 데이터:", dataByTable);
// CustomEvent의 detail에 테이블별 데이터 추가
if (event instanceof CustomEvent && event.detail) {
// 각 테이블별로 데이터 전달
Object.entries(dataByTable).forEach(([tableName, rows]) => {
const key = `${columnName || component?.id}_${tableName}`;
event.detail.formData[key] = rows.map((row: any) => ({
...row,
_targetTable: tableName,
}));
});
console.log("✅ [SimpleRepeaterTable] 저장 데이터 준비:", {
tables: Object.keys(dataByTable),
totalRows: Object.values(dataByTable).reduce((sum, rows) => sum + rows.length, 0),
});
}
// 기존 onFormDataChange도 호출 (호환성)
if (onFormDataChange && columnName) {
// 테이블별 데이터를 통합하여 전달
onFormDataChange(columnName, Object.entries(dataByTable).flatMap(([table, rows]) =>
rows.map((row: any) => ({ ...row, _targetTable: table }))
));
}
};
// 저장 버튼 클릭 시 데이터 수집
window.addEventListener("beforeFormSave", handleSaveRequest as EventListener);
return () => {
window.removeEventListener("beforeFormSave", handleSaveRequest as EventListener);
};
}, [value, columns, columnName, component?.id, onFormDataChange]);
const handleCellEdit = (rowIndex: number, field: string, cellValue: any) => {
const newRow = { ...value[rowIndex], [field]: cellValue };
// 계산 필드 업데이트
const calculatedRow = calculateRow(newRow);
const newData = [...value];
newData[rowIndex] = calculatedRow;
handleChange(newData);
};
const handleRowDelete = (rowIndex: number) => {
const newData = value.filter((_, i) => i !== rowIndex);
handleChange(newData);
};
const renderCell = (
row: any,
column: SimpleRepeaterColumnConfig,
rowIndex: number
) => {
const cellValue = row[column.field];
// 계산 필드는 편집 불가
if (column.calculated || !column.editable || readOnly) {
return (
<div className="px-2 py-1">
{column.type === "number"
? typeof cellValue === "number"
? cellValue.toLocaleString()
: cellValue || "0"
: cellValue || "-"}
</div>
);
}
// 편집 가능한 필드
switch (column.type) {
case "number":
return (
<Input
type="number"
value={cellValue || ""}
onChange={(e) =>
handleCellEdit(rowIndex, column.field, parseFloat(e.target.value) || 0)
}
className="h-7 text-xs"
/>
);
case "date":
return (
<Input
type="date"
value={cellValue || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
case "select":
return (
<Select
value={cellValue || ""}
onValueChange={(newValue) =>
handleCellEdit(rowIndex, column.field, newValue)
}
>
<SelectTrigger className="h-7 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{column.selectOptions?.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
);
default: // text
return (
<Input
type="text"
value={cellValue || ""}
onChange={(e) => handleCellEdit(rowIndex, column.field, e.target.value)}
className="h-7 text-xs"
/>
);
}
};
// 로딩 중일 때
if (isLoading) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> ...</p>
</div>
</div>
</div>
);
}
// 에러 발생 시
if (loadError) {
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div className="flex items-center justify-center py-12" style={{ minHeight: maxHeight }}>
<div className="text-center">
<div className="w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-2">
<X className="h-6 w-6 text-destructive" />
</div>
<p className="text-sm font-medium text-destructive mb-1"> </p>
<p className="text-xs text-muted-foreground">{loadError}</p>
</div>
</div>
</div>
);
}
return (
<div className={cn("border rounded-md overflow-hidden bg-background", className)}>
<div
className="overflow-x-auto overflow-y-auto"
style={{ maxHeight }}
>
<table className="w-full text-xs sm:text-sm">
<thead className="bg-muted sticky top-0 z-10">
<tr>
{showRowNumber && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-12">
#
</th>
)}
{columns.map((col) => (
<th
key={col.field}
className="px-4 py-2 text-left font-medium text-muted-foreground"
style={{ width: col.width }}
>
{col.label}
{col.required && <span className="text-destructive ml-1">*</span>}
</th>
))}
{!readOnly && allowDelete && (
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-20">
</th>
)}
</tr>
</thead>
<tbody className="bg-background">
{value.length === 0 ? (
<tr>
<td
colSpan={columns.length + (showRowNumber ? 1 : 0) + (allowDelete && !readOnly ? 1 : 0)}
className="px-4 py-8 text-center text-muted-foreground"
>
</td>
</tr>
) : (
value.map((row, rowIndex) => (
<tr key={rowIndex} className="border-t hover:bg-accent/50">
{showRowNumber && (
<td className="px-4 py-2 text-center text-muted-foreground">
{rowIndex + 1}
</td>
)}
{columns.map((col) => (
<td key={col.field} className="px-2 py-1">
{renderCell(row, col, rowIndex)}
</td>
))}
{!readOnly && allowDelete && (
<td className="px-4 py-2 text-center">
<Button
variant="ghost"
size="sm"
onClick={() => handleRowDelete(rowIndex)}
className="h-7 w-7 p-0 text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
"use client";
import React from "react";
import { ComponentRegistry } from "../../ComponentRegistry";
import { SimpleRepeaterTableDefinition } from "./index";
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
import { ComponentRendererProps } from "@/types/component";
// 컴포넌트 자동 등록
ComponentRegistry.registerComponent(SimpleRepeaterTableDefinition);
console.log("✅ SimpleRepeaterTable 컴포넌트 등록 완료");
export function SimpleRepeaterTableRenderer(props: ComponentRendererProps) {
return <SimpleRepeaterTableComponent {...props} />;
}

View File

@ -0,0 +1,60 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
import { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
/**
* 🆕 SimpleRepeaterTable
* - /
*
* :
* - 로드: 어떤
* - 설정: ( / / )
* - 설정:
* - 계산: 수량 * =
* - 모드: 전체
*/
export const SimpleRepeaterTableDefinition = createComponentDefinition({
id: "simple-repeater-table",
name: "단순 반복 테이블",
nameEng: "Simple Repeater Table",
description: "어떤 테이블에서 조회하고 어떤 테이블에 저장할지 컬럼별로 설정 가능한 반복 테이블 (검색/추가 없음, 자동 계산 지원)",
category: ComponentCategory.DATA,
webType: "table",
component: SimpleRepeaterTableComponent,
defaultConfig: {
columns: [],
calculationRules: [],
initialDataConfig: undefined,
readOnly: false,
showRowNumber: true,
allowDelete: true,
maxHeight: "240px",
},
defaultSize: { width: 800, height: 400 },
configPanel: SimpleRepeaterTableConfigPanel,
icon: "Table",
tags: ["테이블", "반복", "편집", "데이터", "목록", "계산", "조회", "저장"],
version: "2.0.0",
author: "개발팀",
});
// 타입 내보내기
export type {
SimpleRepeaterTableProps,
SimpleRepeaterColumnConfig,
CalculationRule,
ColumnSourceConfig,
ColumnTargetConfig,
InitialDataConfig,
DataFilterCondition,
SourceJoinCondition,
} from "./types";
// 컴포넌트 내보내기
export { SimpleRepeaterTableComponent } from "./SimpleRepeaterTableComponent";
export { SimpleRepeaterTableConfigPanel } from "./SimpleRepeaterTableConfigPanel";
export { useCalculation } from "./useCalculation";

View File

@ -0,0 +1,67 @@
import { useCallback } from "react";
import { CalculationRule } from "./types";
/**
*
*/
export function useCalculation(calculationRules: CalculationRule[] = []) {
/**
*
*/
const calculateRow = useCallback(
(row: any): any => {
if (calculationRules.length === 0) return row;
const updatedRow = { ...row };
for (const rule of calculationRules) {
try {
// formula에서 필드명 자동 추출 (영문자, 숫자, 언더스코어로 구성된 단어)
let formula = rule.formula;
const fieldMatches = formula.match(/[a-zA-Z_][a-zA-Z0-9_]*/g) || [];
// 추출된 필드명들을 사용 (dependencies가 없으면 자동 추출 사용)
const dependencies = rule.dependencies && rule.dependencies.length > 0
? rule.dependencies
: fieldMatches;
// 필드명을 실제 값으로 대체
for (const dep of dependencies) {
// 결과 필드는 제외
if (dep === rule.result) continue;
const value = parseFloat(row[dep]) || 0;
// 정확한 필드명만 대체 (단어 경계 사용)
formula = formula.replace(new RegExp(`\\b${dep}\\b`, "g"), value.toString());
}
// 계산 실행 (Function 사용)
const result = new Function(`return ${formula}`)();
updatedRow[rule.result] = result;
} catch (error) {
console.error(`계산 오류 (${rule.formula}):`, error);
updatedRow[rule.result] = 0;
}
}
return updatedRow;
},
[calculationRules]
);
/**
*
*/
const calculateAll = useCallback(
(data: any[]): any[] => {
return data.map((row) => calculateRow(row));
},
[calculateRow]
);
return {
calculateRow,
calculateAll,
};
}

View File

@ -48,6 +48,9 @@ import { TableOptionsModal } from "@/components/common/TableOptionsModal";
import { useTableOptions } from "@/contexts/TableOptionsContext"; import { useTableOptions } from "@/contexts/TableOptionsContext";
import { TableFilter, ColumnVisibility } from "@/types/table-options"; import { TableFilter, ColumnVisibility } from "@/types/table-options";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useScreenContextOptional } from "@/contexts/ScreenContext";
import { useSplitPanelContext, SplitPanelPosition } from "@/contexts/SplitPanelContext";
import type { DataProvidable, DataReceivable, DataReceiverConfig } from "@/types/data-transfer";
// ======================================== // ========================================
// 인터페이스 // 인터페이스
@ -251,6 +254,17 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const { userId: authUserId } = useAuth(); const { userId: authUserId } = useAuth();
const currentUserId = userId || authUserId; const currentUserId = userId || authUserId;
// 화면 컨텍스트 (데이터 제공자로 등록)
const screenContext = useScreenContextOptional();
// 분할 패널 컨텍스트 (분할 패널 내부에서 데이터 수신자로 등록)
const splitPanelContext = useSplitPanelContext();
// 🆕 ScreenContext에서 splitPanelPosition 가져오기 (중첩 화면에서도 작동)
const splitPanelPosition = screenContext?.splitPanelPosition;
// 🆕 연결된 필터 상태 (다른 컴포넌트 값으로 필터링)
const [linkedFilterValues, setLinkedFilterValues] = useState<Record<string, any>>({});
// TableOptions Context // TableOptions Context
const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions(); const { registerTable, unregisterTable, updateTableDataCount } = useTableOptions();
const [filters, setFilters] = useState<TableFilter[]>([]); const [filters, setFilters] = useState<TableFilter[]>([]);
@ -359,6 +373,199 @@ export const TableListComponent: React.FC<TableListComponentProps> = ({
const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table"); const [viewMode, setViewMode] = useState<"table" | "card" | "grouped-card">("table");
const [frozenColumns, setFrozenColumns] = useState<string[]>([]); const [frozenColumns, setFrozenColumns] = useState<string[]>([]);
// 🆕 연결된 필터 처리 (셀렉트박스 등 다른 컴포넌트 값으로 필터링)
useEffect(() => {
const linkedFilters = tableConfig.linkedFilters;
if (!linkedFilters || linkedFilters.length === 0 || !screenContext) {
return;
}
// 연결된 소스 컴포넌트들의 값을 주기적으로 확인
const checkLinkedFilters = () => {
const newFilterValues: Record<string, any> = {};
let hasChanges = false;
linkedFilters.forEach((filter) => {
if (filter.enabled === false) return;
const sourceProvider = screenContext.getDataProvider(filter.sourceComponentId);
if (sourceProvider) {
const selectedData = sourceProvider.getSelectedData();
if (selectedData && selectedData.length > 0) {
const sourceField = filter.sourceField || "value";
const value = selectedData[0][sourceField];
if (value !== linkedFilterValues[filter.targetColumn]) {
newFilterValues[filter.targetColumn] = value;
hasChanges = true;
} else {
newFilterValues[filter.targetColumn] = linkedFilterValues[filter.targetColumn];
}
}
}
});
if (hasChanges) {
console.log("🔗 [TableList] 연결된 필터 값 변경:", newFilterValues);
setLinkedFilterValues(newFilterValues);
// searchValues에 연결된 필터 값 병합
setSearchValues(prev => ({
...prev,
...newFilterValues
}));
// 첫 페이지로 이동
setCurrentPage(1);
}
};
// 초기 체크
checkLinkedFilters();
// 주기적으로 체크 (500ms마다)
const intervalId = setInterval(checkLinkedFilters, 500);
return () => {
clearInterval(intervalId);
};
}, [screenContext, tableConfig.linkedFilters, linkedFilterValues]);
// DataProvidable 인터페이스 구현
const dataProvider: DataProvidable = {
componentId: component.id,
componentType: "table-list",
getSelectedData: () => {
// 선택된 행의 실제 데이터 반환
const selectedData = data.filter((row) => {
const rowId = String(row.id || row[tableConfig.selectedTable + "_id"] || "");
return selectedRows.has(rowId);
});
return selectedData;
},
getAllData: () => {
return data;
},
clearSelection: () => {
setSelectedRows(new Set());
setIsAllSelected(false);
},
};
// DataReceivable 인터페이스 구현
const dataReceiver: DataReceivable = {
componentId: component.id,
componentType: "table",
receiveData: async (receivedData: any[], config: DataReceiverConfig) => {
console.log("📥 TableList 데이터 수신:", {
componentId: component.id,
receivedDataCount: receivedData.length,
mode: config.mode,
currentDataCount: data.length,
});
try {
let newData: any[] = [];
switch (config.mode) {
case "append":
// 기존 데이터에 추가
newData = [...data, ...receivedData];
console.log("✅ Append 모드: 기존 데이터에 추가", { newDataCount: newData.length });
break;
case "replace":
// 기존 데이터를 완전히 교체
newData = receivedData;
console.log("✅ Replace 모드: 데이터 교체", { newDataCount: newData.length });
break;
case "merge":
// 기존 데이터와 병합 (ID 기반)
const existingMap = new Map(data.map(item => [item.id, item]));
receivedData.forEach(item => {
if (item.id && existingMap.has(item.id)) {
// 기존 데이터 업데이트
existingMap.set(item.id, { ...existingMap.get(item.id), ...item });
} else {
// 새 데이터 추가
existingMap.set(item.id || Date.now() + Math.random(), item);
}
});
newData = Array.from(existingMap.values());
console.log("✅ Merge 모드: 데이터 병합", { newDataCount: newData.length });
break;
}
// 상태 업데이트
setData(newData);
// 총 아이템 수 업데이트
setTotalItems(newData.length);
console.log("✅ 데이터 수신 완료:", { finalDataCount: newData.length });
} catch (error) {
console.error("❌ 데이터 수신 실패:", error);
throw error;
}
},
getData: () => {
return data;
},
};
// 화면 컨텍스트에 데이터 제공자/수신자로 등록
useEffect(() => {
if (screenContext && component.id) {
screenContext.registerDataProvider(component.id, dataProvider);
screenContext.registerDataReceiver(component.id, dataReceiver);
return () => {
screenContext.unregisterDataProvider(component.id);
screenContext.unregisterDataReceiver(component.id);
};
}
}, [screenContext, component.id, data, selectedRows]);
// 분할 패널 컨텍스트에 데이터 수신자로 등록
// useSplitPanelPosition 훅으로 위치 가져오기 (중첩된 화면에서도 작동)
const currentSplitPosition = splitPanelPosition || splitPanelContext?.getPositionByScreenId(screenId as number) || null;
useEffect(() => {
if (splitPanelContext && component.id && currentSplitPosition) {
const splitPanelReceiver = {
componentId: component.id,
componentType: "table-list",
receiveData: async (incomingData: any[], mode: "append" | "replace" | "merge") => {
console.log("📥 [TableListComponent] 분할 패널에서 데이터 수신:", {
count: incomingData.length,
mode,
position: currentSplitPosition,
});
await dataReceiver.receiveData(incomingData, {
targetComponentId: component.id,
targetComponentType: "table-list",
mode,
mappingRules: [],
});
},
};
splitPanelContext.registerReceiver(currentSplitPosition, component.id, splitPanelReceiver);
return () => {
splitPanelContext.unregisterReceiver(currentSplitPosition, component.id);
};
}
}, [splitPanelContext, component.id, currentSplitPosition, dataReceiver]);
// 테이블 등록 (Context에 등록) // 테이블 등록 (Context에 등록)
const tableId = `table-list-${component.id}`; const tableId = `table-list-${component.id}`;

View File

@ -1214,6 +1214,114 @@ export const TableListConfigPanel: React.FC<TableListConfigPanelProps> = ({
onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)} onConfigChange={(dataFilter) => handleChange("dataFilter", dataFilter)}
/> />
</div> </div>
{/* 🆕 연결된 필터 설정 (셀렉트박스 등 다른 컴포넌트 값으로 필터링) */}
<div className="space-y-3">
<div>
<h3 className="text-sm font-semibold"> </h3>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
<hr className="border-border" />
{/* 연결된 필터 목록 */}
<div className="space-y-2">
{(config.linkedFilters || []).map((filter, index) => (
<div key={index} className="flex items-center gap-2 rounded border p-2">
<div className="flex-1 space-y-1">
<div className="flex items-center gap-2">
<Input
placeholder="소스 컴포넌트 ID"
value={filter.sourceComponentId || ""}
onChange={(e) => {
const newFilters = [...(config.linkedFilters || [])];
newFilters[index] = { ...filter, sourceComponentId: e.target.value };
handleChange("linkedFilters", newFilters);
}}
className="h-7 text-xs flex-1"
/>
<span className="text-xs text-muted-foreground"></span>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="h-7 flex-1 justify-between text-xs"
>
{filter.targetColumn || "필터링할 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-3 w-3 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." className="h-8 text-xs" />
<CommandList>
<CommandEmpty className="text-xs py-2"> </CommandEmpty>
<CommandGroup>
{availableColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={() => {
const newFilters = [...(config.linkedFilters || [])];
newFilters[index] = { ...filter, targetColumn: col.columnName };
handleChange("linkedFilters", newFilters);
}}
className="text-xs"
>
<Check
className={cn(
"mr-2 h-3 w-3",
filter.targetColumn === col.columnName ? "opacity-100" : "opacity-0"
)}
/>
{col.label || col.columnName}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
const newFilters = (config.linkedFilters || []).filter((_, i) => i !== index);
handleChange("linkedFilters", newFilters);
}}
className="h-7 w-7 p-0 text-red-500 hover:text-red-600"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
{/* 연결된 필터 추가 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => {
const newFilters = [
...(config.linkedFilters || []),
{ sourceComponentId: "", targetColumn: "", operator: "equals" as const, enabled: true }
];
handleChange("linkedFilters", newFilters);
}}
className="h-7 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
<p className="text-[10px] text-muted-foreground">
: 셀렉트박스(ID: select-basic-123) inbound_type
</p>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@ -170,6 +170,18 @@ export interface CheckboxConfig {
selectAll: boolean; // 전체 선택/해제 버튼 표시 여부 selectAll: boolean; // 전체 선택/해제 버튼 표시 여부
} }
/**
*
* ( )
*/
export interface LinkedFilterConfig {
sourceComponentId: string; // 소스 컴포넌트 ID (셀렉트박스 등)
sourceField?: string; // 소스 컴포넌트에서 가져올 필드명 (기본: value)
targetColumn: string; // 필터링할 테이블 컬럼명
operator?: "equals" | "contains" | "in"; // 필터 연산자 (기본: equals)
enabled?: boolean; // 활성화 여부 (기본: true)
}
/** /**
* TableList * TableList
*/ */
@ -231,6 +243,9 @@ export interface TableListConfig extends ComponentConfig {
// 🆕 컬럼 값 기반 데이터 필터링 // 🆕 컬럼 값 기반 데이터 필터링
dataFilter?: DataFilterConfig; dataFilter?: DataFilterConfig;
// 🆕 연결된 필터 (다른 컴포넌트 값으로 필터링)
linkedFilters?: LinkedFilterConfig[];
// 이벤트 핸들러 // 이벤트 핸들러
onRowClick?: (row: any) => void; onRowClick?: (row: any) => void;
onRowDoubleClick?: (row: any) => void; onRowDoubleClick?: (row: any) => void;

View File

@ -26,7 +26,8 @@ export type ButtonActionType =
| "code_merge" // 코드 병합 | "code_merge" // 코드 병합
| "geolocation" // 위치정보 가져오기 | "geolocation" // 위치정보 가져오기
| "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지) | "swap_fields" // 필드 값 교환 (출발지 ↔ 목적지)
| "update_field"; // 특정 필드 값 변경 (예: status를 active로) | "update_field" // 특정 필드 값 변경 (예: status를 active로)
| "transferData"; // 🆕 데이터 전달 (컴포넌트 간 or 화면 간)
/** /**
* *
@ -126,6 +127,43 @@ export interface ButtonActionConfig {
editModalTitle?: string; // 편집 모달 제목 editModalTitle?: string; // 편집 모달 제목
editModalDescription?: string; // 편집 모달 설명 editModalDescription?: string; // 편집 모달 설명
groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"]) groupByColumns?: string[]; // 같은 그룹의 여러 행을 함께 편집 (예: ["order_no"])
// 데이터 전달 관련 (transferData 액션용)
dataTransfer?: {
// 소스 설정
sourceComponentId: string; // 데이터를 가져올 컴포넌트 ID (테이블 등)
sourceComponentType?: string; // 소스 컴포넌트 타입
// 타겟 설정
targetType: "component" | "screen"; // 타겟 타입 (같은 화면의 컴포넌트 or 다른 화면)
// 타겟이 컴포넌트인 경우
targetComponentId?: string; // 타겟 컴포넌트 ID
// 타겟이 화면인 경우
targetScreenId?: number; // 타겟 화면 ID
// 데이터 매핑 규칙
mappingRules: Array<{
sourceField: string; // 소스 필드명
targetField: string; // 타겟 필드명
transform?: "sum" | "average" | "concat" | "first" | "last" | "count"; // 변환 함수
defaultValue?: any; // 기본값
}>;
// 전달 옵션
mode?: "append" | "replace" | "merge"; // 수신 모드 (기본: append)
clearAfterTransfer?: boolean; // 전달 후 소스 데이터 초기화
confirmBeforeTransfer?: boolean; // 전달 전 확인 메시지
confirmMessage?: string; // 확인 메시지 내용
// 검증
validation?: {
requireSelection?: boolean; // 선택 필수 (기본: true)
minSelection?: number; // 최소 선택 개수
maxSelection?: number; // 최대 선택 개수
};
};
} }
/** /**
@ -455,6 +493,66 @@ export class ButtonActionExecutor {
} }
} }
// 🆕 반복 필드 그룹에서 삭제된 항목 처리
// formData의 각 필드에서 _deletedItemIds가 있는지 확인
console.log("🔍 [handleSave] 삭제 항목 검색 시작 - dataWithUserInfo 키:", Object.keys(dataWithUserInfo));
for (const [key, value] of Object.entries(dataWithUserInfo)) {
console.log(`🔍 [handleSave] 필드 검사: ${key}`, {
type: typeof value,
isArray: Array.isArray(value),
isString: typeof value === "string",
valuePreview: typeof value === "string" ? value.substring(0, 100) : value,
});
let parsedValue = value;
// JSON 문자열인 경우 파싱 시도
if (typeof value === "string" && value.startsWith("[")) {
try {
parsedValue = JSON.parse(value);
console.log(`🔍 [handleSave] JSON 파싱 성공: ${key}`, parsedValue);
} catch (e) {
// 파싱 실패하면 원본 값 유지
}
}
if (Array.isArray(parsedValue) && parsedValue.length > 0) {
const firstItem = parsedValue[0];
const deletedItemIds = firstItem?._deletedItemIds;
const targetTable = firstItem?._targetTable;
console.log(`🔍 [handleSave] 배열 필드 분석: ${key}`, {
firstItemKeys: firstItem ? Object.keys(firstItem) : [],
deletedItemIds,
targetTable,
});
if (deletedItemIds && deletedItemIds.length > 0 && targetTable) {
console.log("🗑️ [handleSave] 삭제할 항목 발견:", {
fieldKey: key,
targetTable,
deletedItemIds,
});
// 삭제 API 호출
for (const itemId of deletedItemIds) {
try {
console.log(`🗑️ [handleSave] 항목 삭제 중: ${itemId} from ${targetTable}`);
const deleteResult = await DynamicFormApi.deleteFormDataFromTable(itemId, targetTable);
if (deleteResult.success) {
console.log(`✅ [handleSave] 항목 삭제 완료: ${itemId}`);
} else {
console.warn(`⚠️ [handleSave] 항목 삭제 실패: ${itemId}`, deleteResult.message);
}
} catch (deleteError) {
console.error(`❌ [handleSave] 항목 삭제 오류: ${itemId}`, deleteError);
}
}
}
}
}
saveResult = await DynamicFormApi.saveFormData({ saveResult = await DynamicFormApi.saveFormData({
screenId, screenId,
tableName, tableName,
@ -979,6 +1077,7 @@ export class ButtonActionExecutor {
title: config.modalTitle, title: config.modalTitle,
size: config.modalSize, size: config.modalSize,
targetScreenId: config.targetScreenId, targetScreenId: config.targetScreenId,
selectedRowsData: context.selectedRowsData,
}); });
if (config.targetScreenId) { if (config.targetScreenId) {
@ -995,6 +1094,10 @@ export class ButtonActionExecutor {
} }
} }
// 🆕 선택된 행 데이터 수집
const selectedData = context.selectedRowsData || [];
console.log("📦 [handleModal] 선택된 데이터:", selectedData);
// 전역 모달 상태 업데이트를 위한 이벤트 발생 // 전역 모달 상태 업데이트를 위한 이벤트 발생
const modalEvent = new CustomEvent("openScreenModal", { const modalEvent = new CustomEvent("openScreenModal", {
detail: { detail: {
@ -1002,6 +1105,9 @@ export class ButtonActionExecutor {
title: config.modalTitle || "화면", title: config.modalTitle || "화면",
description: description, description: description,
size: config.modalSize || "md", size: config.modalSize || "md",
// 🆕 선택된 행 데이터 전달
selectedData: selectedData,
selectedIds: selectedData.map((row: any) => row.id).filter(Boolean),
}, },
}); });
@ -1382,16 +1488,59 @@ export class ButtonActionExecutor {
let description = config.editModalDescription || ""; let description = config.editModalDescription || "";
// 2. config에 없으면 화면 정보에서 가져오기 // 2. config에 없으면 화면 정보에서 가져오기
if (!description && config.targetScreenId) { let screenInfo: any = null;
if (config.targetScreenId) {
try { try {
const screenInfo = await screenApi.getScreen(config.targetScreenId); screenInfo = await screenApi.getScreen(config.targetScreenId);
description = screenInfo?.description || ""; if (!description) {
description = screenInfo?.description || "";
}
} catch (error) { } catch (error) {
console.warn("화면 설명을 가져오지 못했습니다:", error); console.warn("화면 설명을 가져오지 못했습니다:", error);
} }
} }
// 🔧 항상 EditModal 사용 (groupByColumns는 EditModal에서 처리) // 🆕 화면이 분할 패널을 포함하는지 확인 (레이아웃에 screen-split-panel 컴포넌트가 있는지)
let hasSplitPanel = false;
if (config.targetScreenId) {
try {
const layoutData = await screenApi.getLayout(config.targetScreenId);
if (layoutData?.components) {
hasSplitPanel = layoutData.components.some(
(comp: any) =>
comp.type === "screen-split-panel" ||
comp.componentType === "screen-split-panel" ||
comp.type === "split-panel-layout" ||
comp.componentType === "split-panel-layout"
);
}
console.log("🔍 [openEditModal] 분할 패널 확인:", {
targetScreenId: config.targetScreenId,
hasSplitPanel,
componentTypes: layoutData?.components?.map((c: any) => c.type || c.componentType) || [],
});
} catch (error) {
console.warn("레이아웃 정보를 가져오지 못했습니다:", error);
}
}
// 🆕 분할 패널 화면인 경우 ScreenModal 사용 (editData 전달)
if (hasSplitPanel) {
console.log("📋 [openEditModal] 분할 패널 화면 - ScreenModal 사용");
const screenModalEvent = new CustomEvent("openScreenModal", {
detail: {
screenId: config.targetScreenId,
title: config.editModalTitle || "데이터 수정",
description: description,
size: config.modalSize || "lg",
editData: rowData, // 🆕 수정 데이터 전달
},
});
window.dispatchEvent(screenModalEvent);
return;
}
// 🔧 일반 화면은 EditModal 사용 (groupByColumns는 EditModal에서 처리)
const modalEvent = new CustomEvent("openEditModal", { const modalEvent = new CustomEvent("openEditModal", {
detail: { detail: {
screenId: config.targetScreenId, screenId: config.targetScreenId,

View File

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

View File

@ -864,11 +864,14 @@ export class ImprovedButtonActionExecutor {
context: ButtonExecutionContext, context: ButtonExecutionContext,
): Promise<ExecutionResult> { ): Promise<ExecutionResult> {
try { try {
// 기존 ButtonActionExecutor 로직을 여기서 호출하거나
// 간단한 액션들을 직접 구현
const startTime = performance.now(); const startTime = performance.now();
// 임시 구현 - 실제로는 기존 ButtonActionExecutor를 호출해야 함 // transferData 액션 처리
if (buttonConfig.actionType === "transferData") {
return await this.executeTransferDataAction(buttonConfig, formData, context);
}
// 기존 액션들 (임시 구현)
const result = { const result = {
success: true, success: true,
message: `${buttonConfig.actionType} 액션 실행 완료`, message: `${buttonConfig.actionType} 액션 실행 완료`,
@ -889,6 +892,43 @@ export class ImprovedButtonActionExecutor {
} }
} }
/**
*
*/
private static async executeTransferDataAction(
buttonConfig: ExtendedButtonTypeConfig,
formData: Record<string, any>,
context: ButtonExecutionContext,
): Promise<ExecutionResult> {
const startTime = performance.now();
try {
const dataTransferConfig = buttonConfig.dataTransfer;
if (!dataTransferConfig) {
throw new Error("데이터 전달 설정이 없습니다.");
}
console.log("📦 데이터 전달 시작:", dataTransferConfig);
// 1. 화면 컨텍스트에서 소스 컴포넌트 찾기
const { ScreenContextProvider } = await import("@/contexts/ScreenContext");
// 실제로는 현재 화면의 컨텍스트를 사용해야 하지만, 여기서는 전역적으로 접근할 수 없음
// 대신 context에 screenContext를 전달하도록 수정 필요
throw new Error("데이터 전달 기능은 버튼 컴포넌트에서 직접 구현되어야 합니다.");
} catch (error) {
console.error("❌ 데이터 전달 실패:", error);
return {
success: false,
message: `데이터 전달 실패: ${error.message}`,
executionTime: performance.now() - startTime,
error: error.message,
};
}
}
/** /**
* 🔥 * 🔥
*/ */

View File

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

View File

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

View File

@ -2,7 +2,50 @@
* (Repeater) * (Repeater)
*/ */
export type RepeaterFieldType = "text" | "number" | "email" | "tel" | "date" | "select" | "textarea"; /**
* (table_type_columns) input_type
*/
export type RepeaterFieldType =
| "text" // 텍스트
| "number" // 숫자
| "textarea" // 텍스트영역
| "date" // 날짜
| "select" // 선택박스
| "checkbox" // 체크박스
| "radio" // 라디오
| "category" // 카테고리
| "entity" // 엔티티 참조
| "code" // 공통코드
| "image" // 이미지
| "direct" // 직접입력
| "calculated" // 계산식 필드
| string; // 기타 커스텀 타입 허용
/**
*
*/
export type CalculationOperator = "+" | "-" | "*" | "/" | "%" | "round" | "floor" | "ceil" | "abs";
/**
*
* : { field1: "order_qty", operator: "*", field2: "unit_price" } order_qty * unit_price
* : { field1: "amount", operator: "round", decimalPlaces: 2 } round(amount, 2)
*/
export interface CalculationFormula {
field1: string; // 첫 번째 필드명
operator: CalculationOperator; // 연산자
field2?: string; // 두 번째 필드명 (단항 연산자의 경우 불필요)
constantValue?: number; // 상수값 (field2 대신 사용 가능)
decimalPlaces?: number; // 소수점 자릿수 (round, floor, ceil에서 사용)
}
/**
*
* - input: 입력 ( )
* - readonly:
* - ( )
*/
export type RepeaterFieldDisplayMode = "input" | "readonly";
/** /**
* *
@ -13,8 +56,18 @@ export interface RepeaterFieldDefinition {
type: RepeaterFieldType; // 입력 타입 type: RepeaterFieldType; // 입력 타입
placeholder?: string; placeholder?: string;
required?: boolean; required?: boolean;
readonly?: boolean; // 읽기 전용 여부
options?: Array<{ label: string; value: string }>; // select용 options?: Array<{ label: string; value: string }>; // select용
width?: string; // 필드 너비 (예: "200px", "50%") width?: string; // 필드 너비 (예: "200px", "50%")
displayMode?: RepeaterFieldDisplayMode; // 표시 모드: input(입력), readonly(읽기전용)
categoryCode?: string; // category 타입일 때 사용할 카테고리 코드
formula?: CalculationFormula; // 계산식 (type이 "calculated"일 때 사용)
numberFormat?: {
useThousandSeparator?: boolean; // 천 단위 구분자 사용
prefix?: string; // 접두사 (예: "₩")
suffix?: string; // 접미사 (예: "원")
decimalPlaces?: number; // 소수점 자릿수
};
validation?: { validation?: {
minLength?: number; minLength?: number;
maxLength?: number; maxLength?: number;
@ -30,6 +83,7 @@ export interface RepeaterFieldDefinition {
export interface RepeaterFieldGroupConfig { export interface RepeaterFieldGroupConfig {
fields: RepeaterFieldDefinition[]; // 반복될 필드 정의 fields: RepeaterFieldDefinition[]; // 반복될 필드 정의
targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블) targetTable?: string; // 저장할 대상 테이블 (미지정 시 메인 화면 테이블)
groupByColumn?: string; // 수정 모드에서 그룹화할 컬럼 (예: "inbound_number")
minItems?: number; // 최소 항목 수 minItems?: number; // 최소 항목 수
maxItems?: number; // 최대 항목 수 maxItems?: number; // 최대 항목 수
addButtonText?: string; // 추가 버튼 텍스트 addButtonText?: string; // 추가 버튼 텍스트

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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